| @@ -58,4 +58,6 @@ Desktop.ini | |||
| /scripts | |||
| /docs/images | |||
| /docs/类图Mermaid代码.md | |||
| /other | |||
| /other | |||
| 设计方案书.doc | |||
| /学习 | |||
| @@ -37,11 +37,11 @@ struct HmiRect | |||
| */ | |||
| enum class HmiControlType | |||
| { | |||
| Button, | |||
| Indicator, | |||
| NumericDisplay, | |||
| NumericInput, | |||
| Label | |||
| Button, // 按钮 | |||
| Indicator, // 指示灯 | |||
| NumericDisplay, // 数值显示 | |||
| NumericInput, // 数值输入 | |||
| Label // 标签 | |||
| }; | |||
| /** | |||
| @@ -12,12 +12,7 @@ void setError(std::string *error, const std::string &message) | |||
| } | |||
| } | |||
| /** | |||
| * @brief 检查对象集合中是否存在重复 id | |||
| * @tparam TItem 包含 id 成员的对象类型 | |||
| * @param items 需要检查的对象集合 | |||
| * @return 存在重复 id 时返回 true 否则返回 false | |||
| */ | |||
| // 通过相邻区间查找避免为不同领域对象重复实现标识唯一性校验 | |||
| template <typename TItem> | |||
| bool containsDuplicateId(const std::vector<TItem> &items) | |||
| { | |||
| @@ -59,6 +54,7 @@ bool Project::validate(std::string *error) const | |||
| } | |||
| for (const HmiPage &page : hmiPages) | |||
| { | |||
| // 工程聚合校验会向下委托页面和控件的完整规则 | |||
| if (!page.validate(error)) | |||
| { | |||
| return false; | |||
| @@ -4,6 +4,7 @@ namespace { | |||
| bool isSupportedArea(RegisterArea area) | |||
| { | |||
| // 地址值对象只承认项目定义的 M 区和 D 区 | |||
| return area == RegisterArea::M || area == RegisterArea::D; | |||
| } | |||
| @@ -43,6 +44,7 @@ std::string RegisterAddress::toString() const | |||
| areaName = "D"; | |||
| break; | |||
| default: | |||
| // 未知枚举值不能伪装成有效 PLC 地址 | |||
| return "InvalidRegisterAddress"; | |||
| } | |||
| @@ -12,6 +12,7 @@ ModePolicy RuntimeState::policy() const | |||
| ModeTransitionResult RuntimeState::enterEditing() | |||
| { | |||
| // 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点 | |||
| if (mode_ == ApplicationMode::Editing) | |||
| { | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||
| @@ -46,6 +47,7 @@ ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_comp | |||
| } | |||
| if (!initial_plc_read_completed) | |||
| { | |||
| // 未读取 PLC 时禁止进入真机态,防止用未知缓存值驱动界面 | |||
| return {false, ModeTransitionError::InitialPlcReadRequired}; | |||
| } | |||
| mode_ = ApplicationMode::OnlineRunning; | |||
| @@ -30,15 +30,36 @@ constexpr ModePolicy policyForMode(ApplicationMode mode) | |||
| { | |||
| case ApplicationMode::Editing: | |||
| { | |||
| return {true, false, false, false, false}; | |||
| // 编辑态只允许修改工程,不连接寄存器,也不执行软件逻辑 | |||
| return { | |||
| true, // allowsProjectEditing 允许编辑工程 | |||
| false, // usesVirtualRegisters 不使用虚拟寄存器 | |||
| false, // usesPlcRegisters 不使用 PLC 寄存器 | |||
| false, // runsLogicExecutor 不运行软件逻辑执行器 | |||
| false // requiresInitialPlcRead 不要求 PLC 初次读取 | |||
| }; | |||
| } | |||
| case ApplicationMode::OfflineRunning: | |||
| { | |||
| return {false, true, false, true, false}; | |||
| // 离线运行态禁止改工程,使用虚拟 M/D 并运行软件逻辑 | |||
| return { | |||
| false, // allowsProjectEditing 禁止编辑工程 | |||
| true, // usesVirtualRegisters 使用虚拟寄存器 | |||
| false, // usesPlcRegisters 不使用 PLC 寄存器 | |||
| true, // runsLogicExecutor 运行软件逻辑执行器 | |||
| false // requiresInitialPlcRead 不要求 PLC 初次读取 | |||
| }; | |||
| } | |||
| case ApplicationMode::OnlineRunning: | |||
| { | |||
| return {false, false, true, false, true}; | |||
| // 真机运行态禁止改工程,使用 PLC 数据且禁止软件逻辑重复控制 PLC | |||
| return { | |||
| false, // allowsProjectEditing 禁止编辑工程 | |||
| false, // usesVirtualRegisters 不使用虚拟寄存器 | |||
| true, // usesPlcRegisters 使用 PLC 寄存器 | |||
| false, // runsLogicExecutor 不运行软件逻辑执行器 | |||
| true // requiresInitialPlcRead 必须先完成 PLC 初次读取 | |||
| }; | |||
| } | |||
| default: | |||
| { | |||
| @@ -21,9 +21,11 @@ int main(int argc, char *argv[]) | |||
| application.setApplicationName(QObject::tr("综合平台编程器")); | |||
| application.setOrganizationName(QStringLiteral("QtProXinJe")); | |||
| // 组合根负责创建具体实现并将抽象依赖注入服务和 UI | |||
| JsonProjectStorage project_storage; | |||
| ProjectService project_service(project_storage); | |||
| HmiEditorService hmi_editor_service(project_service); | |||
| // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 | |||
| VirtualRegisterRepository virtual_register_repository; | |||
| HmiRuntimeService hmi_runtime_service(virtual_register_repository); | |||
| RuntimeModeService runtime_mode_service; | |||
| @@ -83,6 +83,7 @@ HmiEditorService::HmiEditorService(ProjectService &project_service) | |||
| { | |||
| } | |||
| // 遍历工程所有 HmiPage,根据页面 ID 查找页面,找不到返回空指针 | |||
| const HmiPage *HmiEditorService::findPage(const std::string &page_id) const | |||
| { | |||
| const Project &project = project_service_.project(); | |||
| @@ -96,6 +97,7 @@ const HmiPage *HmiEditorService::findPage(const std::string &page_id) const | |||
| return page == project.hmiPages.cend() ? nullptr : &*page; | |||
| } | |||
| // 先找到页面 → 在页面内查找指定控件;页面不存在 / 控件不存在都返回 nullptr | |||
| const HmiControl *HmiEditorService::findControl( | |||
| const std::string &page_id, const std::string &control_id) const | |||
| { | |||
| @@ -114,12 +116,14 @@ const HmiControl *HmiEditorService::findControl( | |||
| return control == page->controls.cend() ? nullptr : &*control; | |||
| } | |||
| // 获取第一个页面 ID;没有页面返回空字符串 | |||
| std::string HmiEditorService::firstPageId() const | |||
| { | |||
| const Project &project = project_service_.project(); | |||
| return project.hmiPages.empty() ? std::string{} : project.hmiPages.front().id; | |||
| } | |||
| // 保证工程至少存在一个 HMI 页面 | |||
| HmiEditorResult HmiEditorService::ensureDefaultPage() | |||
| { | |||
| if (!project_service_.project().hmiPages.empty()) | |||
| @@ -127,6 +131,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() | |||
| return {true, HmiEditorError::None, {}, firstPageId()}; | |||
| } | |||
| // 仅在首个控件操作前创建默认页面,空工程仍可正常保存 | |||
| HmiPage page; | |||
| page.id = "page-1"; | |||
| page.name = "主操作页面"; | |||
| @@ -145,6 +150,7 @@ HmiEditorResult HmiEditorService::addControl( | |||
| return failure(HmiEditorError::PageNotFound, "HMI page was not found"); | |||
| } | |||
| // 新控件初始不绑定寄存器,避免自动分配地址造成误写风险 | |||
| HmiControl control = makeControl(*page, type); | |||
| Project &project = project_service_.editProject(); | |||
| auto target_page = std::find_if( | |||
| @@ -193,6 +199,7 @@ HmiEditorResult HmiEditorService::removeControl( | |||
| return {true, HmiEditorError::None, {}, control_id}; | |||
| } | |||
| // 移动 / 缩放控件 | |||
| HmiEditorResult HmiEditorService::moveControl( | |||
| const std::string &page_id, | |||
| const std::string &control_id, | |||
| @@ -209,8 +216,10 @@ HmiEditorResult HmiEditorService::moveControl( | |||
| return failure(HmiEditorError::ControlNotFound, "HMI control was not found"); | |||
| } | |||
| // 先校验候选位置,失败时不修改工程模型 | |||
| HmiControl candidate = *control; | |||
| candidate.bounds = bounds; | |||
| // 更新前保留原控件,只有全部编辑规则通过才覆盖原值 | |||
| std::string error; | |||
| if (!validateEditableControl(*page, candidate, &error)) | |||
| { | |||
| @@ -228,6 +237,7 @@ HmiEditorResult HmiEditorService::moveControl( | |||
| return {true, HmiEditorError::None, {}, control_id}; | |||
| } | |||
| // 更新控件全部属性 | |||
| HmiEditorResult HmiEditorService::updateControl( | |||
| const std::string &page_id, | |||
| const std::string &control_id, | |||
| @@ -327,10 +337,11 @@ bool HmiEditorService::hasDuplicateControlId( | |||
| page.controls.cbegin(), page.controls.cend(), | |||
| [&excluded_id, &candidate_id](const HmiControl &item) | |||
| { | |||
| return item.id != excluded_id && item.id == candidate_id; | |||
| return item.id != excluded_id && item.id == candidate_id; // 如果这个控件不是正在编辑的原控件,并且它的 ID 等于准备使用的新 ID | |||
| }); | |||
| } | |||
| // 根据控件类型生成默认样式、文字、大小、初始坐标 | |||
| HmiControl HmiEditorService::makeControl( | |||
| const HmiPage &page, HmiControlType type) | |||
| { | |||
| @@ -346,9 +357,11 @@ HmiControl HmiEditorService::makeControl( | |||
| return control; | |||
| } | |||
| // 生成页面内不重复控件 ID,前缀 + 自增数字 | |||
| std::string HmiEditorService::makeUniqueId( | |||
| const HmiPage &page, const std::string &prefix) | |||
| { | |||
| // 同类控件从 1 递增命名,保证页面内标识稳定且唯一 | |||
| int suffix = 1; | |||
| while (true) | |||
| { | |||
| @@ -118,18 +118,47 @@ public: | |||
| const HmiControl &control); | |||
| private: | |||
| /** | |||
| * @brief 校验控件编辑后仍满足页面内的基础约束 | |||
| * @param page 控件所属页面 | |||
| * @param control 待校验控件 | |||
| * @param error 校验失败时写入原因,可为 nullptr | |||
| * @return 控件标识、尺寸、位置和寄存器绑定区域均有效时返回 true | |||
| */ | |||
| static bool validateEditableControl( | |||
| const HmiPage &page, | |||
| const HmiControl &control, | |||
| std::string *error); | |||
| /** | |||
| * @brief 检查候选控件标识是否与页面内其他控件重复 | |||
| * @param page 待检查页面 | |||
| * @param excluded_id 更新场景中需要排除的原控件标识 | |||
| * @param candidate_id 待使用的控件标识 | |||
| * @return 存在同标识的其他控件时返回 true | |||
| */ | |||
| static bool hasDuplicateControlId( | |||
| const HmiPage &page, | |||
| const std::string &excluded_id, | |||
| const std::string &candidate_id); | |||
| /** | |||
| * @brief 按控件类型创建带默认属性的新控件 | |||
| * @param page 新控件所属页面,用于确定初始位置和唯一标识 | |||
| * @param type 新控件类型 | |||
| * @return 未绑定寄存器的默认控件配置 | |||
| */ | |||
| static HmiControl makeControl( | |||
| const HmiPage &page, HmiControlType type); | |||
| /** | |||
| * @brief 在页面内生成带指定前缀的唯一控件标识 | |||
| * @param page 待检查页面 | |||
| * @param prefix 控件类型对应的标识前缀 | |||
| * @return 从 1 开始递增且不与现有控件重复的标识 | |||
| */ | |||
| static std::string makeUniqueId( | |||
| const HmiPage &page, const std::string &prefix); | |||
| /** | |||
| * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | |||
| */ | |||
| ProjectService &project_service_; | |||
| }; | |||
| @@ -32,6 +32,7 @@ HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | |||
| HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) const | |||
| { | |||
| // 标签等静态控件不参与寄存器读写 | |||
| if (!isBitControl(control.type) && !isWordControl(control.type)) | |||
| { | |||
| return readFailure(HmiRuntimeError::UnsupportedControl); | |||
| @@ -46,6 +47,7 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||
| } | |||
| if (isBitControl(control.type)) | |||
| { | |||
| // 按控件类型选择仓库的 M 位读取契约 | |||
| const BitReadResult result = repository_.readBit(*control.binding); | |||
| if (!result.succeeded) | |||
| { | |||
| @@ -76,6 +78,7 @@ HmiRuntimeWriteResult HmiRuntimeService::toggleButton(const HmiControl &control) | |||
| { | |||
| return writeFailure(current.error); | |||
| } | |||
| // 先读后取反实现按钮点击切换 M 位语义 | |||
| const RegisterWriteResult result = repository_.writeBit( | |||
| *control.binding, !current.bit_value); | |||
| return result.succeeded | |||
| @@ -106,6 +109,7 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | |||
| HmiRuntimeError HmiRuntimeService::repositoryError(RegisterError error) | |||
| { | |||
| // 领域仓库错误被收敛为 HMI 可理解的运行错误 | |||
| switch (error) | |||
| { | |||
| case RegisterError::InvalidAddress: | |||
| @@ -17,11 +17,11 @@ | |||
| */ | |||
| enum class HmiRuntimeError | |||
| { | |||
| None, | |||
| UnsupportedControl, | |||
| MissingBinding, | |||
| InvalidBinding, | |||
| RepositoryFailure | |||
| None, // 无错误 | |||
| UnsupportedControl, // 控件类型不支持寄存器读写 | |||
| MissingBinding, // 控件未配置寄存器绑定 | |||
| InvalidBinding, // 控件绑定地址无效或地址区域不匹配 | |||
| RepositoryFailure // 寄存器仓库读取或写入失败 | |||
| }; | |||
| /** | |||
| @@ -9,7 +9,7 @@ | |||
| namespace { | |||
| // 使用高精度时间戳和随机值生成工程标识 | |||
| // 生成唯一工程 ID:project-时间戳-随机数 | |||
| std::string generateProjectId() | |||
| { | |||
| const auto timestamp = static_cast<unsigned long long>( | |||
| @@ -51,6 +51,7 @@ bool ProjectService::hasCurrentFile() const | |||
| return !current_file_path_.empty(); | |||
| } | |||
| // 查询是否存在未保存修改 | |||
| bool ProjectService::isModified() const | |||
| { | |||
| return modified_; | |||
| @@ -82,6 +83,7 @@ ProjectOperationResult ProjectService::save() | |||
| ProjectStorageError::None, | |||
| "project file path is required"}; | |||
| } | |||
| // 复用另存为流程,确保两种保存方式拥有相同的校验和错误处理 | |||
| return saveAs(current_file_path_); | |||
| } | |||
| @@ -149,15 +151,18 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) | |||
| return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | |||
| } | |||
| // 创建一个全新空白工程实例 | |||
| Project ProjectService::makeNewProject(const std::string &name) | |||
| { | |||
| Project project; | |||
| project.metadata.id = generateProjectId(); | |||
| project.metadata.name = name; | |||
| // 新工程固定使用当前存储格式版本 | |||
| project.metadata.formatVersion = "1.0"; | |||
| return project; | |||
| } | |||
| // 判断字符串是不是空白:空字符串 / 全是空格、制表符都算空白 | |||
| bool ProjectService::isBlank(const std::string &value) | |||
| { | |||
| return value.empty() | |||
| @@ -170,6 +175,7 @@ bool ProjectService::isBlank(const std::string &value) | |||
| }); | |||
| } | |||
| // 包装存储层失败结果 | |||
| ProjectOperationResult ProjectService::storageFailure( | |||
| ProjectStorageError error, const std::string &message) | |||
| { | |||
| @@ -30,11 +30,13 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||
| ModeTransitionResult RuntimeModeService::enterOnlineRunning() | |||
| { | |||
| // 首次 PLC 读取状态由服务维护,再交给领域状态机统一裁决 | |||
| return state_.enterOnlineRunning(initial_plc_read_completed_); | |||
| } | |||
| void RuntimeModeService::setInitialPlcReadCompleted(bool completed) | |||
| { | |||
| // 通信服务完成有效读回后才允许把此标志设为 true | |||
| initial_plc_read_completed_ = completed; | |||
| } | |||
| @@ -14,15 +14,44 @@ | |||
| class RuntimeModeService | |||
| { | |||
| public: | |||
| /** | |||
| * @brief 获取当前应用运行模式 | |||
| * @return 当前的编辑、离线运行或真机运行模式 | |||
| */ | |||
| ApplicationMode mode() const; | |||
| /** | |||
| * @brief 获取当前模式下允许的工程编辑和寄存器使用策略 | |||
| * @return 与当前运行模式对应的能力策略 | |||
| */ | |||
| ModePolicy policy() const; | |||
| /** | |||
| * @brief 退出离线或真机运行态并返回编辑态 | |||
| * @return 已处于编辑态或状态机拒绝切换时返回失败结果 | |||
| */ | |||
| ModeTransitionResult enterEditing(); | |||
| /** | |||
| * @brief 从编辑态进入离线运行态 | |||
| * @return 非编辑态进入时返回必须先回到编辑态的失败结果 | |||
| */ | |||
| ModeTransitionResult enterOfflineRunning(); | |||
| /** | |||
| * @brief 从编辑态进入真机运行态 | |||
| * @return 未完成 PLC 初次读取或非编辑态进入时返回失败结果 | |||
| */ | |||
| ModeTransitionResult enterOnlineRunning(); | |||
| // 由后续 PLC 通信服务在首次读取成功或缓存失效时更新 | |||
| /** | |||
| * @brief 更新 PLC 是否已完成首次有效读取 | |||
| * @param completed 为 true 时允许在编辑态进入真机运行模式 | |||
| * | |||
| * 由 PLC 通信服务在首次读取成功或缓存失效时调用 | |||
| */ | |||
| void setInitialPlcReadCompleted(bool completed); | |||
| /** | |||
| * @brief 查询 PLC 是否已完成首次有效读取 | |||
| * @return 已建立可用于进入真机运行态的 PLC 缓存时返回 true | |||
| */ | |||
| bool initialPlcReadCompleted() const; | |||
| private: | |||
| @@ -20,9 +20,11 @@ | |||
| namespace { | |||
| // 将领域层 HMI 控件投影为可绘制、可选择和可交互的场景图元 | |||
| class HmiGraphicsItem final : public QGraphicsItem | |||
| { | |||
| public: | |||
| // 保存控件快照、页面边界和回调,使图元不直接依赖服务层 | |||
| HmiGraphicsItem( | |||
| const HmiControl &control, | |||
| int page_width, | |||
| @@ -35,11 +37,17 @@ public: | |||
| moved_(std::move(moved)), | |||
| activated_(std::move(activated)) | |||
| { | |||
| // 领域坐标直接作为图元在场景中的初始位置 | |||
| setPos(control_.bounds.x, control_.bounds.y); | |||
| // 所有控件均可选中,便于主窗口显示对应属性 | |||
| setFlag(ItemIsSelectable, true); | |||
| // 开启可选中 | |||
| setFlag(ItemSendsGeometryChanges, true); | |||
| // 画布只处理左键交互,保留其他按键给视图默认行为 | |||
| setAcceptedMouseButtons(Qt::LeftButton); | |||
| } | |||
| // 返回图元自身坐标系中的矩形范围,用于绘制和命中测试 | |||
| QRectF boundingRect() const override | |||
| { | |||
| return {0, | |||
| @@ -53,6 +61,7 @@ public: | |||
| const QStyleOptionGraphicsItem *option, | |||
| QWidget *) override | |||
| { | |||
| // 留出一个像素边距,避免描边被图元边界裁剪 | |||
| const QRectF rect = boundingRect().adjusted(1, 1, -1, -1); | |||
| painter->setRenderHint(QPainter::Antialiasing, true); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | |||
| @@ -61,6 +70,7 @@ public: | |||
| { | |||
| case HmiControlType::Button: | |||
| { | |||
| // 按钮显示文字,运行态点击行为由鼠标事件处理 | |||
| painter->setBrush(QColor(QStringLiteral("#dcece3"))); | |||
| painter->drawRoundedRect(rect, 4, 4); | |||
| painter->setPen(QColor(QStringLiteral("#205c3b"))); | |||
| @@ -69,6 +79,7 @@ public: | |||
| } | |||
| case HmiControlType::Indicator: | |||
| { | |||
| // 指示灯颜色由最近一次读取到的 M 位值决定 | |||
| const qreal diameter = std::min(rect.width(), rect.height() - 18.0); | |||
| const QRectF lamp( | |||
| rect.center().x() - diameter / 2.0, | |||
| @@ -88,6 +99,7 @@ public: | |||
| } | |||
| case HmiControlType::NumericDisplay: | |||
| { | |||
| // 数值显示为只读样式,文本由运行值刷新 | |||
| painter->setBrush(QColor(QStringLiteral("#edf2f6"))); | |||
| painter->drawRect(rect); | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| @@ -98,6 +110,7 @@ public: | |||
| } | |||
| case HmiControlType::NumericInput: | |||
| { | |||
| // 数值输入以白色编辑框样式呈现,双击后才请求写入 | |||
| painter->setBrush(QColor(QStringLiteral("#ffffff"))); | |||
| painter->drawRoundedRect(rect, 3, 3); | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| @@ -109,6 +122,7 @@ public: | |||
| case HmiControlType::Label: | |||
| default: | |||
| { | |||
| // 标签只显示固定文本,不绑定寄存器运行值 | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->drawText(rect, Qt::AlignCenter, | |||
| QString::fromUtf8(control_.text.data(), | |||
| @@ -119,22 +133,26 @@ public: | |||
| if ((option->state & QStyle::State_Selected) != 0) | |||
| { | |||
| // 选中框独立于控件类型,提示当前可编辑对象 | |||
| painter->setBrush(Qt::NoBrush); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2)); | |||
| painter->drawRect(boundingRect().adjusted(0, 0, -1, -1)); | |||
| } | |||
| } | |||
| // 向场景和外部控件返回该图元对应的领域控件标识 | |||
| const std::string &controlId() const | |||
| { | |||
| return control_.id; | |||
| } | |||
| // 编辑态允许拖动,运行态保留点击或双击交互 | |||
| void setInteractionEnabled(bool editable) | |||
| { | |||
| setFlag(ItemIsMovable, editable); | |||
| } | |||
| // 缓存运行服务读出的值并触发 Qt 重绘 | |||
| void setRuntimeValue(bool bit_value, std::int16_t word_value, bool available) | |||
| { | |||
| bit_value_ = bit_value; | |||
| @@ -144,10 +162,13 @@ public: | |||
| } | |||
| protected: | |||
| // 拖拽过程中将新位置限制在页面可见边界内 | |||
| QVariant itemChange(GraphicsItemChange change, const QVariant &value) override | |||
| { | |||
| // 只有开启 ItemIsMovable 拖拽时,才做坐标钳位 | |||
| if (change == ItemPositionChange && flags().testFlag(ItemIsMovable)) | |||
| { | |||
| // 在图元层预先截断拖拽坐标,避免控件视觉上越出页面 | |||
| QPointF position = value.toPointF(); | |||
| const qreal maximum_x = std::max( | |||
| 0.0, static_cast<double>(page_width_ - control_.bounds.width)); | |||
| @@ -160,24 +181,30 @@ protected: | |||
| return QGraphicsItem::itemChange(change, value); | |||
| } | |||
| // 运行态点击按钮时通知外层执行 M 位切换 | |||
| void mousePressEvent(QGraphicsSceneMouseEvent *event) override | |||
| { | |||
| // 条件:!ItemIsMovable 【也就是运行模式】 + 是按钮 + 有回调 | |||
| if (!flags().testFlag(ItemIsMovable) | |||
| && control_.type == HmiControlType::Button && activated_) | |||
| { | |||
| // 运行态单击按钮才触发 M 位切换,编辑态只用于选择和拖动 | |||
| setSelected(true); | |||
| activated_(control_.id); | |||
| event->accept(); | |||
| return; | |||
| } | |||
| // 编辑模式:不进if分支,执行基类事件——只做选中、拖拽 | |||
| QGraphicsItem::mousePressEvent(event); | |||
| } | |||
| // 运行态双击数值输入时通知外层弹出数值编辑对话框 | |||
| void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override | |||
| { | |||
| if (!flags().testFlag(ItemIsMovable) | |||
| && control_.type == HmiControlType::NumericInput && activated_) | |||
| { | |||
| // 数值输入使用双击,避免普通选择操作意外写入 D 字 | |||
| setSelected(true); | |||
| activated_(control_.id); | |||
| event->accept(); | |||
| @@ -186,9 +213,11 @@ protected: | |||
| QGraphicsItem::mouseDoubleClickEvent(event); | |||
| } | |||
| // 拖拽结束后才提交最终坐标,避免移动过程频繁修改领域模型 | |||
| void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override | |||
| { | |||
| QGraphicsItem::mouseReleaseEvent(event); | |||
| // 只有ItemIsMovable打开(编辑态),松开鼠标才提交位置给业务层 | |||
| if (flags().testFlag(ItemIsMovable) && moved_) | |||
| { | |||
| moved_(control_.id, pos()); | |||
| @@ -196,6 +225,7 @@ protected: | |||
| } | |||
| private: | |||
| // 根据控件类型和运行数据组合当前应绘制的文字 | |||
| QString textWithValue() const | |||
| { | |||
| const QString text = QString::fromUtf8( | |||
| @@ -212,16 +242,25 @@ private: | |||
| return text; | |||
| } | |||
| // 图元创建时的领域控件快照,提供类型、尺寸、文本和标识 | |||
| HmiControl control_; | |||
| // 当前页面宽度,用于限制图元横向拖拽范围 | |||
| int page_width_ = 0; | |||
| // 当前页面高度,用于限制图元纵向拖拽范围 | |||
| int page_height_ = 0; | |||
| // 拖拽完成后回调 HmiEditorWidget 提交控件新位置 | |||
| std::function<void(const std::string &, const QPointF &)> moved_; | |||
| // 运行模式点击按钮 / 双击输入框回调,通知外层做寄存器读写 | |||
| std::function<void(const std::string &)> activated_; | |||
| // 指示灯读取到的 M 位值 | |||
| bool bit_value_ = false; | |||
| // 数值控件读取到的 D 字值 | |||
| std::int16_t word_value_ = 0; | |||
| // 标记当前缓存值是否来自一次成功的运行时读取 | |||
| bool has_runtime_value_ = false; | |||
| }; | |||
| // 将场景通用图元安全转换为本文件定义的 HMI 控件图元 | |||
| HmiGraphicsItem *asHmiItem(QGraphicsItem *item) | |||
| { | |||
| return dynamic_cast<HmiGraphicsItem *>(item); | |||
| @@ -247,6 +286,7 @@ HmiEditorWidget::HmiEditorWidget( | |||
| this, &HmiEditorWidget::handleSelectionChanged); | |||
| } | |||
| // 切换显示页面 | |||
| void HmiEditorWidget::setPageId(const std::string &page_id) | |||
| { | |||
| if (page_id_ == page_id) | |||
| @@ -280,8 +320,10 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||
| } | |||
| } | |||
| // 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形 | |||
| void HmiEditorWidget::reloadPage() | |||
| { | |||
| // 画布始终从当前领域页面重建,避免保留已删除控件的图元 | |||
| scene_->clear(); | |||
| const HmiPage *page = editor_service_.findPage(page_id_); | |||
| if (page == nullptr) | |||
| @@ -318,6 +360,7 @@ void HmiEditorWidget::reloadPage() | |||
| refreshRuntimeValues(); | |||
| } | |||
| // 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来 | |||
| void HmiEditorWidget::selectControl(const std::string &control_id) | |||
| { | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| @@ -365,6 +408,7 @@ void HmiEditorWidget::refreshRuntimeValues() | |||
| { | |||
| continue; | |||
| } | |||
| // 运行值通过服务读取,图元不直接接触寄存器仓库 | |||
| const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | |||
| control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded); | |||
| } | |||
| @@ -389,6 +433,7 @@ void HmiEditorWidget::fitCurrentPage() | |||
| void HmiEditorWidget::updateItemInteractions() | |||
| { | |||
| // 遍历所有控件图元,统一设置flag | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| HmiGraphicsItem *control_item = asHmiItem(item); | |||
| @@ -412,6 +457,7 @@ void HmiEditorWidget::handleControlMoved( | |||
| { | |||
| return; | |||
| } | |||
| // 鼠标坐标取整后再交给服务校验并写回模型 | |||
| HmiRect bounds = control->bounds; | |||
| bounds.x = static_cast<int>(std::lround(position.x())); | |||
| bounds.y = static_cast<int>(std::lround(position.y())); | |||
| @@ -438,6 +484,7 @@ void HmiEditorWidget::handleControlActivated(const std::string &control_id) | |||
| return; | |||
| } | |||
| HmiRuntimeWriteResult result; | |||
| // 只有可写控件允许激活,其余控件只展示最新运行值 | |||
| if (control->type == HmiControlType::Button) | |||
| { | |||
| result = runtime_service_.toggleButton(*control); | |||
| @@ -92,16 +92,27 @@ protected: | |||
| void resizeEvent(QResizeEvent *event) override; | |||
| private: | |||
| // 按页面尺寸缩放视图,保证完整画布保持可见 | |||
| void fitCurrentPage(); | |||
| // 将当前编辑开关同步到所有控件图元 | |||
| void updateItemInteractions(); | |||
| // 将场景选择转换为控件标识并通知属性面板 | |||
| void handleSelectionChanged(); | |||
| // 将图元拖动后的坐标提交给 HMI 编辑服务 | |||
| void handleControlMoved(const std::string &control_id, const QPointF &position); | |||
| // 在运行态处理按钮点击或数值输入激活 | |||
| void handleControlActivated(const std::string &control_id); | |||
| // 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 | |||
| HmiEditorService &editor_service_; | |||
| // 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 | |||
| HmiRuntimeService &runtime_service_; | |||
| // 持有所有页面图元和页面边框 | |||
| QGraphicsScene *scene_ = nullptr; | |||
| // 当前画布投影的页面标识 | |||
| std::string page_id_; | |||
| // 控制是否允许拖动和编辑控件 | |||
| bool editing_enabled_ = true; | |||
| // 控制是否刷新运行值并响应运行态控件操作 | |||
| bool runtime_active_ = false; | |||
| }; | |||
| @@ -134,10 +134,10 @@ MainWindow::MainWindow( | |||
| hmi_runtime_service_(hmi_runtime_service) | |||
| { | |||
| ui_->setupUi(this); | |||
| configureAppearance(); | |||
| configureActions(); | |||
| configurePropertyEditor(); | |||
| configureHmiEditor(); | |||
| configureAppearance(); // 窗口样式、布局、状态栏标签初始化,纯视觉代码,无业务逻辑 | |||
| configureActions(); // 创建所有菜单 Action、工具栏按钮,绑定点击槽函数 | |||
| configurePropertyEditor(); // 动态构建属性表单:ID、文本、坐标宽高、寄存器区域 (M/D)、地址索引 + 应用按钮 | |||
| configureHmiEditor(); // 创建画布控件HmiEditorWidget嵌入主窗口,绑定画布信号:选中控件、控件拖动修改、编辑器报错;同时启动运行刷新定时器 | |||
| hmi_editor_service_.ensureDefaultPage(); | |||
| refreshProjectUi(); | |||
| updateModeUi(tr("系统已进入编辑态")); | |||
| @@ -290,12 +290,14 @@ void MainWindow::configureHmiEditor() | |||
| connect(hmi_editor_widget_, &HmiEditorWidget::editorError, | |||
| this, | |||
| [this](const QString &message) { statusBar()->showMessage(message, 5000); }); | |||
| // 运行态定时投影仓库最新值,编辑态不触发寄存器读取 | |||
| runtime_refresh_timer_ = new QTimer(this); | |||
| runtime_refresh_timer_->setInterval(150); | |||
| connect(runtime_refresh_timer_, &QTimer::timeout, | |||
| this, | |||
| [this] | |||
| { | |||
| // 离线 / 联机运行模式 | |||
| if (runtime_mode_service_.mode() != ApplicationMode::Editing) | |||
| { | |||
| hmi_editor_widget_->refreshRuntimeValues(); | |||
| @@ -352,6 +354,7 @@ void MainWindow::configurePropertyEditor() | |||
| void MainWindow::refreshProjectUi() | |||
| { | |||
| // 工程树只展示模型摘要,画布内容由 HmiEditorWidget 单独投影 | |||
| const std::string current_page_id = hmi_editor_service_.firstPageId(); | |||
| if (hmi_editor_widget_ != nullptr) | |||
| { | |||
| @@ -376,14 +379,23 @@ void MainWindow::refreshProjectUi() | |||
| ui_->projectTree->expandAll(); | |||
| } | |||
| // 根据控件ID加载控件属性到右侧属性面板 | |||
| void MainWindow::showControlProperties(const std::string &control_id) | |||
| { | |||
| // 更新窗口全局状态:记录当前选中控件ID | |||
| selected_control_id_ = control_id; | |||
| // 在第一页画面中,根据ID查找目标控件,获取只读模型指针 | |||
| const HmiControl *control = hmi_editor_service_.findControl( | |||
| hmi_editor_service_.firstPageId(), control_id); | |||
| // 判断是否查询到有效控件 | |||
| const bool has_control = control != nullptr; | |||
| // 更新属性面板顶部状态标签,显示选中控件ID或提示未选择 | |||
| ui_->selectionValueLabel->setText( | |||
| has_control ? fromUtf8(control->id) : tr("未选择")); | |||
| // 批量控制所有属性编辑组件可用性:无选中控件时整体置灰,禁止编辑 | |||
| for (QWidget *widget : {static_cast<QWidget *>(control_id_edit_), | |||
| static_cast<QWidget *>(control_text_edit_), | |||
| static_cast<QWidget *>(control_x_spin_box_), | |||
| @@ -396,21 +408,33 @@ void MainWindow::showControlProperties(const std::string &control_id) | |||
| { | |||
| widget->setEnabled(has_control); | |||
| } | |||
| // 未选中有效控件,无需填充属性,直接退出 | |||
| if (!has_control) | |||
| { | |||
| return; | |||
| } | |||
| // 将控件基础信息填充至UI输入框 | |||
| control_id_edit_->setText(fromUtf8(control->id)); | |||
| control_text_edit_->setText(fromUtf8(control->text)); | |||
| // 控件位置坐标 | |||
| control_x_spin_box_->setValue(control->bounds.x); | |||
| control_y_spin_box_->setValue(control->bounds.y); | |||
| // 控件宽高尺寸 | |||
| control_width_spin_box_->setValue(control->bounds.width); | |||
| control_height_spin_box_->setValue(control->bounds.height); | |||
| // 根据控件类型获取默认寄存器区域:按钮/指示灯默认M区;数值类控件默认D区 | |||
| const RegisterArea default_area = usesMAddress(control->type) | |||
| ? RegisterArea::M : RegisterArea::D; | |||
| // 优先使用控件已绑定的寄存器区域;无绑定则使用类型对应的默认区域 | |||
| const RegisterArea area = control->binding.has_value() | |||
| ? control->binding->area() : default_area; | |||
| // 同步寄存器区域下拉框选中项(0=M,1=D,与combo初始化顺序严格对应) | |||
| binding_area_combo_box_->setCurrentIndex(area == RegisterArea::M ? 0 : 1); | |||
| // 填充寄存器地址索引;无绑定时默认地址为0 | |||
| binding_index_spin_box_->setValue( | |||
| control->binding.has_value() ? control->binding->index() : 0); | |||
| } | |||
| @@ -458,6 +482,7 @@ void MainWindow::applySelectedControlProperties() | |||
| { | |||
| return; | |||
| } | |||
| // 从现有控件复制不可见属性,避免属性面板覆盖扩展配置 | |||
| HmiControl control = *old_control; | |||
| control.id = toUtf8(control_id_edit_->text()); | |||
| control.text = toUtf8(control_text_edit_->text()); | |||
| @@ -566,6 +591,7 @@ void MainWindow::showProjectResult( | |||
| void MainWindow::requestMode(ApplicationMode requested_mode) | |||
| { | |||
| // UI 仅转发模式意图,合法性由服务层和领域状态机决定 | |||
| ModeTransitionResult result; | |||
| switch (requested_mode) | |||
| { | |||
| @@ -605,6 +631,7 @@ void MainWindow::updateModeUi(const QString &message) | |||
| const ApplicationMode mode = runtime_mode_service_.mode(); | |||
| const ModePolicy policy = runtime_mode_service_.policy(); | |||
| restoreCurrentModeAction(); | |||
| // 将同一份模式策略同步到所有可编辑入口,避免只禁用部分操作 | |||
| ui_->projectDock->setEnabled(policy.allowsProjectEditing); | |||
| ui_->propertiesDock->setEnabled(policy.allowsProjectEditing); | |||
| hmi_editor_widget_->setEditingEnabled(policy.allowsProjectEditing); | |||
| @@ -646,6 +673,7 @@ void MainWindow::updateModeUi(const QString &message) | |||
| ui_->outputList->scrollToBottom(); | |||
| } | |||
| // 根据当前真实运行模式,同步更新模式工具栏单选按钮选中状态 | |||
| void MainWindow::restoreCurrentModeAction() | |||
| { | |||
| ui_->editingModeAction->setChecked(runtime_mode_service_.mode() | |||
| @@ -65,19 +65,33 @@ public: | |||
| ~MainWindow() override; | |||
| private: | |||
| // 配置菜单和工具栏动作 | |||
| void configureActions(); | |||
| // 配置主窗口外观和状态栏 | |||
| void configureAppearance(); | |||
| // 创建并连接 HMI 编辑画布 | |||
| void configureHmiEditor(); | |||
| // 创建并连接控件属性编辑表单 | |||
| void configurePropertyEditor(); | |||
| // 刷新工程树和当前 HMI 页面信息 | |||
| void refreshProjectUi(); | |||
| // 显示指定 HMI 控件的可编辑属性 | |||
| void showControlProperties(const std::string &control_id); | |||
| // 向当前页面添加指定类型的 HMI 控件 | |||
| void addHmiControl(HmiControlType type); | |||
| // 删除当前选中的 HMI 控件 | |||
| void deleteSelectedControl(); | |||
| // 将属性表单内容应用到当前选中控件 | |||
| void applySelectedControlProperties(); | |||
| // 创建新的工程并刷新编辑界面 | |||
| void createNewProject(); | |||
| // 保存当前工程到已关联的路径 | |||
| void saveProject(); | |||
| // 将当前工程保存到用户指定的路径 | |||
| void saveProjectAs(); | |||
| // 从用户指定的路径加载工程 | |||
| void loadProject(); | |||
| // 在状态栏和输出面板显示工程操作结果 | |||
| void showProjectResult(const QString &action, const QString &message, bool succeeded); | |||
| /** | |||
| @@ -23,6 +23,7 @@ void require(bool condition, const std::string &message) | |||
| void testRegisterAddressBoundaries() | |||
| { | |||
| // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径 | |||
| require(RegisterAddress{RegisterArea::M, 0}.isValid(), | |||
| "M0 must be valid"); | |||
| require(RegisterAddress{RegisterArea::D, 4000}.isValid(), | |||
| @@ -37,6 +38,7 @@ void testRegisterAddressBoundaries() | |||
| void testRegisterRepositorySeparatesAreas() | |||
| { | |||
| // 验证离线仓库不会把 M 位和 D 字交叉解释 | |||
| VirtualRegisterRepository repository; | |||
| const RegisterAddress m0{RegisterArea::M, 0}; | |||
| const RegisterAddress d0{RegisterArea::D, 0}; | |||
| @@ -54,6 +56,7 @@ void testRegisterRepositorySeparatesAreas() | |||
| Project makeValidProject() | |||
| { | |||
| // 构造包含 HMI 绑定和逻辑连接的最小合法工程作为测试基线 | |||
| HmiControl start_button; | |||
| start_button.id = "start-button"; | |||
| start_button.type = HmiControlType::Button; | |||
| @@ -92,6 +95,7 @@ Project makeValidProject() | |||
| void testLogicNodeConfigurationBoundaries() | |||
| { | |||
| // 触点只能绑定 M 区,数值比较只能绑定 D 区 | |||
| LogicNode contact; | |||
| contact.id = "contact"; | |||
| contact.config = ContactNodeConfig{ | |||
| @@ -115,6 +119,7 @@ void testLogicNodeConfigurationBoundaries() | |||
| void testModelsValidateBindingsAndIdentifiers() | |||
| { | |||
| // 聚合验证必须拒绝错误绑定、重复标识和越界控件 | |||
| Project project = makeValidProject(); | |||
| require(project.validate(), "valid project model must pass validation"); | |||
| @@ -141,6 +146,7 @@ void testModelsValidateBindingsAndIdentifiers() | |||
| void testRuntimeStateBoundaries() | |||
| { | |||
| // 运行模式测试覆盖离线和真机的互斥及 PLC 首读前置条件 | |||
| RuntimeState state; | |||
| require(state.policy().allowsProjectEditing, "editing mode must allow project editing"); | |||
| require(state.enterOfflineRunning().succeeded, "editing may enter offline running"); | |||
| @@ -167,6 +173,7 @@ int main() | |||
| { | |||
| try | |||
| { | |||
| // 每个测试函数独立覆盖一个领域边界,首个异常即终止测试进程 | |||
| testRegisterAddressBoundaries(); | |||
| testRegisterRepositorySeparatesAreas(); | |||
| testLogicNodeConfigurationBoundaries(); | |||
| @@ -14,6 +14,7 @@ namespace { | |||
| class TestProjectStorage final : public ProjectStorage | |||
| { | |||
| public: | |||
| // 编辑服务测试只关注内存模型变更,不依赖真实文件系统 | |||
| ProjectSaveResult save(const Project &, const std::string &) override | |||
| { | |||
| return {true, ProjectStorageError::None, {}}; | |||
| @@ -35,6 +36,7 @@ void require(bool condition, const std::string &message) | |||
| void testControlEditing() | |||
| { | |||
| // 覆盖控件创建、移动、绑定校验、重命名冲突和删除流程 | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| HmiEditorService service(project_service); | |||
| @@ -81,6 +83,7 @@ void testControlEditing() | |||
| void testRuntimeUsesRegisterRepository() | |||
| { | |||
| // 运行服务只能经由仓库接口读写 M/D,不依赖具体离线实现 | |||
| VirtualRegisterRepository repository; | |||
| HmiRuntimeService runtime_service(repository); | |||
| @@ -123,6 +126,7 @@ int main() | |||
| { | |||
| try | |||
| { | |||
| // 编辑和运行场景分别验证服务层两条独立职责 | |||
| testControlEditing(); | |||
| testRuntimeUsesRegisterRepository(); | |||
| } | |||
| @@ -22,6 +22,7 @@ namespace { | |||
| class TestProjectStorage final : public ProjectStorage | |||
| { | |||
| public: | |||
| // 主窗口测试使用无副作用存储实现,隔离文件对 UI 行为的影响 | |||
| ProjectSaveResult save(const Project &, const std::string &) override | |||
| { | |||
| return {true, ProjectStorageError::None, {}}; | |||
| @@ -44,6 +45,7 @@ void require(bool condition, const std::string &message) | |||
| template<typename ObjectType> | |||
| ObjectType *requiredChild(MainWindow &window, const char *name) | |||
| { | |||
| // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息 | |||
| ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name)); | |||
| require(child != nullptr, std::string("missing UI object ") + name); | |||
| return child; | |||
| @@ -51,6 +53,7 @@ ObjectType *requiredChild(MainWindow &window, const char *name) | |||
| void testModeActionsControlEditingAvailability() | |||
| { | |||
| // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择 | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| HmiEditorService editor_service(project_service); | |||
| @@ -134,6 +137,7 @@ void testModeActionsControlEditingAvailability() | |||
| int main(int argc, char *argv[]) | |||
| { | |||
| // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行 | |||
| qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); | |||
| QApplication application(argc, argv); | |||
| @@ -22,6 +22,7 @@ void require(bool condition, const std::string &message) | |||
| Project makeExampleProject() | |||
| { | |||
| // 构造覆盖四种 HMI 控件和三种逻辑节点的完整 JSON 往返样本 | |||
| HmiControl start_button; | |||
| start_button.id = "start-button"; | |||
| start_button.type = HmiControlType::Button; | |||
| @@ -98,6 +99,7 @@ Project makeExampleProject() | |||
| void writeText(const QString &path, const QByteArray &content) | |||
| { | |||
| // 直接写入故障样本文件,以验证加载失败时的保护行为 | |||
| QFile file(path); | |||
| require(file.open(QIODevice::WriteOnly), "test file must be writable"); | |||
| require(file.write(content) == content.size(), "test file must be written completely"); | |||
| @@ -112,6 +114,7 @@ QByteArray readBytes(const QString &path) | |||
| void testEmptyProjectRoundTrip() | |||
| { | |||
| // 空工程是合法工程,保存再加载后不应凭空产生页面或逻辑 | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| @@ -136,6 +139,7 @@ void testEmptyProjectRoundTrip() | |||
| void testExampleProjectRoundTrip() | |||
| { | |||
| // 验证各层嵌套字段往返后保持不变且序列化结果稳定 | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| @@ -186,6 +190,7 @@ void testExampleProjectRoundTrip() | |||
| void testInvalidFiles() | |||
| { | |||
| // 非法文件必须被拒绝,并且不得覆盖服务中当前工程 | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| @@ -219,6 +224,7 @@ void testInvalidFiles() | |||
| void testServiceStateAndSaveErrors() | |||
| { | |||
| // 保存路径和修改标记只在成功持久化后更新 | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| @@ -252,6 +258,7 @@ int main() | |||
| { | |||
| try | |||
| { | |||
| // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | |||
| testEmptyProjectRoundTrip(); | |||
| testExampleProjectRoundTrip(); | |||
| testInvalidFiles(); | |||
| @@ -16,6 +16,7 @@ void require(bool condition, const std::string &message) | |||
| void testModeTransitions() | |||
| { | |||
| // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 | |||
| RuntimeModeService service; | |||
| require(service.mode() == ApplicationMode::Editing, | |||
| @@ -54,6 +55,7 @@ int main() | |||
| { | |||
| try | |||
| { | |||
| // 运行模式只有这一组状态机边界测试 | |||
| testModeTransitions(); | |||
| } | |||
| catch (const std::exception &error) | |||