41 次程式碼提交

作者 SHA1 備註 提交日期
  suyu 138b092ff2 docs: 补充HMI页面尺寸规则 1 月之前
  suyu 187a499284 feat: 支持HMI页面尺寸设置 1 月之前
  suyu bfb9943b34 fix: 限制HMI控件属性文本长度 1 月之前
  suyu 2fd887169e docs: 补充测试约定和交接记录 1 月之前
  suyu ba6249bd4a test: 统一测试目标和回归脚本 1 月之前
  suyu 0cf37a0b63 fix: 完善逻辑编辑和工程服务 1 月之前
  suyu eb46b9339d docs: 完成真机联动验收与交付资料更新 1 月之前
  suyu e1640f0edc docs: 更新运行监控与界面规则说明 1 月之前
  suyu 0e4a2c3ecd feat: 完善工程师运行监控与界面交互 1 月之前
  suyu 898aaf5e6a fix: 限制梯形图注释为单行文本 1 月之前
  suyu dad40726ba docs: 更新当前开发交接 1 月之前
  suyu 85703f72f9 docs: 补充 HMI 外观工程格式 1 月之前
  suyu f33976b5da feat: 完善梯形图网格连续插入 1 月之前
  suyu 9f4bf2b9b8 feat: 支持 HMI 字体外观属性 1 月之前
  suyu 0914a3ccfb docs: 更新独立HMI运行架构说明 1 月之前
  suyu 45f8db7780 feat: 增加独立HMI运行窗口 1 月之前
  suyu 2468cc6d36 fix: 修复并联支路批量删除残留 1 月之前
  suyu 2000305681 docs: 整理项目入口与数量边界文档 1 月之前
  suyu c68da9ae6b feat: 完善数量边界界面约束 1 月之前
  suyu 6a2b79b477 feat: 统一项目数量边界与梯形图运行语义 1 月之前
  suyu ae9400f1c1 feat: 支持自由监视器单点写入 1 月之前
  suyu c0566a1c0a docs: 补充梯形图布线说明与软件手册 1 月之前
  suyu 2fa8c1f226 feat: 支持梯形图结构化横线与竖线编辑 1 月之前
  suyu 5923bcd5dd feat: 增加工程未保存变更保护 1 月之前
  suyu 4d80ea4147 docs: 更新编辑历史与批量操作说明 1 月之前
  suyu 9c9573ca43 feat: 支持编辑器撤销重做与批量删除 1 月之前
  suyu 7c3a232f77 refactor: 统一工具栏图标与分组 1 月之前
  suyu f2f3fa7358 feat: 增加HMI进度条控件 1 月之前
  suyu 5e55a922b9 docs: 更新界面控制器与HMI注册表架构 1 月之前
  suyu 3b862e7c3c refactor: 统一HMI控件注册与运行能力 1 月之前
  suyu d2643d9a91 refactor: 拆分主窗口界面控制职责 1 月之前
  suyu ded0288b1b docs: 明确离线指令与PLC边界 1 月之前
  suyu aedead25df feat: 增加逻辑指令编辑配置界面 1 月之前
  suyu 1aa9e53b77 feat: 实现计数与字操作离线执行 1 月之前
  suyu ab1457cd68 feat: 增加计数与字操作指令模型 1 月之前
  suyu e03a687406 docs: 更新梯形图与注释功能说明 1 月之前
  suyu 0dc0de3172 feat: 集成梯形图与软元件注释界面 1 月之前
  suyu 610fbb71a2 feat: 完善离线逻辑执行与定时器扫描 1 月之前
  suyu 66a0a2a472 feat: 支持边沿定时器与软元件注释模型 1 月之前
  suyu cda8f0eef0 fix: 修正运行时报警列表显示 1 月之前
  suyu 8ed43bfedb feat: 增加HMI报警管理与运行显示 1 月之前
共有 100 個文件被更改,包括 16398 次插入1957 次删除
分割檢視
  1. +3
    -2
      .gitignore
  2. +65
    -49
      AGENTS.md
  3. +32
    -0
      app/integrated_platform.pro
  4. +65
    -0
      app/src/domain/alarm_model.cpp
  5. +24
    -0
      app/src/domain/alarm_model.h
  6. +684
    -37
      app/src/domain/control_logic_model.cpp
  7. +165
    -2
      app/src/domain/control_logic_model.h
  8. +134
    -0
      app/src/domain/hmi_control_registry.cpp
  9. +68
    -0
      app/src/domain/hmi_control_registry.h
  10. +181
    -45
      app/src/domain/hmi_model.cpp
  11. +33
    -4
      app/src/domain/hmi_model.h
  12. +65
    -0
      app/src/domain/project_limits.h
  13. +176
    -0
      app/src/domain/project_model.cpp
  14. +14
    -0
      app/src/domain/project_model.h
  15. +817
    -77
      app/src/infrastructure/json_project_storage.cpp
  16. +101
    -9
      app/src/infrastructure/plc_communication_service.cpp
  17. +3
    -1
      app/src/infrastructure/plc_communication_service.h
  18. +9
    -0
      app/src/main.cpp
  19. +108
    -0
      app/src/services/alarm_editor_service.cpp
  20. +42
    -0
      app/src/services/alarm_editor_service.h
  21. +95
    -0
      app/src/services/alarm_service.cpp
  22. +38
    -0
      app/src/services/alarm_service.h
  23. +84
    -0
      app/src/services/editor_history.h
  24. +263
    -154
      app/src/services/hmi_editor_service.cpp
  25. +31
    -1
      app/src/services/hmi_editor_service.h
  26. +23
    -16
      app/src/services/hmi_runtime_service.cpp
  27. +1059
    -27
      app/src/services/logic_editor_service.cpp
  28. +73
    -1
      app/src/services/logic_editor_service.h
  29. +3
    -0
      app/src/services/offline_simulation_service.cpp
  30. +59
    -1
      app/src/services/plc_communication_gateway.h
  31. +5
    -0
      app/src/services/project_service.cpp
  32. +2
    -0
      app/src/services/project_service.h
  33. +102
    -0
      app/src/services/register_comment_service.cpp
  34. +37
    -0
      app/src/services/register_comment_service.h
  35. +34
    -0
      app/src/services/register_monitor_service.cpp
  36. +11
    -0
      app/src/services/register_monitor_service.h
  37. +18
    -7
      app/src/services/runtime_mode_service.cpp
  38. +1
    -1
      app/src/services/runtime_mode_service.h
  39. +427
    -20
      app/src/services/software_logic_executor.cpp
  40. +76
    -5
      app/src/services/software_logic_executor.h
  41. +248
    -0
      app/src/ui/alarm_configuration_dialog.cpp
  42. +35
    -0
      app/src/ui/alarm_configuration_dialog.h
  43. +49
    -0
      app/src/ui/alarm_configuration_dialog.ui
  44. +109
    -5
      app/src/ui/free_monitor_widget.cpp
  45. +4
    -0
      app/src/ui/free_monitor_widget.h
  46. +8
    -6
      app/src/ui/free_monitor_widget.ui
  47. +478
    -18
      app/src/ui/hmi_editor_widget.cpp
  48. +5
    -0
      app/src/ui/hmi_editor_widget.h
  49. +965
    -45
      app/src/ui/logic_editor_widget.cpp
  50. +18
    -1
      app/src/ui/logic_editor_widget.h
  51. +207
    -0
      app/src/ui/logic_instruction_dialog.cpp
  52. +43
    -0
      app/src/ui/logic_instruction_dialog.h
  53. +66
    -0
      app/src/ui/logic_instruction_dialog.ui
  54. +744
    -949
      app/src/ui/main_window.cpp
  55. +41
    -23
      app/src/ui/main_window.h
  56. +391
    -33
      app/src/ui/main_window.ui
  57. +1
    -1
      app/src/ui/plc_connection_dialog.ui
  58. +520
    -0
      app/src/ui/project_workspace_controller.cpp
  59. +107
    -0
      app/src/ui/project_workspace_controller.h
  60. +799
    -0
      app/src/ui/property_panel_controller.cpp
  61. +80
    -0
      app/src/ui/property_panel_controller.h
  62. +155
    -0
      app/src/ui/register_comment_dialog.cpp
  63. +34
    -0
      app/src/ui/register_comment_dialog.h
  64. +40
    -0
      app/src/ui/register_comment_dialog.ui
  65. +28
    -5
      app/src/ui/runtime_monitor_widget.cpp
  66. +5
    -1
      app/src/ui/runtime_monitor_widget.h
  67. +1
    -0
      app/src/ui/runtime_monitor_widget.ui
  68. +54
    -0
      app/src/ui/runtime_monitor_window.cpp
  69. +38
    -0
      app/src/ui/runtime_monitor_window.h
  70. +44
    -0
      app/src/ui/runtime_monitor_window.ui
  71. +307
    -0
      app/src/ui/runtime_panel_controller.cpp
  72. +96
    -0
      app/src/ui/runtime_panel_controller.h
  73. +551
    -0
      app/src/ui/toolbar_icon_factory.cpp
  74. +58
    -0
      app/src/ui/toolbar_icon_factory.h
  75. +120
    -0
      app/tests/alarm_service_tests.cpp
  76. +24
    -0
      app/tests/alarm_service_tests.pro
  77. +563
    -11
      app/tests/domain_tests.cpp
  78. +8
    -18
      app/tests/domain_tests.pro
  79. +189
    -25
      app/tests/hmi_editor_service_tests.cpp
  80. +16
    -25
      app/tests/hmi_editor_service_tests.pro
  81. +669
    -15
      app/tests/logic_editor_service_tests.cpp
  82. +18
    -21
      app/tests/logic_editor_service_tests.pro
  83. +1359
    -37
      app/tests/main_window_tests.cpp
  84. +24
    -62
      app/tests/main_window_tests.pro
  85. +507
    -8
      app/tests/offline_simulation_service_tests.cpp
  86. +13
    -20
      app/tests/offline_simulation_service_tests.pro
  87. +97
    -0
      app/tests/performance_tests.cpp
  88. +16
    -0
      app/tests/performance_tests.pro
  89. +72
    -21
      app/tests/plc_runtime_tests.cpp
  90. +15
    -35
      app/tests/plc_runtime_tests.pro
  91. +8
    -0
      app/tests/pri/test_defaults.pri
  92. +175
    -0
      app/tests/pri/test_layers.pri
  93. +660
    -13
      app/tests/project_management_tests.cpp
  94. +13
    -24
      app/tests/project_management_tests.pro
  95. +32
    -7
      app/tests/register_monitor_service_tests.cpp
  96. +9
    -16
      app/tests/register_monitor_service_tests.pro
  97. +158
    -21
      app/tests/runtime_mode_service_tests.cpp
  98. +12
    -32
      app/tests/runtime_mode_service_tests.pro
  99. +34
    -0
      app/tests/support/test_support.h
  100. +28
    -0
      app/tests/tests.pro

+ 3
- 2
.gitignore 查看文件

@@ -55,10 +55,11 @@ tmp/
Thumbs.db
Desktop.ini

/scripts
/scripts/*
!/scripts/run_qt_tests.ps1
/docs/images
/docs/类图Mermaid代码.md
/other
设计方案书.doc
/学习
/json
/json

+ 65
- 49
AGENTS.md 查看文件

@@ -1,57 +1,73 @@
# 项目长期约定

- 项目作者:`suyu`。
- 项目作者:`suyu`
- 文件、终端输出和源代码统一使用 UTF-8
- 答复我的文本要说人话,口语化答复我

## 新会话阅读顺序

1. 先读 `docs/ai/handoff.md`,确认当前状态、未提交改动、验证结果和下一步
2. 涉及模块边界、数据流、运行模式或持久化时,再读 `docs/architecture.md`
3. 按任务读取下方索引中的业务文档;Markdown 没有答案时再查 `docs/pdf/`

三个入口文档各自只负责一类信息:

- `AGENTS.md`:长期有效的开发、构建和安全约束
- `docs/architecture.md`:稳定的分层、模块边界、核心模型和数据流
- `docs/ai/handoff.md`:当前进度、工作区状态、最近验证和下一步;更新时替换旧状态,不追加开发流水账

## 关键文件索引

- C++ 代码规范:`docs/C++代码规范.md`。
- 需求参考文档:`docs/0_综合平台编程器_修改.md`。
- 推荐开发顺序:`docs/开发顺序.md`。
- Qt 应用源码根目录:`app/`。
- 本机构建输出目录:`build/`(不纳入 Git)。
- Qt 一键构建运行脚本:`scripts/build_and_run_qt.ps1`。
- XDH-60T4-E 硬件与接线要点:`docs/XDH-60T4-E硬件与接线要点.md`。
- XDH-60T4-E 指令与 Modbus 要点:`docs/XDH-60T4-E指令与Modbus要点.md`。
- PLC 官方手册:`docs/pdf/`。
- 当前开发进度和未完成事项:`docs/ai/handoff.md`(创建后每次新会话优先阅读;不要把动态进度写入本文件)。
- 架构、模块边界和数据流:`docs/architecture.md`(创建后优先阅读)。
- 查阅资料时,先读取 UTF-8 编码的 Markdown 文档;只有 Markdown 中找不到所需信息时,才查阅 `docs/pdf/` 内的 PDF 手册。
- 当前交接:`docs/ai/handoff.md`
- 架构与数据流:`docs/architecture.md`
- C++ 规范:`docs/C++代码规范.md`
- 原始需求:`docs/0_综合平台编程器_修改.md`
- 数量边界最终规则:`docs/用户使用/数量边界确认方案.md`
- 工程 JSON 格式:`docs/工程格式说明.md`
- 开发阶段顺序:`docs/开发顺序.md`(阶段参考,当前状态以 handoff 为准)
- 测试约定:`docs/测试约定.md`
- XDH-60T4-E 硬件接线:`docs/XDH-60T4-E硬件与接线要点.md`
- XDH-60T4-E 指令与 Modbus:`docs/XDH-60T4-E指令与Modbus要点.md`
- PLC 官方手册:`docs/pdf/`
- Qt 源码:`app/`
- 构建输出:`build/`(不纳入 Git)

## 技术栈与构建

- 使用 C++、Qt 5.15.2 和 Qt Widgets;UI 默认使用 Qt Designer 的 `.ui` 文件。
- 使用 qmake,不使用 CMake。
- Qt MinGW 套件根目录:`D:\Qt5.15.2\5.15.2\mingw81_64`。
- 构建前在 PowerShell 7 中设置 Qt 工具链路径;MinGW 工具目录为常见安装位置,如实际安装位置不同,以本机路径为准:

```powershell
$env:QTDIR = 'D:\Qt5.15.2\5.15.2\mingw81_64'
$env:Path = "$env:QTDIR\bin;D:\Qt5.15.2\Tools\mingw810_64\bin;$env:Path"
qmake <项目文件>.pro
mingw32-make -j2
```

- 优先在独立构建目录执行 qmake 和 `mingw32-make`,不要将生成物混入源代码目录。
- 文件读写、终端查看和源代码统一使用 UTF-8。
- 一键构建并运行 Debug 程序:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1`。
- 一键构建并运行 Release 程序:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1 -Configuration Release`。
- 打开 ui 文件:` & 'D:\Qt5.15.2\5.15.2\mingw81_64\bin\designer.exe' '.\app\src\ui\main_window.ui'`

## Git 提交约定

- 每次提交只包含一个逻辑完整的改动;提交前必须完成对应的构建或测试验证。
- 提交信息使用 `类型: 简短说明` 格式,例如 `feat: 建立 Qt 工程基线`。
- 类型使用 `feat`、`fix`、`docs`、`test`、`refactor` 或 `chore`。

## 架构与运行模式

- 保持 UI、领域、服务和基础设施分层;业务逻辑不得堆入 `MainWindow` 或 Qt 槽函数。
- HMI 只绑定 M/D 寄存器仓库,不得直接读写串口;串口通信必须异步,不能阻塞 UI。
- M 区和 D 区的项目可用地址均为 `0~4000`;Modbus 代码使用从 `0` 开始的原始地址。
- 离线模式使用虚拟 M/D,并运行软件逻辑执行器。
- 真机模式使用 PLC 的真实 M/D,软件逻辑执行器必须停止;切换时先读取 PLC 数据,不自动写入离线虚拟值。
- 真机联动设备时 PLC 必须处于 RUN;STOP 仅用于安全通信验证。

## 注意
- 生成注释的文本结尾别加逗号 `。`。
- 你写完代码需要测试真机PLC可以直接连 COM3,9600 8e1配置。
- 使用 C++17、Qt 5.15.2、Qt Widgets 和 qmake,不使用 CMake
- UI 默认使用 Qt Designer `.ui` 文件;不要把静态表单重新堆回 C++
- Qt MinGW 套件:`D:\Qt5.15.2\5.15.2\mingw81_64`
- Windows 命令优先使用 PowerShell 7;显式调用使用 `pwsh -NoLogo -NoProfile -Command`,脚本使用 `pwsh -NoLogo -NoProfile -File`
- 复杂 PowerShell 逻辑写入 `.ps1` 后执行,避免嵌套引号;排查兼容问题时先确认 `$PSVersionTable.PSVersion` 和 `$PSVersionTable.PSEdition`
- 在独立构建目录运行 qmake 和 `mingw32-make`,不要把生成物写进 `app/`
- Debug 构建运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1`
- Release 构建运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1 -Configuration Release`
- 打开主界面:`& 'D:\Qt5.15.2\5.15.2\mingw81_64\bin\designer.exe' '.\app\src\ui\main_window.ui'`

## 代码与架构约束

- 保持 `UI -> Services -> Domain` 依赖方向,基础设施实现服务契约;`domain` 不依赖 Qt、串口或文件系统
- 业务规则放领域或服务层,不得堆入 `MainWindow`、Qt 槽函数或图元绘制代码
- HMI 和运行界面只访问寄存器仓库,不直接访问串口;PLC 通信必须异步,不能阻塞 UI
- 编辑操作通过服务完成并保持原子性,失败时不得留下部分修改
- 数量边界统一定义在 `app/src/domain/project_limits.h`,修改时同步更新数量边界文档和边界测试
- M/D 项目地址范围固定为 `0~4000`;Modbus 使用从 `0` 开始的原始地址
- 离线模式使用虚拟 M/D 并运行软件逻辑执行器
- 真机模式使用 PLC 读回缓存并停止软件逻辑执行器;切换时先读 PLC,不复制离线值
- 本项目不生成、编译或下载 PLC 程序,不得把本地梯形图轨迹当成 PLC 内部程序轨迹
- 代码注释只解释不明显的约束,末尾不要加句号 `。`

## 验证与安全

- 代码改动必须构建相关测试;共享模型、持久化、运行模式或 UI 工作流改动还要扩大回归范围
- 提交前至少完成对应自动化测试、Release 构建和 `git diff --check`
- 涉及 PLC 通信或真机运行模式的代码,使用 `COM3 / 9600 / 8E1 / 站号 1` 做真实读写验证;先读取原值,测试后恢复并读回确认
- STOP 用于安全通信验证;带真实设备的联动测试必须在确认接线和地址后切到 RUN
- 纯文档、领域校验或离线执行器改动不要求连接 PLC,但要在 handoff 中说明未做真机测试的原因
- 离线仿真不会向 PLC 下载程序

## Git 提交

- 不覆盖或回退工作区中来源不明的改动,开始前先检查 `git status` 和相关 diff
- 每次提交只包含一个逻辑完整改动,提交前完成对应验证
- 提交信息使用 `类型: 简短说明`,类型限 `feat`、`fix`、`docs`、`test`、`refactor`、`chore`

+ 32
- 0
app/integrated_platform.pro 查看文件

@@ -17,18 +17,30 @@ INCLUDEPATH += src
SOURCES += \
src/main.cpp \
src/ui/main_window.cpp \
src/ui/logic_instruction_dialog.cpp \
src/ui/alarm_configuration_dialog.cpp \
src/ui/register_comment_dialog.cpp \
src/ui/plc_connection_dialog.cpp \
src/ui/free_monitor_widget.cpp \
src/ui/runtime_monitor_window.cpp \
src/ui/runtime_monitor_widget.cpp \
src/ui/toolbar_icon_factory.cpp \
src/ui/project_workspace_controller.cpp \
src/ui/property_panel_controller.cpp \
src/ui/runtime_panel_controller.cpp \
src/domain/register_address.cpp \
src/domain/register_repository.cpp \
src/domain/active_register_repository.cpp \
src/domain/register_monitor_model.cpp \
src/domain/alarm_model.cpp \
src/domain/hmi_model.cpp \
src/domain/hmi_control_registry.cpp \
src/domain/control_logic_model.cpp \
src/domain/project_model.cpp \
src/domain/runtime_state.cpp \
src/services/project_service.cpp \
src/services/alarm_editor_service.cpp \
src/services/alarm_service.cpp \
src/services/hmi_editor_service.cpp \
src/services/hmi_navigation_service.cpp \
src/services/logic_editor_service.cpp \
@@ -37,6 +49,7 @@ SOURCES += \
src/services/offline_simulation_service.cpp \
src/services/runtime_mode_service.cpp \
src/services/register_monitor_service.cpp \
src/services/register_comment_service.cpp \
src/infrastructure/plc_register_repository.cpp \
src/infrastructure/plc_communication_error_classifier.cpp \
src/infrastructure/plc_communication_service.cpp \
@@ -46,19 +59,33 @@ SOURCES += \

HEADERS += \
src/ui/main_window.h \
src/ui/logic_instruction_dialog.h \
src/ui/alarm_configuration_dialog.h \
src/ui/register_comment_dialog.h \
src/ui/plc_connection_dialog.h \
src/ui/free_monitor_widget.h \
src/ui/runtime_monitor_window.h \
src/ui/runtime_monitor_widget.h \
src/ui/toolbar_icon_factory.h \
src/ui/project_workspace_controller.h \
src/ui/property_panel_controller.h \
src/ui/runtime_panel_controller.h \
src/domain/register_address.h \
src/domain/register_repository.h \
src/domain/active_register_repository.h \
src/domain/register_monitor_model.h \
src/domain/alarm_model.h \
src/domain/hmi_model.h \
src/domain/hmi_control_registry.h \
src/domain/control_logic_model.h \
src/domain/project_limits.h \
src/domain/project_model.h \
src/domain/runtime_state.h \
src/domain/project_storage.h \
src/services/project_service.h \
src/services/editor_history.h \
src/services/alarm_editor_service.h \
src/services/alarm_service.h \
src/services/hmi_editor_service.h \
src/services/hmi_navigation_service.h \
src/services/logic_editor_service.h \
@@ -67,6 +94,7 @@ HEADERS += \
src/services/offline_simulation_service.h \
src/services/runtime_mode_service.h \
src/services/register_monitor_service.h \
src/services/register_comment_service.h \
src/services/plc_communication_gateway.h \
src/infrastructure/plc_register_repository.h \
src/infrastructure/plc_communication_error_classifier.h \
@@ -77,6 +105,10 @@ HEADERS += \

FORMS += \
src/ui/main_window.ui \
src/ui/logic_instruction_dialog.ui \
src/ui/alarm_configuration_dialog.ui \
src/ui/register_comment_dialog.ui \
src/ui/plc_connection_dialog.ui \
src/ui/free_monitor_widget.ui \
src/ui/runtime_monitor_window.ui \
src/ui/runtime_monitor_widget.ui

+ 65
- 0
app/src/domain/alarm_model.cpp 查看文件

@@ -0,0 +1,65 @@
#include "alarm_model.h"

#include "project_limits.h"

namespace {

void setError(std::string *error, const std::string &message)
{
if (error != nullptr)
{
*error = message;
}
}

} // namespace

bool AlarmDefinition::validate(std::string *error) const
{
if (id.empty())
{
setError(error, "报警定义 ID 不能为空");
return false;
}
if (id.size() > ProjectLimits::kMaximumIdBytes)
{
setError(error, "报警定义 ID 不能超过 128 个 UTF-8 字节");
return false;
}
if (message.empty())
{
setError(error, "报警文本不能为空");
return false;
}
if (message.size() > ProjectLimits::kMaximumTextBytes)
{
setError(error, "报警文本不能超过 4096 个 UTF-8 字节");
return false;
}
if (!address.isValid())
{
setError(error, "报警定义使用了无效地址");
return false;
}
if (condition == AlarmCondition::MOn)
{
if (address.area() != RegisterArea::M)
{
setError(error, "M ON 报警必须使用 M 地址");
return false;
}
return true;
}
if (condition == AlarmCondition::DHigh
|| condition == AlarmCondition::DLow)
{
if (address.area() != RegisterArea::D)
{
setError(error, "D 高限和低限报警必须使用 D 地址");
return false;
}
return true;
}
setError(error, "不支持的报警触发条件");
return false;
}

+ 24
- 0
app/src/domain/alarm_model.h 查看文件

@@ -0,0 +1,24 @@
#pragma once

#include "register_address.h"

#include <cstdint>
#include <string>

enum class AlarmCondition
{
MOn,
DHigh,
DLow
};

struct AlarmDefinition
{
std::string id;
RegisterAddress address{RegisterArea::M, 0};
AlarmCondition condition = AlarmCondition::MOn;
std::int16_t threshold = 0;
std::string message;

bool validate(std::string *error = nullptr) const;
};

+ 684
- 37
app/src/domain/control_logic_model.cpp 查看文件

@@ -1,6 +1,10 @@
#include "control_logic_model.h"

#include "project_limits.h"

#include <algorithm>
#include <map>
#include <type_traits>
#include <utility>

namespace {
@@ -13,6 +17,12 @@ void setError(std::string *error, const std::string &message)
}
}

bool containsLineBreak(const std::string &value)
{
return value.find('\r') != std::string::npos
|| value.find('\n') != std::string::npos;
}

bool validateConfig(const ContactNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::M)
@@ -29,6 +39,50 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error)
return true;
}

bool validateWordAddress(
const RegisterAddress &address,
const std::string &description,
std::string *error)
{
if (!address.isValid() || address.area() != RegisterArea::D)
{
setError(error, description + "必须使用有效的 D 区地址");
return false;
}
return true;
}

bool validateConfig(const CounterContactNodeConfig &config, std::string *error)
{
if (!config.address.isValid())
{
setError(error, "计数器触点必须使用有效的 C 地址");
return false;
}
if (config.mode != ContactMode::NormallyOpen
&& config.mode != ContactMode::NormallyClosed)
{
setError(error, "计数器触点使用了不支持的模式");
return false;
}
return true;
}

bool validateConfig(const EdgeContactNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::M)
{
setError(error, "边沿触点必须使用有效的 M 区地址");
return false;
}
if (config.mode != EdgeMode::Rising && config.mode != EdgeMode::Falling)
{
setError(error, "边沿触点使用了不支持的模式");
return false;
}
return true;
}

bool validateConfig(const CoilNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::M)
@@ -66,6 +120,94 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error)
return true;
}

bool validateConfig(const TimerContactNodeConfig &config, std::string *error)
{
if (!config.address.isValid())
{
setError(error, "定时器触点必须使用有效的 T 地址");
return false;
}
if (config.mode != ContactMode::NormallyOpen
&& config.mode != ContactMode::NormallyClosed)
{
setError(error, "定时器触点使用了不支持的模式");
return false;
}
return true;
}

bool validateConfig(const TonNodeConfig &config, std::string *error)
{
if (!config.address.isValid())
{
setError(error, "TON 必须使用有效的 T 地址");
return false;
}
if (config.presetMs < TonNodeConfig::kMinimumPresetMs
|| config.presetMs > TonNodeConfig::kMaximumPresetMs)
{
setError(error, "TON 预设时间必须在 1~86400000 ms 范围内");
return false;
}
return true;
}

bool validateConfig(const CounterNodeConfig &config, std::string *error)
{
if (!config.address.isValid())
{
setError(error, "计数器指令必须使用有效的 C 地址");
return false;
}
if (config.mode != CounterMode::Up && config.mode != CounterMode::Down)
{
setError(error, "计数器指令使用了不支持的方向");
return false;
}
if (!validateWordAddress(config.currentValueAddress, "计数当前值", error)
|| !config.preset.validate(error))
{
return false;
}
if (config.preset.kind == WordOperandKind::Constant
&& config.preset.constant < 0)
{
setError(error, "计数器常量预设值必须在 0~32767 范围内");
return false;
}
if (!config.resetAddress.isValid()
|| config.resetAddress.area() != RegisterArea::M)
{
setError(error, "计数器复位输入必须使用有效的 M 区地址");
return false;
}
return true;
}

bool validateConfig(const MoveNodeConfig &config, std::string *error)
{
if (!config.source.validate(error))
{
return false;
}
return validateWordAddress(config.destination, "MOVE 目标", error);
}

bool validateConfig(const ArithmeticNodeConfig &config, std::string *error)
{
if (config.operation != ArithmeticOperation::Add
&& config.operation != ArithmeticOperation::Subtract)
{
setError(error, "算术指令使用了不支持的运算");
return false;
}
if (!config.left.validate(error) || !config.right.validate(error))
{
return false;
}
return validateWordAddress(config.destination, "算术指令目标", error);
}

template<typename TItem>
bool hasDuplicateId(const std::vector<TItem> &items)
{
@@ -101,7 +243,9 @@ bool validateUniqueExpressionIds(

void normalizeExpression(ConditionExpression *expression)
{
if (expression == nullptr || expression->kind == ConditionExpressionKind::Node)
if (expression == nullptr
|| expression->kind == ConditionExpressionKind::Node
|| expression->kind == ConditionExpressionKind::Wire)
{
return;
}
@@ -113,6 +257,12 @@ void normalizeExpression(ConditionExpression *expression)
std::vector<ConditionExpression> flattened;
for (ConditionExpression &child : expression->children)
{
if ((child.kind == ConditionExpressionKind::Series
|| child.kind == ConditionExpressionKind::Parallel)
&& child.children.empty())
{
continue;
}
if (child.kind == expression->kind)
{
for (ConditionExpression &grandchild : child.children)
@@ -132,8 +282,306 @@ void normalizeExpression(ConditionExpression *expression)
}
}

void appendValidAddress(
std::vector<RegisterAddress> *addresses,
const RegisterAddress &address)
{
if (address.isValid())
{
addresses->push_back(address);
}
}

bool validateConditionExpression(
const ConditionExpression &expression,
std::size_t depth,
std::size_t *node_count,
int *columns,
int *rows,
std::string *error)
{
if (depth > ProjectLimits::kMaximumExpressionDepth)
{
setError(error, "条件表达式最多嵌套 20 层");
return false;
}
++*node_count;
if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung)
{
setError(error, "单个网络最多包含 4096 个条件表达式节点和叶子");
return false;
}
if (expression.id.empty())
{
setError(error, "条件表达式 ID 不能为空");
return false;
}
if (expression.id.size() > ProjectLimits::kMaximumIdBytes)
{
setError(error, "条件表达式 ID 不能超过 128 个 UTF-8 字节");
return false;
}
if (expression.kind == ConditionExpressionKind::Node)
{
if (!expression.node.has_value() || expression.wire.has_value()
|| !expression.children.empty() || !expression.node->isCondition())
{
setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式");
return false;
}
*columns = 1;
*rows = 1;
return expression.node->validate(error);
}
if (expression.kind == ConditionExpressionKind::Wire)
{
if (expression.node.has_value() || !expression.wire.has_value()
|| !expression.children.empty())
{
setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式");
return false;
}
if (!expression.wire->validate(error))
{
return false;
}
*columns = expression.wire->columnSpan;
*rows = 1;
return true;
}
if (expression.kind != ConditionExpressionKind::Series
&& expression.kind != ConditionExpressionKind::Parallel)
{
setError(error, "条件表达式使用了不支持的类型");
return false;
}
if (expression.node.has_value() || expression.wire.has_value()
|| expression.children.size() < 2U)
{
setError(error, "串联和并联表达式至少需要两个子表达式");
return false;
}
if (expression.children.size() > ProjectLimits::kMaximumExpressionChildren)
{
setError(error, "单个串联或并联容器最多包含 256 个子表达式");
return false;
}

int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1;
for (const ConditionExpression &child : expression.children)
{
if (child.kind == expression.kind)
{
setError(error, "同类型嵌套表达式必须先完成归一化");
return false;
}
int child_columns = 0;
int child_rows = 0;
if (!validateConditionExpression(
child, depth + 1U, node_count,
&child_columns, &child_rows, error))
{
return false;
}
if (expression.kind == ConditionExpressionKind::Series)
{
if (total_columns
> ProjectLimits::kMaximumConditionColumns - child_columns)
{
setError(error, "单个网络的条件区最多为 10 列,第 11 列固定用于输出指令");
return false;
}
total_columns += child_columns;
total_rows = std::max(total_rows, child_rows);
}
else
{
total_columns = std::max(total_columns, child_columns);
if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows)
{
setError(error, "单个网络的逻辑总行数最多为 256 行");
return false;
}
total_rows += child_rows;
}
}
if (total_columns > ProjectLimits::kMaximumConditionColumns
|| total_rows > ProjectLimits::kMaximumLogicRows)
{
setError(error, "单个网络最多使用 10 列条件、1 列输出和 256 行逻辑网格");
return false;
}
*columns = total_columns;
*rows = total_rows;
return true;
}

} // namespace

TimerAddress::TimerAddress(int index)
: index_(index)
{
}

bool WordOperand::validate(std::string *error) const
{
if (kind == WordOperandKind::Constant)
{
return true;
}
if (kind != WordOperandKind::Register)
{
setError(error, "字操作数使用了不支持的类型");
return false;
}
if (!address.isValid() || address.area() != RegisterArea::D)
{
setError(error, "字操作数必须使用有效的 D 区地址");
return false;
}
return true;
}

CounterAddress::CounterAddress(int index)
: index_(index)
{
}

int CounterAddress::index() const
{
return index_;
}

bool CounterAddress::isValid() const
{
return index_ >= kMinimumIndex && index_ <= kMaximumIndex;
}

std::string CounterAddress::toString() const
{
return isValid() ? "C" + std::to_string(index_) : "InvalidCounterAddress";
}

bool CounterAddress::operator==(const CounterAddress &other) const
{
return index_ == other.index_;
}

bool CounterAddress::operator!=(const CounterAddress &other) const
{
return !(*this == other);
}

int TimerAddress::index() const
{
return index_;
}

bool TimerAddress::isValid() const
{
return index_ >= kMinimumIndex && index_ <= kMaximumIndex;
}

std::string TimerAddress::toString() const
{
return isValid() ? "T" + std::to_string(index_) : "InvalidTimerAddress";
}

bool TimerAddress::operator==(const TimerAddress &other) const
{
return index_ == other.index_;
}

bool TimerAddress::operator!=(const TimerAddress &other) const
{
return !(*this == other);
}

std::optional<RegisterAddress> registerAddressForLogicNode(
const LogicNodeConfig &config)
{
return std::visit(
[](const auto &value) -> std::optional<RegisterAddress>
{
using Config = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Config, ContactNodeConfig>
|| std::is_same_v<Config, EdgeContactNodeConfig>
|| std::is_same_v<Config, CoilNodeConfig>
|| std::is_same_v<Config, CompareNodeConfig>)
{
return value.address;
}
else if constexpr (std::is_same_v<Config, CounterNodeConfig>)
{
return value.currentValueAddress;
}
else if constexpr (std::is_same_v<Config, MoveNodeConfig>
|| std::is_same_v<Config, ArithmeticNodeConfig>)
{
return value.destination;
}
return std::nullopt;
},
config);
}

void collectRegisterAddressesForLogicNode(
const LogicNodeConfig &config,
std::vector<RegisterAddress> *addresses)
{
if (addresses == nullptr)
{
return;
}
std::visit(
[addresses](const auto &value)
{
using Config = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Config, ContactNodeConfig>
|| std::is_same_v<Config, EdgeContactNodeConfig>
|| std::is_same_v<Config, CoilNodeConfig>
|| std::is_same_v<Config, CompareNodeConfig>)
{
appendValidAddress(addresses, value.address);
}
else if constexpr (std::is_same_v<Config, CounterNodeConfig>)
{
appendValidAddress(addresses, value.currentValueAddress);
appendValidAddress(addresses, value.resetAddress);
if (value.preset.kind == WordOperandKind::Register)
{
appendValidAddress(addresses, value.preset.address);
}
}
else if constexpr (std::is_same_v<Config, MoveNodeConfig>)
{
appendValidAddress(addresses, value.destination);
if (value.source.kind == WordOperandKind::Register)
{
appendValidAddress(addresses, value.source.address);
}
}
else if constexpr (std::is_same_v<Config, ArithmeticNodeConfig>)
{
appendValidAddress(addresses, value.destination);
if (value.left.kind == WordOperandKind::Register)
{
appendValidAddress(addresses, value.left.address);
}
if (value.right.kind == WordOperandKind::Register)
{
appendValidAddress(addresses, value.right.address);
}
}
else if constexpr (std::is_same_v<Config, CounterContactNodeConfig>)
{
return;
}
},
config);
}

bool LogicNode::validate(std::string *error) const
{
if (id.empty())
@@ -141,6 +589,11 @@ bool LogicNode::validate(std::string *error) const
setError(error, "逻辑节点 ID 不能为空");
return false;
}
if (id.size() > ProjectLimits::kMaximumIdBytes)
{
setError(error, "逻辑节点 ID 不能超过 128 个 UTF-8 字节");
return false;
}
return std::visit(
[error](const auto &config) { return validateConfig(config, error); },
config);
@@ -153,12 +606,20 @@ bool LogicNode::isConfigured() const

bool LogicNode::isCondition() const
{
return !std::holds_alternative<CoilNodeConfig>(config);
return std::holds_alternative<ContactNodeConfig>(config)
|| std::holds_alternative<EdgeContactNodeConfig>(config)
|| std::holds_alternative<TimerContactNodeConfig>(config)
|| std::holds_alternative<CounterContactNodeConfig>(config)
|| std::holds_alternative<CompareNodeConfig>(config);
}

bool LogicNode::isOutput() const
{
return std::holds_alternative<CoilNodeConfig>(config);
return std::holds_alternative<CoilNodeConfig>(config)
|| std::holds_alternative<TonNodeConfig>(config)
|| std::holds_alternative<CounterNodeConfig>(config)
|| std::holds_alternative<MoveNodeConfig>(config)
|| std::holds_alternative<ArithmeticNodeConfig>(config);
}

ConditionExpression ConditionExpression::fromNode(LogicNode logic_node)
@@ -170,42 +631,35 @@ ConditionExpression ConditionExpression::fromNode(LogicNode logic_node)
return expression;
}

bool ConditionExpression::validate(std::string *error) const
bool WireSegment::validate(std::string *error) const
{
if (id.empty())
if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan)
{
setError(error, "条件表达式 ID 不能为空");
setError(error, "横线跨度必须在 1~10 列范围内");
return false;
}
if (kind == ConditionExpressionKind::Node)
{
if (!node.has_value() || !children.empty() || !node->isCondition())
{
setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式");
return false;
}
return node->validate(error);
}
if (node.has_value() || children.size() < 2U)
{
setError(error, "串联和并联表达式至少需要两个子表达式");
return false;
}
for (const ConditionExpression &child : children)
{
if (child.kind == kind)
{
setError(error, "同类型嵌套表达式必须先完成归一化");
return false;
}
if (!child.validate(error))
{
return false;
}
}
return true;
}

ConditionExpression ConditionExpression::fromWire(
std::string expression_id, int column_span)
{
ConditionExpression expression;
expression.id = std::move(expression_id);
expression.kind = ConditionExpressionKind::Wire;
expression.wire = WireSegment{column_span};
return expression;
}

bool ConditionExpression::validate(std::string *error) const
{
std::size_t node_count = 0U;
int columns = 0;
int rows = 0;
return validateConditionExpression(
*this, 1U, &node_count, &columns, &rows, error);
}

bool ConditionExpression::validateForRunning(std::string *error) const
{
if (!validate(error))
@@ -221,6 +675,10 @@ bool ConditionExpression::validateForRunning(std::string *error) const
}
return true;
}
if (kind == ConditionExpressionKind::Wire)
{
return true;
}
for (const ConditionExpression &child : children)
{
if (!child.validateForRunning(error))
@@ -239,6 +697,7 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression
}
normalizeExpression(&expression->value());
if (expression->value().kind != ConditionExpressionKind::Node
&& expression->value().kind != ConditionExpressionKind::Wire
&& expression->value().children.empty())
{
expression->reset();
@@ -248,6 +707,10 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression
const LogicNode *findConditionNode(
const ConditionExpression &expression, const std::string &node_id)
{
if (expression.kind == ConditionExpressionKind::Wire)
{
return nullptr;
}
if (expression.kind == ConditionExpressionKind::Node)
{
return expression.node->id == node_id ? &*expression.node : nullptr;
@@ -306,6 +769,10 @@ void collectConditionNodes(
nodes->push_back(&*expression.node);
return;
}
if (expression.kind == ConditionExpressionKind::Wire)
{
return;
}
for (const ConditionExpression &child : expression.children)
{
collectConditionNodes(child, nodes);
@@ -341,18 +808,18 @@ bool LadderRung::validateForRunning(std::string *error) const
{
return true;
}
if (!condition.has_value() || !output.has_value())
if (!output.has_value())
{
setError(error, "未完成的梯形图网络必须同时包含条件和输出线圈");
setError(error, "梯形图网络必须包含输出指令");
return false;
}
if (!condition->validateForRunning(error))
if (condition.has_value() && !condition->validateForRunning(error))
{
return false;
}
if (!output->isConfigured())
{
setError(error, "输出线圈 " + output->id + " 尚未配置");
setError(error, "输出节点 " + output->id + " 尚未配置");
return false;
}
return true;
@@ -365,6 +832,26 @@ bool LadderRung::validateStructure(std::string *error) const
setError(error, "梯形图网络 ID 和名称不能为空");
return false;
}
if (id.size() > ProjectLimits::kMaximumIdBytes)
{
setError(error, "梯形图网络 ID 不能超过 128 个 UTF-8 字节");
return false;
}
if (name.size() > ProjectLimits::kMaximumTextBytes)
{
setError(error, "梯形图网络名称不能超过 4096 个 UTF-8 字节");
return false;
}
if (containsLineBreak(comment))
{
setError(error, "梯形图网络注释只能使用单行文本");
return false;
}
if (comment.size() > ProjectLimits::kMaximumRungCommentBytes)
{
setError(error, "梯形图网络注释不能超过 128 个 UTF-8 字节");
return false;
}
std::vector<std::string> node_ids;
if (condition.has_value())
{
@@ -383,7 +870,7 @@ bool LadderRung::validateStructure(std::string *error) const
{
if (!output->validate(error) || !output->isOutput())
{
setError(error, "梯形图网络输出必须是线圈节点");
setError(error, "梯形图网络输出必须是有效的输出指令");
return false;
}
node_ids.push_back(output->id);
@@ -420,6 +907,21 @@ bool ControlLogic::validateStructure(std::string *error) const
setError(error, "控制逻辑 ID 和名称不能为空");
return false;
}
if (id.size() > ProjectLimits::kMaximumIdBytes)
{
setError(error, "控制逻辑 ID 不能超过 128 个 UTF-8 字节");
return false;
}
if (name.size() > ProjectLimits::kMaximumTextBytes)
{
setError(error, "控制逻辑名称不能超过 4096 个 UTF-8 字节");
return false;
}
if (rungs.size() > ProjectLimits::kMaximumRungsPerLogic)
{
setError(error, "单组控制逻辑最多包含 1024 个网络");
return false;
}
if (hasDuplicateId(rungs))
{
setError(error, "同一控制逻辑内的网络 ID 必须唯一");
@@ -479,3 +981,148 @@ bool ControlLogic::validateForRunning(std::string *error) const
}
return true;
}

bool validateTimerReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error)
{
std::map<int, std::string> timer_outputs;
for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (!rung.output.has_value())
{
continue;
}
const auto *ton = std::get_if<TonNodeConfig>(&rung.output->config);
if (ton == nullptr)
{
continue;
}
const auto inserted = timer_outputs.emplace(
ton->address.index(), rung.output->id);
if (!inserted.second)
{
setError(
error,
"定时器 " + ton->address.toString()
+ " 只能由一个已启用 TON 指令驱动");
return false;
}
}
}

for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (!rung.condition.has_value())
{
continue;
}
std::vector<const LogicNode *> nodes;
collectConditionNodes(*rung.condition, &nodes);
for (const LogicNode *node : nodes)
{
const auto *contact = std::get_if<TimerContactNodeConfig>(
&node->config);
if (contact != nullptr
&& timer_outputs.count(contact->address.index()) == 0U)
{
setError(
error,
"定时器触点 " + contact->address.toString()
+ " 没有对应的已启用 TON 指令");
return false;
}
}
}
}
return true;
}

bool validateCounterReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error)
{
std::map<int, std::string> counter_outputs;
for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (!rung.output.has_value())
{
continue;
}
const auto *counter = std::get_if<CounterNodeConfig>(
&rung.output->config);
if (counter == nullptr)
{
continue;
}
const auto inserted = counter_outputs.emplace(
counter->address.index(), rung.output->id);
if (!inserted.second)
{
setError(
error,
"计数器 " + counter->address.toString()
+ " 只能由一个已启用计数指令驱动");
return false;
}
}
}

for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (!rung.condition.has_value())
{
continue;
}
std::vector<const LogicNode *> nodes;
collectConditionNodes(*rung.condition, &nodes);
for (const LogicNode *node : nodes)
{
const auto *contact = std::get_if<CounterContactNodeConfig>(
&node->config);
if (contact != nullptr
&& counter_outputs.count(contact->address.index()) == 0U)
{
setError(
error,
"计数器触点 " + contact->address.toString()
+ " 没有对应的已启用计数指令");
return false;
}
}
}
}
return true;
}

bool validateLogicResourceReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error)
{
return validateTimerReferencesForRunning(logics, error)
&& validateCounterReferencesForRunning(logics, error);
}

+ 165
- 2
app/src/domain/control_logic_model.h 查看文件

@@ -1,5 +1,6 @@
#pragma once

#include "project_limits.h"
#include "register_address.h"

#include <cstdint>
@@ -21,6 +22,12 @@ enum class CoilMode
Reset
};

enum class EdgeMode
{
Rising,
Falling
};

enum class ComparisonOperator
{
Equal,
@@ -31,12 +38,33 @@ enum class ComparisonOperator
GreaterThanOrEqual
};

enum class WordOperandKind
{
Constant,
Register
};

struct WordOperand
{
WordOperandKind kind = WordOperandKind::Constant;
RegisterAddress address{RegisterArea::D, 0};
std::int16_t constant = 0;

bool validate(std::string *error = nullptr) const;
};

struct ContactNodeConfig
{
RegisterAddress address{RegisterArea::M, 0};
ContactMode mode = ContactMode::NormallyOpen;
};

struct EdgeContactNodeConfig
{
RegisterAddress address{RegisterArea::M, 0};
EdgeMode mode = EdgeMode::Rising;
};

struct CoilNodeConfig
{
RegisterAddress address{RegisterArea::M, 0};
@@ -50,7 +78,117 @@ struct CompareNodeConfig
std::int16_t value = 0;
};

using LogicNodeConfig = std::variant<ContactNodeConfig, CoilNodeConfig, CompareNodeConfig>;
class TimerAddress
{
public:
static constexpr int kMinimumIndex = 0;
static constexpr int kMaximumIndex = 4000;

explicit TimerAddress(int index = 0);

int index() const;
bool isValid() const;
std::string toString() const;

bool operator==(const TimerAddress &other) const;
bool operator!=(const TimerAddress &other) const;

private:
int index_ = 0;
};

struct TimerContactNodeConfig
{
TimerAddress address;
ContactMode mode = ContactMode::NormallyOpen;
};

class CounterAddress
{
public:
static constexpr int kMinimumIndex = 0;
static constexpr int kMaximumIndex = 4000;

explicit CounterAddress(int index = 0);

int index() const;
bool isValid() const;
std::string toString() const;

bool operator==(const CounterAddress &other) const;
bool operator!=(const CounterAddress &other) const;

private:
int index_ = 0;
};

enum class CounterMode
{
Up,
Down
};

struct CounterContactNodeConfig
{
CounterAddress address;
ContactMode mode = ContactMode::NormallyOpen;
};

struct TonNodeConfig
{
static constexpr int kMinimumPresetMs = 1;
static constexpr int kMaximumPresetMs = 86400000;

TimerAddress address;
int presetMs = 1000;
};

struct CounterNodeConfig
{
CounterAddress address;
CounterMode mode = CounterMode::Up;
RegisterAddress currentValueAddress{RegisterArea::D, 0};
WordOperand preset;
RegisterAddress resetAddress{RegisterArea::M, 0};
};

struct MoveNodeConfig
{
WordOperand source;
RegisterAddress destination{RegisterArea::D, 0};
};

enum class ArithmeticOperation
{
Add,
Subtract
};

struct ArithmeticNodeConfig
{
ArithmeticOperation operation = ArithmeticOperation::Add;
WordOperand left;
WordOperand right;
RegisterAddress destination{RegisterArea::D, 0};
};

using LogicNodeConfig = std::variant<
ContactNodeConfig,
EdgeContactNodeConfig,
TimerContactNodeConfig,
CounterContactNodeConfig,
CoilNodeConfig,
CompareNodeConfig,
TonNodeConfig,
CounterNodeConfig,
MoveNodeConfig,
ArithmeticNodeConfig>;

std::optional<RegisterAddress> registerAddressForLogicNode(
const LogicNodeConfig &config);
void collectRegisterAddressesForLogicNode(
const LogicNodeConfig &config,
std::vector<RegisterAddress> *addresses);

struct LogicNode
{
@@ -67,19 +205,33 @@ struct LogicNode
enum class ConditionExpressionKind
{
Node,
Wire,
Series,
Parallel
};

struct WireSegment
{
static constexpr int kMinimumColumnSpan = 1;
static constexpr int kMaximumColumnSpan =
ProjectLimits::kMaximumConditionColumns;

int columnSpan = 1;

bool validate(std::string *error = nullptr) const;
};

// 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接
struct ConditionExpression
{
std::string id;
ConditionExpressionKind kind = ConditionExpressionKind::Node;
std::optional<LogicNode> node;
std::optional<WireSegment> wire;
std::vector<ConditionExpression> children;

static ConditionExpression fromNode(LogicNode node);
static ConditionExpression fromWire(std::string id, int column_span = 1);
bool validate(std::string *error = nullptr) const;
bool validateForRunning(std::string *error = nullptr) const;
};
@@ -99,11 +251,12 @@ void collectConditionNodes(
void collectConditionExpressionIds(
const ConditionExpression &expression, std::vector<std::string> *ids);

// 一个网络包含一棵结构化条件表达式,输出线圈固定在最右侧
// 条件表达式为空时表示恒真网络,输出指令固定在最右侧
struct LadderRung
{
std::string id;
std::string name;
std::string comment;
std::optional<ConditionExpression> condition;
std::optional<LogicNode> output;

@@ -123,3 +276,13 @@ struct ControlLogic
bool validateStructure(std::string *error = nullptr) const;
bool validateForRunning(std::string *error = nullptr) const;
};

bool validateTimerReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error = nullptr);
bool validateCounterReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error = nullptr);
bool validateLogicResourceReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error = nullptr);

+ 134
- 0
app/src/domain/hmi_control_registry.cpp 查看文件

@@ -0,0 +1,134 @@
#include "hmi_control_registry.h"

#include <algorithm>
#include <array>
#include <cstddef>

namespace {

constexpr std::array kControlDescriptors = {
HmiControlDescriptor{HmiControlType::Button,
"button",
"按钮",
"button",
"按钮",
{0, 0, 120, 40},
HmiBindingKind::Bit,
HmiRuntimeValueKind::Bit,
true},
HmiControlDescriptor{HmiControlType::Indicator,
"indicator",
"指示灯",
"indicator",
"指示灯",
{0, 0, 64, 64},
HmiBindingKind::Bit,
HmiRuntimeValueKind::Bit,
true},
HmiControlDescriptor{HmiControlType::NumericDisplay,
"numericDisplay",
"数值显示",
"numeric-display",
"数值显示",
{0, 0, 120, 40},
HmiBindingKind::Word,
HmiRuntimeValueKind::Word,
true},
HmiControlDescriptor{HmiControlType::NumericInput,
"numericInput",
"数值输入",
"numeric-input",
"数值输入",
{0, 0, 120, 40},
HmiBindingKind::Word,
HmiRuntimeValueKind::Word,
true},
HmiControlDescriptor{HmiControlType::Label,
"label",
"文本",
"label",
"文本",
{0, 0, 120, 40},
HmiBindingKind::None,
HmiRuntimeValueKind::None,
false},
HmiControlDescriptor{HmiControlType::PageJump,
"pageJump",
"页面跳转",
"page-jump",
"页面跳转",
{0, 0, 120, 40},
HmiBindingKind::None,
HmiRuntimeValueKind::None,
false},
HmiControlDescriptor{HmiControlType::AlarmList,
"alarmList",
"报警列表",
"alarm-list",
"报警信息",
{0, 0, 360, 136},
HmiBindingKind::None,
HmiRuntimeValueKind::None,
false},
HmiControlDescriptor{HmiControlType::ProgressBar,
"progressBar",
"进度条",
"progress-bar",
"进度",
{0, 0, 240, 36},
HmiBindingKind::Word,
HmiRuntimeValueKind::Word,
true}
};

static_assert(
kControlDescriptors.size()
== static_cast<std::size_t>(HmiControlType::Count),
"每个 HMI 控件类型都必须注册描述");

} // namespace

const HmiControlDescriptor *findHmiControlDescriptor(HmiControlType type)
{
const auto descriptor = std::find_if(
kControlDescriptors.cbegin(),
kControlDescriptors.cend(),
[type](const HmiControlDescriptor &candidate)
{
return candidate.type == type;
});
return descriptor == kControlDescriptors.cend() ? nullptr : &*descriptor;
}

const HmiControlDescriptor *findHmiControlDescriptor(
std::string_view storage_name)
{
const auto descriptor = std::find_if(
kControlDescriptors.cbegin(),
kControlDescriptors.cend(),
[storage_name](const HmiControlDescriptor &candidate)
{
return storage_name == candidate.storageName;
});
return descriptor == kControlDescriptors.cend() ? nullptr : &*descriptor;
}

std::optional<RegisterArea> hmiBindingArea(HmiBindingKind binding_kind)
{
switch (binding_kind)
{
case HmiBindingKind::Bit:
{
return RegisterArea::M;
}
case HmiBindingKind::Word:
{
return RegisterArea::D;
}
case HmiBindingKind::None:
default:
{
return std::nullopt;
}
}
}

+ 68
- 0
app/src/domain/hmi_control_registry.h 查看文件

@@ -0,0 +1,68 @@
/**
* @file hmi_control_registry.h
* @brief 定义 HMI 控件公共描述及查询接口
* @author suyu
* @date 2026-08-17
*/

#pragma once

#include "hmi_model.h"

#include <optional>
#include <string_view>

/**
* @brief 控件允许绑定的寄存器数据类型
*/
enum class HmiBindingKind
{
None,
Bit,
Word
};

/**
* @brief 控件运行态读取的值类型
*/
enum class HmiRuntimeValueKind
{
None,
Bit,
Word
};

/**
* @brief 保存一种 HMI 控件的稳定名称、默认值和寄存器能力
*/
struct HmiControlDescriptor
{
HmiControlType type;
const char *storageName;
const char *displayName;
const char *idPrefix;
const char *defaultText;
HmiRect defaultBounds;
HmiBindingKind bindingKind;
HmiRuntimeValueKind runtimeValueKind;
bool requiresBindingForRunning;
};

/**
* @brief 按领域类型查找控件描述
* @return 找到时返回描述指针,不支持的类型返回 nullptr
*/
const HmiControlDescriptor *findHmiControlDescriptor(HmiControlType type);

/**
* @brief 按工程文件中的稳定类型名查找控件描述
* @return 找到时返回描述指针,未知名称返回 nullptr
*/
const HmiControlDescriptor *findHmiControlDescriptor(
std::string_view storage_name);

/**
* @brief 返回绑定类型要求的寄存器区域
* @return 不支持寄存器绑定时返回空值
*/
std::optional<RegisterArea> hmiBindingArea(HmiBindingKind binding_kind);

+ 181
- 45
app/src/domain/hmi_model.cpp 查看文件

@@ -1,6 +1,11 @@
#include "hmi_model.h"

#include "hmi_control_registry.h"
#include "project_limits.h"

#include <algorithm>
#include <cctype>
#include <charconv>

namespace {

@@ -13,43 +18,98 @@ void setError(std::string *error, const std::string &message)
}
}

// 判断控件是否必须绑定 M 区位地址
bool requiresBitBinding(HmiControlType type)
bool isHexDigit(char value)
{
return type == HmiControlType::Button || type == HmiControlType::Indicator;
return std::isxdigit(static_cast<unsigned char>(value)) != 0;
}

// 判断控件是否必须绑定 D 区字地址
bool requiresWordBinding(HmiControlType type)
bool isValidTextColor(const std::string &value)
{
return type == HmiControlType::NumericDisplay || type == HmiControlType::NumericInput;
return value.size() == 7U
&& value.front() == '#'
&& std::all_of(value.cbegin() + 1, value.cend(), isHexDigit);
}

bool supportsRegisterBinding(HmiControlType type)
bool parseInteger(const std::string &value, int *result)
{
return requiresBitBinding(type) || requiresWordBinding(type);
if (value.empty() || result == nullptr)
{
return false;
}
const char *begin = value.data();
const char *end = begin + value.size();
const auto parsed = std::from_chars(begin, end, *result);
return parsed.ec == std::errc{} && parsed.ptr == end;
}

bool isSupportedControlType(HmiControlType type)
bool isBooleanValue(const std::string &value)
{
switch (type)
{
case HmiControlType::Button:
case HmiControlType::Indicator:
case HmiControlType::NumericDisplay:
case HmiControlType::NumericInput:
case HmiControlType::Label:
case HmiControlType::PageJump:
return true;
return value == "true" || value == "false";
}

bool validateAppearanceProperty(
const std::string &key, const std::string &value, std::string *error)
{
if (key == HmiAppearanceProperty::kTextColor)
{
if (!isValidTextColor(value))
{
setError(error, "HMI 字体颜色必须是 #RRGGBB 格式");
return false;
}
}
else if (key == HmiAppearanceProperty::kFontSize)
{
int point_size = 0;
if (!parseInteger(value, &point_size)
|| point_size < ProjectLimits::kMinimumHmiFontPointSize
|| point_size > ProjectLimits::kMaximumHmiFontPointSize)
{
setError(
error,
"HMI 字号必须在 "
+ std::to_string(ProjectLimits::kMinimumHmiFontPointSize)
+ "~"
+ std::to_string(ProjectLimits::kMaximumHmiFontPointSize)
+ " 范围内");
return false;
}
}
else if (key == HmiAppearanceProperty::kFontBold
|| key == HmiAppearanceProperty::kFontItalic)
{
if (!isBooleanValue(value))
{
setError(error, "HMI 粗体和斜体属性必须是 true 或 false");
return false;
}
}
return false;
return true;
}

} // namespace

bool HmiProgressBarConfig::isValid() const
{
return minimumValue < maximumValue;
}

int HmiProgressBarConfig::percentageForValue(std::int16_t value) const
{
if (!isValid())
{
return 0;
}
const int minimum = minimumValue;
const int maximum = maximumValue;
const int bounded_value = std::clamp(static_cast<int>(value), minimum, maximum);
return (bounded_value - minimum) * 100 / (maximum - minimum);
}

bool HmiControl::validate(std::string *error) const
{
if (!isSupportedControlType(type))
const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type);
if (descriptor == nullptr)
{
setError(error, "不支持的 HMI 控件类型");
return false;
@@ -59,9 +119,26 @@ bool HmiControl::validate(std::string *error) const
setError(error, "HMI 控件 ID 不能为空");
return false;
}
if (bounds.width <= 0 || bounds.height <= 0)
if (id.size() > ProjectLimits::kMaximumIdBytes)
{
setError(error, "HMI 控件的宽度和高度必须大于 0");
setError(error, "HMI 控件 ID 不能超过 128 个 UTF-8 字节");
return false;
}
if (text.size() > ProjectLimits::kMaximumTextBytes)
{
setError(error, "HMI 控件文本不能超过 4096 个 UTF-8 字节");
return false;
}
if (bounds.width <= 0 || bounds.height <= 0
|| bounds.width > ProjectLimits::kMaximumHmiControlWidth
|| bounds.height > ProjectLimits::kMaximumHmiControlHeight)
{
setError(error, "HMI 控件宽度和高度必须在 1~8192 范围内");
return false;
}
if (properties.size() > ProjectLimits::kMaximumHmiProperties)
{
setError(error, "单个 HMI 控件最多保存 64 对扩展属性");
return false;
}
for (const auto &property : properties)
@@ -71,31 +148,44 @@ bool HmiControl::validate(std::string *error) const
setError(error, "HMI 控件属性名称不能为空");
return false;
}
}
if (requiresBitBinding(type))
{
if (binding.has_value() && binding->area() != RegisterArea::M)
if (property.first.size() > ProjectLimits::kMaximumPropertyKeyBytes)
{
setError(error, "按钮和指示灯必须绑定 M 区地址");
setError(error, "HMI 控件属性名称不能超过 128 个 UTF-8 字节");
return false;
}
}
if (requiresWordBinding(type))
{
if (binding.has_value() && binding->area() != RegisterArea::D)
if (property.second.size() > ProjectLimits::kMaximumPropertyValueBytes)
{
setError(error, "HMI 控件属性值不能超过 4096 个 UTF-8 字节");
return false;
}
if (!validateAppearanceProperty(property.first, property.second, error))
{
setError(error, "数值控件必须绑定 D 区地址");
return false;
}
}
if (binding.has_value() && !binding->isValid())
const std::optional<RegisterArea> binding_area =
hmiBindingArea(descriptor->bindingKind);
if (binding.has_value() && !binding_area.has_value())
{
setError(error, "HMI 控件绑定了无效地址");
setError(error, "该 HMI 控件不能绑定寄存器");
return false;
}
if (binding.has_value() && binding_area.has_value()
&& binding->area() != *binding_area)
{
if (descriptor->bindingKind == HmiBindingKind::Bit)
{
setError(error, "HMI 位控件必须绑定 M 区地址");
}
else
{
setError(error, "HMI 字控件必须绑定 D 区地址");
}
return false;
}
if (!supportsRegisterBinding(type) && binding.has_value())
if (binding.has_value() && !binding->isValid())
{
setError(error, "文本和页面跳转控件不能绑定寄存器");
setError(error, "HMI 控件绑定了无效地址");
return false;
}
if (type == HmiControlType::PageJump)
@@ -105,18 +195,47 @@ bool HmiControl::validate(std::string *error) const
setError(error, "页面跳转控件缺少跳转配置");
return false;
}
if (pageJump->targetPageId.size() > ProjectLimits::kMaximumIdBytes)
{
setError(error, "页面跳转目标 ID 不能超过 128 个 UTF-8 字节");
return false;
}
}
else if (pageJump.has_value())
{
setError(error, "非页面跳转控件不能包含跳转配置");
return false;
}
if (type == HmiControlType::ProgressBar)
{
if (!progressBar.has_value() || !progressBar->isValid())
{
setError(error, "进度条最小值必须小于最大值");
return false;
}
}
else if (progressBar.has_value())
{
setError(error, "非进度条控件不能包含进度条配置");
return false;
}
return true;
}

bool HmiControl::isConfigured() const
{
if (type == HmiControlType::Label)
const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type);
if (descriptor == nullptr)
{
return false;
}
if (type == HmiControlType::ProgressBar
&& (!progressBar.has_value() || !progressBar->isValid()))
{
return false;
}
if (!descriptor->requiresBindingForRunning
&& type != HmiControlType::PageJump)
{
return true;
}
@@ -124,15 +243,15 @@ bool HmiControl::isConfigured() const
{
return pageJump.has_value() && !pageJump->targetPageId.empty();
}
if (!binding.has_value() || !binding->isValid())
if (!descriptor->requiresBindingForRunning
|| !binding.has_value()
|| !binding->isValid())
{
return false;
}
if (requiresBitBinding(type))
{
return binding->area() == RegisterArea::M;
}
return !requiresWordBinding(type) || binding->area() == RegisterArea::D;
const std::optional<RegisterArea> binding_area =
hmiBindingArea(descriptor->bindingKind);
return binding_area.has_value() && binding->area() == *binding_area;
}

bool HmiPage::validate(std::string *error) const
@@ -142,9 +261,26 @@ bool HmiPage::validate(std::string *error) const
setError(error, "HMI 页面 ID 和名称不能为空");
return false;
}
if (width <= 0 || height <= 0)
if (id.size() > ProjectLimits::kMaximumIdBytes)
{
setError(error, "HMI 页面 ID 不能超过 128 个 UTF-8 字节");
return false;
}
if (name.size() > ProjectLimits::kMaximumTextBytes)
{
setError(error, "HMI 页面名称不能超过 4096 个 UTF-8 字节");
return false;
}
if (width <= 0 || height <= 0
|| width > ProjectLimits::kMaximumHmiPageWidth
|| height > ProjectLimits::kMaximumHmiPageHeight)
{
setError(error, "HMI 页面宽度和高度必须在 1~8192 范围内");
return false;
}
if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage)
{
setError(error, "HMI 页面尺寸必须大于 0");
setError(error, "单个 HMI 页面最多包含 512 个控件");
return false;
}



+ 33
- 4
app/src/domain/hmi_model.h 查看文件

@@ -8,12 +8,24 @@
#pragma once

#include "register_address.h"
#include "project_limits.h"

#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <vector>

namespace HmiAppearanceProperty
{

inline constexpr const char kTextColor[] = "textColor";
inline constexpr const char kFontSize[] = "fontSize";
inline constexpr const char kFontBold[] = "fontBold";
inline constexpr const char kFontItalic[] = "fontItalic";

} // namespace HmiAppearanceProperty

/**
* @brief 描述 HMI 控件在页面坐标系中的位置和尺寸
*
@@ -42,7 +54,10 @@ enum class HmiControlType
NumericDisplay, // 数值显示
NumericInput, // 数值输入
Label, // 标签
PageJump // 页面跳转
PageJump, // 页面跳转
AlarmList, // 报警列表
ProgressBar, // 进度条
Count // 已注册控件类型数量,不作为实际控件使用
};

/**
@@ -61,6 +76,19 @@ struct HmiPageJumpConfig
std::string targetPageId;
};

/**
* @brief 进度条的有效值范围和文字显示方式
*/
struct HmiProgressBarConfig
{
std::int16_t minimumValue = 0;
std::int16_t maximumValue = 100;
bool showValue = true;

bool isValid() const;
int percentageForValue(std::int16_t value) const;
};

/**
* @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置
*
@@ -76,13 +104,14 @@ struct HmiControl
std::map<std::string, std::string> properties;
HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn;
std::optional<HmiPageJumpConfig> pageJump;
std::optional<HmiProgressBarConfig> progressBar;

/**
* @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定
* @param error 可选错误输出,失败时写入首个校验原因
* @return 配置完整且满足控件类型绑定规则时返回 true
*
* 按钮和指示灯必须绑定有效 M 地址,数值显示和数值输入必须绑定有效 D 地址
* 位控件必须绑定有效 M 地址,字控件必须绑定有效 D 地址
*/
bool validate(std::string *error = nullptr) const;
bool isConfigured() const;
@@ -97,8 +126,8 @@ struct HmiPage
{
std::string id;
std::string name;
int width = 800;
int height = 480;
int width = ProjectLimits::kDefaultHmiPageWidth;
int height = ProjectLimits::kDefaultHmiPageHeight;
std::vector<HmiControl> controls;

/**


+ 65
- 0
app/src/domain/project_limits.h 查看文件

@@ -0,0 +1,65 @@
#pragma once

#include <cstddef>

namespace ProjectLimits {

constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U;

constexpr std::size_t kMaximumHmiPages = 128U;
constexpr std::size_t kMaximumHmiControlsPerPage = 512U;
constexpr std::size_t kMaximumAlarmDefinitions = 512U;
constexpr std::size_t kMaximumRegisterComments = 8002U;
constexpr std::size_t kMaximumControlLogics = 128U;
constexpr std::size_t kMaximumRungsPerLogic = 1024U;

constexpr std::size_t kMaximumExpressionNodesPerRung = 4096U;
constexpr std::size_t kMaximumExpressionDepth = 20U;
constexpr std::size_t kMaximumExpressionChildren = 256U;
constexpr int kMaximumConditionColumns = 10;
constexpr int kMaximumLadderColumns = 11;
constexpr int kMaximumLogicRows = 256;

static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1);

constexpr std::size_t kMaximumHmiProperties = 64U;
constexpr std::size_t kMaximumIdBytes = 128U;
constexpr std::size_t kMaximumTextBytes = 4096U;
constexpr std::size_t kMaximumHmiControlTextCharacters = 12U;
constexpr std::size_t kMaximumAlarmTitleCharacters = 12U;
constexpr std::size_t kMaximumAlarmMessageCharacters = 20U;
constexpr std::size_t kMaximumPropertyKeyBytes = 128U;
constexpr std::size_t kMaximumPropertyValueBytes = 4096U;
constexpr std::size_t kMaximumRegisterCommentBytes = 64U;
constexpr std::size_t kMaximumRungCommentBytes = 128U;

constexpr int kMinimumHmiPageWidth = 320;
constexpr int kMaximumHmiPageWidth = 1600;
constexpr int kMinimumHmiPageHeight = 200;
constexpr int kMaximumHmiPageHeight = 800;
constexpr int kDefaultHmiPageWidth = 800;
constexpr int kDefaultHmiPageHeight = 400;
constexpr int kMaximumHmiControlWidth = 8192;
constexpr int kMaximumHmiControlHeight = 8192;
constexpr int kMinimumHmiFontPointSize = 6;
constexpr int kMaximumHmiFontPointSize = 72;
constexpr int kAlarmDefaultFontPointReduction = 2;
constexpr int kMaximumVisibleAlarmRows = 5;

constexpr std::size_t kMaximumPollAddresses = 1024U;
constexpr std::size_t kMaximumPollBlocks = 64U;
constexpr int kMaximumModbusReadCount = 120;

constexpr int kMinimumPlcServerAddress = 1;
constexpr int kMaximumPlcServerAddress = 247;
constexpr int kMinimumResponseTimeoutMs = 100;
constexpr int kMaximumResponseTimeoutMs = 30000;
constexpr int kMinimumRetries = 0;
constexpr int kMaximumRetries = 5;
constexpr int kMinimumPollIntervalMs = 50;
constexpr int kMaximumPollIntervalMs = 10000;

constexpr std::size_t kMaximumPendingWrites = 1U;
constexpr int kMaximumOutputMessages = 1000;

} // namespace ProjectLimits

+ 176
- 0
app/src/domain/project_model.cpp 查看文件

@@ -1,6 +1,9 @@
#include "project_model.h"

#include "project_limits.h"

#include <algorithm>
#include <cctype>

namespace {

@@ -53,8 +56,59 @@ bool containsDuplicateName(const std::vector<TItem> &items)
return false;
}

bool isBlank(const std::string &value)
{
return value.empty()
|| std::all_of(
value.cbegin(), value.cend(),
[](unsigned char character) { return std::isspace(character) != 0; });
}

bool containsLineBreak(const std::string &value)
{
return value.find('\r') != std::string::npos
|| value.find('\n') != std::string::npos;
}

} // namespace

bool RegisterComment::validate(std::string *error) const
{
if (!address.isValid())
{
setError(error, "软元件注释必须使用有效的 M/D 地址");
return false;
}
if (isBlank(text))
{
setError(error, "软元件注释内容不能为空");
return false;
}
if (containsLineBreak(text))
{
setError(error, "软元件注释只能使用单行文本");
return false;
}
if (text.size() > ProjectLimits::kMaximumRegisterCommentBytes)
{
setError(error, "软元件注释不能超过 64 个 UTF-8 字节");
return false;
}
return true;
}

const RegisterComment *Project::findRegisterComment(
const RegisterAddress &address) const
{
const auto comment = std::find_if(
registerComments.cbegin(), registerComments.cend(),
[&address](const RegisterComment &candidate)
{
return candidate.address == address;
});
return comment == registerComments.cend() ? nullptr : &*comment;
}

bool Project::validate(std::string *error) const
{
if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty())
@@ -62,6 +116,42 @@ bool Project::validate(std::string *error) const
setError(error, "工程 ID、名称和格式版本不能为空");
return false;
}
if (metadata.id.size() > ProjectLimits::kMaximumIdBytes
|| metadata.formatVersion.size() > ProjectLimits::kMaximumIdBytes)
{
setError(error, "工程 ID 和格式版本不能超过 128 个 UTF-8 字节");
return false;
}
if (metadata.name.size() > ProjectLimits::kMaximumTextBytes)
{
setError(error, "工程名称不能超过 4096 个 UTF-8 字节");
return false;
}
if (initialHmiPageId.size() > ProjectLimits::kMaximumIdBytes)
{
setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节");
return false;
}
if (hmiPages.size() > ProjectLimits::kMaximumHmiPages)
{
setError(error, "单个工程最多包含 128 个 HMI 页面");
return false;
}
if (alarmDefinitions.size() > ProjectLimits::kMaximumAlarmDefinitions)
{
setError(error, "单个工程最多包含 512 条报警定义");
return false;
}
if (registerComments.size() > ProjectLimits::kMaximumRegisterComments)
{
setError(error, "单个工程最多包含 8002 条软元件注释");
return false;
}
if (controlLogics.size() > ProjectLimits::kMaximumControlLogics)
{
setError(error, "单个工程最多包含 128 组控制逻辑");
return false;
}
if (containsDuplicateId(hmiPages))
{
setError(error, "工程内的 HMI 页面 ID 必须唯一");
@@ -101,6 +191,37 @@ bool Project::validate(std::string *error) const
setError(error, "工程内的控制逻辑名称必须唯一");
return false;
}
if (containsDuplicateId(alarmDefinitions))
{
setError(error, "工程内的报警定义 ID 必须唯一");
return false;
}
for (const AlarmDefinition &definition : alarmDefinitions)
{
if (!definition.validate(error))
{
return false;
}
}
for (auto current = registerComments.cbegin(); current != registerComments.cend(); ++current)
{
if (!current->validate(error))
{
return false;
}
const auto duplicate = std::find_if(
current + 1,
registerComments.cend(),
[&current](const RegisterComment &candidate)
{
return candidate.address == current->address;
});
if (duplicate != registerComments.cend())
{
setError(error, "同一 M/D 地址只能保存一条软元件注释");
return false;
}
}
for (const HmiPage &page : hmiPages)
{
// 工程聚合校验会向下委托页面和控件的完整规则
@@ -136,6 +257,57 @@ bool Project::validate(std::string *error) const
return false;
}
}

std::vector<RegisterAddress> poll_addresses;
for (const HmiPage &page : hmiPages)
{
for (const HmiControl &control : page.controls)
{
if (control.binding.has_value())
{
poll_addresses.push_back(*control.binding);
}
}
}
for (const AlarmDefinition &definition : alarmDefinitions)
{
poll_addresses.push_back(definition.address);
}
for (const ControlLogic &logic : controlLogics)
{
for (const LadderRung &rung : logic.rungs)
{
std::vector<const LogicNode *> nodes;
if (rung.condition.has_value())
{
collectConditionNodes(*rung.condition, &nodes);
}
if (rung.output.has_value())
{
nodes.push_back(&*rung.output);
}
for (const LogicNode *node : nodes)
{
collectRegisterAddressesForLogicNode(node->config, &poll_addresses);
}
}
}
std::sort(
poll_addresses.begin(), poll_addresses.end(),
[](const RegisterAddress &left, const RegisterAddress &right)
{
return left.area() == right.area()
? left.index() < right.index()
: left.area() == RegisterArea::M;
});
poll_addresses.erase(
std::unique(poll_addresses.begin(), poll_addresses.end()),
poll_addresses.end());
if (poll_addresses.size() > ProjectLimits::kMaximumPollAddresses)
{
setError(error, "工程中需要 PLC 轮询的去重 M/D 地址最多为 1024 个");
return false;
}
return true;
}

@@ -159,5 +331,9 @@ bool Project::validateForRunning(std::string *error) const
return false;
}
}
if (!validateLogicResourceReferencesForRunning(controlLogics, error))
{
return false;
}
return true;
}

+ 14
- 0
app/src/domain/project_model.h 查看文件

@@ -1,11 +1,20 @@
#pragma once

#include "alarm_model.h"
#include "control_logic_model.h"
#include "hmi_model.h"

#include <string>
#include <vector>

struct RegisterComment
{
RegisterAddress address{RegisterArea::M, 0};
std::string text;

bool validate(std::string *error = nullptr) const;
};

// 描述工程的基本身份和文件格式信息
struct ProjectMetadata
{
@@ -26,9 +35,14 @@ struct Project
std::vector<HmiPage> hmiPages;
// 进入运行态时显示的初始 HMI 页面
std::string initialHmiPageId;
// 工程统一管理的报警定义
std::vector<AlarmDefinition> alarmDefinitions;
// 工程级 M/D 地址注释,不包含当前值和引用位置
std::vector<RegisterComment> registerComments;
// 工程包含的控制逻辑集合
std::vector<ControlLogic> controlLogics;

const RegisterComment *findRegisterComment(const RegisterAddress &address) const;
// 校验工程配置并通过 error 返回失败原因
bool validate(std::string *error = nullptr) const;
bool validateForRunning(std::string *error = nullptr) const;


+ 817
- 77
app/src/infrastructure/json_project_storage.cpp
文件差異過大導致無法顯示
查看文件


+ 101
- 9
app/src/infrastructure/plc_communication_service.cpp 查看文件

@@ -15,7 +15,6 @@

namespace {

constexpr int kMaximumReadCount = 120;
constexpr int kRecoveryProbeIntervalMs = 2000;

std::string toUtf8(const QString &value)
@@ -42,6 +41,67 @@ bool isReadingState(PlcConnectionState state)
|| state == PlcConnectionState::Recovering;
}

bool normalizePollAddresses(
const std::vector<RegisterAddress> &addresses,
std::vector<RegisterAddress> *normalized,
std::string *error)
{
*normalized = addresses;
if (std::any_of(
normalized->cbegin(), normalized->cend(),
[](const RegisterAddress &address) { return !address.isValid(); }))
{
*error = "PLC 轮询地址中包含无效的 M/D 地址";
return false;
}
std::sort(
normalized->begin(), normalized->end(),
[](const RegisterAddress &left, const RegisterAddress &right)
{
return left.area() == right.area()
? left.index() < right.index()
: left.area() == RegisterArea::M;
});
normalized->erase(
std::unique(normalized->begin(), normalized->end()), normalized->end());
if (normalized->size() > ProjectLimits::kMaximumPollAddresses)
{
*error = "PLC 轮询的去重 M/D 地址最多为 1024 个";
return false;
}
return true;
}

std::size_t pollBlockCount(const std::vector<RegisterAddress> &addresses)
{
if (addresses.empty())
{
return 2U;
}
std::size_t blocks = 0U;
RegisterArea current_area = RegisterArea::M;
int start_address = 0;
int count = 0;
for (const RegisterAddress &address : addresses)
{
if (count == 0
|| address.area() != current_area
|| address.index() > start_address + count
|| count >= ProjectLimits::kMaximumModbusReadCount)
{
++blocks;
current_area = address.area();
start_address = address.index();
count = 1;
}
else
{
count = address.index() - start_address + 1;
}
}
return blocks;
}

} // namespace

PlcCommunicationService::PlcCommunicationService(
@@ -115,10 +175,11 @@ PlcCommunicationService::~PlcCommunicationService() = default;
PlcCommunicationResult PlcCommunicationService::connectDevice(
const PlcSerialConfiguration &configuration)
{
if (QString::fromStdString(configuration.portName).trimmed().isEmpty()
|| configuration.serverAddress < 1 || configuration.serverAddress > 247)
const PlcCommunicationResult validation =
validatePlcSerialConfiguration(configuration);
if (!validation.succeeded)
{
return {false, "必须填写串口端口,并将 PLC 站号设置为 1~247"};
return validation;
}
if (state_ == PlcConnectionState::Disconnected
&& master_->state() != QModbusDevice::UnconnectedState)
@@ -179,16 +240,27 @@ void PlcCommunicationService::disconnectDevice()
setState(PlcConnectionState::Disconnected);
}

void PlcCommunicationService::setPollAddresses(
PlcCommunicationResult PlcCommunicationService::setPollAddresses(
const std::vector<RegisterAddress> &addresses)
{
std::vector<RegisterAddress> normalized;
std::string error;
if (!normalizePollAddresses(addresses, &normalized, &error))
{
return {false, error};
}
if (pollBlockCount(normalized) > ProjectLimits::kMaximumPollBlocks)
{
return {false, "PLC 轮询地址拆分后最多允许 64 个读块"};
}
if (pending_reply_ != nullptr)
{
pending_poll_addresses_ = addresses;
pending_poll_addresses_ = std::move(normalized);
poll_update_pending_ = true;
return;
return {true, {}};
}
applyPollAddresses(addresses);
applyPollAddresses(normalized);
return {true, {}};
}

PlcConnectionState PlcCommunicationService::state() const
@@ -259,7 +331,8 @@ void PlcCommunicationService::rebuildPollBlocks()
|| poll_blocks_.back().area != address.area()
|| address.index() > poll_blocks_.back().startAddress
+ poll_blocks_.back().count
|| poll_blocks_.back().count >= kMaximumReadCount)
|| poll_blocks_.back().count
>= ProjectLimits::kMaximumModbusReadCount)
{
poll_blocks_.push_back({address.area(), address.index(), 1});
}
@@ -491,6 +564,10 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite(
{
return {false, RegisterError::Unavailable};
}
if (pending_write_reply_ != nullptr)
{
return {false, RegisterError::WriteRejected};
}
QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1);
unit.setValue(0, value ? 1U : 0U);
QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress);
@@ -499,10 +576,15 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite(
handleModbusError(master_->error());
return {false, RegisterError::WriteRejected};
}
pending_write_reply_ = reply;
connect(reply, &QModbusReply::finished,
this,
[this, reply, generation = connection_generation_]
{
if (pending_write_reply_ == reply)
{
pending_write_reply_ = nullptr;
}
if (generation != connection_generation_)
{
reply->deleteLater();
@@ -524,6 +606,10 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite(
{
return {false, RegisterError::Unavailable};
}
if (pending_write_reply_ != nullptr)
{
return {false, RegisterError::WriteRejected};
}
QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1);
unit.setValue(0, static_cast<quint16>(value));
QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress);
@@ -532,10 +618,15 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite(
handleModbusError(master_->error());
return {false, RegisterError::WriteRejected};
}
pending_write_reply_ = reply;
connect(reply, &QModbusReply::finished,
this,
[this, reply, generation = connection_generation_]
{
if (pending_write_reply_ == reply)
{
pending_write_reply_ = nullptr;
}
if (generation != connection_generation_)
{
reply->deleteLater();
@@ -601,6 +692,7 @@ void PlcCommunicationService::closeSerialSession()
poll_timer_.stop();
recovery_timer_.stop();
pending_reply_ = nullptr;
pending_write_reply_ = nullptr;
poll_update_pending_ = false;
pending_poll_addresses_.clear();
if (master_->state() != QModbusDevice::UnconnectedState)


+ 3
- 1
app/src/infrastructure/plc_communication_service.h 查看文件

@@ -30,7 +30,8 @@ public:
PlcCommunicationResult connectDevice(
const PlcSerialConfiguration &configuration) override;
void disconnectDevice() override;
void setPollAddresses(const std::vector<RegisterAddress> &addresses) override;
PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses) override;

PlcConnectionState state() const override;
bool initialReadCompleted() const override;
@@ -84,6 +85,7 @@ private:
std::vector<PollBlock> poll_blocks_;
std::size_t next_poll_block_ = 0;
QModbusReply *pending_reply_ = nullptr;
QModbusReply *pending_write_reply_ = nullptr;
bool poll_update_pending_ = false;
bool disconnecting_ = false;
bool serial_session_opened_ = false;


+ 9
- 0
app/src/main.cpp 查看文件

@@ -14,12 +14,15 @@
#include "domain/active_register_repository.h"
#include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h"
#include "services/alarm_editor_service.h"
#include "services/alarm_service.h"
#include "services/hmi_navigation_service.h"
#include "services/logic_editor_service.h"
#include "services/offline_simulation_service.h"
#include "services/project_service.h"
#include "services/runtime_mode_service.h"
#include "services/register_monitor_service.h"
#include "services/register_comment_service.h"
#include "ui/main_window.h"

int main(int argc, char *argv[])
@@ -40,6 +43,9 @@ int main(int argc, char *argv[])
ActiveRegisterRepository active_register_repository(virtual_register_repository);
PlcCommunicationService plc_communication_service(plc_register_repository);
HmiRuntimeService hmi_runtime_service(active_register_repository);
AlarmEditorService alarm_editor_service(project_service);
AlarmService alarm_service(project_service, active_register_repository);
RegisterCommentService register_comment_service(project_service);
RegisterMonitorService register_monitor_service(active_register_repository);
OfflineSimulationService offline_simulation_service(virtual_register_repository);
RuntimeModeService runtime_mode_service(
@@ -55,7 +61,10 @@ int main(int argc, char *argv[])
hmi_editor_service,
logic_editor_service,
hmi_runtime_service,
alarm_editor_service,
alarm_service,
hmi_navigation_service,
register_comment_service,
register_monitor_service);
main_window.show();



+ 108
- 0
app/src/services/alarm_editor_service.cpp 查看文件

@@ -0,0 +1,108 @@
#include "alarm_editor_service.h"

#include "project_service.h"
#include "domain/project_limits.h"

#include <algorithm>

namespace {

AlarmEditorResult failure(AlarmEditorError error, const std::string &message)
{
return {false, error, message, {}};
}

} // namespace

AlarmEditorService::AlarmEditorService(ProjectService &project_service)
: project_service_(project_service)
{
}

const std::vector<AlarmDefinition> &AlarmEditorService::definitions() const
{
return project_service_.project().alarmDefinitions;
}

const AlarmDefinition *AlarmEditorService::findDefinition(
const std::string &id) const
{
const auto &definitions = project_service_.project().alarmDefinitions;
const auto definition = std::find_if(
definitions.cbegin(), definitions.cend(),
[&id](const AlarmDefinition &candidate) { return candidate.id == id; });
return definition == definitions.cend() ? nullptr : &*definition;
}

AlarmEditorResult AlarmEditorService::addDefinition(
const AlarmDefinition &definition)
{
if (definitions().size() >= ProjectLimits::kMaximumAlarmDefinitions)
{
return failure(AlarmEditorError::InvalidDefinition, "单个工程最多包含 512 条报警定义");
}
AlarmDefinition candidate = definition;
candidate.id = makeUniqueId();
std::string error;
if (!candidate.validate(&error))
{
return failure(AlarmEditorError::InvalidDefinition, error);
}
project_service_.editProject().alarmDefinitions.push_back(candidate);
return {true, AlarmEditorError::None, {}, candidate.id};
}

AlarmEditorResult AlarmEditorService::updateDefinition(
const std::string &id, const AlarmDefinition &definition)
{
if (findDefinition(id) == nullptr)
{
return failure(AlarmEditorError::NotFound, "未找到报警定义");
}
AlarmDefinition candidate = definition;
candidate.id = id;
std::string error;
if (!candidate.validate(&error))
{
return failure(AlarmEditorError::InvalidDefinition, error);
}

Project &project = project_service_.editProject();
auto editable = std::find_if(
project.alarmDefinitions.begin(), project.alarmDefinitions.end(),
[&id](const AlarmDefinition &item) { return item.id == id; });
*editable = std::move(candidate);
return {true, AlarmEditorError::None, {}, id};
}

AlarmEditorResult AlarmEditorService::removeDefinition(const std::string &id)
{
if (findDefinition(id) == nullptr)
{
return failure(AlarmEditorError::NotFound, "未找到报警定义");
}
Project &project = project_service_.editProject();
project.alarmDefinitions.erase(
std::remove_if(
project.alarmDefinitions.begin(), project.alarmDefinitions.end(),
[&id](const AlarmDefinition &definition)
{
return definition.id == id;
}),
project.alarmDefinitions.end());
return {true, AlarmEditorError::None, {}, id};
}

std::string AlarmEditorService::makeUniqueId() const
{
int suffix = 1;
while (true)
{
const std::string candidate = "alarm-" + std::to_string(suffix);
if (findDefinition(candidate) == nullptr)
{
return candidate;
}
++suffix;
}
}

+ 42
- 0
app/src/services/alarm_editor_service.h 查看文件

@@ -0,0 +1,42 @@
#pragma once

#include "domain/alarm_model.h"

#include <string>
#include <vector>

class ProjectService;

enum class AlarmEditorError
{
None,
NotFound,
DuplicateId,
InvalidDefinition
};

struct AlarmEditorResult
{
bool succeeded = false;
AlarmEditorError error = AlarmEditorError::None;
std::string message;
std::string id;
};

class AlarmEditorService
{
public:
explicit AlarmEditorService(ProjectService &project_service);

const std::vector<AlarmDefinition> &definitions() const;
const AlarmDefinition *findDefinition(const std::string &id) const;
AlarmEditorResult addDefinition(const AlarmDefinition &definition);
AlarmEditorResult updateDefinition(
const std::string &id, const AlarmDefinition &definition);
AlarmEditorResult removeDefinition(const std::string &id);

private:
std::string makeUniqueId() const;

ProjectService &project_service_;
};

+ 95
- 0
app/src/services/alarm_service.cpp 查看文件

@@ -0,0 +1,95 @@
#include "alarm_service.h"

#include "domain/alarm_model.h"
#include "domain/register_repository.h"
#include "project_service.h"

#include <algorithm>

AlarmService::AlarmService(
const ProjectService &project_service,
RegisterRepository &repository)
: project_service_(project_service),
repository_(repository)
{
}

void AlarmService::refresh()
{
const auto now = std::chrono::system_clock::now();
for (const AlarmDefinition &definition
: project_service_.project().alarmDefinitions)
{
const std::optional<bool> active = evaluate(definition);
if (!active.has_value())
{
continue;
}
const auto current = std::find_if(
records_.begin(), records_.end(),
[&definition](const AlarmRecord &record)
{
return record.definitionId == definition.id;
});
if (*active && current == records_.end())
{
records_.insert(
records_.begin(),
AlarmRecord{definition.id, definition.message, false, now});
}
else if (!*active && current != records_.end())
{
records_.erase(current);
}
}
}

bool AlarmService::acknowledge(const std::string &definition_id)
{
const auto record = std::find_if(
records_.begin(), records_.end(),
[&definition_id](const AlarmRecord &candidate)
{
return candidate.definitionId == definition_id;
});
if (record == records_.end())
{
return false;
}
record->acknowledged = true;
return true;
}

void AlarmService::reset()
{
records_.clear();
}

const std::vector<AlarmRecord> &AlarmService::records() const
{
return records_;
}

std::optional<bool> AlarmService::evaluate(
const AlarmDefinition &definition) const
{
if (definition.condition == AlarmCondition::MOn)
{
const BitReadResult value = repository_.readBit(definition.address);
return value.succeeded ? std::optional<bool>{value.value} : std::nullopt;
}
const WordReadResult value = repository_.readWord(definition.address);
if (!value.succeeded)
{
return std::nullopt;
}
if (definition.condition == AlarmCondition::DHigh)
{
return value.value >= definition.threshold;
}
if (definition.condition == AlarmCondition::DLow)
{
return value.value <= definition.threshold;
}
return std::nullopt;
}

+ 38
- 0
app/src/services/alarm_service.h 查看文件

@@ -0,0 +1,38 @@
#pragma once

#include <chrono>
#include <optional>
#include <string>
#include <vector>

class ProjectService;
class RegisterRepository;
struct AlarmDefinition;

struct AlarmRecord
{
std::string definitionId;
std::string message;
bool acknowledged = false;
std::chrono::system_clock::time_point occurredAt;
};

class AlarmService
{
public:
AlarmService(
const ProjectService &project_service,
RegisterRepository &repository);

void refresh();
bool acknowledge(const std::string &definition_id);
void reset();
const std::vector<AlarmRecord> &records() const;

private:
std::optional<bool> evaluate(const AlarmDefinition &definition) const;

const ProjectService &project_service_;
RegisterRepository &repository_;
std::vector<AlarmRecord> records_;
};

+ 84
- 0
app/src/services/editor_history.h 查看文件

@@ -0,0 +1,84 @@
#pragma once

#include <cstddef>
#include <optional>
#include <utility>
#include <vector>

/**
* @brief 保存有限数量的编辑前快照并提供撤销、重做
*
* 历史只存在当前编辑会话内。新编辑会清空重做栈,超过容量时丢弃最早记录。
*/
template <typename State>
class EditorHistory final
{
public:
static constexpr std::size_t kMaximumEntries = 100U;

template <typename Equal>
void record(State before, const State &after, Equal equal)
{
if (equal(before, after))
{
return;
}
undo_states_.push_back(std::move(before));
trim(&undo_states_);
redo_states_.clear();
}

std::optional<State> undo(State current)
{
if (undo_states_.empty())
{
return std::nullopt;
}
redo_states_.push_back(std::move(current));
trim(&redo_states_);
State target = std::move(undo_states_.back());
undo_states_.pop_back();
return target;
}

std::optional<State> redo(State current)
{
if (redo_states_.empty())
{
return std::nullopt;
}
undo_states_.push_back(std::move(current));
trim(&undo_states_);
State target = std::move(redo_states_.back());
redo_states_.pop_back();
return target;
}

void clear()
{
undo_states_.clear();
redo_states_.clear();
}

bool canUndo() const
{
return !undo_states_.empty();
}

bool canRedo() const
{
return !redo_states_.empty();
}

private:
static void trim(std::vector<State> *states)
{
while (states->size() > kMaximumEntries)
{
states->erase(states->begin());
}
}

std::vector<State> undo_states_;
std::vector<State> redo_states_;
};

+ 263
- 154
app/src/services/hmi_editor_service.cpp 查看文件

@@ -1,10 +1,14 @@
#include "hmi_editor_service.h"

#include "domain/hmi_control_registry.h"
#include "project_service.h"
#include "domain/project_limits.h"

#include <algorithm>
#include <cctype>
#include <cstddef>
#include <map>
#include <set>
#include <utility>

namespace {
@@ -17,76 +21,6 @@ void setError(std::string *error, const std::string &message)
}
}

std::string controlPrefix(HmiControlType type)
{
switch (type)
{
case HmiControlType::Button:
{
return "button";
}
case HmiControlType::Indicator:
{
return "indicator";
}
case HmiControlType::NumericDisplay:
{
return "numeric-display";
}
case HmiControlType::NumericInput:
{
return "numeric-input";
}
case HmiControlType::Label:
{
return "label";
}
case HmiControlType::PageJump:
{
return "page-jump";
}
default:
{
return "control";
}
}
}

std::string defaultText(HmiControlType type)
{
switch (type)
{
case HmiControlType::Button:
{
return "按钮";
}
case HmiControlType::Indicator:
{
return "指示灯";
}
case HmiControlType::NumericDisplay:
{
return "数值显示";
}
case HmiControlType::NumericInput:
{
return "数值输入";
}
case HmiControlType::Label:
{
return "文本";
}
case HmiControlType::PageJump:
{
return "页面跳转";
}
default:
{
return "控件";
}
}
}

HmiEditorResult failure(HmiEditorError error, const std::string &message)
{
return {false, error, message, {}};
@@ -124,6 +58,86 @@ HmiEditorService::HmiEditorService(ProjectService &project_service)
{
}

HmiEditorService::HistoryState HmiEditorService::captureState() const
{
const Project &project = project_service_.project();
return {project.hmiPages, project.initialHmiPageId};
}

void HmiEditorService::recordHistory(HistoryState before)
{
const HistoryState after = captureState();
history_.record(std::move(before), after, &HmiEditorService::statesEqual);
}

bool HmiEditorService::statesEqual(
const HistoryState &left, const HistoryState &right)
{
if (left.initial_page_id != right.initial_page_id
|| left.pages.size() != right.pages.size())
{
return false;
}
for (std::size_t index = 0; index < left.pages.size(); ++index)
{
if (!pagesEqual(left.pages[index], right.pages[index]))
{
return false;
}
}
return true;
}

bool HmiEditorService::pagesEqual(
const HmiPage &left, const HmiPage &right)
{
if (left.id != right.id || left.name != right.name
|| left.width != right.width || left.height != right.height
|| left.controls.size() != right.controls.size())
{
return false;
}
for (std::size_t index = 0; index < left.controls.size(); ++index)
{
if (!controlsEqual(left.controls[index], right.controls[index]))
{
return false;
}
}
return true;
}

bool HmiEditorService::controlsEqual(
const HmiControl &left, const HmiControl &right)
{
const bool progress_equal = left.progressBar.has_value()
== right.progressBar.has_value()
&& (!left.progressBar.has_value()
|| (left.progressBar->minimumValue == right.progressBar->minimumValue
&& left.progressBar->maximumValue == right.progressBar->maximumValue
&& left.progressBar->showValue == right.progressBar->showValue));
const bool page_jump_equal = left.pageJump.has_value() == right.pageJump.has_value()
&& (!left.pageJump.has_value()
|| left.pageJump->targetPageId == right.pageJump->targetPageId);
return left.id == right.id
&& left.type == right.type
&& left.bounds.x == right.bounds.x
&& left.bounds.y == right.bounds.y
&& left.bounds.width == right.bounds.width
&& left.bounds.height == right.bounds.height
&& left.text == right.text
&& left.binding == right.binding
&& left.properties == right.properties
&& left.buttonOperation == right.buttonOperation
&& page_jump_equal
&& progress_equal;
}

HmiEditorResult HmiEditorService::historyFailure(const std::string &message)
{
return {false, HmiEditorError::InvalidOperation, message, {}};
}

// 遍历工程所有 HmiPage,根据页面 ID 查找页面,找不到返回空指针
const HmiPage *HmiEditorService::findPage(const std::string &page_id) const
{
@@ -178,6 +192,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage()
}

// 仅在首个控件操作前创建默认页面,空工程仍可正常保存
HistoryState before = captureState();
HmiPage page;
page.id = "page-1";
page.name = "主操作页面";
@@ -185,6 +200,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage()
Project &project = project_service_.editProject();
project.hmiPages.push_back(std::move(page));
project.initialHmiPageId = project.hmiPages.back().id;
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, project.hmiPages.back().id};
}

@@ -195,6 +211,14 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name)
return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空");
}
const Project &current = project_service_.project();
if (current.hmiPages.size() >= ProjectLimits::kMaximumHmiPages)
{
return failure(HmiEditorError::InvalidPage, "单个工程最多包含 128 个 HMI 页面");
}
if (name.size() > ProjectLimits::kMaximumTextBytes)
{
return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节");
}
const bool duplicate = std::any_of(
current.hmiPages.cbegin(), current.hmiPages.cend(),
[&name](const HmiPage &page) { return page.name == name; });
@@ -206,15 +230,51 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name)
HmiPage page;
page.id = makeUniquePageId(current);
page.name = name;
HistoryState before = captureState();
Project &project = project_service_.editProject();
project.hmiPages.push_back(std::move(page));
if (project.initialHmiPageId.empty())
{
project.initialHmiPageId = project.hmiPages.back().id;
}
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, project.hmiPages.back().id};
}

HmiEditorResult HmiEditorService::resizePage(
const std::string &page_id, int width, int height)
{
const HmiPage *page = findPage(page_id);
if (page == nullptr)
{
return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面");
}
if (page->width == width && page->height == height)
{
return {true, HmiEditorError::None, {}, page_id};
}

// 先在副本上校验新页面边界,失败时不触碰当前工程
HmiPage candidate = *page;
candidate.width = width;
candidate.height = height;
std::string error;
if (!candidate.validate(&error))
{
return failure(HmiEditorError::InvalidPage, error);
}

HistoryState before = captureState();
Project &project = project_service_.editProject();
auto editable = std::find_if(
project.hmiPages.begin(), project.hmiPages.end(),
[&page_id](const HmiPage &item) { return item.id == page_id; });
editable->width = width;
editable->height = height;
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, page_id};
}

HmiEditorResult HmiEditorService::renamePage(
const std::string &page_id, const std::string &name)
{
@@ -222,6 +282,10 @@ HmiEditorResult HmiEditorService::renamePage(
{
return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空");
}
if (name.size() > ProjectLimits::kMaximumTextBytes)
{
return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节");
}
const Project &current = project_service_.project();
const auto existing = std::find_if(
current.hmiPages.cbegin(), current.hmiPages.cend(),
@@ -240,12 +304,18 @@ HmiEditorResult HmiEditorService::renamePage(
{
return failure(HmiEditorError::DuplicateName, "HMI 页面名称必须唯一");
}
if (existing->name == name)
{
return {true, HmiEditorError::None, {}, page_id};
}

HistoryState before = captureState();
Project &project = project_service_.editProject();
auto page = std::find_if(
project.hmiPages.begin(), project.hmiPages.end(),
[&page_id](const HmiPage &candidate) { return candidate.id == page_id; });
page->name = name;
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, page_id};
}

@@ -287,12 +357,14 @@ HmiEditorResult HmiEditorService::removePage(const std::string &page_id)
}
}

HistoryState before = captureState();
Project &project = project_service_.editProject();
project.hmiPages.erase(
std::remove_if(
project.hmiPages.begin(), project.hmiPages.end(),
[&page_id](const HmiPage &candidate) { return candidate.id == page_id; }),
project.hmiPages.end());
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, page_id};
}

@@ -318,10 +390,12 @@ HmiEditorResult HmiEditorService::movePage(const std::string &page_id, int offse
return failure(HmiEditorError::InvalidOperation, "HMI 页面已经位于目标边界");
}

HistoryState before = captureState();
Project &project = project_service_.editProject();
std::iter_swap(
project.hmiPages.begin() + index,
project.hmiPages.begin() + target_index);
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, page_id};
}

@@ -331,7 +405,13 @@ HmiEditorResult HmiEditorService::setInitialPage(const std::string &page_id)
{
return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面");
}
if (project_service_.project().initialHmiPageId == page_id)
{
return {true, HmiEditorError::None, {}, page_id};
}
HistoryState before = captureState();
project_service_.editProject().initialHmiPageId = page_id;
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, page_id};
}

@@ -343,18 +423,29 @@ HmiEditorResult HmiEditorService::addControl(
{
return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面");
}
if (page->controls.size() >= ProjectLimits::kMaximumHmiControlsPerPage)
{
return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 512 个控件");
}
const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type);
if (descriptor == nullptr)
{
return failure(HmiEditorError::InvalidControl, "不支持的 HMI 控件类型");
}

// 新控件初始不绑定寄存器,避免自动分配地址造成误写风险
HmiControl control = makeControl(*page, type);
HmiControl control = makeControl(*page, *descriptor);
HistoryState before = captureState();
Project &project = project_service_.editProject();
auto target_page = std::find_if(
project.hmiPages.begin(),
project.hmiPages.end(),
[&page_id](const HmiPage &candidate)
{
return candidate.id == page_id;
{
return candidate.id == page_id;
});
target_page->controls.push_back(std::move(control));
recordHistory(std::move(before));
return {true,
HmiEditorError::None,
{},
@@ -363,16 +454,36 @@ HmiEditorResult HmiEditorService::addControl(

HmiEditorResult HmiEditorService::removeControl(
const std::string &page_id, const std::string &control_id)
{
return removeControls(page_id, {control_id});
}

HmiEditorResult HmiEditorService::removeControls(
const std::string &page_id,
const std::vector<std::string> &control_ids)
{
if (findPage(page_id) == nullptr)
{
return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面");
}
if (findControl(page_id, control_id) == nullptr)
if (control_ids.empty())
{
return failure(HmiEditorError::ControlNotFound, "未找到 HMI 控件");
return failure(HmiEditorError::InvalidOperation, "请先选择要删除的 HMI 控件");
}
std::set<std::string> selected_ids;
for (const std::string &control_id : control_ids)
{
if (!selected_ids.insert(control_id).second)
{
return failure(HmiEditorError::InvalidOperation, "删除列表中存在重复控件");
}
if (findControl(page_id, control_id) == nullptr)
{
return failure(HmiEditorError::ControlNotFound, "未找到 HMI 控件");
}
}

HistoryState before = captureState();
Project &project = project_service_.editProject();
auto page = std::find_if(
project.hmiPages.begin(),
@@ -385,12 +496,13 @@ HmiEditorResult HmiEditorService::removeControl(
std::remove_if(
page->controls.begin(),
page->controls.end(),
[&control_id](const HmiControl &candidate)
[&selected_ids](const HmiControl &candidate)
{
return candidate.id == control_id;
return selected_ids.find(candidate.id) != selected_ids.end();
}),
page->controls.end());
return {true, HmiEditorError::None, {}, control_id};
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, control_ids.front()};
}

// 移动 / 缩放控件
@@ -419,7 +531,15 @@ HmiEditorResult HmiEditorService::moveControl(
{
return failure(HmiEditorError::InvalidControl, error);
}
if (control->bounds.x == bounds.x
&& control->bounds.y == bounds.y
&& control->bounds.width == bounds.width
&& control->bounds.height == bounds.height)
{
return {true, HmiEditorError::None, {}, control_id};
}

HistoryState before = captureState();
Project &project = project_service_.editProject();
auto target = std::find_if(
project.hmiPages.begin(), project.hmiPages.end(),
@@ -428,6 +548,7 @@ HmiEditorResult HmiEditorService::moveControl(
target->controls.begin(), target->controls.end(),
[&control_id](const HmiControl &item) { return item.id == control_id; });
editable->bounds = bounds;
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, control_id};
}

@@ -462,6 +583,11 @@ HmiEditorResult HmiEditorService::updateControl(
return failure(HmiEditorError::DuplicateId, "同一 HMI 页面内的控件 ID 必须唯一");
}

if (controlsEqual(*existing, control))
{
return {true, HmiEditorError::None, {}, control.id};
}
HistoryState before = captureState();
Project &project = project_service_.editProject();
auto target = std::find_if(
project.hmiPages.begin(), project.hmiPages.end(),
@@ -470,32 +596,59 @@ HmiEditorResult HmiEditorService::updateControl(
target->controls.begin(), target->controls.end(),
[&control_id](const HmiControl &item) { return item.id == control_id; });
*editable = control;
recordHistory(std::move(before));
return {true, HmiEditorError::None, {}, control.id};
}

bool HmiEditorService::canUndo() const
{
return history_.canUndo();
}

bool HmiEditorService::canRedo() const
{
return history_.canRedo();
}

HmiEditorResult HmiEditorService::undo()
{
const std::optional<HistoryState> target = history_.undo(captureState());
if (!target.has_value())
{
return historyFailure("没有可撤销的 HMI 编辑操作");
}
Project &project = project_service_.editProject();
project.hmiPages = target->pages;
project.initialHmiPageId = target->initial_page_id;
return {true, HmiEditorError::None, {}, {}};
}

HmiEditorResult HmiEditorService::redo()
{
const std::optional<HistoryState> target = history_.redo(captureState());
if (!target.has_value())
{
return historyFailure("没有可重做的 HMI 编辑操作");
}
Project &project = project_service_.editProject();
project.hmiPages = target->pages;
project.initialHmiPageId = target->initial_page_id;
return {true, HmiEditorError::None, {}, {}};
}

void HmiEditorService::clearHistory()
{
history_.clear();
}

bool HmiEditorService::validateEditableControl(
const HmiPage &page, const HmiControl &control, std::string *error) const
{
switch (control.type)
{
case HmiControlType::Button:
case HmiControlType::Indicator:
case HmiControlType::NumericDisplay:
case HmiControlType::NumericInput:
case HmiControlType::Label:
case HmiControlType::PageJump:
break;
default:
setError(error, "不支持的 HMI 控件类型");
return false;
}
if (control.id.empty())
if (!control.validate(error))
{
setError(error, "HMI 控件 ID 不能为空");
return false;
}
if (control.bounds.width <= 0 || control.bounds.height <= 0
|| control.bounds.x < 0 || control.bounds.y < 0
if (control.bounds.x < 0 || control.bounds.y < 0
|| control.bounds.width > page.width || control.bounds.height > page.height
|| control.bounds.x > page.width - control.bounds.width
|| control.bounds.y > page.height - control.bounds.height)
@@ -503,59 +656,11 @@ bool HmiEditorService::validateEditableControl(
setError(error, "HMI 控件不能超出页面范围");
return false;
}
for (const auto &property : control.properties)
{
if (property.first.empty())
{
setError(error, "HMI 控件属性名称不能为空");
return false;
}
}
if (control.binding.has_value() && !control.binding->isValid())
{
setError(error, "HMI 控件绑定了无效地址");
return false;
}
if (control.binding.has_value()
&& (control.type == HmiControlType::Button
|| control.type == HmiControlType::Indicator)
&& control.binding->area() != RegisterArea::M)
{
setError(error, "按钮和指示灯必须绑定 M 区地址");
return false;
}
if (control.binding.has_value()
&& (control.type == HmiControlType::NumericDisplay
|| control.type == HmiControlType::NumericInput)
&& control.binding->area() != RegisterArea::D)
{
setError(error, "数值控件必须绑定 D 区地址");
return false;
}
if ((control.type == HmiControlType::Label
|| control.type == HmiControlType::PageJump)
&& control.binding.has_value())
{
setError(error, "文本和页面跳转控件不能绑定寄存器");
return false;
}
if (control.type == HmiControlType::PageJump)
if (control.type == HmiControlType::PageJump
&& !control.pageJump->targetPageId.empty()
&& findPage(control.pageJump->targetPageId) == nullptr)
{
if (!control.pageJump.has_value())
{
setError(error, "页面跳转控件缺少跳转配置");
return false;
}
if (!control.pageJump->targetPageId.empty()
&& findPage(control.pageJump->targetPageId) == nullptr)
{
setError(error, "页面跳转控件的目标页面不存在");
return false;
}
}
else if (control.pageJump.has_value())
{
setError(error, "非页面跳转控件不能包含跳转配置");
setError(error, "页面跳转控件的目标页面不存在");
return false;
}
return true;
@@ -576,18 +681,22 @@ bool HmiEditorService::hasDuplicateControlId(

// 根据控件类型生成默认样式、文字、大小、初始坐标
HmiControl HmiEditorService::makeControl(
const HmiPage &page, HmiControlType type)
const HmiPage &page, const HmiControlDescriptor &descriptor)
{
HmiControl control;
control.id = makeUniqueId(page, controlPrefix(type));
control.type = type;
control.text = defaultText(type);
control.bounds.width = type == HmiControlType::Indicator ? 64 : 120;
control.bounds.height = type == HmiControlType::Indicator ? 64 : 40;
if (type == HmiControlType::PageJump)
control.id = makeUniqueId(page, descriptor.idPrefix);
control.type = descriptor.type;
control.text = descriptor.defaultText;
control.bounds.width = std::min(descriptor.defaultBounds.width, page.width);
control.bounds.height = std::min(descriptor.defaultBounds.height, page.height);
if (descriptor.type == HmiControlType::PageJump)
{
control.pageJump = HmiPageJumpConfig{};
}
if (descriptor.type == HmiControlType::ProgressBar)
{
control.progressBar = HmiProgressBarConfig{};
}
const int offset = static_cast<int>(page.controls.size()) * 16;
control.bounds.x = std::min(20 + offset, page.width - control.bounds.width);
control.bounds.y = std::min(20 + offset, page.height - control.bounds.height);


+ 31
- 1
app/src/services/hmi_editor_service.h 查看文件

@@ -8,10 +8,13 @@
#pragma once

#include "domain/hmi_model.h"
#include "editor_history.h"

#include <string>
#include <vector>

class ProjectService;
struct HmiControlDescriptor;

/**
* @brief HMI 编辑操作的失败分类
@@ -80,6 +83,8 @@ public:
*/
HmiEditorResult ensureDefaultPage();
HmiEditorResult addPage(const std::string &name);
HmiEditorResult resizePage(
const std::string &page_id, int width, int height);
HmiEditorResult renamePage(
const std::string &page_id, const std::string &name);
HmiEditorResult removePage(const std::string &page_id);
@@ -103,6 +108,9 @@ public:
*/
HmiEditorResult removeControl(
const std::string &page_id, const std::string &control_id);
HmiEditorResult removeControls(
const std::string &page_id,
const std::vector<std::string> &control_ids);
/**
* @brief 更新控件位置和尺寸
* @param page_id 所属页面唯一标识
@@ -128,7 +136,28 @@ public:
const std::string &control_id,
const HmiControl &control);

bool canUndo() const;
bool canRedo() const;
HmiEditorResult undo();
HmiEditorResult redo();
void clearHistory();

private:
struct HistoryState
{
std::vector<HmiPage> pages;
std::string initial_page_id;
};

HistoryState captureState() const;
void recordHistory(HistoryState before);
static bool statesEqual(
const HistoryState &left, const HistoryState &right);
static bool controlsEqual(
const HmiControl &left, const HmiControl &right);
static bool pagesEqual(const HmiPage &left, const HmiPage &right);
static HmiEditorResult historyFailure(const std::string &message);

/**
* @brief 校验控件编辑后仍满足页面内的基础约束
* @param page 控件所属页面
@@ -158,7 +187,7 @@ private:
* @return 未绑定寄存器的默认控件配置
*/
static HmiControl makeControl(
const HmiPage &page, HmiControlType type);
const HmiPage &page, const HmiControlDescriptor &descriptor);
/**
* @brief 在页面内生成带指定前缀的唯一控件标识
* @param page 待检查页面
@@ -172,4 +201,5 @@ private:
* @brief 非拥有的工程服务依赖,由应用入口保证生命周期
*/
ProjectService &project_service_;
EditorHistory<HistoryState> history_;
};

+ 23
- 16
app/src/services/hmi_runtime_service.cpp 查看文件

@@ -1,5 +1,7 @@
#include "hmi_runtime_service.h"

#include "domain/hmi_control_registry.h"

namespace {

HmiRuntimeReadResult readFailure(HmiRuntimeError error)
@@ -12,17 +14,6 @@ HmiRuntimeWriteResult writeFailure(HmiRuntimeError error)
return {false, error};
}

bool isBitControl(HmiControlType type)
{
return type == HmiControlType::Button || type == HmiControlType::Indicator;
}

bool isWordControl(HmiControlType type)
{
return type == HmiControlType::NumericDisplay
|| type == HmiControlType::NumericInput;
}

} // namespace

HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository)
@@ -32,8 +23,10 @@ HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository)

HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) const
{
// 标签等静态控件不参与寄存器读写
if (!isBitControl(control.type) && !isWordControl(control.type))
const HmiControlDescriptor *descriptor =
findHmiControlDescriptor(control.type);
if (descriptor == nullptr
|| descriptor->runtimeValueKind == HmiRuntimeValueKind::None)
{
return readFailure(HmiRuntimeError::UnsupportedControl);
}
@@ -45,7 +38,16 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c
{
return readFailure(HmiRuntimeError::InvalidBinding);
}
if (isBitControl(control.type))
const std::optional<RegisterArea> binding_area =
hmiBindingArea(descriptor->bindingKind);
if (!binding_area.has_value() || control.binding->area() != *binding_area)
{
return readFailure(HmiRuntimeError::InvalidBinding);
}

switch (descriptor->runtimeValueKind)
{
case HmiRuntimeValueKind::Bit:
{
// 按控件类型选择仓库的 M 位读取契约
const BitReadResult result = repository_.readBit(*control.binding);
@@ -55,7 +57,7 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c
}
return {true, HmiRuntimeError::None, result.value, 0};
}
if (isWordControl(control.type))
case HmiRuntimeValueKind::Word:
{
const WordReadResult result = repository_.readWord(*control.binding);
if (!result.succeeded)
@@ -64,7 +66,12 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c
}
return {true, HmiRuntimeError::None, false, result.value};
}
return readFailure(HmiRuntimeError::UnsupportedControl);
case HmiRuntimeValueKind::None:
default:
{
return readFailure(HmiRuntimeError::UnsupportedControl);
}
}
}

HmiRuntimeWriteResult HmiRuntimeService::operateButton(


+ 1059
- 27
app/src/services/logic_editor_service.cpp
文件差異過大導致無法顯示
查看文件


+ 73
- 1
app/src/services/logic_editor_service.h 查看文件

@@ -1,6 +1,7 @@
#pragma once

#include "domain/control_logic_model.h"
#include "editor_history.h"

#include <string>
#include <vector>
@@ -47,6 +48,7 @@ public:
std::string firstRungId(const std::string &logic_id) const;
std::string rungIdForNode(
const std::string &logic_id, const std::string &node_id) const;
std::string registerCommentFor(const RegisterAddress &address) const;

LogicEditorResult ensureDefaultLogic();
LogicEditorResult addLogic(const std::string &name);
@@ -58,45 +60,115 @@ public:
LogicEditorResult addRung(const std::string &logic_id);
LogicEditorResult removeRung(
const std::string &logic_id, const std::string &rung_id);
LogicEditorResult updateRungComment(
const std::string &logic_id,
const std::string &rung_id,
const std::string &comment);
LogicEditorResult appendCondition(
const std::string &logic_id,
const std::string &rung_id,
const LogicNodeConfig &config);
LogicEditorResult insertConditionAtColumn(
const std::string &logic_id,
const std::string &rung_id,
int column,
const LogicNodeConfig &config);
LogicEditorResult appendWire(
const std::string &logic_id,
const std::string &rung_id,
int column_span = 1);
LogicEditorResult insertConditionAfter(
const std::string &logic_id,
const std::string &rung_id,
const std::string &target_node_id,
const LogicNodeConfig &config);
LogicEditorResult insertWireAfter(
const std::string &logic_id,
const std::string &rung_id,
const std::string &target_expression_id,
int column_span = 1);
LogicEditorResult replaceWireWithCondition(
const std::string &logic_id,
const std::string &rung_id,
const std::string &wire_expression_id,
const LogicNodeConfig &config);
LogicEditorResult replaceWireColumnWithCondition(
const std::string &logic_id,
const std::string &rung_id,
const std::string &wire_expression_id,
int column_offset,
const LogicNodeConfig &config);
LogicEditorResult addParallelBranch(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &selected_node_ids,
const LogicNodeConfig &config);
LogicEditorResult addParallelWireBranch(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &selected_expression_ids);
LogicEditorResult setOutput(
const std::string &logic_id,
const std::string &rung_id,
const LogicNodeConfig &config);
const LogicNodeConfig &config,
bool configured = false);
LogicEditorResult updateNodeConfig(
const std::string &logic_id,
const std::string &node_id,
const LogicNodeConfig &config);
LogicEditorResult removeNode(
const std::string &logic_id, const std::string &node_id);
LogicEditorResult removeNodes(
const std::string &logic_id,
const std::vector<std::string> &node_ids);
LogicEditorResult removeExpression(
const std::string &logic_id,
const std::string &rung_id,
const std::string &expression_id);
LogicEditorResult removeExpressions(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &expression_ids);

bool canUndo() const;
bool canRedo() const;
LogicEditorResult undo();
LogicEditorResult redo();
void clearHistory();

private:
struct HistoryState
{
std::vector<ControlLogic> logics;
};

HistoryState captureState() const;
void recordHistory(HistoryState before);
void rollbackEdit(HistoryState before, bool modified_before);
static bool statesEqual(
const HistoryState &left, const HistoryState &right);
static bool logicsEqual(
const ControlLogic &left, const ControlLogic &right);
static bool rungsEqual(
const LadderRung &left, const LadderRung &right);
static bool expressionsEqual(
const ConditionExpression &left, const ConditionExpression &right);
static bool nodesEqual(const LogicNode &left, const LogicNode &right);
static bool configsEqual(
const LogicNodeConfig &left, const LogicNodeConfig &right);
static LogicEditorResult historyFailure(const std::string &message);

static bool isConditionConfig(const LogicNodeConfig &config);
static bool isOutputConfig(const LogicNodeConfig &config);
static std::string nodePrefix(const LogicNodeConfig &config);
static std::string makeUniqueNodeId(
const ControlLogic &logic, const std::string &prefix);
static std::string makeUniqueWireId(const ControlLogic &logic);
static std::string makeUniqueExpressionId(const ControlLogic &logic);
static std::string makeUniqueRungId(const ControlLogic &logic);
static LogicEditorResult failure(
LogicEditorError error, const std::string &message);

ProjectService &project_service_;
EditorHistory<HistoryState> history_;
};

+ 3
- 0
app/src/services/offline_simulation_service.cpp 查看文件

@@ -31,6 +31,7 @@ SimulationStartResult OfflineSimulationService::start(
}

timer_.stop();
executor_.resetRuntime();
repository_.clear();
logic_snapshot_ = std::move(snapshot);
successful_scan_count_ = 0;
@@ -45,6 +46,7 @@ SimulationStartResult OfflineSimulationService::start(
void OfflineSimulationService::stop()
{
timer_.stop();
executor_.resetRuntime();
logic_snapshot_.clear();
if (state_ == SimulationState::Stopped)
{
@@ -111,6 +113,7 @@ void OfflineSimulationService::handleTimeout()
void OfflineSimulationService::enterFault(const LogicScanResult &error)
{
timer_.stop();
executor_.resetRuntime();
last_error_ = error;
state_ = SimulationState::Faulted;
emit stateChanged();


+ 59
- 1
app/src/services/plc_communication_gateway.h 查看文件

@@ -1,7 +1,10 @@
#pragma once

#include "domain/register_address.h"
#include "domain/project_limits.h"

#include <algorithm>
#include <cctype>
#include <functional>
#include <string>
#include <vector>
@@ -50,6 +53,61 @@ struct PlcCommunicationResult
std::string message;
};

inline PlcCommunicationResult validatePlcSerialConfiguration(
const PlcSerialConfiguration &configuration)
{
const bool has_port_name = std::any_of(
configuration.portName.cbegin(), configuration.portName.cend(),
[](unsigned char character) { return std::isspace(character) == 0; });
if (!has_port_name)
{
return {false, "必须填写串口端口"};
}
if (configuration.serverAddress < ProjectLimits::kMinimumPlcServerAddress
|| configuration.serverAddress > ProjectLimits::kMaximumPlcServerAddress)
{
return {false, "PLC 站号必须在 1~247 范围内"};
}
if (configuration.baudRate != 9600
&& configuration.baudRate != 19200
&& configuration.baudRate != 38400
&& configuration.baudRate != 57600
&& configuration.baudRate != 115200)
{
return {false, "波特率只支持 9600、19200、38400、57600 或 115200"};
}
if (configuration.dataBits != 7 && configuration.dataBits != 8)
{
return {false, "数据位只支持 7 或 8"};
}
if (configuration.parity != 0
&& configuration.parity != 2
&& configuration.parity != 3)
{
return {false, "校验方式只支持无校验、偶校验或奇校验"};
}
if (configuration.stopBits != 1 && configuration.stopBits != 2)
{
return {false, "停止位只支持 1 或 2"};
}
if (configuration.responseTimeoutMs < ProjectLimits::kMinimumResponseTimeoutMs
|| configuration.responseTimeoutMs > ProjectLimits::kMaximumResponseTimeoutMs)
{
return {false, "PLC 响应超时必须在 100~30000 ms 范围内"};
}
if (configuration.retries < ProjectLimits::kMinimumRetries
|| configuration.retries > ProjectLimits::kMaximumRetries)
{
return {false, "PLC 失败重试次数必须在 0~5 范围内"};
}
if (configuration.pollIntervalMs < ProjectLimits::kMinimumPollIntervalMs
|| configuration.pollIntervalMs > ProjectLimits::kMaximumPollIntervalMs)
{
return {false, "PLC 轮询周期必须在 50~10000 ms 范围内"};
}
return {true, {}};
}

class PlcCommunicationGateway
{
public:
@@ -58,7 +116,7 @@ public:
virtual PlcCommunicationResult connectDevice(
const PlcSerialConfiguration &configuration) = 0;
virtual void disconnectDevice() = 0;
virtual void setPollAddresses(
virtual PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses) = 0;
virtual PlcConnectionState state() const = 0;
virtual bool initialReadCompleted() const = 0;


+ 5
- 0
app/src/services/project_service.cpp 查看文件

@@ -57,6 +57,11 @@ bool ProjectService::isModified() const
return modified_;
}

void ProjectService::restoreModifiedState(bool modified)
{
modified_ = modified;
}

ProjectOperationResult ProjectService::createNewProject(const std::string &name)
{
if (isBlank(name))


+ 2
- 0
app/src/services/project_service.h 查看文件

@@ -35,6 +35,8 @@ public:
const std::string &currentFilePath() const;
bool hasCurrentFile() const;
bool isModified() const;
// 失败编辑回滚时恢复进入编辑前的脏状态
void restoreModifiedState(bool modified);

/**
* @brief 创建未保存的新工程


+ 102
- 0
app/src/services/register_comment_service.cpp 查看文件

@@ -0,0 +1,102 @@
#include "register_comment_service.h"

#include "project_service.h"

#include <algorithm>
#include <cctype>
#include <utility>

namespace {

std::string trim(const std::string &value)
{
const auto first = std::find_if_not(
value.cbegin(), value.cend(),
[](unsigned char character) { return std::isspace(character) != 0; });
const auto last = std::find_if_not(
value.crbegin(), value.crend(),
[](unsigned char character) { return std::isspace(character) != 0; }).base();
return first >= last ? std::string{} : std::string(first, last);
}

RegisterCommentResult failure(
RegisterCommentError error, const std::string &message)
{
return {false, error, message};
}

} // namespace

RegisterCommentService::RegisterCommentService(ProjectService &project_service)
: project_service_(project_service)
{
}

const std::vector<RegisterComment> &RegisterCommentService::comments() const
{
return project_service_.project().registerComments;
}

const RegisterComment *RegisterCommentService::findComment(
const RegisterAddress &address) const
{
return project_service_.project().findRegisterComment(address);
}

RegisterCommentResult RegisterCommentService::setComment(
const RegisterAddress &address, const std::string &text)
{
const std::string normalized = trim(text);
RegisterComment candidate{address, normalized};
std::string error;
if (!candidate.validate(&error))
{
return failure(RegisterCommentError::InvalidComment, error);
}

Project &project = project_service_.editProject();
auto existing = std::find_if(
project.registerComments.begin(), project.registerComments.end(),
[&address](const RegisterComment &comment)
{
return comment.address == address;
});
if (existing == project.registerComments.end())
{
project.registerComments.push_back(std::move(candidate));
}
else
{
existing->text = normalized;
}
std::sort(
project.registerComments.begin(), project.registerComments.end(),
[](const RegisterComment &left, const RegisterComment &right)
{
if (left.address.area() != right.address.area())
{
return left.address.area() == RegisterArea::M;
}
return left.address.index() < right.address.index();
});
return {true, RegisterCommentError::None, {}};
}

RegisterCommentResult RegisterCommentService::removeComment(
const RegisterAddress &address)
{
if (findComment(address) == nullptr)
{
return failure(RegisterCommentError::NotFound, "未找到软元件注释");
}
Project &project = project_service_.editProject();
project.registerComments.erase(
std::remove_if(
project.registerComments.begin(), project.registerComments.end(),
[&address](const RegisterComment &comment)
{
return comment.address == address;
}),
project.registerComments.end());
return {true, RegisterCommentError::None, {}};
}

+ 37
- 0
app/src/services/register_comment_service.h 查看文件

@@ -0,0 +1,37 @@
#pragma once

#include "domain/project_model.h"

#include <string>
#include <vector>

class ProjectService;

enum class RegisterCommentError
{
None,
InvalidComment,
NotFound
};

struct RegisterCommentResult
{
bool succeeded = false;
RegisterCommentError error = RegisterCommentError::None;
std::string message;
};

class RegisterCommentService
{
public:
explicit RegisterCommentService(ProjectService &project_service);

const std::vector<RegisterComment> &comments() const;
const RegisterComment *findComment(const RegisterAddress &address) const;
RegisterCommentResult setComment(
const RegisterAddress &address, const std::string &text);
RegisterCommentResult removeComment(const RegisterAddress &address);

private:
ProjectService &project_service_;
};

+ 34
- 0
app/src/services/register_monitor_service.cpp 查看文件

@@ -112,6 +112,40 @@ RegisterMonitorResult RegisterMonitorService::clear()
return {true, RegisterMonitorError::None, {}, removed_count};
}

RegisterMonitorWriteResult RegisterMonitorService::writeBit(
const RegisterAddress &address, bool value)
{
const RegisterWriteResult result = repository_.writeBit(address, value);
if (result.succeeded)
{
return {true, RegisterError::None, {}};
}
return {false, result.error, result.error == RegisterError::AreaMismatch
? "位写入只允许访问 M 区"
: result.error == RegisterError::InvalidAddress
? "写入地址无效"
: result.error == RegisterError::Unavailable
? "当前寄存器源不可用"
: "寄存器写入请求被拒绝"};
}

RegisterMonitorWriteResult RegisterMonitorService::writeWord(
const RegisterAddress &address, std::int16_t value)
{
const RegisterWriteResult result = repository_.writeWord(address, value);
if (result.succeeded)
{
return {true, RegisterError::None, {}};
}
return {false, result.error, result.error == RegisterError::AreaMismatch
? "字写入只允许访问 D 区"
: result.error == RegisterError::InvalidAddress
? "写入地址无效"
: result.error == RegisterError::Unavailable
? "当前寄存器源不可用"
: "寄存器写入请求被拒绝"};
}

const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const
{
return model_.addresses();


+ 11
- 0
app/src/services/register_monitor_service.h 查看文件

@@ -3,6 +3,7 @@
#include "domain/register_monitor_model.h"
#include "domain/register_repository.h"

#include <cstdint>
#include <functional>
#include <string>
#include <vector>
@@ -24,6 +25,13 @@ struct RegisterMonitorResult
int affectedCount = 0;
};

struct RegisterMonitorWriteResult
{
bool succeeded = false;
RegisterError error = RegisterError::Unavailable;
std::string message;
};

class RegisterMonitorService
{
public:
@@ -32,6 +40,9 @@ public:
RegisterMonitorResult addRange(const std::string &start_address, int count);
RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses);
RegisterMonitorResult clear();
RegisterMonitorWriteResult writeBit(const RegisterAddress &address, bool value);
RegisterMonitorWriteResult writeWord(
const RegisterAddress &address, std::int16_t value);
const std::vector<RegisterAddress> &addresses() const;
std::vector<MonitorValue> values(bool communication_fault) const;
void setAddressesChangedCallback(std::function<void()> callback);


+ 18
- 7
app/src/services/runtime_mode_service.cpp 查看文件

@@ -10,6 +10,7 @@

#include "project_service.h"
#include "domain/active_register_repository.h"
#include "domain/project_limits.h"

#include <algorithm>

@@ -189,7 +190,11 @@ PlcCommunicationResult RuntimeModeService::connectPlc(
{
plc_gateway_->disconnectDevice();
}
refreshPlcPollAddresses();
const PlcCommunicationResult poll_result = refreshPlcPollAddresses();
if (!poll_result.succeeded)
{
return poll_result;
}
setInitialPlcReadCompleted(false);
return plc_gateway_->connectDevice(configuration);
}
@@ -201,11 +206,11 @@ void RuntimeModeService::setMonitorAddresses(
refreshPlcPollAddresses();
}

void RuntimeModeService::refreshPlcPollAddresses()
PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses()
{
if (plc_gateway_ == nullptr)
{
return;
return {false, "PLC 通信服务尚未配置"};
}
std::vector<RegisterAddress> addresses = monitor_addresses_;
const Project &project = project_service_.project();
@@ -219,6 +224,10 @@ void RuntimeModeService::refreshPlcPollAddresses()
}
}
}
for (const AlarmDefinition &definition : project.alarmDefinitions)
{
addresses.push_back(definition.address);
}
for (const ControlLogic &logic : project.controlLogics)
{
for (const LadderRung &rung : logic.rungs)
@@ -234,9 +243,7 @@ void RuntimeModeService::refreshPlcPollAddresses()
}
for (const LogicNode *node : nodes)
{
std::visit(
[&addresses](const auto &config) { addresses.push_back(config.address); },
node->config);
collectRegisterAddressesForLogicNode(node->config, &addresses);
}
}
}
@@ -256,7 +263,11 @@ void RuntimeModeService::refreshPlcPollAddresses()
return left.index() < right.index();
});
addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end());
plc_gateway_->setPollAddresses(addresses);
if (addresses.size() > ProjectLimits::kMaximumPollAddresses)
{
return {false, "PLC 轮询的去重 M/D 地址最多为 1024 个"};
}
return plc_gateway_->setPollAddresses(addresses);
}

void RuntimeModeService::disconnectPlc()


+ 1
- 1
app/src/services/runtime_mode_service.h 查看文件

@@ -76,7 +76,7 @@ public:
RegisterRepository &plc_repository);
PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration);
void setMonitorAddresses(const std::vector<RegisterAddress> &addresses);
void refreshPlcPollAddresses();
PlcCommunicationResult refreshPlcPollAddresses();
void disconnectPlc();
PlcConnectionState plcConnectionState() const;
const std::string &plcError() const;


+ 427
- 20
app/src/services/software_logic_executor.cpp 查看文件

@@ -1,6 +1,7 @@
#include "software_logic_executor.h"

#include <algorithm>
#include <limits>
#include <map>
#include <type_traits>

@@ -73,8 +74,14 @@ bool coilModesAreCompatible(CoilMode existing, CoilMode current)
void LogicTraceValues::clear()
{
nodeValues.clear();
nodePowerValues.clear();
expressionValues.clear();
expressionInputValues.clear();
expressionPowerValues.clear();
rungValues.clear();
tonValues.clear();
counterValues.clear();
wordValues.clear();
}

void LogicTraceSnapshot::clear()
@@ -91,8 +98,14 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic(
if (values != logicValues.cend())
{
projection.nodeValues = values->second.nodeValues;
projection.nodePowerValues = values->second.nodePowerValues;
projection.expressionValues = values->second.expressionValues;
projection.expressionInputValues = values->second.expressionInputValues;
projection.expressionPowerValues = values->second.expressionPowerValues;
projection.rungValues = values->second.rungValues;
projection.tonValues = values->second.tonValues;
projection.counterValues = values->second.counterValues;
projection.wordValues = values->second.wordValues;
}
return projection;
}
@@ -131,9 +144,17 @@ LogicScanResult SoftwareLogicExecutor::validate(
const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config);
if (output == nullptr)
{
if (std::holds_alternative<TonNodeConfig>(rung.output->config)
|| std::holds_alternative<CounterNodeConfig>(rung.output->config)
|| std::holds_alternative<MoveNodeConfig>(rung.output->config)
|| std::holds_alternative<ArithmeticNodeConfig>(
rung.output->config))
{
continue;
}
return failure(
LogicScanError::InvalidLogic,
"梯形图输出不是线圈节点",
"梯形图输出不是有效的输出指令",
logic.id,
rung.id,
rung.output->id);
@@ -152,13 +173,35 @@ LogicScanResult SoftwareLogicExecutor::validate(
output_modes[output->address.index()] = output->mode;
}
}
std::string resource_error;
if (!validateLogicResourceReferencesForRunning(logics, &resource_error))
{
return failure(LogicScanError::InvalidLogic, resource_error);
}
return success();
}

void SoftwareLogicExecutor::resetRuntime()
{
previous_edge_inputs_.clear();
previous_counter_inputs_.clear();
ton_states_.clear();
counter_states_.clear();
}

LogicScanResult SoftwareLogicExecutor::executeScan(
const std::vector<ControlLogic> &logics,
RegisterRepository &repository,
LogicTraceSnapshot *trace) const
LogicTraceSnapshot *trace)
{
return executeScanAt(logics, repository, Clock::now(), trace);
}

LogicScanResult SoftwareLogicExecutor::executeScanAt(
const std::vector<ControlLogic> &logics,
RegisterRepository &repository,
TimePoint now,
LogicTraceSnapshot *trace)
{
const LogicScanResult validation = validate(logics);
if (!validation.succeeded)
@@ -185,9 +228,19 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
continue;
}

bool rung_value = false;
LogicScanResult result = evaluateExpression(
*rung.condition, repository, logic_trace, &rung_value);
// 没有条件节点的网络等价于左侧电源线直接接通,作为恒真条件执行
bool rung_value = true;
LogicScanResult result = success();
if (rung.condition.has_value())
{
result = evaluateExpression(
logic.id,
*rung.condition,
repository,
logic_trace,
true,
&rung_value);
}
if (!result.succeeded)
{
result.logicId = logic.id;
@@ -198,15 +251,28 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
{
logic_trace->rungValues[rung.id] = rung_value;
logic_trace->nodeValues[rung.output->id] = rung_value;
logic_trace->nodePowerValues[rung.output->id] = rung_value;
}
result = writeOutput(
*rung.output, rung_value, repository);
bool output_value = false;
result = executeOutput(
logic.id,
*rung.output,
rung_value,
repository,
now,
logic_trace,
&output_value);
if (!result.succeeded)
{
result.logicId = logic.id;
result.rungId = rung.id;
return result;
}
if (logic_trace != nullptr)
{
logic_trace->nodeValues[rung.output->id] = output_value;
logic_trace->nodePowerValues[rung.output->id] = output_value;
}
}
}
if (trace != nullptr)
@@ -220,8 +286,14 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
if (values != trace->logicValues.cend())
{
trace->nodeValues = values->second.nodeValues;
trace->nodePowerValues = values->second.nodePowerValues;
trace->expressionValues = values->second.expressionValues;
trace->expressionInputValues = values->second.expressionInputValues;
trace->expressionPowerValues = values->second.expressionPowerValues;
trace->rungValues = values->second.rungValues;
trace->tonValues = values->second.tonValues;
trace->counterValues = values->second.counterValues;
trace->wordValues = values->second.wordValues;
}
}
}
@@ -229,50 +301,84 @@ LogicScanResult SoftwareLogicExecutor::executeScan(
}

LogicScanResult SoftwareLogicExecutor::evaluateExpression(
const std::string &logic_id,
const ConditionExpression &expression,
RegisterRepository &repository,
LogicTraceValues *trace,
bool *value) const
bool input_power,
bool *value)
{
if (value == nullptr)
{
return failure(LogicScanError::InvalidLogic, "缺少表达式结果接收对象");
}
if (trace != nullptr)
{
trace->expressionInputValues[expression.id] = input_power;
}
if (expression.kind == ConditionExpressionKind::Wire)
{
*value = true;
if (trace != nullptr)
{
trace->expressionValues[expression.id] = true;
trace->expressionPowerValues[expression.id] = input_power;
}
return success();
}
if (expression.kind == ConditionExpressionKind::Node)
{
LogicScanResult result = evaluateCondition(*expression.node, repository, value);
LogicScanResult result = evaluateCondition(
logic_id, *expression.node, repository, value);
if (result.succeeded && trace != nullptr)
{
trace->nodeValues[expression.node->id] = *value;
trace->nodePowerValues[expression.node->id] = input_power && *value;
trace->expressionValues[expression.id] = *value;
trace->expressionPowerValues[expression.id] = input_power && *value;
}
return result;
}

bool accumulated = expression.kind == ConditionExpressionKind::Series;
bool power = input_power;
for (const ConditionExpression &child : expression.children)
{
bool child_value = false;
LogicScanResult result = evaluateExpression(child, repository, trace, &child_value);
const bool child_input = expression.kind == ConditionExpressionKind::Series
? power : input_power;
LogicScanResult result = evaluateExpression(
logic_id,
child,
repository,
trace,
child_input,
&child_value);
if (!result.succeeded)
{
return result;
}
accumulated = expression.kind == ConditionExpressionKind::Series
? accumulated && child_value : accumulated || child_value;
if (expression.kind == ConditionExpressionKind::Series)
{
power = power && child_value;
}
}
*value = accumulated;
if (trace != nullptr)
{
trace->expressionValues[expression.id] = accumulated;
trace->expressionPowerValues[expression.id] = input_power && accumulated;
}
return success();
}

LogicScanResult SoftwareLogicExecutor::evaluateCondition(
const std::string &logic_id,
const LogicNode &node,
RegisterRepository &repository,
bool *value) const
bool *value)
{
if (value == nullptr)
{
@@ -285,7 +391,8 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition(
}

return std::visit(
[&repository, value, &node](const auto &config) -> LogicScanResult
[this, &logic_id, &repository, value, &node](
const auto &config) -> LogicScanResult
{
using Config = std::decay_t<decltype(config)>;
if constexpr (std::is_same_v<Config, ContactNodeConfig>)
@@ -304,6 +411,37 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition(
? read.value : !read.value;
return success();
}
else if constexpr (std::is_same_v<Config, EdgeContactNodeConfig>)
{
const BitReadResult read = repository.readBit(config.address);
if (!read.succeeded)
{
return failure(
LogicScanError::RegisterReadFailed,
"读取寄存器失败:" + config.address.toString(),
{},
{},
node.id);
}
const auto runtime_key = std::make_pair(logic_id, node.id);
const bool previous = previous_edge_inputs_[runtime_key];
*value = config.mode == EdgeMode::Rising
? read.value && !previous : !read.value && previous;
previous_edge_inputs_[runtime_key] = read.value;
return success();
}
else if constexpr (std::is_same_v<Config, TimerContactNodeConfig>)
{
const bool done = ton_states_[config.address.index()].done;
*value = config.mode == ContactMode::NormallyOpen ? done : !done;
return success();
}
else if constexpr (std::is_same_v<Config, CounterContactNodeConfig>)
{
const bool done = counter_states_[config.address.index()].done;
*value = config.mode == ContactMode::NormallyOpen ? done : !done;
return success();
}
else if constexpr (std::is_same_v<Config, CompareNodeConfig>)
{
const WordReadResult read = repository.readWord(config.address);
@@ -323,7 +461,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition(
{
return failure(
LogicScanError::InvalidLogic,
"条件节点不能包含输出线圈",
"条件节点不能包含输出节点",
{},
{},
node.id);
@@ -332,24 +470,291 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition(
node.config);
}

LogicScanResult SoftwareLogicExecutor::writeOutput(
LogicScanResult SoftwareLogicExecutor::readWordOperand(
const WordOperand &operand,
RegisterRepository &repository,
std::int16_t *value,
const std::string &node_id) const
{
if (value == nullptr)
{
return failure(
LogicScanError::InvalidLogic,
"缺少字操作数结果接收对象",
{},
{},
node_id);
}
if (operand.kind == WordOperandKind::Constant)
{
*value = operand.constant;
return success();
}
if (operand.kind != WordOperandKind::Register)
{
return failure(
LogicScanError::InvalidLogic,
"字操作数类型无效",
{},
{},
node_id);
}
const WordReadResult read = repository.readWord(operand.address);
if (!read.succeeded)
{
return failure(
LogicScanError::RegisterReadFailed,
"读取寄存器失败:" + operand.address.toString(),
{},
{},
node_id);
}
*value = read.value;
return success();
}

LogicScanResult SoftwareLogicExecutor::executeOutput(
const std::string &logic_id,
const LogicNode &node,
bool rung_value,
RegisterRepository &repository) const
RegisterRepository &repository,
TimePoint now,
LogicTraceValues *trace,
bool *output_value)
{
if (output_value == nullptr)
{
return failure(
LogicScanError::InvalidLogic,
"缺少输出指令结果接收对象",
{},
{},
node.id);
}
if (const auto *ton = std::get_if<TonNodeConfig>(&node.config))
{
TonRuntimeState &state = ton_states_[ton->address.index()];
if (!rung_value)
{
state = TonRuntimeState{};
}
else if (!state.timing)
{
state.timing = true;
state.startedAt = now;
state.elapsed = std::chrono::milliseconds{0};
state.done = false;
}
else
{
state.elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
now >= state.startedAt ? now - state.startedAt : TimePoint::duration::zero());
state.done = state.elapsed.count() >= ton->presetMs;
}
*output_value = state.done;
if (trace != nullptr)
{
trace->tonValues[node.id] = {
rung_value,
state.done,
state.elapsed.count(),
ton->presetMs};
}
return success();
}

if (const auto *counter = std::get_if<CounterNodeConfig>(&node.config))
{
const BitReadResult reset_read = repository.readBit(counter->resetAddress);
if (!reset_read.succeeded)
{
return failure(
LogicScanError::RegisterReadFailed,
"读取计数器复位输入失败:" + counter->resetAddress.toString(),
{},
{},
node.id);
}
std::int16_t preset = 0;
LogicScanResult result = readWordOperand(
counter->preset, repository, &preset, node.id);
if (!result.succeeded)
{
return result;
}
const std::int32_t bounded_preset = std::max<std::int32_t>(0, preset);
preset = static_cast<std::int16_t>(
std::min<std::int32_t>(bounded_preset, std::numeric_limits<std::int16_t>::max()));
const WordReadResult current_read = repository.readWord(
counter->currentValueAddress);
if (!current_read.succeeded)
{
return failure(
LogicScanError::RegisterReadFailed,
"读取计数当前值失败:" + counter->currentValueAddress.toString(),
{},
{},
node.id);
}
std::int32_t current = std::max<std::int32_t>(0, current_read.value);
current = std::min<std::int32_t>(current, std::numeric_limits<std::int16_t>::max());
const auto runtime_key = std::make_pair(logic_id, node.id);
const bool previous = previous_counter_inputs_[runtime_key];
const bool rising = rung_value && !previous;
previous_counter_inputs_[runtime_key] = rung_value;
bool done = counter_states_[counter->address.index()].done;
if (reset_read.value)
{
current = counter->mode == CounterMode::Up ? 0 : preset;
done = counter->mode == CounterMode::Down && current == 0;
}
else if (rising)
{
if (counter->mode == CounterMode::Up)
{
if (current < preset)
{
++current;
}
done = current >= preset;
}
else
{
if (current > 0)
{
--current;
}
done = current <= 0;
}
}
if (counter->mode == CounterMode::Up && current >= preset)
{
done = true;
}
if (counter->mode == CounterMode::Down && current <= 0)
{
done = true;
}
const std::int16_t current_value = static_cast<std::int16_t>(current);
const RegisterWriteResult write = repository.writeWord(
counter->currentValueAddress, current_value);
if (!write.succeeded)
{
return failure(
LogicScanError::RegisterWriteFailed,
"写入计数当前值失败:" + counter->currentValueAddress.toString(),
{},
{},
node.id);
}
counter_states_[counter->address.index()].done = done;
*output_value = done;
if (trace != nullptr)
{
trace->counterValues[node.id] = {
rung_value,
reset_read.value,
done,
current_value,
preset};
}
return success();
}

if (const auto *move = std::get_if<MoveNodeConfig>(&node.config))
{
if (!rung_value)
{
*output_value = false;
return success();
}
std::int16_t source = 0;
LogicScanResult result = readWordOperand(
move->source, repository, &source, node.id);
if (!result.succeeded)
{
return result;
}
const RegisterWriteResult write = repository.writeWord(
move->destination, source);
if (!write.succeeded)
{
return failure(
LogicScanError::RegisterWriteFailed,
"MOVE 写入寄存器失败:" + move->destination.toString(),
{},
{},
node.id);
}
*output_value = true;
if (trace != nullptr)
{
trace->wordValues[node.id] = {source, false};
}
return success();
}

if (const auto *arithmetic = std::get_if<ArithmeticNodeConfig>(&node.config))
{
if (!rung_value)
{
*output_value = false;
return success();
}
std::int16_t left = 0;
std::int16_t right = 0;
LogicScanResult result = readWordOperand(
arithmetic->left, repository, &left, node.id);
if (!result.succeeded)
{
return result;
}
result = readWordOperand(arithmetic->right, repository, &right, node.id);
if (!result.succeeded)
{
return result;
}
const std::int32_t raw = arithmetic->operation == ArithmeticOperation::Add
? static_cast<std::int32_t>(left) + right
: static_cast<std::int32_t>(left) - right;
const bool overflow = raw < std::numeric_limits<std::int16_t>::min()
|| raw > std::numeric_limits<std::int16_t>::max();
const std::int32_t bounded = std::max<std::int32_t>(
std::numeric_limits<std::int16_t>::min(),
std::min<std::int32_t>(raw, std::numeric_limits<std::int16_t>::max()));
const std::int16_t result_value = static_cast<std::int16_t>(bounded);
const RegisterWriteResult write = repository.writeWord(
arithmetic->destination, result_value);
if (!write.succeeded)
{
return failure(
LogicScanError::RegisterWriteFailed,
"算术指令写入寄存器失败:" + arithmetic->destination.toString(),
{},
{},
node.id);
}
*output_value = true;
if (trace != nullptr)
{
trace->wordValues[node.id] = {result_value, overflow};
}
return success();
}

const auto *config = std::get_if<CoilNodeConfig>(&node.config);
if (config == nullptr)
{
return failure(
LogicScanError::InvalidLogic,
"梯形图输出不是线圈节点",
"梯形图输出不是有效的输出指令",
{},
{},
node.id);
}

bool should_write = true;
bool output_value = rung_value;
bool register_value = rung_value;
switch (config->mode)
{
case CoilMode::Normal:
@@ -359,13 +764,13 @@ LogicScanResult SoftwareLogicExecutor::writeOutput(
case CoilMode::Set:
{
should_write = rung_value;
output_value = true;
register_value = true;
break;
}
case CoilMode::Reset:
{
should_write = rung_value;
output_value = false;
register_value = false;
break;
}
default:
@@ -381,10 +786,11 @@ LogicScanResult SoftwareLogicExecutor::writeOutput(

if (!should_write)
{
*output_value = rung_value;
return success();
}
const RegisterWriteResult write = repository.writeBit(
config->address, output_value);
config->address, register_value);
if (!write.succeeded)
{
return failure(
@@ -394,5 +800,6 @@ LogicScanResult SoftwareLogicExecutor::writeOutput(
{},
node.id);
}
*output_value = rung_value;
return success();
}

+ 76
- 5
app/src/services/software_logic_executor.h 查看文件

@@ -3,8 +3,12 @@
#include "domain/control_logic_model.h"
#include "domain/register_repository.h"

#include <chrono>
#include <cstdint>
#include <map>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>

enum class LogicScanError
@@ -26,11 +30,40 @@ struct LogicScanResult
std::string nodeId;
};

struct TonTraceValue
{
bool input = false;
bool done = false;
std::int64_t elapsedMs = 0;
int presetMs = 0;
};

struct CounterTraceValue
{
bool input = false;
bool reset = false;
bool done = false;
std::int16_t value = 0;
std::int16_t preset = 0;
};

struct WordTraceValue
{
std::int16_t value = 0;
bool overflow = false;
};

struct LogicTraceValues
{
std::unordered_map<std::string, bool> nodeValues;
std::unordered_map<std::string, bool> nodePowerValues;
std::unordered_map<std::string, bool> expressionValues;
std::unordered_map<std::string, bool> expressionInputValues;
std::unordered_map<std::string, bool> expressionPowerValues;
std::unordered_map<std::string, bool> rungValues;
std::unordered_map<std::string, TonTraceValue> tonValues;
std::unordered_map<std::string, CounterTraceValue> counterValues;
std::unordered_map<std::string, WordTraceValue> wordValues;

void clear();
};
@@ -47,24 +80,62 @@ struct LogicTraceSnapshot : LogicTraceValues
class SoftwareLogicExecutor
{
public:
using Clock = std::chrono::steady_clock;
using TimePoint = Clock::time_point;

LogicScanResult validate(const std::vector<ControlLogic> &logics) const;
void resetRuntime();
LogicScanResult executeScan(
const std::vector<ControlLogic> &logics,
RegisterRepository &repository,
LogicTraceSnapshot *trace = nullptr) const;
LogicTraceSnapshot *trace = nullptr);
LogicScanResult executeScanAt(
const std::vector<ControlLogic> &logics,
RegisterRepository &repository,
TimePoint now,
LogicTraceSnapshot *trace = nullptr);

private:
struct TonRuntimeState
{
bool timing = false;
bool done = false;
TimePoint startedAt{};
std::chrono::milliseconds elapsed{0};
};

LogicScanResult evaluateCondition(
const std::string &logic_id,
const LogicNode &node,
RegisterRepository &repository,
bool *value) const;
bool *value);
LogicScanResult evaluateExpression(
const std::string &logic_id,
const ConditionExpression &expression,
RegisterRepository &repository,
LogicTraceValues *trace,
bool *value) const;
LogicScanResult writeOutput(
bool input_power,
bool *value);
LogicScanResult executeOutput(
const std::string &logic_id,
const LogicNode &node,
bool rung_value,
RegisterRepository &repository) const;
RegisterRepository &repository,
TimePoint now,
LogicTraceValues *trace,
bool *output_value);
LogicScanResult readWordOperand(
const WordOperand &operand,
RegisterRepository &repository,
std::int16_t *value,
const std::string &node_id) const;

std::map<std::pair<std::string, std::string>, bool> previous_edge_inputs_;
std::map<std::pair<std::string, std::string>, bool> previous_counter_inputs_;
std::unordered_map<int, TonRuntimeState> ton_states_;
struct CounterRuntimeState
{
bool done = false;
};
std::unordered_map<int, CounterRuntimeState> counter_states_;
};

+ 248
- 0
app/src/ui/alarm_configuration_dialog.cpp 查看文件

@@ -0,0 +1,248 @@
#include "alarm_configuration_dialog.h"

#include "domain/project_limits.h"
#include "services/alarm_editor_service.h"
#include "ui_alarm_configuration_dialog.h"

#include <QHeaderView>
#include <QMessageBox>
#include <QTableWidgetItem>

namespace {

QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

QString conditionText(AlarmCondition condition)
{
switch (condition)
{
case AlarmCondition::MOn:
{
return AlarmConfigurationDialog::tr("M 为 ON");
}
case AlarmCondition::DHigh:
{
return AlarmConfigurationDialog::tr("D 大于等于阈值");
}
case AlarmCondition::DLow:
{
return AlarmConfigurationDialog::tr("D 小于等于阈值");
}
default:
{
return {};
}
}
}

} // namespace

AlarmConfigurationDialog::AlarmConfigurationDialog(
AlarmEditorService &service,
QWidget *parent)
: QDialog(parent),
ui_(std::make_unique<Ui::AlarmConfigurationDialog>()),
service_(service)
{
ui_->setupUi(this);
ui_->messageEdit->setMaxLength(
static_cast<int>(ProjectLimits::kMaximumAlarmMessageCharacters));
ui_->alarmTable->horizontalHeader()->setSectionResizeMode(
0, QHeaderView::ResizeToContents);
ui_->alarmTable->horizontalHeader()->setSectionResizeMode(
1, QHeaderView::ResizeToContents);
ui_->alarmTable->horizontalHeader()->setSectionResizeMode(
2, QHeaderView::ResizeToContents);
ui_->alarmTable->horizontalHeader()->setSectionResizeMode(
3, QHeaderView::ResizeToContents);
ui_->alarmTable->horizontalHeader()->setSectionResizeMode(
4, QHeaderView::Stretch);
ui_->areaComboBox->setItemData(0, static_cast<int>(RegisterArea::M));
ui_->areaComboBox->setItemData(1, static_cast<int>(RegisterArea::D));
connect(ui_->areaComboBox,
QOverload<int>::of(&QComboBox::currentIndexChanged),
this,
[this] { updateConditionOptions(); });
connect(ui_->alarmTable, &QTableWidget::itemSelectionChanged,
this, [this] { loadSelectedDefinition(); });
connect(ui_->addButton, &QPushButton::clicked,
this, [this] { addDefinition(); });
connect(ui_->updateButton, &QPushButton::clicked,
this, [this] { updateDefinition(); });
connect(ui_->removeButton, &QPushButton::clicked,
this, [this] { removeDefinition(); });
connect(ui_->buttonBox, &QDialogButtonBox::rejected,
this, &QDialog::reject);
updateConditionOptions();
reloadDefinitions();
}

AlarmConfigurationDialog::~AlarmConfigurationDialog() = default;

void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id)
{
const auto &definitions = service_.definitions();
ui_->alarmTable->setRowCount(static_cast<int>(definitions.size()));
int selected_row = -1;
for (std::size_t index = 0; index < definitions.size(); ++index)
{
const AlarmDefinition &definition = definitions[index];
const int row = static_cast<int>(index);
auto *id_item = new QTableWidgetItem(fromUtf8(definition.id));
id_item->setData(Qt::UserRole, fromUtf8(definition.id));
ui_->alarmTable->setItem(row, 0, id_item);
ui_->alarmTable->setItem(
row, 1, new QTableWidgetItem(QString::fromStdString(
definition.address.toString())));
ui_->alarmTable->setItem(
row, 2, new QTableWidgetItem(conditionText(definition.condition)));
ui_->alarmTable->setItem(
row,
3,
new QTableWidgetItem(
definition.condition == AlarmCondition::MOn
? QStringLiteral("-")
: QString::number(definition.threshold)));
ui_->alarmTable->setItem(
row, 4, new QTableWidgetItem(fromUtf8(definition.message)));
if (definition.id == selected_id)
{
selected_row = row;
}
}
if (selected_row >= 0)
{
ui_->alarmTable->selectRow(selected_row);
}
else
{
ui_->alarmTable->clearSelection();
ui_->updateButton->setEnabled(false);
ui_->removeButton->setEnabled(false);
}
}

void AlarmConfigurationDialog::loadSelectedDefinition()
{
const AlarmDefinition *definition = service_.findDefinition(selectedId());
const bool selected = definition != nullptr;
ui_->updateButton->setEnabled(selected);
ui_->removeButton->setEnabled(selected);
if (!selected)
{
return;
}
ui_->areaComboBox->setCurrentIndex(
definition->address.area() == RegisterArea::M ? 0 : 1);
updateConditionOptions();
ui_->conditionComboBox->setCurrentIndex(
ui_->conditionComboBox->findData(
static_cast<int>(definition->condition)));
ui_->indexSpinBox->setValue(definition->address.index());
ui_->thresholdSpinBox->setValue(definition->threshold);
ui_->messageEdit->setText(fromUtf8(definition->message));
}

void AlarmConfigurationDialog::updateConditionOptions()
{
const RegisterArea area = static_cast<RegisterArea>(
ui_->areaComboBox->currentData().toInt());
ui_->conditionComboBox->clear();
if (area == RegisterArea::M)
{
ui_->conditionComboBox->addItem(
conditionText(AlarmCondition::MOn),
static_cast<int>(AlarmCondition::MOn));
}
else
{
ui_->conditionComboBox->addItem(
conditionText(AlarmCondition::DHigh),
static_cast<int>(AlarmCondition::DHigh));
ui_->conditionComboBox->addItem(
conditionText(AlarmCondition::DLow),
static_cast<int>(AlarmCondition::DLow));
}
ui_->thresholdLabel->setEnabled(area == RegisterArea::D);
ui_->thresholdSpinBox->setEnabled(area == RegisterArea::D);
}

void AlarmConfigurationDialog::addDefinition()
{
const AlarmEditorResult result = service_.addDefinition(definitionFromInputs());
if (!result.succeeded)
{
QMessageBox::warning(this, tr("新增报警"), fromUtf8(result.message));
return;
}
reloadDefinitions(result.id);
}

void AlarmConfigurationDialog::updateDefinition()
{
const std::string id = selectedId();
if (id.empty())
{
return;
}
const AlarmEditorResult result = service_.updateDefinition(
id, definitionFromInputs());
if (!result.succeeded)
{
QMessageBox::warning(this, tr("更新报警"), fromUtf8(result.message));
return;
}
reloadDefinitions(result.id);
}

void AlarmConfigurationDialog::removeDefinition()
{
const std::string id = selectedId();
if (id.empty())
{
return;
}
if (QMessageBox::question(
this, tr("删除报警"), tr("确定删除当前报警定义吗?"))
!= QMessageBox::Yes)
{
return;
}
const AlarmEditorResult result = service_.removeDefinition(id);
if (!result.succeeded)
{
QMessageBox::warning(this, tr("删除报警"), fromUtf8(result.message));
return;
}
reloadDefinitions();
}

AlarmDefinition AlarmConfigurationDialog::definitionFromInputs() const
{
AlarmDefinition definition;
const RegisterArea area = static_cast<RegisterArea>(
ui_->areaComboBox->currentData().toInt());
definition.address = RegisterAddress{area, ui_->indexSpinBox->value()};
definition.condition = static_cast<AlarmCondition>(
ui_->conditionComboBox->currentData().toInt());
definition.threshold = static_cast<std::int16_t>(
ui_->thresholdSpinBox->value());
definition.message = toUtf8(ui_->messageEdit->text().trimmed());
return definition;
}

std::string AlarmConfigurationDialog::selectedId() const
{
const int row = ui_->alarmTable->currentRow();
const QTableWidgetItem *item = row >= 0 ? ui_->alarmTable->item(row, 0) : nullptr;
return item == nullptr ? std::string{} : toUtf8(item->data(Qt::UserRole).toString());
}

+ 35
- 0
app/src/ui/alarm_configuration_dialog.h 查看文件

@@ -0,0 +1,35 @@
#pragma once

#include <QDialog>

#include <memory>
#include <string>

namespace Ui {
class AlarmConfigurationDialog;
}

class AlarmEditorService;
struct AlarmDefinition;

class AlarmConfigurationDialog final : public QDialog
{
public:
explicit AlarmConfigurationDialog(
AlarmEditorService &service,
QWidget *parent = nullptr);
~AlarmConfigurationDialog() override;

private:
void reloadDefinitions(const std::string &selected_id = {});
void loadSelectedDefinition();
void updateConditionOptions();
void addDefinition();
void updateDefinition();
void removeDefinition();
AlarmDefinition definitionFromInputs() const;
std::string selectedId() const;

std::unique_ptr<Ui::AlarmConfigurationDialog> ui_;
AlarmEditorService &service_;
};

+ 49
- 0
app/src/ui/alarm_configuration_dialog.ui 查看文件

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>AlarmConfigurationDialog</class>
<widget class="QDialog" name="AlarmConfigurationDialog">
<property name="windowTitle"><string>报警配置</string></property>
<property name="minimumSize"><size><width>720</width><height>430</height></size></property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTableWidget" name="alarmTable">
<property name="selectionBehavior"><enum>QAbstractItemView::SelectRows</enum></property>
<property name="selectionMode"><enum>QAbstractItemView::SingleSelection</enum></property>
<property name="editTriggers"><set>QAbstractItemView::NoEditTriggers</set></property>
<property name="columnCount"><number>5</number></property>
<column><property name="text"><string>ID</string></property></column>
<column><property name="text"><string>地址</string></property></column>
<column><property name="text"><string>条件</string></property></column>
<column><property name="text"><string>阈值</string></property></column>
<column><property name="text"><string>报警文本</string></property></column>
</widget>
</item>
<item>
<layout class="QFormLayout" name="alarmForm">
<property name="fieldGrowthPolicy"><enum>QFormLayout::AllNonFixedFieldsGrow</enum></property>
<item row="0" column="0"><widget class="QLabel" name="areaLabel"><property name="text"><string>地址区域</string></property></widget></item>
<item row="0" column="1"><widget class="QComboBox" name="areaComboBox"><item><property name="text"><string>M</string></property></item><item><property name="text"><string>D</string></property></item></widget></item>
<item row="1" column="0"><widget class="QLabel" name="indexLabel"><property name="text"><string>地址</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="indexSpinBox"><property name="maximum"><number>4000</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="conditionLabel"><property name="text"><string>触发条件</string></property></widget></item>
<item row="2" column="1"><widget class="QComboBox" name="conditionComboBox"/></item>
<item row="3" column="0"><widget class="QLabel" name="thresholdLabel"><property name="text"><string>阈值</string></property></widget></item>
<item row="3" column="1"><widget class="QSpinBox" name="thresholdSpinBox"><property name="minimum"><number>-32768</number></property><property name="maximum"><number>32767</number></property></widget></item>
<item row="4" column="0"><widget class="QLabel" name="messageLabel"><property name="text"><string>报警文本</string></property></widget></item>
<item row="4" column="1"><widget class="QLineEdit" name="messageEdit"><property name="placeholderText"><string>例如:温度过高</string></property></widget></item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="operationLayout">
<item><widget class="QPushButton" name="addButton"><property name="text"><string>新增</string></property></widget></item>
<item><widget class="QPushButton" name="updateButton"><property name="text"><string>更新</string></property></widget></item>
<item><widget class="QPushButton" name="removeButton"><property name="text"><string>删除</string></property></widget></item>
<item><spacer name="operationSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::Close</set></property></widget></item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

+ 109
- 5
app/src/ui/free_monitor_widget.cpp 查看文件

@@ -1,13 +1,18 @@
#include "free_monitor_widget.h"

#include "services/register_monitor_service.h"
#include "toolbar_icon_factory.h"
#include "ui_free_monitor_widget.h"

#include <QComboBox>
#include <QHeaderView>
#include <QIntValidator>
#include <QLineEdit>
#include <QMessageBox>
#include <QStyle>
#include <QPushButton>
#include <QTableWidgetItem>

#include <cstdint>
#include <set>

namespace {
@@ -32,12 +37,14 @@ FreeMonitorWidget::FreeMonitorWidget(
service_(service)
{
ui_->setupUi(this);
ui_->removeButton->setIcon(style()->standardIcon(QStyle::SP_TrashIcon));
ui_->clearButton->setIcon(style()->standardIcon(QStyle::SP_DialogResetButton));
ui_->removeButton->setIcon(makeUiIcon(UiIcon::Delete));
ui_->clearButton->setIcon(makeUiIcon(UiIcon::ClearList));
ui_->monitorTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
ui_->monitorTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents);
ui_->monitorTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch);
ui_->monitorTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch);
ui_->monitorTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents);
ui_->monitorTable->horizontalHeader()->setSectionResizeMode(5, QHeaderView::Stretch);
ui_->monitorTable->verticalHeader()->setVisible(false);
connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses);
connect(ui_->addressEdit, &QLineEdit::returnPressed,
@@ -51,6 +58,12 @@ FreeMonitorWidget::FreeMonitorWidget(

FreeMonitorWidget::~FreeMonitorWidget() = default;

void FreeMonitorWidget::setWriteEnabled(bool enabled)
{
write_enabled_ = enabled;
updateWriteControls();
}

void FreeMonitorWidget::refreshValues(
ApplicationMode mode, PlcConnectionState plc_state)
{
@@ -75,7 +88,7 @@ void FreeMonitorWidget::refreshValues(
for (int row = 0; row < ui_->monitorTable->rowCount(); ++row)
{
QTableWidgetItem *value_item = ui_->monitorTable->item(row, 2);
QTableWidgetItem *state_item = ui_->monitorTable->item(row, 3);
QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5);
if (!values_available || static_cast<std::size_t>(row) >= values.size())
{
value_item->setText(QStringLiteral("--"));
@@ -97,6 +110,7 @@ void FreeMonitorWidget::refreshValues(
state_item->setText(value.state == MonitorValueState::CommunicationFault
? tr("通信故障,最后有效值") : tr("有效"));
}
updateWriteControls();
}

void FreeMonitorWidget::reloadAddresses()
@@ -112,7 +126,97 @@ void FreeMonitorWidget::reloadAddresses()
row, 1, new QTableWidgetItem(address.area() == RegisterArea::M
? tr("位") : tr("16 位整数")));
ui_->monitorTable->setItem(row, 2, new QTableWidgetItem(QStringLiteral("--")));
ui_->monitorTable->setItem(row, 3, new QTableWidgetItem(tr("未运行")));
if (address.area() == RegisterArea::M)
{
auto *target = new QComboBox(ui_->monitorTable);
target->addItem(tr("OFF"), false);
target->addItem(tr("ON"), true);
ui_->monitorTable->setCellWidget(row, 3, target);
}
else
{
auto *target = new QLineEdit(ui_->monitorTable);
target->setValidator(new QIntValidator(-32768, 32767, target));
target->setText(QStringLiteral("0"));
target->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
ui_->monitorTable->setCellWidget(row, 3, target);
}
auto *write_button = new QPushButton(tr("写入"), ui_->monitorTable);
write_button->setProperty("monitorRow", row);
connect(write_button, &QPushButton::clicked, this,
[this, write_button]
{
writeRow(write_button->property("monitorRow").toInt());
});
ui_->monitorTable->setCellWidget(row, 4, write_button);
ui_->monitorTable->setItem(row, 5, new QTableWidgetItem(tr("未运行")));
}
updateWriteControls();
}

void FreeMonitorWidget::writeRow(int row)
{
if (!write_enabled_ || row < 0
|| static_cast<std::size_t>(row) >= service_.addresses().size())
{
return;
}

const RegisterAddress address = service_.addresses().at(static_cast<std::size_t>(row));
RegisterMonitorWriteResult result;
if (address.area() == RegisterArea::M)
{
auto *target = qobject_cast<QComboBox *>(ui_->monitorTable->cellWidget(row, 3));
if (target == nullptr)
{
return;
}
result = service_.writeBit(address, target->currentData().toBool());
}
else
{
auto *target = qobject_cast<QLineEdit *>(ui_->monitorTable->cellWidget(row, 3));
if (target == nullptr)
{
return;
}
bool converted = false;
const int value = target->text().toInt(&converted);
if (!converted || value < -32768 || value > 32767)
{
ui_->monitorTable->item(row, 5)->setText(tr("目标值无效"));
emit operationMessage(tr("写入 D 区地址失败:请输入 -32768~32767 的整数"));
return;
}
result = service_.writeWord(address, static_cast<std::int16_t>(value));
}

QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5);
if (result.succeeded)
{
state_item->setText(tr("写入请求已发送"));
emit operationMessage(tr("%1 写入请求已发送,等待当前值刷新")
.arg(addressText(address)));
return;
}
state_item->setText(tr("写入失败"));
handleResult(tr("写入 %1").arg(addressText(address)), false, result.message);
}

void FreeMonitorWidget::updateWriteControls()
{
for (int row = 0; row < ui_->monitorTable->rowCount(); ++row)
{
QWidget *target = ui_->monitorTable->cellWidget(row, 3);
if (target != nullptr)
{
target->setEnabled(write_enabled_);
}
QWidget *write_button = ui_->monitorTable->cellWidget(row, 4);
if (write_button != nullptr)
{
write_button->setEnabled(write_enabled_);
}
}
}



+ 4
- 0
app/src/ui/free_monitor_widget.h 查看文件

@@ -23,6 +23,7 @@ public:
QWidget *parent = nullptr);
~FreeMonitorWidget() override;

void setWriteEnabled(bool enabled);
void refreshValues(ApplicationMode mode, PlcConnectionState plc_state);
void reloadAddresses();

@@ -34,8 +35,11 @@ private:
void addAddresses();
void removeSelectedAddresses();
void clearAddresses();
void writeRow(int row);
void updateWriteControls();
void handleResult(const QString &action, bool succeeded, const std::string &message);

std::unique_ptr<Ui::FreeMonitorWidget> ui_;
RegisterMonitorService &service_;
bool write_enabled_ = false;
};

+ 8
- 6
app/src/ui/free_monitor_widget.ui 查看文件

@@ -3,7 +3,7 @@
<class>FreeMonitorWidget</class>
<widget class="QWidget" name="FreeMonitorWidget">
<property name="minimumSize">
<size><width>420</width><height>190</height></size>
<size><width>620</width><height>190</height></size>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing"><number>6</number></property>
@@ -31,12 +31,14 @@
<property name="selectionBehavior"><enum>QAbstractItemView::SelectRows</enum></property>
<property name="editTriggers"><set>QAbstractItemView::NoEditTriggers</set></property>
<property name="showGrid"><bool>true</bool></property>
<property name="columnCount"><number>4</number></property>
<property name="columnCount"><number>6</number></property>
<property name="rowCount"><number>0</number></property>
<column><property name="text"><string>地址</string></property></column>
<column><property name="text"><string>类型</string></property></column>
<column><property name="text"><string>当前值</string></property></column>
<column><property name="text"><string>状态</string></property></column>
<column><property name="text"><string>地址</string></property></column>
<column><property name="text"><string>类型</string></property></column>
<column><property name="text"><string>当前值</string></property></column>
<column><property name="text"><string>目标值</string></property></column>
<column><property name="text"><string>写入</string></property></column>
<column><property name="text"><string>状态</string></property></column>
</widget>
</item>
</layout>


+ 478
- 18
app/src/ui/hmi_editor_widget.cpp 查看文件

@@ -2,7 +2,12 @@

#include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h"
#include "services/alarm_service.h"
#include "domain/project_limits.h"

#include <QApplication>
#include <QDateTime>
#include <QFont>
#include <QGraphicsItem>
#include <QGraphicsRectItem>
#include <QGraphicsScene>
@@ -11,6 +16,8 @@
#include <QInputDialog>
#include <QPainter>
#include <QResizeEvent>
#include <QStyle>
#include <QStyleOption>
#include <QStyleOptionGraphicsItem>

#include <algorithm>
@@ -21,6 +28,23 @@

namespace {

constexpr qreal kAlarmHeaderHeight = 24.0;
constexpr qreal kAlarmRowHeight = 22.0;
constexpr qreal kAlarmCellLeftPadding = 5.0;
constexpr qreal kAlarmCellRightPadding = 5.0;
constexpr qreal kAlarmColumnSpacing = 3.0;
constexpr qreal kAlarmTimeColumnWidth = 76.0;
constexpr qreal kAlarmStateColumnWidth = 52.0;
constexpr qreal kAlarmPageButtonSize = 16.0;
constexpr qreal kAlarmPageIndicatorWidth = 34.0;

QString alarmTimeText(const std::chrono::system_clock::time_point &time)
{
const auto seconds = std::chrono::duration_cast<std::chrono::seconds>(
time.time_since_epoch()).count();
return QDateTime::fromSecsSinceEpoch(seconds).toString(QStringLiteral("HH:mm:ss"));
}

// 将领域层 HMI 控件投影为可绘制、可选择和可交互的场景图元
class HmiGraphicsItem final : public QGraphicsItem
{
@@ -33,14 +57,16 @@ public:
std::function<void(const std::string &, const QPointF &)> moved,
std::function<void(const std::string &, HmiButtonEvent)> button_event,
std::function<void(const std::string &)> numeric_input_activated,
std::function<void(const std::string &)> page_navigation)
std::function<void(const std::string &)> page_navigation,
std::function<void(const std::string &)> alarm_acknowledge)
: control_(control),
page_width_(page_width),
page_height_(page_height),
moved_(std::move(moved)),
button_event_(std::move(button_event)),
numeric_input_activated_(std::move(numeric_input_activated)),
page_navigation_(std::move(page_navigation))
page_navigation_(std::move(page_navigation)),
alarm_acknowledge_(std::move(alarm_acknowledge))
{
// 领域坐标直接作为图元在场景中的初始位置
setPos(control_.bounds.x, control_.bounds.y);
@@ -57,9 +83,9 @@ public:
{
if (!control_.binding.has_value())
{
return controlRect();
return visibleControlRect();
}
return controlRect().united(addressRect());
return visibleControlRect().united(addressRect());
}

void paint(
@@ -68,7 +94,7 @@ public:
QWidget *) override
{
// 留出一个像素边距,避免描边被图元边界裁剪
const QRectF rect = controlRect().adjusted(1, 1, -1, -1);
const QRectF rect = visibleControlRect().adjusted(1, 1, -1, -1);
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1));

@@ -84,6 +110,8 @@ public:
painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1));
}

applyConfiguredFont(painter);

switch (control_.type)
{
case HmiControlType::Button:
@@ -101,7 +129,7 @@ public:
: QColor(QStringLiteral("#5e816b"));
const QColor text = disabled
? QColor(QStringLiteral("#7a837e"))
: QColor(QStringLiteral("#205c3b"));
: configuredTextColor(QColor(QStringLiteral("#205c3b")));

// 未按下时保留下沿阴影,按下后将按钮面下移形成明确的回弹感
QRectF face = rect.adjusted(0, 0, 0, -2);
@@ -136,7 +164,7 @@ public:
painter->setBrush(bit_value_ ? QColor(QStringLiteral("#24a148"))
: QColor(QStringLiteral("#b8c1c8")));
painter->drawEllipse(lamp);
painter->setPen(QColor(QStringLiteral("#24313b")));
painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b"))));
painter->drawText(
QRectF(rect.left(), lamp.bottom() + 1, rect.width(), 16),
Qt::AlignCenter,
@@ -149,7 +177,7 @@ public:
// 数值显示为只读样式,文本由运行值刷新
painter->setBrush(QColor(QStringLiteral("#edf2f6")));
painter->drawRect(rect);
painter->setPen(QColor(QStringLiteral("#24313b")));
painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b"))));
painter->drawText(rect.adjusted(7, 0, -7, 0),
Qt::AlignVCenter | Qt::AlignLeft,
textWithValue());
@@ -160,12 +188,54 @@ public:
// 数值输入以白色编辑框样式呈现,双击后才请求写入
painter->setBrush(QColor(QStringLiteral("#ffffff")));
painter->drawRoundedRect(rect, 3, 3);
painter->setPen(QColor(QStringLiteral("#24313b")));
painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b"))));
painter->drawText(rect.adjusted(7, 0, -7, 0),
Qt::AlignVCenter | Qt::AlignLeft,
textWithValue());
break;
}
case HmiControlType::ProgressBar:
{
const HmiProgressBarConfig config = control_.progressBar.value_or(
HmiProgressBarConfig{});
const int percentage = has_runtime_value_
? config.percentageForValue(word_value_) : 0;

painter->setPen(Qt::NoPen);
painter->setBrush(QColor(QStringLiteral("#e4e9e7")));
painter->drawRoundedRect(rect, 4, 4);

const QRectF track = rect.adjusted(2, 2, -2, -2);
if (has_runtime_value_ && percentage > 0)
{
QRectF fill = track;
fill.setWidth(track.width() * percentage / 100.0);
painter->setBrush(QColor(QStringLiteral("#3c8c62")));
painter->drawRoundedRect(fill, 3, 3);
}

painter->setBrush(Qt::NoBrush);
painter->setPen(QPen(QColor(QStringLiteral("#617069")), 1));
painter->drawRoundedRect(rect, 4, 4);

QString display_text = QString::fromUtf8(
control_.text.data(), static_cast<int>(control_.text.size()));
if (config.showValue)
{
const QString percentage_text = has_runtime_value_
? QString::number(percentage) + QLatin1Char('%')
: runtime_active_ ? QStringLiteral("--")
: QStringLiteral("0%");
display_text = display_text.isEmpty()
? percentage_text
: display_text + QStringLiteral(": ") + percentage_text;
}
painter->setPen(configuredTextColor(QColor(QStringLiteral("#20342a"))));
painter->drawText(rect.adjusted(6, 0, -6, 0),
Qt::AlignCenter,
display_text);
break;
}
case HmiControlType::PageJump:
{
const QColor fill = runtime_active_ && page_hovered_
@@ -174,15 +244,141 @@ public:
painter->setBrush(fill);
painter->setPen(QPen(QColor(QStringLiteral("#4e789f")), 1));
painter->drawRoundedRect(rect, 4, 4);
painter->setPen(QColor(QStringLiteral("#244b6b")));
painter->setPen(configuredTextColor(QColor(QStringLiteral("#244b6b"))));
painter->drawText(rect, Qt::AlignCenter, textWithValue());
break;
}
case HmiControlType::AlarmList:
{
painter->setPen(QPen(QColor(QStringLiteral("#9b3a3a")), 1));
painter->setBrush(QColor(QStringLiteral("#ffffff")));
painter->drawRect(rect);
const QRectF header(
rect.left(), rect.top(), rect.width(), kAlarmHeaderHeight);
painter->fillRect(header, QColor(QStringLiteral("#a63f3f")));
painter->setPen(Qt::white);
QRectF title_rect = header.adjusted(7, 0, -7, 0);
const std::size_t page_count = alarmPageCount();
if (runtime_active_ && page_count > 1U)
{
const QRectF previous_rect = alarmPreviousPageRect(header);
const QRectF next_rect = alarmNextPageRect(header);
const QRectF indicator_rect = alarmPageIndicatorRect(header);
title_rect.setRight(previous_rect.left() - kAlarmColumnSpacing);

QStyleOption previous_option;
previous_option.rect = previous_rect.toAlignedRect();
previous_option.state = alarm_page_ > 0U
? QStyle::State_Enabled : QStyle::State_None;
QApplication::style()->drawPrimitive(
QStyle::PE_IndicatorArrowLeft,
&previous_option,
painter);

QStyleOption next_option;
next_option.rect = next_rect.toAlignedRect();
next_option.state = alarm_page_ + 1U < page_count
? QStyle::State_Enabled : QStyle::State_None;
QApplication::style()->drawPrimitive(
QStyle::PE_IndicatorArrowRight,
&next_option,
painter);
painter->setPen(Qt::white);
painter->drawText(
indicator_rect,
Qt::AlignCenter,
QStringLiteral("%1/%2")
.arg(static_cast<qulonglong>(alarm_page_ + 1U))
.arg(static_cast<qulonglong>(page_count)));
}
const QString title = QString::fromUtf8(
control_.text.data(), static_cast<int>(control_.text.size()));
painter->drawText(
title_rect,
Qt::AlignVCenter | Qt::AlignLeft,
painter->fontMetrics().elidedText(
title,
Qt::ElideRight,
std::max(0, static_cast<int>(title_rect.width()))));

const int visible_rows = static_cast<int>(visibleAlarmRecordCount());
if (visible_rows == 0)
{
painter->setPen(configuredTextColor(QColor(QStringLiteral("#6f7a82"))));
painter->drawText(
QRectF(
rect.left(),
header.bottom(),
rect.width(),
rect.height() - kAlarmHeaderHeight),
Qt::AlignCenter,
runtime_active_ ? QObject::tr("暂无报警")
: QObject::tr("运行时显示当前报警"));
break;
}
const std::size_t first_record = alarmFirstRecordIndex();
for (int row = 0; row < visible_rows; ++row)
{
const AlarmRecord &record = alarm_records_[
first_record + static_cast<std::size_t>(row)];
const QRectF row_rect(
rect.left(),
header.bottom() + row * kAlarmRowHeight,
rect.width(),
kAlarmRowHeight);
painter->fillRect(
row_rect,
QColor(QStringLiteral("#fde8e8")));
painter->setPen(QColor(QStringLiteral("#d3d8dc")));
painter->drawLine(row_rect.bottomLeft(), row_rect.bottomRight());
painter->setPen(configuredTextColor(QColor(QStringLiteral("#8d1f1f"))));
const QString state = record.acknowledged
? QObject::tr("已确认") : QObject::tr("未确认");
const QRectF time_rect(
row_rect.left() + kAlarmCellLeftPadding,
row_rect.top(),
kAlarmTimeColumnWidth,
row_rect.height());
const QRectF state_rect(
time_rect.right() + kAlarmColumnSpacing,
row_rect.top(),
kAlarmStateColumnWidth,
row_rect.height());
const qreal message_left =
state_rect.right() + kAlarmColumnSpacing;
const QRectF message_rect(
message_left,
row_rect.top(),
std::max(
0.0,
row_rect.right() - kAlarmCellRightPadding
- message_left),
row_rect.height());
painter->drawText(
time_rect,
Qt::AlignVCenter | Qt::AlignLeft,
alarmTimeText(record.occurredAt));
painter->drawText(
state_rect,
Qt::AlignVCenter | Qt::AlignLeft,
state);
painter->drawText(
message_rect,
Qt::AlignVCenter | Qt::AlignLeft,
painter->fontMetrics().elidedText(
QString::fromUtf8(
record.message.data(),
static_cast<int>(record.message.size())),
Qt::ElideRight,
std::max(0, static_cast<int>(message_rect.width()))));
}
break;
}
case HmiControlType::Label:
default:
{
// 标签只显示固定文本,不绑定寄存器运行值
painter->setPen(QColor(QStringLiteral("#24313b")));
painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b"))));
painter->drawText(rect, Qt::AlignCenter,
QString::fromUtf8(control_.text.data(),
static_cast<int>(control_.text.size())));
@@ -209,6 +405,14 @@ public:
void setInteractionState(
bool editable, bool runtime_active, bool runtime_write_enabled)
{
if (control_.type == HmiControlType::AlarmList)
{
prepareGeometryChange();
if (!runtime_active)
{
alarm_page_ = 0U;
}
}
editing_enabled_ = editable;
runtime_active_ = runtime_active;
runtime_write_enabled_ = runtime_active && runtime_write_enabled;
@@ -222,9 +426,12 @@ public:
const bool runtime_input = runtime_active_
&& (control_.type == HmiControlType::Button
|| control_.type == HmiControlType::NumericInput
|| control_.type == HmiControlType::PageJump);
|| control_.type == HmiControlType::PageJump
|| control_.type == HmiControlType::AlarmList);
const bool runtime_input_enabled = runtime_input
&& (control_.type == HmiControlType::PageJump || runtime_write_enabled_);
&& (control_.type == HmiControlType::PageJump
|| control_.type == HmiControlType::AlarmList
|| runtime_write_enabled_);
setAcceptedMouseButtons(
editable || runtime_input_enabled ? Qt::LeftButton : Qt::NoButton);

@@ -232,8 +439,11 @@ public:
&& control_.type == HmiControlType::Button && runtime_write_enabled_;
const bool runtime_page_jump_enabled = runtime_active_
&& control_.type == HmiControlType::PageJump;
const bool runtime_alarm_enabled = runtime_active_
&& control_.type == HmiControlType::AlarmList;
setAcceptHoverEvents(runtime_button_enabled || runtime_page_jump_enabled);
if (runtime_button_enabled || runtime_page_jump_enabled)
if (runtime_button_enabled || runtime_page_jump_enabled
|| runtime_alarm_enabled)
{
setCursor(Qt::PointingHandCursor);
}
@@ -244,6 +454,7 @@ public:
page_hovered_ = false;
button_pressed_ = false;
}
updateAlarmVisibility();
update();
}

@@ -256,6 +467,20 @@ public:
update();
}

void setAlarmRecords(const std::vector<AlarmRecord> &records)
{
if (control_.type == HmiControlType::AlarmList)
{
prepareGeometryChange();
}
alarm_records_ = records;
const std::size_t page_count = alarmPageCount();
alarm_page_ = page_count == 0U
? 0U : std::min(alarm_page_, page_count - 1U);
updateAlarmVisibility();
update();
}

protected:
// 拖拽过程中将新位置限制在页面可见边界内
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override
@@ -279,6 +504,52 @@ protected:
// 运行态按钮按下时通知外层执行配置的 M 位操作
void mousePressEvent(QGraphicsSceneMouseEvent *event) override
{
if (runtime_active_ && control_.type == HmiControlType::AlarmList)
{
if (event->pos().y() < kAlarmHeaderHeight)
{
const QRectF content_rect =
visibleControlRect().adjusted(1, 1, -1, -1);
const QRectF header(
content_rect.left(),
content_rect.top(),
content_rect.width(),
kAlarmHeaderHeight);
const std::size_t page_count = alarmPageCount();
if (alarmPreviousPageRect(header).contains(event->pos())
&& alarm_page_ > 0U)
{
prepareGeometryChange();
--alarm_page_;
update();
}
else if (alarmNextPageRect(header).contains(event->pos())
&& alarm_page_ + 1U < page_count)
{
prepareGeometryChange();
++alarm_page_;
update();
}
event->accept();
return;
}
const int row = static_cast<int>(
(event->pos().y() - kAlarmHeaderHeight) / kAlarmRowHeight);
const std::size_t record_index = alarmFirstRecordIndex()
+ static_cast<std::size_t>(std::max(0, row));
if (row >= 0
&& row < static_cast<int>(visibleAlarmRecordCount())
&& record_index < alarm_records_.size())
{
const AlarmRecord &record = alarm_records_[record_index];
if (!record.acknowledged && alarm_acknowledge_)
{
alarm_acknowledge_(record.definitionId);
}
}
event->accept();
return;
}
if (runtime_active_ && control_.type == HmiControlType::PageJump)
{
page_pressed_ = true;
@@ -378,6 +649,97 @@ protected:
}

private:
std::size_t alarmPageSize() const
{
const QRectF rect = controlRect().adjusted(1, 1, -1, -1);
const int rows_by_height = std::max(
0,
static_cast<int>((rect.height() - kAlarmHeaderHeight)
/ kAlarmRowHeight));
return static_cast<std::size_t>(std::min(
rows_by_height, ProjectLimits::kMaximumVisibleAlarmRows));
}

std::size_t alarmPageCount() const
{
const std::size_t page_size = alarmPageSize();
return page_size == 0U || alarm_records_.empty()
? 0U
: (alarm_records_.size() + page_size - 1U) / page_size;
}

std::size_t alarmFirstRecordIndex() const
{
return alarm_page_ * alarmPageSize();
}

std::size_t visibleAlarmRecordCount() const
{
const std::size_t first_record = alarmFirstRecordIndex();
if (first_record >= alarm_records_.size())
{
return 0U;
}
return std::min(
alarmPageSize(), alarm_records_.size() - first_record);
}

QRectF visibleControlRect() const
{
const QRectF full_rect = controlRect();
if (control_.type != HmiControlType::AlarmList || !runtime_active_)
{
return full_rect;
}
const qreal content_height = kAlarmHeaderHeight
+ static_cast<qreal>(visibleAlarmRecordCount()) * kAlarmRowHeight
+ 2.0;
return {full_rect.left(),
full_rect.top(),
full_rect.width(),
std::min(full_rect.height(), content_height)};
}

QRectF alarmNextPageRect(const QRectF &header) const
{
return {
header.right() - kAlarmCellRightPadding - kAlarmPageButtonSize,
header.center().y() - kAlarmPageButtonSize / 2.0,
kAlarmPageButtonSize,
kAlarmPageButtonSize};
}

QRectF alarmPageIndicatorRect(const QRectF &header) const
{
const QRectF next_rect = alarmNextPageRect(header);
return {
next_rect.left() - kAlarmPageIndicatorWidth,
header.top(),
kAlarmPageIndicatorWidth,
header.height()};
}

QRectF alarmPreviousPageRect(const QRectF &header) const
{
const QRectF indicator_rect = alarmPageIndicatorRect(header);
return {
indicator_rect.left() - kAlarmPageButtonSize,
header.center().y() - kAlarmPageButtonSize / 2.0,
kAlarmPageButtonSize,
kAlarmPageButtonSize};
}

void updateAlarmVisibility()
{
if (control_.type != HmiControlType::AlarmList)
{
return;
}
const bool has_active_alarm = !alarm_records_.empty();
setVisible(editing_enabled_ || !runtime_active_ || has_active_alarm);
setZValue(runtime_active_ && has_active_alarm ? 1000.0 : 0.0);
}

// 控件本体范围保持领域坐标和尺寸语义不变
QRectF controlRect() const
{
@@ -405,6 +767,64 @@ private:
: QString{};
}

QColor configuredTextColor(const QColor &fallback) const
{
const auto property = control_.properties.find(
HmiAppearanceProperty::kTextColor);
if (property == control_.properties.cend())
{
return fallback;
}
const QColor color = QColor(QString::fromUtf8(
property->second.data(), static_cast<int>(property->second.size())));
return color.isValid() ? color : fallback;
}

void applyConfiguredFont(QPainter *painter) const
{
if (painter == nullptr)
{
return;
}
QFont font = painter->font();
const auto font_size = control_.properties.find(
HmiAppearanceProperty::kFontSize);
if (font_size != control_.properties.cend())
{
bool ok = false;
const int point_size = QString::fromUtf8(
font_size->second.data(),
static_cast<int>(font_size->second.size())).toInt(&ok);
if (ok
&& point_size >= ProjectLimits::kMinimumHmiFontPointSize
&& point_size <= ProjectLimits::kMaximumHmiFontPointSize)
{
font.setPointSize(point_size);
}
}
else if (control_.type == HmiControlType::AlarmList
&& font.pointSize() > 0)
{
font.setPointSize(std::max(
ProjectLimits::kMinimumHmiFontPointSize,
font.pointSize()
- ProjectLimits::kAlarmDefaultFontPointReduction));
}
const auto font_bold = control_.properties.find(
HmiAppearanceProperty::kFontBold);
if (font_bold != control_.properties.cend())
{
font.setBold(font_bold->second == "true");
}
const auto font_italic = control_.properties.find(
HmiAppearanceProperty::kFontItalic);
if (font_italic != control_.properties.cend())
{
font.setItalic(font_italic->second == "true");
}
painter->setFont(font);
}

// 根据控件类型和运行数据组合当前应绘制的文字
QString textWithValue() const
{
@@ -433,6 +853,9 @@ private:
std::function<void(const std::string &, HmiButtonEvent)> button_event_;
std::function<void(const std::string &)> numeric_input_activated_;
std::function<void(const std::string &)> page_navigation_;
std::function<void(const std::string &)> alarm_acknowledge_;
std::vector<AlarmRecord> alarm_records_;
std::size_t alarm_page_ = 0U;
bool editing_enabled_ = true;
bool runtime_active_ = false;
bool runtime_write_enabled_ = false;
@@ -459,10 +882,12 @@ HmiGraphicsItem *asHmiItem(QGraphicsItem *item)
HmiEditorWidget::HmiEditorWidget(
HmiEditorService &editor_service,
HmiRuntimeService &runtime_service,
AlarmService &alarm_service,
QWidget *parent)
: QGraphicsView(parent),
editor_service_(editor_service),
runtime_service_(runtime_service),
alarm_service_(alarm_service),
scene_(new QGraphicsScene(this))
{
setObjectName(QStringLiteral("hmiEditorWidget"));
@@ -504,6 +929,7 @@ void HmiEditorWidget::setRuntimeActive(bool active)
if (control_item != nullptr)
{
control_item->setRuntimeValue(false, 0, false);
control_item->setAlarmRecords({});
}
}
}
@@ -557,6 +983,11 @@ void HmiEditorWidget::reloadPage()
{
emit pageNavigationRequested(
QString::fromStdString(target_page_id));
},
[this](const std::string &definition_id)
{
alarm_service_.acknowledge(definition_id);
refreshRuntimeValues();
});
scene_->addItem(item);
item->setInteractionState(
@@ -569,6 +1000,7 @@ void HmiEditorWidget::reloadPage()
// 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来
void HmiEditorWidget::selectControl(const std::string &control_id)
{
scene_->clearSelection();
for (QGraphicsItem *item : scene_->items())
{
HmiGraphicsItem *control_item = asHmiItem(item);
@@ -583,16 +1015,39 @@ void HmiEditorWidget::selectControl(const std::string &control_id)

std::string HmiEditorWidget::selectedControlId() const
{
const QList<QGraphicsItem *> selected = scene_->selectedItems();
for (QGraphicsItem *item : selected)
const std::vector<std::string> ids = selectedControlIds();
return ids.empty() ? std::string{} : ids.front();
}

std::vector<std::string> HmiEditorWidget::selectedControlIds() const
{
std::vector<std::pair<QPointF, std::string>> positioned_ids;
for (QGraphicsItem *item : scene_->selectedItems())
{
const HmiGraphicsItem *control_item = asHmiItem(item);
if (control_item != nullptr)
{
return control_item->controlId();
positioned_ids.emplace_back(
control_item->scenePos(), control_item->controlId());
}
}
return {};
std::sort(
positioned_ids.begin(), positioned_ids.end(),
[](const auto &left, const auto &right)
{
if (!qFuzzyCompare(left.first.y(), right.first.y()))
{
return left.first.y() < right.first.y();
}
return left.first.x() < right.first.x();
});
std::vector<std::string> ids;
ids.reserve(positioned_ids.size());
for (const auto &positioned_id : positioned_ids)
{
ids.push_back(positioned_id.second);
}
return ids;
}

void HmiEditorWidget::refreshRuntimeValues()
@@ -614,6 +1069,11 @@ void HmiEditorWidget::refreshRuntimeValues()
{
continue;
}
if (control->type == HmiControlType::AlarmList)
{
control_item->setAlarmRecords(alarm_service_.records());
continue;
}
// 运行值通过服务读取,图元不直接接触寄存器仓库
const HmiRuntimeReadResult value = runtime_service_.readControl(*control);
control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded);


+ 5
- 0
app/src/ui/hmi_editor_widget.h 查看文件

@@ -10,9 +10,11 @@
#include <QGraphicsView>

#include <string>
#include <vector>

class HmiEditorService;
class HmiRuntimeService;
class AlarmService;
class QGraphicsScene;
class QResizeEvent;
enum class HmiButtonEvent;
@@ -36,6 +38,7 @@ public:
explicit HmiEditorWidget(
HmiEditorService &editor_service,
HmiRuntimeService &runtime_service,
AlarmService &alarm_service,
QWidget *parent = nullptr);

/**
@@ -67,6 +70,7 @@ public:
*/
void selectControl(const std::string &control_id);
std::string selectedControlId() const;
std::vector<std::string> selectedControlIds() const;
/**
* @brief 从统一寄存器仓库读取当前值并刷新画布显示
*
@@ -114,6 +118,7 @@ private:
HmiEditorService &editor_service_;
// 提供运行态寄存器读写能力,画布不直接访问寄存器仓库
HmiRuntimeService &runtime_service_;
AlarmService &alarm_service_;
// 持有所有页面图元和页面边框
QGraphicsScene *scene_ = nullptr;
// 当前画布投影的页面标识


+ 965
- 45
app/src/ui/logic_editor_widget.cpp
文件差異過大導致無法顯示
查看文件


+ 18
- 1
app/src/ui/logic_editor_widget.h 查看文件

@@ -7,6 +7,7 @@
#include <QGraphicsView>

#include <string>
#include <utility>
#include <vector>

class QGraphicsScene;
@@ -18,7 +19,11 @@ class LogicEditorWidget final : public QGraphicsView

public:
class NodeItem;
class WireItem;
class WireCellItem;
class VerticalConnectorItem;
class RungItem;
class EmptySlotItem;

explicit LogicEditorWidget(
LogicEditorService &editor_service,
@@ -39,7 +44,12 @@ public:
LogicEditorResult addRung();
LogicEditorResult addCondition(const LogicNodeConfig &config);
LogicEditorResult addParallelBranch(const LogicNodeConfig &config);
LogicEditorResult setOutput(const LogicNodeConfig &config);
LogicEditorResult addHorizontalWire();
LogicEditorResult addVerticalWire();
LogicEditorResult deleteHorizontalWire();
LogicEditorResult deleteVerticalWire();
LogicEditorResult setOutput(
const LogicNodeConfig &config, bool configured = false);
LogicEditorResult deleteSelected();

signals:
@@ -54,6 +64,13 @@ private:
void handleSelectionChanged();
void reportFailure(const LogicEditorResult &result);
std::string currentRungId() const;
void selectExpression(const std::string &expression_id);
void selectWire(const std::string &wire_id);
std::vector<std::string> selectedExpressionIds() const;
std::vector<std::string> selectedWireIds() const;
std::vector<std::string> selectedBranchIds() const;
std::vector<int> selectedEmptySlotColumns() const;
std::vector<std::pair<std::string, int>> selectedWireCells() const;

LogicEditorService &editor_service_;
QGraphicsScene *scene_ = nullptr;


+ 207
- 0
app/src/ui/logic_instruction_dialog.cpp 查看文件

@@ -0,0 +1,207 @@
#include "logic_instruction_dialog.h"

#include "ui_logic_instruction_dialog.h"

#include <QComboBox>
#include <QDialogButtonBox>
#include <QLabel>
#include <QSpinBox>

#include <limits>

namespace {

constexpr int kConstantOperandData = 0;
constexpr int kRegisterOperandData = 1;

void configureOperandCombo(QComboBox *combo)
{
combo->clear();
combo->addItem(LogicInstructionDialog::tr("常量"), kConstantOperandData);
combo->addItem(LogicInstructionDialog::tr("D 寄存器"), kRegisterOperandData);
}

} // namespace

LogicInstructionDialog::LogicInstructionDialog(
const LogicNodeConfig &config,
QWidget *parent)
: QDialog(parent),
ui_(std::make_unique<Ui::LogicInstructionDialog>()),
original_config_(config)
{
ui_->setupUi(this);
configureOperandCombo(ui_->counterPresetKindComboBox);
configureOperandCombo(ui_->leftOperandKindComboBox);
configureOperandCombo(ui_->rightOperandKindComboBox);

connect(ui_->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(ui_->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
for (QComboBox *combo : {
ui_->counterPresetKindComboBox,
ui_->leftOperandKindComboBox,
ui_->rightOperandKindComboBox})
{
connect(
combo,
qOverload<int>(&QComboBox::currentIndexChanged),
this,
&LogicInstructionDialog::updateOperandRanges);
}

if (const auto *counter = std::get_if<CounterNodeConfig>(&config))
{
configureCounter(*counter);
}
else if (const auto *move = std::get_if<MoveNodeConfig>(&config))
{
configureMove(*move);
}
else if (const auto *arithmetic = std::get_if<ArithmeticNodeConfig>(&config))
{
configureArithmetic(*arithmetic);
}
updateOperandRanges();
}

LogicInstructionDialog::~LogicInstructionDialog() = default;

LogicNodeConfig LogicInstructionDialog::config() const
{
if (std::holds_alternative<CounterNodeConfig>(original_config_))
{
return CounterNodeConfig{
CounterAddress{ui_->counterAddressSpinBox->value()},
static_cast<CounterMode>(ui_->counterModeComboBox->currentData().toInt()),
RegisterAddress{
RegisterArea::D,
ui_->counterCurrentAddressSpinBox->value()},
operandFrom(
ui_->counterPresetKindComboBox,
ui_->counterPresetValueSpinBox),
RegisterAddress{
RegisterArea::M,
ui_->counterResetAddressSpinBox->value()}};
}
if (std::holds_alternative<MoveNodeConfig>(original_config_))
{
return MoveNodeConfig{
operandFrom(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox),
RegisterAddress{
RegisterArea::D,
ui_->destinationAddressSpinBox->value()}};
}
const auto *arithmetic = std::get_if<ArithmeticNodeConfig>(&original_config_);
return ArithmeticNodeConfig{
arithmetic->operation,
operandFrom(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox),
operandFrom(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox),
RegisterAddress{
RegisterArea::D,
ui_->destinationAddressSpinBox->value()}};
}

void LogicInstructionDialog::configureCounter(const CounterNodeConfig &config)
{
setWindowTitle(tr("配置计数器"));
ui_->instructionStackedWidget->setCurrentWidget(ui_->counterPage);
ui_->counterModeComboBox->clear();
ui_->counterModeComboBox->addItem(tr("CTU 加计数"), static_cast<int>(CounterMode::Up));
ui_->counterModeComboBox->addItem(
tr("CTD 减计数"), static_cast<int>(CounterMode::Down));
ui_->counterModeComboBox->setCurrentIndex(
ui_->counterModeComboBox->findData(static_cast<int>(config.mode)));
ui_->counterAddressSpinBox->setValue(config.address.index());
ui_->counterCurrentAddressSpinBox->setValue(config.currentValueAddress.index());
ui_->counterResetAddressSpinBox->setValue(config.resetAddress.index());
setOperand(
ui_->counterPresetKindComboBox,
ui_->counterPresetValueSpinBox,
config.preset);
}

void LogicInstructionDialog::configureMove(const MoveNodeConfig &config)
{
setWindowTitle(tr("配置 MOVE"));
ui_->instructionStackedWidget->setCurrentWidget(ui_->dataInstructionPage);
ui_->instructionNameLabel->setText(QStringLiteral("MOVE"));
ui_->leftOperandLabel->setText(tr("源操作数"));
ui_->rightOperandLabel->setVisible(false);
ui_->rightOperandEditor->setVisible(false);
setOperand(
ui_->leftOperandKindComboBox,
ui_->leftOperandValueSpinBox,
config.source);
ui_->destinationAddressSpinBox->setValue(config.destination.index());
}

void LogicInstructionDialog::configureArithmetic(const ArithmeticNodeConfig &config)
{
const bool is_add = config.operation == ArithmeticOperation::Add;
setWindowTitle(is_add ? tr("配置 ADD") : tr("配置 SUB"));
ui_->instructionStackedWidget->setCurrentWidget(ui_->dataInstructionPage);
ui_->instructionNameLabel->setText(is_add
? QStringLiteral("ADD") : QStringLiteral("SUB"));
ui_->leftOperandLabel->setText(tr("左操作数"));
ui_->rightOperandLabel->setVisible(true);
ui_->rightOperandEditor->setVisible(true);
setOperand(
ui_->leftOperandKindComboBox,
ui_->leftOperandValueSpinBox,
config.left);
setOperand(
ui_->rightOperandKindComboBox,
ui_->rightOperandValueSpinBox,
config.right);
ui_->destinationAddressSpinBox->setValue(config.destination.index());
}

void LogicInstructionDialog::updateOperandRanges()
{
const auto update = [](const QComboBox *combo, QSpinBox *spin)
{
const bool register_operand = combo->currentData().toInt()
== kRegisterOperandData;
spin->setPrefix(register_operand ? QStringLiteral("D") : QString{});
spin->setMinimum(register_operand
? RegisterAddress::kMinimumIndex
: (combo->objectName() == QStringLiteral("counterPresetKindComboBox")
? 0 : std::numeric_limits<std::int16_t>::min()));
spin->setMaximum(register_operand
? RegisterAddress::kMaximumIndex
: std::numeric_limits<std::int16_t>::max());
};
update(ui_->counterPresetKindComboBox, ui_->counterPresetValueSpinBox);
update(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox);
update(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox);
}

void LogicInstructionDialog::setOperand(
QComboBox *kind_combo,
QSpinBox *value_spin,
const WordOperand &operand)
{
kind_combo->setCurrentIndex(kind_combo->findData(
operand.kind == WordOperandKind::Register
? kRegisterOperandData : kConstantOperandData));
value_spin->setValue(
operand.kind == WordOperandKind::Register
? operand.address.index() : operand.constant);
}

WordOperand LogicInstructionDialog::operandFrom(
const QComboBox *kind_combo,
const QSpinBox *value_spin)
{
if (kind_combo->currentData().toInt() == kRegisterOperandData)
{
return WordOperand{
WordOperandKind::Register,
RegisterAddress{RegisterArea::D, value_spin->value()},
0};
}
return WordOperand{
WordOperandKind::Constant,
RegisterAddress{RegisterArea::D, 0},
static_cast<std::int16_t>(value_spin->value())};
}

+ 43
- 0
app/src/ui/logic_instruction_dialog.h 查看文件

@@ -0,0 +1,43 @@
#pragma once

#include "domain/control_logic_model.h"

#include <QDialog>

#include <memory>

class QComboBox;
class QSpinBox;

namespace Ui {
class LogicInstructionDialog;
}

class LogicInstructionDialog final : public QDialog
{
Q_OBJECT

public:
explicit LogicInstructionDialog(
const LogicNodeConfig &config,
QWidget *parent = nullptr);
~LogicInstructionDialog() override;

LogicNodeConfig config() const;

private:
void configureCounter(const CounterNodeConfig &config);
void configureMove(const MoveNodeConfig &config);
void configureArithmetic(const ArithmeticNodeConfig &config);
void updateOperandRanges();
static void setOperand(
QComboBox *kind_combo,
QSpinBox *value_spin,
const WordOperand &operand);
static WordOperand operandFrom(
const QComboBox *kind_combo,
const QSpinBox *value_spin);

std::unique_ptr<Ui::LogicInstructionDialog> ui_;
LogicNodeConfig original_config_;
};

+ 66
- 0
app/src/ui/logic_instruction_dialog.ui 查看文件

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>LogicInstructionDialog</class>
<widget class="QDialog" name="LogicInstructionDialog">
<property name="geometry">
<rect><x>0</x><y>0</y><width>420</width><height>310</height></rect>
</property>
<property name="windowTitle"><string>配置逻辑指令</string></property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QStackedWidget" name="instructionStackedWidget">
<widget class="QWidget" name="counterPage">
<layout class="QFormLayout" name="counterFormLayout">
<item row="0" column="0"><widget class="QLabel" name="counterModeLabel"><property name="text"><string>计数方式</string></property></widget></item>
<item row="0" column="1"><widget class="QComboBox" name="counterModeComboBox"/></item>
<item row="1" column="0"><widget class="QLabel" name="counterAddressLabel"><property name="text"><string>计数器资源</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="counterAddressSpinBox"><property name="prefix"><string>C</string></property><property name="maximum"><number>4000</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="counterCurrentAddressLabel"><property name="text"><string>当前值 CV</string></property></widget></item>
<item row="2" column="1"><widget class="QSpinBox" name="counterCurrentAddressSpinBox"><property name="prefix"><string>D</string></property><property name="maximum"><number>4000</number></property></widget></item>
<item row="3" column="0"><widget class="QLabel" name="counterPresetLabel"><property name="text"><string>预设值 PV</string></property></widget></item>
<item row="3" column="1">
<widget class="QWidget" name="counterPresetEditor" native="true">
<layout class="QHBoxLayout" name="counterPresetLayout"><property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property>
<item><widget class="QComboBox" name="counterPresetKindComboBox"/></item>
<item><widget class="QSpinBox" name="counterPresetValueSpinBox"><property name="maximum"><number>32767</number></property><property name="value"><number>10</number></property></widget></item>
</layout>
</widget>
</item>
<item row="4" column="0"><widget class="QLabel" name="counterResetAddressLabel"><property name="text"><string>复位输入</string></property></widget></item>
<item row="4" column="1"><widget class="QSpinBox" name="counterResetAddressSpinBox"><property name="prefix"><string>M</string></property><property name="maximum"><number>4000</number></property></widget></item>
</layout>
</widget>
<widget class="QWidget" name="dataInstructionPage">
<layout class="QFormLayout" name="dataInstructionFormLayout">
<item row="0" column="0"><widget class="QLabel" name="instructionCaptionLabel"><property name="text"><string>指令</string></property></widget></item>
<item row="0" column="1"><widget class="QLabel" name="instructionNameLabel"><property name="text"><string>MOVE</string></property></widget></item>
<item row="1" column="0"><widget class="QLabel" name="leftOperandLabel"><property name="text"><string>源操作数</string></property></widget></item>
<item row="1" column="1">
<widget class="QWidget" name="leftOperandEditor" native="true">
<layout class="QHBoxLayout" name="leftOperandLayout"><property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property>
<item><widget class="QComboBox" name="leftOperandKindComboBox"/></item>
<item><widget class="QSpinBox" name="leftOperandValueSpinBox"><property name="minimum"><number>-32768</number></property><property name="maximum"><number>32767</number></property></widget></item>
</layout>
</widget>
</item>
<item row="2" column="0"><widget class="QLabel" name="rightOperandLabel"><property name="text"><string>右操作数</string></property></widget></item>
<item row="2" column="1">
<widget class="QWidget" name="rightOperandEditor" native="true">
<layout class="QHBoxLayout" name="rightOperandLayout"><property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property>
<item><widget class="QComboBox" name="rightOperandKindComboBox"/></item>
<item><widget class="QSpinBox" name="rightOperandValueSpinBox"><property name="minimum"><number>-32768</number></property><property name="maximum"><number>32767</number></property></widget></item>
</layout>
</widget>
</item>
<item row="3" column="0"><widget class="QLabel" name="destinationAddressLabel"><property name="text"><string>目标地址</string></property></widget></item>
<item row="3" column="1"><widget class="QSpinBox" name="destinationAddressSpinBox"><property name="prefix"><string>D</string></property><property name="maximum"><number>4000</number></property></widget></item>
</layout>
</widget>
</widget>
</item>
<item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set></property></widget></item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

+ 744
- 949
app/src/ui/main_window.cpp
文件差異過大導致無法顯示
查看文件


+ 41
- 23
app/src/ui/main_window.h 查看文件

@@ -20,8 +20,9 @@
QT_BEGIN_NAMESPACE
class QAction;
class QActionGroup;
class QCloseEvent;
class QEvent;
class QLabel;
class QTimer;

namespace Ui {
class MainWindow;
@@ -32,12 +33,18 @@ class RuntimeModeService;
class ProjectService;
class HmiEditorService;
class HmiRuntimeService;
class AlarmEditorService;
class AlarmService;
class HmiNavigationService;
class RegisterCommentService;
class HmiEditorWidget;
class LogicEditorService;
class LogicEditorWidget;
class RegisterMonitorService;
class RuntimeMonitorWidget;
class ProjectWorkspaceController;
class PropertyPanelController;
class RuntimePanelController;

/**
* @brief 组织主界面区域并将用户操作转交给服务层
@@ -63,6 +70,9 @@ public:
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
HmiRuntimeService &hmi_runtime_service,
AlarmEditorService &alarm_editor_service,
AlarmService &alarm_service,
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
QWidget *parent = nullptr);
MainWindow(
@@ -71,7 +81,10 @@ public:
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
HmiRuntimeService &hmi_runtime_service,
AlarmEditorService &alarm_editor_service,
AlarmService &alarm_service,
HmiNavigationService &hmi_navigation_service,
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
QWidget *parent = nullptr);

@@ -80,6 +93,10 @@ public:
const std::string &currentHmiPageId() const;
const std::string &currentLogicId() const;

protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void closeEvent(QCloseEvent *event) override;

private:
// 配置菜单和工具栏动作
void configureActions();
@@ -94,17 +111,19 @@ private:
// 创建并连接控件属性编辑表单
void configurePropertyEditor();
void configurePlcConnection();
void configureAlarms();
void configureRegisterComments();
void undoActiveEditor();
void redoActiveEditor();
void deleteActiveSelection();
void clearActiveSelection();
void updateEditActions();
void clearEditorHistories();
void updateWindowTitle();
bool confirmSaveBeforeDestructiveAction();
// 刷新工程树和当前 HMI 页面信息
void refreshProjectUi();
void handleProjectTreeSelection();
void updateProjectTreeActions();
void addPage();
void addLogic();
void renameProjectItem();
void deleteProjectItem();
void moveProjectItem(int offset);
void setSelectedPageAsInitial();
void toggleSelectedLogicEnabled();
// 显示指定 HMI 控件的可编辑属性
void showControlProperties(const std::string &control_id);
// 显示指定控制逻辑节点的可编辑属性
@@ -113,20 +132,22 @@ private:
void addHmiControl(HmiControlType type);
// 删除当前选中的 HMI 控件
void deleteSelectedControl();
// 将属性表单内容应用到当前选中控件
void applySelectedControlProperties();
// 在当前光标节点后添加串联条件,没有选择时追加到网络末尾
void addLogicCondition(const LogicNodeConfig &config);
// 为当前选中的连续逻辑范围建立并联支路
void addLogicParallelBranch(const LogicNodeConfig &config);
void addLogicHorizontalWire();
void addLogicVerticalWire();
void deleteLogicHorizontalWire();
void deleteLogicVerticalWire();
// 设置当前网络右侧的输出线圈
void setLogicOutput(const LogicNodeConfig &config);
void configureAndSetLogicOutput(const LogicNodeConfig &config);
// 新增一个梯形图网络
void addLogicRung();
void editSelectedRungComment();
// 删除当前选中的逻辑节点或网络
void deleteSelectedLogicObject();
// 将逻辑属性表单内容应用到当前选中节点
void applySelectedLogicNodeProperties();
void connectPlc();
void disconnectPlc();
// 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志
@@ -141,6 +162,7 @@ private:
void loadProject();
// 在状态栏和输出面板显示工程操作结果
void showProjectResult(const QString &action, const QString &message, bool succeeded);
void appendOutputMessage(const QString &message);

/**
* @brief 请求服务层切换模式并同步界面状态
@@ -176,30 +198,26 @@ private:
HmiEditorService &hmi_editor_service_;
LogicEditorService &logic_editor_service_;
HmiRuntimeService &hmi_runtime_service_;
AlarmEditorService &alarm_editor_service_;
AlarmService &alarm_service_;
RegisterCommentService &register_comment_service_;
RegisterMonitorService &register_monitor_service_;
std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_;
HmiNavigationService *hmi_navigation_service_ = nullptr;
std::unique_ptr<ProjectWorkspaceController> project_workspace_controller_;
std::unique_ptr<PropertyPanelController> property_panel_controller_;
std::unique_ptr<RuntimePanelController> runtime_panel_controller_;
QActionGroup *mode_action_group_ = nullptr;
HmiEditorWidget *hmi_editor_widget_ = nullptr;
LogicEditorWidget *logic_editor_widget_ = nullptr;
RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr;
QTimer *runtime_refresh_timer_ = nullptr;
QLabel *mode_status_label_ = nullptr;
QLabel *register_status_label_ = nullptr;
QLabel *executor_status_label_ = nullptr;
QAction *add_page_action_ = nullptr;
QAction *add_logic_action_ = nullptr;
QAction *rename_project_item_action_ = nullptr;
QAction *delete_project_item_action_ = nullptr;
QAction *move_project_item_up_action_ = nullptr;
QAction *move_project_item_down_action_ = nullptr;
QAction *set_initial_page_action_ = nullptr;
QAction *toggle_logic_enabled_action_ = nullptr;
PlcSerialConfiguration plc_configuration_;
std::string selected_control_id_;
std::string selected_logic_node_id_;
std::string current_hmi_page_id_;
std::string current_logic_id_;
bool plc_status_update_pending_ = false;
int last_editing_tab_index_ = 0;
};

+ 391
- 33
app/src/ui/main_window.ui 查看文件

@@ -132,7 +132,7 @@
<item>
<widget class="QLabel" name="hmiPageSizeLabel">
<property name="text">
<string>800 x 480</string>
<string>800 x 400</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
@@ -207,18 +207,7 @@
</item>
</layout>
</widget>
<widget class="QWidget" name="runtimeMonitorTab">
<attribute name="title">
<string>运行监控</string>
</attribute>
<layout class="QVBoxLayout" name="runtimeMonitorLayout">
<property name="leftMargin"><number>0</number></property>
<property name="topMargin"><number>0</number></property>
<property name="rightMargin"><number>0</number></property>
<property name="bottomMargin"><number>0</number></property>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
@@ -240,7 +229,19 @@
<addaction name="saveAsProjectAction"/>
<addaction name="loadProjectAction"/>
<addaction name="separator"/>
<addaction name="exitAction"/>
<addaction name="exitAction"/>
</widget>
<widget class="QMenu" name="editMenu">
<property name="title">
<string>编辑(&amp;E)</string>
</property>
<addaction name="undoAction"/>
<addaction name="redoAction"/>
<addaction name="separator"/>
<addaction name="deleteSelectionAction"/>
<addaction name="deleteHorizontalWireAction"/>
<addaction name="deleteVerticalWireAction"/>
<addaction name="clearSelectionAction"/>
</widget>
<widget class="QMenu" name="runMenu">
<property name="title">
@@ -254,11 +255,13 @@
<addaction name="disconnectPlcAction"/>
</widget>
<widget class="QMenu" name="viewMenu">
<property name="title">
<string>视图(&amp;V)</string>
</property>
<property name="title">
<string>视图(&amp;V)</string>
</property>
<addaction name="configureRegisterCommentsAction"/>
</widget>
<addaction name="fileMenu"/>
<addaction name="editMenu"/>
<addaction name="runMenu"/>
<addaction name="viewMenu"/>
</widget>
@@ -296,14 +299,13 @@
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
<bool>true</bool>
</attribute>
<addaction name="addButtonAction"/>
<addaction name="addIndicatorAction"/>
<addaction name="addNumericDisplayAction"/>
<addaction name="addNumericInputAction"/>
<addaction name="addLabelAction"/>
<addaction name="addPageJumpAction"/>
<addaction name="deleteControlAction"/>
</widget>
<widget class="QToolBar" name="logicToolBar">
@@ -317,18 +319,18 @@
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
<bool>true</bool>
</attribute>
<addaction name="addRungAction"/>
<addaction name="insertHorizontalWireAction"/>
<addaction name="insertVerticalWireAction"/>
<addaction name="parallelInsertAction"/>
<addaction name="deleteLogicAction"/>
<addaction name="separator"/>
<addaction name="addNormallyOpenAction"/>
<addaction name="addNormallyClosedAction"/>
<addaction name="addNormalCoilAction"/>
<addaction name="addSetCoilAction"/>
<addaction name="addResetCoilAction"/>
<addaction name="addCompareAction"/>
<addaction name="deleteLogicAction"/>
<addaction name="editRungCommentAction"/>
</widget>
<widget class="QDockWidget" name="projectDock">
<property name="minimumSize">
@@ -450,6 +452,54 @@
</item>
<item>
<widget class="QStackedWidget" name="propertyStack">
<widget class="QWidget" name="pagePropertiesPage">
<layout class="QFormLayout" name="pagePropertiesForm">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="pageWidthLabel">
<property name="text">
<string>页面宽度</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="pageWidthSpinBox">
<property name="minimum">
<number>320</number>
</property>
<property name="maximum">
<number>1600</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="pageHeightLabel">
<property name="text">
<string>页面高度</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="pageHeightSpinBox">
<property name="minimum">
<number>200</number>
</property>
<property name="maximum">
<number>800</number>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QPushButton" name="applyPagePropertiesButton">
<property name="text">
<string>应用页面尺寸</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="hmiPropertiesPage">
<layout class="QFormLayout" name="hmiPropertiesForm">
<property name="fieldGrowthPolicy">
@@ -485,7 +535,7 @@
<item row="2" column="1">
<widget class="QSpinBox" name="controlXSpinBox">
<property name="maximum">
<number>4000</number>
<number>8192</number>
</property>
</widget>
</item>
@@ -499,7 +549,7 @@
<item row="3" column="1">
<widget class="QSpinBox" name="controlYSpinBox">
<property name="maximum">
<number>4000</number>
<number>8192</number>
</property>
</widget>
</item>
@@ -516,7 +566,7 @@
<number>1</number>
</property>
<property name="maximum">
<number>4000</number>
<number>8192</number>
</property>
</widget>
</item>
@@ -533,7 +583,7 @@
<number>1</number>
</property>
<property name="maximum">
<number>4000</number>
<number>8192</number>
</property>
</widget>
</item>
@@ -618,7 +668,123 @@
</item>
</widget>
</item>
<item row="10" column="0" colspan="2">
<item row="10" column="0">
<widget class="QLabel" name="progressMinimumLabel">
<property name="text">
<string>最小值</string>
</property>
</widget>
</item>
<item row="10" column="1">
<widget class="QSpinBox" name="progressMinimumSpinBox">
<property name="minimum">
<number>-32768</number>
</property>
<property name="maximum">
<number>32767</number>
</property>
</widget>
</item>
<item row="11" column="0">
<widget class="QLabel" name="progressMaximumLabel">
<property name="text">
<string>最大值</string>
</property>
</widget>
</item>
<item row="11" column="1">
<widget class="QSpinBox" name="progressMaximumSpinBox">
<property name="minimum">
<number>-32768</number>
</property>
<property name="maximum">
<number>32767</number>
</property>
</widget>
</item>
<item row="12" column="0">
<widget class="QLabel" name="progressShowValueLabel">
<property name="text">
<string>数值显示</string>
</property>
</widget>
</item>
<item row="12" column="1">
<widget class="QCheckBox" name="progressShowValueCheckBox">
<property name="text">
<string>显示百分比</string>
</property>
</widget>
</item>
<item row="13" column="0">
<widget class="QLabel" name="textColorLabel">
<property name="text">
<string>字体颜色</string>
</property>
</widget>
</item>
<item row="13" column="1">
<widget class="QWidget" name="textColorContainer">
<layout class="QHBoxLayout" name="textColorLayout">
<property name="leftMargin"><number>0</number></property>
<property name="topMargin"><number>0</number></property>
<property name="rightMargin"><number>0</number></property>
<property name="bottomMargin"><number>0</number></property>
<item>
<widget class="QLineEdit" name="textColorEdit">
<property name="placeholderText">
<string>默认,例如 #E53935</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="textColorButton">
<property name="text">
<string>选择</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="14" column="0">
<widget class="QLabel" name="fontSizeLabel">
<property name="text">
<string>字号</string>
</property>
</widget>
</item>
<item row="14" column="1">
<widget class="QSpinBox" name="fontSizeSpinBox"/>
</item>
<item row="15" column="0">
<widget class="QLabel" name="fontBoldLabel">
<property name="text">
<string>字体样式</string>
</property>
</widget>
</item>
<item row="15" column="1">
<widget class="QWidget" name="fontStyleContainer">
<layout class="QHBoxLayout" name="fontStyleLayout">
<property name="leftMargin"><number>0</number></property>
<property name="topMargin"><number>0</number></property>
<property name="rightMargin"><number>0</number></property>
<property name="bottomMargin"><number>0</number></property>
<item>
<widget class="QCheckBox" name="fontBoldCheckBox">
<property name="text"><string>粗体</string></property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="fontItalicCheckBox">
<property name="text"><string>斜体</string></property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="16" column="0" colspan="2">
<widget class="QPushButton" name="applyPropertiesButton">
<property name="text">
<string>应用属性</string>
@@ -756,7 +922,27 @@
</property>
</widget>
</item>
<item row="7" column="0" colspan="2">
<item row="7" column="0">
<widget class="QLabel" name="logicPresetLabel">
<property name="text">
<string>预设时间(ms)</string>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QSpinBox" name="logicPresetSpinBox">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>86400000</number>
</property>
<property name="value">
<number>1000</number>
</property>
</widget>
</item>
<item row="8" column="0" colspan="2">
<widget class="QPushButton" name="applyLogicPropertiesButton">
<property name="text">
<string>应用属性</string>
@@ -822,21 +1008,53 @@
<property name="text">
<string>新建工程</string>
</property>
<property name="shortcut">
<string>Ctrl+N</string>
</property>
</action>
<action name="saveProjectAction">
<property name="text">
<string>保存工程</string>
</property>
<property name="shortcut">
<string>Ctrl+S</string>
</property>
</action>
<action name="saveAsProjectAction">
<property name="text">
<string>工程另存为</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+S</string>
</property>
</action>
<action name="loadProjectAction">
<property name="text">
<string>加载工程</string>
</property>
<property name="shortcut">
<string>Ctrl+O</string>
</property>
</action>
<action name="undoAction">
<property name="text"><string>撤销</string></property>
<property name="toolTip"><string>撤销当前编辑器的上一步操作</string></property>
<property name="shortcut"><string>Ctrl+Z</string></property>
</action>
<action name="redoAction">
<property name="text"><string>重做</string></property>
<property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property>
<property name="shortcut"><string>Ctrl+Y</string></property>
</action>
<action name="deleteSelectionAction">
<property name="text"><string>删除所选</string></property>
<property name="toolTip"><string>删除当前画布中选中的对象</string></property>
<property name="shortcut"><string>Delete</string></property>
</action>
<action name="clearSelectionAction">
<property name="text"><string>清除选择</string></property>
<property name="toolTip"><string>清除当前画布选择</string></property>
<property name="shortcut"><string>Esc</string></property>
</action>
<action name="configurePlcAction">
<property name="text">
@@ -846,6 +1064,14 @@
<string>配置参数并连接真实 PLC</string>
</property>
</action>
<action name="configureRegisterCommentsAction">
<property name="text">
<string>软元件注释</string>
</property>
<property name="toolTip">
<string>维护工程级 M/D 地址注释</string>
</property>
</action>
<action name="disconnectPlcAction">
<property name="text">
<string>断开 PLC</string>
@@ -886,6 +1112,14 @@
<string>添加数值输入控件</string>
</property>
</action>
<action name="addProgressBarAction">
<property name="text">
<string>进度条</string>
</property>
<property name="toolTip">
<string>添加进度条控件</string>
</property>
</action>
<action name="addLabelAction">
<property name="text">
<string>文本</string>
@@ -902,6 +1136,22 @@
<string>添加页面跳转控件</string>
</property>
</action>
<action name="addAlarmListAction">
<property name="text">
<string>报警列表</string>
</property>
<property name="toolTip">
<string>添加报警列表控件</string>
</property>
</action>
<action name="configureAlarmsAction">
<property name="text">
<string>报警配置</string>
</property>
<property name="toolTip">
<string>配置 M 和 D 报警条件</string>
</property>
</action>
<action name="deleteControlAction">
<property name="text">
<string>删除</string>
@@ -918,6 +1168,50 @@
<string>在梯形图末尾新增网络</string>
</property>
</action>
<action name="insertHorizontalWireAction">
<property name="text">
<string>横线</string>
</property>
<property name="toolTip">
<string>在所选条件或横线后插入横线;没有选择时追加到当前网络</string>
</property>
<property name="shortcut">
<string>F11</string>
</property>
</action>
<action name="insertVerticalWireAction">
<property name="text">
<string>竖线</string>
</property>
<property name="toolTip">
<string>为所选连续逻辑范围建立横线旁路和竖线连接</string>
</property>
<property name="shortcut">
<string>F12</string>
</property>
</action>
<action name="deleteHorizontalWireAction">
<property name="text">
<string>删除横线</string>
</property>
<property name="toolTip">
<string>删除画布中选中的横线</string>
</property>
<property name="shortcut">
<string>Shift+F11</string>
</property>
</action>
<action name="deleteVerticalWireAction">
<property name="text">
<string>删除竖线</string>
</property>
<property name="toolTip">
<string>删除画布中选中的竖线及其对应并联支路</string>
</property>
<property name="shortcut">
<string>Shift+F12</string>
</property>
</action>
<action name="parallelInsertAction">
<property name="text">
<string>并联支路</string>
@@ -931,7 +1225,7 @@
<string>常开</string>
</property>
<property name="toolTip">
<string>向当前网络添加常开触点</string>
<string>在选中的空网格或横线处添加常开触点;选中条件时在其后插入</string>
</property>
</action>
<action name="addNormallyClosedAction">
@@ -939,9 +1233,37 @@
<string>常闭</string>
</property>
<property name="toolTip">
<string>向当前网络添加常闭触点</string>
<string>在选中的空网格或横线处添加常闭触点;选中条件时在其后插入</string>
</property>
</action>
<action name="addRisingEdgeAction">
<property name="text">
<string>上升沿</string>
</property>
<property name="toolTip">
<string>在选中的空网格或横线处添加一个扫描周期的上升沿触点</string>
</property>
</action>
<action name="addFallingEdgeAction">
<property name="text">
<string>下降沿</string>
</property>
<property name="toolTip">
<string>在选中的空网格或横线处添加一个扫描周期的下降沿触点</string>
</property>
</action>
<action name="addTimerContactAction">
<property name="text">
<string>T 触点</string>
</property>
<property name="toolTip">
<string>在选中的空网格或横线处添加离线仿真定时器触点</string>
</property>
</action>
<action name="addCounterContactAction">
<property name="text"><string>C 触点</string></property>
<property name="toolTip"><string>在选中的空网格或横线处添加离线仿真计数器完成触点</string></property>
</action>
<action name="addNormalCoilAction">
<property name="text">
<string>线圈</string>
@@ -966,17 +1288,53 @@
<string>设置当前网络的复位线圈</string>
</property>
</action>
<action name="addTonAction">
<property name="text">
<string>TON</string>
</property>
<property name="toolTip">
<string>设置非保持接通延时定时器输出</string>
</property>
</action>
<action name="addCtuAction">
<property name="text"><string>CTU</string></property>
<property name="toolTip"><string>设置按上升沿执行的加计数器</string></property>
</action>
<action name="addCtdAction">
<property name="text"><string>CTD</string></property>
<property name="toolTip"><string>设置按上升沿执行的减计数器</string></property>
</action>
<action name="addMoveAction">
<property name="text"><string>MOVE</string></property>
<property name="toolTip"><string>设置 D 数据传送指令</string></property>
</action>
<action name="addAddAction">
<property name="text"><string>ADD</string></property>
<property name="toolTip"><string>设置带饱和处理的加法指令</string></property>
</action>
<action name="addSubAction">
<property name="text"><string>SUB</string></property>
<property name="toolTip"><string>设置带饱和处理的减法指令</string></property>
</action>
<action name="addCompareAction">
<property name="text">
<string>D 比较</string>
</property>
<property name="toolTip">
<string>向当前网络添加 D 值与常量比较</string>
<string>在选中的空网格或横线处添加 D 值与常量比较条件</string>
</property>
</action>
<action name="editRungCommentAction">
<property name="text">
<string>网络注释</string>
</property>
<property name="toolTip">
<string>编辑当前梯形图网络说明</string>
</property>
</action>
<action name="deleteLogicAction">
<property name="text">
<string>删除</string>
<string>删除节点/网络</string>
</property>
<property name="toolTip">
<string>删除选中的逻辑节点或网络</string>


+ 1
- 1
app/src/ui/plc_connection_dialog.ui 查看文件

@@ -65,7 +65,7 @@
<item row="0" column="0"><widget class="QLabel" name="responseTimeoutLabel"><property name="text"><string>响应超时</string></property></widget></item>
<item row="0" column="1"><widget class="QSpinBox" name="responseTimeoutSpinBox"><property name="suffix"><string> ms</string></property><property name="minimum"><number>100</number></property><property name="maximum"><number>30000</number></property><property name="singleStep"><number>100</number></property><property name="value"><number>1000</number></property></widget></item>
<item row="1" column="0"><widget class="QLabel" name="retriesLabel"><property name="text"><string>失败重试</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="retriesSpinBox"><property name="suffix"><string> 次</string></property><property name="maximum"><number>10</number></property><property name="value"><number>2</number></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="retriesSpinBox"><property name="suffix"><string> 次</string></property><property name="maximum"><number>5</number></property><property name="value"><number>2</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="pollIntervalLabel"><property name="text"><string>轮询周期</string></property></widget></item>
<item row="2" column="1"><widget class="QSpinBox" name="pollIntervalSpinBox"><property name="suffix"><string> ms</string></property><property name="minimum"><number>50</number></property><property name="maximum"><number>10000</number></property><property name="singleStep"><number>50</number></property><property name="value"><number>200</number></property></widget></item>
</layout>


+ 520
- 0
app/src/ui/project_workspace_controller.cpp 查看文件

@@ -0,0 +1,520 @@
#include "project_workspace_controller.h"

#include "hmi_editor_widget.h"
#include "logic_editor_widget.h"
#include "runtime_monitor_widget.h"
#include "services/hmi_editor_service.h"
#include "services/hmi_navigation_service.h"
#include "services/logic_editor_service.h"
#include "services/project_service.h"
#include "services/runtime_mode_service.h"
#include "ui_main_window.h"

#include <QAction>
#include <QInputDialog>
#include <QLineEdit>
#include <QObject>
#include <QSignalBlocker>
#include <QTabWidget>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QWidget>

#include <utility>

namespace {

constexpr int kProjectTreeKindRole = Qt::UserRole;
constexpr int kProjectTreeIdRole = Qt::UserRole + 1;

QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

} // namespace

ProjectWorkspaceController::ProjectWorkspaceController(
QWidget &parent,
Ui::MainWindow &ui,
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
RuntimeModeService &runtime_mode_service,
HmiNavigationService &hmi_navigation_service,
HmiEditorWidget &hmi_editor_widget,
LogicEditorWidget &logic_editor_widget,
RuntimeMonitorWidget &runtime_monitor_widget,
std::string &current_hmi_page_id,
std::string &current_logic_id,
std::function<void(const std::string &)> show_control_properties,
std::function<void(const std::string &)> show_logic_properties,
ResultReporter result_reporter,
StatusReporter status_reporter,
EditStateChanged edit_state_changed)
: parent_(parent),
ui_(ui),
project_service_(project_service),
hmi_editor_service_(hmi_editor_service),
logic_editor_service_(logic_editor_service),
runtime_mode_service_(runtime_mode_service),
hmi_navigation_service_(hmi_navigation_service),
hmi_editor_widget_(hmi_editor_widget),
logic_editor_widget_(logic_editor_widget),
runtime_monitor_widget_(runtime_monitor_widget),
current_hmi_page_id_(current_hmi_page_id),
current_logic_id_(current_logic_id),
show_control_properties_(std::move(show_control_properties)),
show_logic_properties_(std::move(show_logic_properties)),
result_reporter_(std::move(result_reporter)),
status_reporter_(std::move(status_reporter)),
edit_state_changed_(std::move(edit_state_changed))
{
}

void ProjectWorkspaceController::configure()
{
ui_.projectTree->setContextMenuPolicy(Qt::ActionsContextMenu);

add_page_action_ = new QAction(QObject::tr("新建页面"), &parent_);
add_page_action_->setObjectName(QStringLiteral("addPageAction"));
add_logic_action_ = new QAction(QObject::tr("新建控制逻辑"), &parent_);
add_logic_action_->setObjectName(QStringLiteral("addLogicAction"));
rename_item_action_ = new QAction(QObject::tr("重命名"), &parent_);
rename_item_action_->setObjectName(QStringLiteral("renameProjectItemAction"));
delete_item_action_ = new QAction(QObject::tr("删除"), &parent_);
delete_item_action_->setObjectName(QStringLiteral("deleteProjectItemAction"));
move_item_up_action_ = new QAction(QObject::tr("上移"), &parent_);
move_item_up_action_->setObjectName(QStringLiteral("moveProjectItemUpAction"));
move_item_down_action_ = new QAction(QObject::tr("下移"), &parent_);
move_item_down_action_->setObjectName(
QStringLiteral("moveProjectItemDownAction"));
set_initial_page_action_ = new QAction(QObject::tr("设为初始页面"), &parent_);
set_initial_page_action_->setObjectName(QStringLiteral("setInitialPageAction"));
toggle_logic_enabled_action_ = new QAction(
QObject::tr("禁用控制逻辑"), &parent_);
toggle_logic_enabled_action_->setObjectName(
QStringLiteral("toggleLogicEnabledAction"));

ui_.projectTree->addActions({
add_page_action_,
add_logic_action_,
rename_item_action_,
delete_item_action_,
move_item_up_action_,
move_item_down_action_,
set_initial_page_action_,
toggle_logic_enabled_action_});

QObject::connect(add_page_action_, &QAction::triggered,
&parent_, [this] { addPage(); });
QObject::connect(add_logic_action_, &QAction::triggered,
&parent_, [this] { addLogic(); });
QObject::connect(rename_item_action_, &QAction::triggered,
&parent_, [this] { renameSelectedItem(); });
QObject::connect(delete_item_action_, &QAction::triggered,
&parent_, [this] { deleteSelectedItem(); });
QObject::connect(move_item_up_action_, &QAction::triggered,
&parent_, [this] { moveSelectedItem(-1); });
QObject::connect(move_item_down_action_, &QAction::triggered,
&parent_, [this] { moveSelectedItem(1); });
QObject::connect(set_initial_page_action_, &QAction::triggered,
&parent_, [this] { setSelectedPageAsInitial(); });
QObject::connect(toggle_logic_enabled_action_, &QAction::triggered,
&parent_, [this] { toggleSelectedLogicEnabled(); });
QObject::connect(ui_.projectTree, &QTreeWidget::currentItemChanged,
&parent_, [this](QTreeWidgetItem *, QTreeWidgetItem *)
{
handleSelectionChanged();
});
updateActions();
}

void ProjectWorkspaceController::refresh()
{
if (hmi_editor_service_.findPage(current_hmi_page_id_) == nullptr)
{
const std::string &initial_id = project_service_.project().initialHmiPageId;
current_hmi_page_id_ = hmi_editor_service_.findPage(initial_id) != nullptr
? initial_id : hmi_editor_service_.firstPageId();
show_control_properties_({});
}
if (logic_editor_service_.findLogic(current_logic_id_) == nullptr)
{
current_logic_id_ = logic_editor_service_.firstLogicId();
show_logic_properties_({});
}

hmi_editor_widget_.setPageId(current_hmi_page_id_);
logic_editor_widget_.setLogicId(current_logic_id_);
if (runtime_mode_service_.mode() != ApplicationMode::Editing)
{
runtime_monitor_widget_.setProjectObjects(
hmi_navigation_service_.currentPageId(), current_logic_id_);
}
runtime_mode_service_.refreshPlcPollAddresses();

const QSignalBlocker blocker(ui_.projectTree);
ui_.projectTree->clear();
auto *hmi_root = new QTreeWidgetItem(ui_.projectTree, {QObject::tr("HMI 页面")});
hmi_root->setData(0, kProjectTreeKindRole, static_cast<int>(ItemKind::Root));
QTreeWidgetItem *selected_item = nullptr;
for (const HmiPage &page : project_service_.project().hmiPages)
{
const QString suffix = page.id == project_service_.project().initialHmiPageId
? QObject::tr("(初始)") : QString{};
auto *item = new QTreeWidgetItem(
hmi_root, {fromUtf8(page.name) + suffix});
item->setData(0, kProjectTreeKindRole, static_cast<int>(ItemKind::HmiPage));
item->setData(0, kProjectTreeIdRole, fromUtf8(page.id));
if (page.id == current_hmi_page_id_
&& ui_.editorTabWidget->currentIndex() == 0)
{
selected_item = item;
}
}

const HmiPage *current_page = hmi_editor_service_.findPage(current_hmi_page_id_);
if (current_page != nullptr)
{
ui_.hmiPageTitleLabel->setText(fromUtf8(current_page->name));
ui_.hmiPageSizeLabel->setText(
QObject::tr("%1 x %2").arg(current_page->width).arg(current_page->height));
}
else
{
ui_.hmiPageTitleLabel->setText(QObject::tr("尚未创建 HMI 页面"));
ui_.hmiPageSizeLabel->clear();
}

auto *logic_root = new QTreeWidgetItem(
ui_.projectTree, {QObject::tr("控制逻辑")});
logic_root->setData(0, kProjectTreeKindRole, static_cast<int>(ItemKind::Root));
for (const ControlLogic &logic : project_service_.project().controlLogics)
{
const QString suffix = logic.enabled ? QString{} : QObject::tr("(已禁用)");
auto *item = new QTreeWidgetItem(
logic_root, {fromUtf8(logic.name) + suffix});
item->setData(
0, kProjectTreeKindRole, static_cast<int>(ItemKind::ControlLogic));
item->setData(0, kProjectTreeIdRole, fromUtf8(logic.id));
if (logic.id == current_logic_id_
&& ui_.editorTabWidget->currentIndex() == 1)
{
selected_item = item;
}
}
ui_.projectTree->expandAll();
if (selected_item != nullptr)
{
ui_.projectTree->setCurrentItem(selected_item);
}
updateActions();
}

const std::string &ProjectWorkspaceController::currentHmiPageId() const
{
return current_hmi_page_id_;
}

const std::string &ProjectWorkspaceController::currentLogicId() const
{
return current_logic_id_;
}

void ProjectWorkspaceController::handleSelectionChanged()
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
if (item == nullptr)
{
updateActions();
return;
}
const auto kind = static_cast<ItemKind>(
item->data(0, kProjectTreeKindRole).toInt());
const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString());
if (kind == ItemKind::HmiPage && hmi_editor_service_.findPage(id) != nullptr)
{
current_hmi_page_id_ = id;
hmi_editor_widget_.setPageId(id);
hmi_editor_widget_.reloadPage();
ui_.editorTabWidget->setCurrentWidget(ui_.hmiEditorTab);
show_control_properties_({});
const HmiPage *page = hmi_editor_service_.findPage(id);
ui_.hmiPageTitleLabel->setText(fromUtf8(page->name));
ui_.hmiPageSizeLabel->setText(
QObject::tr("%1 x %2").arg(page->width).arg(page->height));
}
else if (kind == ItemKind::ControlLogic
&& logic_editor_service_.findLogic(id) != nullptr)
{
current_logic_id_ = id;
logic_editor_widget_.setLogicId(id);
logic_editor_widget_.reloadLogic();
ui_.editorTabWidget->setCurrentWidget(ui_.logicEditorTab);
show_logic_properties_({});
}
updateActions();
}

void ProjectWorkspaceController::updateActions()
{
if (add_page_action_ == nullptr)
{
return;
}
QTreeWidgetItem *item = ui_.projectTree->currentItem();
const auto kind = item == nullptr ? ItemKind::Root
: static_cast<ItemKind>(item->data(0, kProjectTreeKindRole).toInt());
const bool page_selected = kind == ItemKind::HmiPage;
const bool logic_selected = kind == ItemKind::ControlLogic;
const bool editable = runtime_mode_service_.policy().allowsProjectEditing;
add_page_action_->setEnabled(editable);
add_logic_action_->setEnabled(editable);
rename_item_action_->setEnabled(editable && (page_selected || logic_selected));
delete_item_action_->setEnabled(editable && (page_selected || logic_selected));
move_item_up_action_->setEnabled(editable && (page_selected || logic_selected));
move_item_down_action_->setEnabled(editable && (page_selected || logic_selected));
set_initial_page_action_->setEnabled(editable && page_selected);
toggle_logic_enabled_action_->setEnabled(editable && logic_selected);
if (logic_selected)
{
const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString());
const ControlLogic *logic = logic_editor_service_.findLogic(id);
toggle_logic_enabled_action_->setText(
logic != nullptr && logic->enabled
? QObject::tr("禁用控制逻辑") : QObject::tr("启用控制逻辑"));
}
}

void ProjectWorkspaceController::addPage()
{
const QString name = QInputDialog::getText(
&parent_, QObject::tr("新建页面"), QObject::tr("页面名称"));
if (name.isEmpty())
{
return;
}
const HmiEditorResult result = hmi_editor_service_.addPage(toUtf8(name));
if (!result.succeeded)
{
reportFailure(QObject::tr("新建页面"), result.message);
return;
}
current_hmi_page_id_ = result.id;
show_control_properties_({});
ui_.editorTabWidget->setCurrentWidget(ui_.hmiEditorTab);
refresh();
hmi_editor_widget_.reloadPage();
edit_state_changed_();
}

void ProjectWorkspaceController::addLogic()
{
const QString name = QInputDialog::getText(
&parent_, QObject::tr("新建控制逻辑"), QObject::tr("逻辑名称"));
if (name.isEmpty())
{
return;
}
const LogicEditorResult result = logic_editor_service_.addLogic(toUtf8(name));
if (!result.succeeded)
{
reportFailure(QObject::tr("新建控制逻辑"), result.message);
return;
}
current_logic_id_ = result.id;
show_logic_properties_({});
ui_.editorTabWidget->setCurrentWidget(ui_.logicEditorTab);
refresh();
logic_editor_widget_.reloadLogic();
edit_state_changed_();
}

void ProjectWorkspaceController::renameSelectedItem()
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
if (item == nullptr)
{
return;
}
const auto kind = static_cast<ItemKind>(
item->data(0, kProjectTreeKindRole).toInt());
const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString());
QString current_name;
if (kind == ItemKind::HmiPage)
{
const HmiPage *page = hmi_editor_service_.findPage(id);
current_name = page == nullptr ? QString{} : fromUtf8(page->name);
}
else if (kind == ItemKind::ControlLogic)
{
const ControlLogic *logic = logic_editor_service_.findLogic(id);
current_name = logic == nullptr ? QString{} : fromUtf8(logic->name);
}
else
{
return;
}
bool accepted = false;
const QString name = QInputDialog::getText(
&parent_, QObject::tr("重命名"), QObject::tr("新名称"),
QLineEdit::Normal, current_name, &accepted);
if (!accepted)
{
return;
}
if (kind == ItemKind::HmiPage)
{
const HmiEditorResult result = hmi_editor_service_.renamePage(id, toUtf8(name));
if (!result.succeeded)
{
reportFailure(QObject::tr("重命名页面"), result.message);
return;
}
}
else
{
const LogicEditorResult result = logic_editor_service_.renameLogic(
id, toUtf8(name));
if (!result.succeeded)
{
reportFailure(QObject::tr("重命名控制逻辑"), result.message);
return;
}
}
refresh();
edit_state_changed_();
}

void ProjectWorkspaceController::deleteSelectedItem()
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
if (item == nullptr)
{
return;
}
const auto kind = static_cast<ItemKind>(
item->data(0, kProjectTreeKindRole).toInt());
const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString());
if (kind == ItemKind::HmiPage)
{
const HmiEditorResult result = hmi_editor_service_.removePage(id);
if (!result.succeeded)
{
reportFailure(QObject::tr("删除页面"), result.message);
return;
}
}
else if (kind == ItemKind::ControlLogic)
{
const LogicEditorResult result = logic_editor_service_.removeLogic(id);
if (!result.succeeded)
{
reportFailure(QObject::tr("删除控制逻辑"), result.message);
return;
}
}
else
{
return;
}
refresh();
show_control_properties_({});
show_logic_properties_({});
edit_state_changed_();
}

void ProjectWorkspaceController::moveSelectedItem(int offset)
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
if (item == nullptr)
{
return;
}
const auto kind = static_cast<ItemKind>(
item->data(0, kProjectTreeKindRole).toInt());
const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString());
if (kind == ItemKind::HmiPage)
{
const HmiEditorResult result = hmi_editor_service_.movePage(id, offset);
if (!result.succeeded)
{
status_reporter_(fromUtf8(result.message), 4000);
return;
}
}
else if (kind == ItemKind::ControlLogic)
{
const LogicEditorResult result = logic_editor_service_.moveLogic(id, offset);
if (!result.succeeded)
{
status_reporter_(fromUtf8(result.message), 4000);
return;
}
}
else
{
return;
}
refresh();
edit_state_changed_();
}

void ProjectWorkspaceController::setSelectedPageAsInitial()
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
if (item == nullptr
|| static_cast<ItemKind>(item->data(0, kProjectTreeKindRole).toInt())
!= ItemKind::HmiPage)
{
return;
}
const HmiEditorResult result = hmi_editor_service_.setInitialPage(
toUtf8(item->data(0, kProjectTreeIdRole).toString()));
if (!result.succeeded)
{
reportFailure(QObject::tr("设置初始页面"), result.message);
return;
}
refresh();
edit_state_changed_();
}

void ProjectWorkspaceController::toggleSelectedLogicEnabled()
{
QTreeWidgetItem *item = ui_.projectTree->currentItem();
if (item == nullptr
|| static_cast<ItemKind>(item->data(0, kProjectTreeKindRole).toInt())
!= ItemKind::ControlLogic)
{
return;
}
const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString());
const ControlLogic *logic = logic_editor_service_.findLogic(id);
if (logic == nullptr)
{
return;
}
const LogicEditorResult result = logic_editor_service_.setLogicEnabled(
id, !logic->enabled);
if (!result.succeeded)
{
reportFailure(QObject::tr("启停控制逻辑"), result.message);
return;
}
refresh();
edit_state_changed_();
}

void ProjectWorkspaceController::reportFailure(
const QString &action, const std::string &message) const
{
result_reporter_(action, fromUtf8(message), false);
}

+ 107
- 0
app/src/ui/project_workspace_controller.h 查看文件

@@ -0,0 +1,107 @@
#pragma once

#include "domain/hmi_model.h"
#include "domain/runtime_state.h"

#include <QString>

#include <functional>
#include <string>

class HmiEditorService;
class HmiEditorWidget;
class HmiNavigationService;
class LogicEditorService;
class LogicEditorWidget;
class ProjectService;
class RuntimeModeService;
class RuntimeMonitorWidget;
class QWidget;
class QAction;

namespace Ui {
class MainWindow;
}

// 组织工程树、当前页面/逻辑选择和工程对象的界面刷新
class ProjectWorkspaceController final
{
public:
using ResultReporter = std::function<void(
const QString &action, const QString &message, bool succeeded)>;
using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>;
using EditStateChanged = std::function<void()>;

ProjectWorkspaceController(
QWidget &parent,
Ui::MainWindow &ui,
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
RuntimeModeService &runtime_mode_service,
HmiNavigationService &hmi_navigation_service,
HmiEditorWidget &hmi_editor_widget,
LogicEditorWidget &logic_editor_widget,
RuntimeMonitorWidget &runtime_monitor_widget,
std::string &current_hmi_page_id,
std::string &current_logic_id,
std::function<void(const std::string &)> show_control_properties,
std::function<void(const std::string &)> show_logic_properties,
ResultReporter result_reporter,
StatusReporter status_reporter,
EditStateChanged edit_state_changed);

void configure();
void refresh();
void updateActions();
void addPage();
void addLogic();
void renameSelectedItem();
void deleteSelectedItem();
void moveSelectedItem(int offset);
void setSelectedPageAsInitial();
void toggleSelectedLogicEnabled();

const std::string &currentHmiPageId() const;
const std::string &currentLogicId() const;

private:
enum class ItemKind
{
Root = 0,
HmiPage = 1,
ControlLogic = 2
};

void handleSelectionChanged();
void reportFailure(
const QString &action, const std::string &message) const;

QWidget &parent_;
Ui::MainWindow &ui_;
ProjectService &project_service_;
HmiEditorService &hmi_editor_service_;
LogicEditorService &logic_editor_service_;
RuntimeModeService &runtime_mode_service_;
HmiNavigationService &hmi_navigation_service_;
HmiEditorWidget &hmi_editor_widget_;
LogicEditorWidget &logic_editor_widget_;
RuntimeMonitorWidget &runtime_monitor_widget_;
std::string &current_hmi_page_id_;
std::string &current_logic_id_;
std::function<void(const std::string &)> show_control_properties_;
std::function<void(const std::string &)> show_logic_properties_;
ResultReporter result_reporter_;
StatusReporter status_reporter_;
EditStateChanged edit_state_changed_;

QAction *add_page_action_ = nullptr;
QAction *add_logic_action_ = nullptr;
QAction *rename_item_action_ = nullptr;
QAction *delete_item_action_ = nullptr;
QAction *move_item_up_action_ = nullptr;
QAction *move_item_down_action_ = nullptr;
QAction *set_initial_page_action_ = nullptr;
QAction *toggle_logic_enabled_action_ = nullptr;
};

+ 799
- 0
app/src/ui/property_panel_controller.cpp 查看文件

@@ -0,0 +1,799 @@
#include "property_panel_controller.h"

#include "domain/hmi_control_registry.h"
#include "domain/project_limits.h"
#include "hmi_editor_widget.h"
#include "logic_editor_widget.h"
#include "logic_instruction_dialog.h"
#include "services/hmi_editor_service.h"
#include "services/logic_editor_service.h"
#include "services/project_service.h"
#include "ui_main_window.h"

#include <QApplication>
#include <QCheckBox>
#include <QColorDialog>
#include <QComboBox>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QDialog>
#include <QObject>
#include <QSpinBox>
#include <QStackedWidget>
#include <QWidget>

#include <algorithm>
#include <cstdint>
#include <utility>
#include <vector>

namespace {

QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

int defaultFontPointSize(HmiControlType type)
{
int point_size = QApplication::font().pointSize();
if (type == HmiControlType::AlarmList)
{
point_size -= ProjectLimits::kAlarmDefaultFontPointReduction;
}
return std::clamp(
point_size,
ProjectLimits::kMinimumHmiFontPointSize,
ProjectLimits::kMaximumHmiFontPointSize);
}

} // namespace

PropertyPanelController::PropertyPanelController(
QWidget &parent,
Ui::MainWindow &ui,
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
std::function<std::string()> current_page_id,
std::function<std::string()> current_logic_id,
std::string &selected_control_id,
std::string &selected_logic_node_id,
std::function<void()> refresh_project_ui,
ResultReporter result_reporter,
StatusReporter status_reporter)
: parent_(parent),
ui_(ui),
project_service_(project_service),
hmi_editor_service_(hmi_editor_service),
logic_editor_service_(logic_editor_service),
current_page_id_(std::move(current_page_id)),
current_logic_id_(std::move(current_logic_id)),
selected_control_id_(selected_control_id),
selected_logic_node_id_(selected_logic_node_id),
refresh_project_ui_(std::move(refresh_project_ui)),
result_reporter_(std::move(result_reporter)),
status_reporter_(std::move(status_reporter))
{
}

void PropertyPanelController::configure()
{
ui_.pageWidthSpinBox->setRange(
ProjectLimits::kMinimumHmiPageWidth,
ProjectLimits::kMaximumHmiPageWidth);
ui_.pageHeightSpinBox->setRange(
ProjectLimits::kMinimumHmiPageHeight,
ProjectLimits::kMaximumHmiPageHeight);
ui_.fontSizeSpinBox->setRange(
ProjectLimits::kMinimumHmiFontPointSize,
ProjectLimits::kMaximumHmiFontPointSize);
ui_.bindingAreaComboBox->setItemData(0, -1);
ui_.bindingAreaComboBox->setItemData(1, 0);
ui_.bindingAreaComboBox->setItemData(2, 1);
ui_.buttonOperationComboBox->setItemData(
0, static_cast<int>(HmiButtonOperation::SetOn));
ui_.buttonOperationComboBox->setItemData(
1, static_cast<int>(HmiButtonOperation::SetOff));
ui_.buttonOperationComboBox->setItemData(
2, static_cast<int>(HmiButtonOperation::Toggle));
ui_.buttonOperationComboBox->setItemData(
3, static_cast<int>(HmiButtonOperation::MomentaryOn));
for (int index = 0; index < ui_.logicComparisonComboBox->count(); ++index)
{
ui_.logicComparisonComboBox->setItemData(index, index);
}
QObject::connect(ui_.applyPropertiesButton, &QPushButton::clicked,
&parent_, [this] { applySelectedControlProperties(); });
QObject::connect(ui_.applyPagePropertiesButton, &QPushButton::clicked,
&parent_, [this] { applySelectedPageProperties(); });
QObject::connect(ui_.applyLogicPropertiesButton, &QPushButton::clicked,
&parent_, [this] { applySelectedLogicNodeProperties(); });
QObject::connect(ui_.textColorButton, &QPushButton::clicked,
&parent_, [this] { chooseTextColor(); });
ui_.targetPageLabel->setVisible(false);
ui_.targetPageComboBox->setVisible(false);
showControlProperties({});
}

void PropertyPanelController::bindEditorWidgets(
HmiEditorWidget &hmi_editor_widget,
LogicEditorWidget &logic_editor_widget)
{
hmi_editor_widget_ = &hmi_editor_widget;
logic_editor_widget_ = &logic_editor_widget;
QObject::connect(&hmi_editor_widget, &HmiEditorWidget::controlSelected,
&parent_, [this](const QString &id)
{
showControlProperties(toUtf8(id));
});
QObject::connect(&hmi_editor_widget, &HmiEditorWidget::controlChanged,
&parent_, [this](const QString &id)
{
showControlProperties(toUtf8(id));
refresh_project_ui_();
});
QObject::connect(&hmi_editor_widget, &HmiEditorWidget::editorError,
&parent_, [this](const QString &message)
{
handleHmiEditorError(message);
});
QObject::connect(&logic_editor_widget, &LogicEditorWidget::nodeSelected,
&parent_, [this](const QString &id)
{
showLogicNodeProperties(toUtf8(id));
});
QObject::connect(&logic_editor_widget, &LogicEditorWidget::graphChanged,
&parent_, [this]
{
refresh_project_ui_();
});
QObject::connect(&logic_editor_widget, &LogicEditorWidget::editorError,
&parent_, [this](const QString &message)
{
handleLogicEditorError(message);
});
}

void PropertyPanelController::showControlProperties(const std::string &control_id)
{
if (control_id.empty())
{
showPageProperties(current_page_id_());
return;
}
ui_.propertyStack->setCurrentWidget(ui_.hmiPropertiesPage);
selected_control_id_ = control_id;
const HmiControl *control = hmi_editor_service_.findControl(
current_page_id_(), control_id);
const bool has_control = control != nullptr;
const HmiControlDescriptor *descriptor = has_control
? findHmiControlDescriptor(control->type) : nullptr;
ui_.selectionValueLabel->setText(
has_control ? fromUtf8(control->id)
+ (control->isConfigured() ? QString{}
: control->type == HmiControlType::PageJump
? QObject::tr("(未配置)") : QObject::tr("(未绑定)"))
: QObject::tr("未选择"));

for (QWidget *widget : {static_cast<QWidget *>(ui_.controlIdEdit),
static_cast<QWidget *>(ui_.controlTextEdit),
static_cast<QWidget *>(ui_.controlXSpinBox),
static_cast<QWidget *>(ui_.controlYSpinBox),
static_cast<QWidget *>(ui_.controlWidthSpinBox),
static_cast<QWidget *>(ui_.controlHeightSpinBox),
static_cast<QWidget *>(ui_.bindingAreaComboBox),
static_cast<QWidget *>(ui_.bindingIndexSpinBox),
static_cast<QWidget *>(ui_.targetPageComboBox),
static_cast<QWidget *>(ui_.buttonOperationComboBox),
static_cast<QWidget *>(ui_.progressMinimumSpinBox),
static_cast<QWidget *>(ui_.progressMaximumSpinBox),
static_cast<QWidget *>(ui_.progressShowValueCheckBox),
static_cast<QWidget *>(ui_.textColorEdit),
static_cast<QWidget *>(ui_.textColorButton),
static_cast<QWidget *>(ui_.fontSizeSpinBox),
static_cast<QWidget *>(ui_.fontBoldCheckBox),
static_cast<QWidget *>(ui_.fontItalicCheckBox),
static_cast<QWidget *>(ui_.applyPropertiesButton)})
{
widget->setEnabled(has_control);
}

if (!has_control)
{
ui_.controlTextEdit->setMaxLength(
static_cast<int>(ProjectLimits::kMaximumHmiControlTextCharacters));
ui_.bindingAreaLabel->setVisible(false);
ui_.bindingAreaComboBox->setVisible(false);
ui_.bindingIndexLabel->setVisible(false);
ui_.bindingIndexSpinBox->setVisible(false);
ui_.targetPageLabel->setVisible(false);
ui_.targetPageComboBox->setVisible(false);
ui_.buttonOperationLabel->setVisible(false);
ui_.buttonOperationComboBox->setVisible(false);
ui_.progressMinimumLabel->setVisible(false);
ui_.progressMinimumSpinBox->setVisible(false);
ui_.progressMaximumLabel->setVisible(false);
ui_.progressMaximumSpinBox->setVisible(false);
ui_.progressShowValueLabel->setVisible(false);
ui_.progressShowValueCheckBox->setVisible(false);
ui_.textColorEdit->clear();
ui_.textColorButton->setStyleSheet(QString{});
ui_.fontSizeSpinBox->setValue(
std::clamp(
QApplication::font().pointSize(),
ProjectLimits::kMinimumHmiFontPointSize,
ProjectLimits::kMaximumHmiFontPointSize));
ui_.fontBoldCheckBox->setChecked(false);
ui_.fontItalicCheckBox->setChecked(false);
return;
}

ui_.controlTextEdit->setMaxLength(
static_cast<int>(ProjectLimits::kMaximumHmiControlTextCharacters));
ui_.controlIdEdit->setText(fromUtf8(control->id));
ui_.controlTextEdit->setText(fromUtf8(control->text));
ui_.controlXSpinBox->setValue(control->bounds.x);
ui_.controlYSpinBox->setValue(control->bounds.y);
ui_.controlWidthSpinBox->setValue(control->bounds.width);
ui_.controlHeightSpinBox->setValue(control->bounds.height);
ui_.bindingAreaComboBox->setCurrentIndex(!control->binding.has_value() ? 0
: control->binding->area() == RegisterArea::M ? 1 : 2);
ui_.bindingIndexSpinBox->setValue(
control->binding.has_value() ? control->binding->index() : 0);
const bool has_binding = descriptor != nullptr
&& descriptor->bindingKind != HmiBindingKind::None;
ui_.bindingAreaLabel->setVisible(has_binding);
ui_.bindingAreaComboBox->setVisible(has_binding);
ui_.bindingIndexLabel->setVisible(has_binding);
ui_.bindingIndexSpinBox->setVisible(has_binding);

const bool is_button = control->type == HmiControlType::Button;
ui_.buttonOperationLabel->setVisible(is_button);
ui_.buttonOperationComboBox->setVisible(is_button);
ui_.buttonOperationComboBox->setEnabled(is_button);
ui_.buttonOperationComboBox->setCurrentIndex(
ui_.buttonOperationComboBox->findData(
static_cast<int>(control->buttonOperation)));

const bool is_page_jump = control->type == HmiControlType::PageJump;
ui_.targetPageLabel->setVisible(is_page_jump);
ui_.targetPageComboBox->setVisible(is_page_jump);
ui_.targetPageComboBox->setEnabled(is_page_jump);
ui_.targetPageComboBox->clear();
if (is_page_jump)
{
for (const HmiPage &page : project_service_.project().hmiPages)
{
ui_.targetPageComboBox->addItem(
fromUtf8(page.name), fromUtf8(page.id));
}
const std::string target_id = control->pageJump.has_value()
? control->pageJump->targetPageId : std::string{};
ui_.targetPageComboBox->setCurrentIndex(
ui_.targetPageComboBox->findData(fromUtf8(target_id)));
}

const bool is_progress_bar = control->type == HmiControlType::ProgressBar;
ui_.progressMinimumLabel->setVisible(is_progress_bar);
ui_.progressMinimumSpinBox->setVisible(is_progress_bar);
ui_.progressMaximumLabel->setVisible(is_progress_bar);
ui_.progressMaximumSpinBox->setVisible(is_progress_bar);
ui_.progressShowValueLabel->setVisible(is_progress_bar);
ui_.progressShowValueCheckBox->setVisible(is_progress_bar);
ui_.progressMinimumSpinBox->setEnabled(is_progress_bar);
ui_.progressMaximumSpinBox->setEnabled(is_progress_bar);
ui_.progressShowValueCheckBox->setEnabled(is_progress_bar);
const HmiProgressBarConfig progress_config = control->progressBar.value_or(
HmiProgressBarConfig{});
ui_.progressMinimumSpinBox->setValue(progress_config.minimumValue);
ui_.progressMaximumSpinBox->setValue(progress_config.maximumValue);
ui_.progressShowValueCheckBox->setChecked(progress_config.showValue);

const auto text_color = control->properties.find(
HmiAppearanceProperty::kTextColor);
ui_.textColorEdit->setText(text_color == control->properties.cend()
? QString{}
: fromUtf8(text_color->second));
const QColor swatch_color(ui_.textColorEdit->text());
ui_.textColorButton->setStyleSheet(
swatch_color.isValid()
? QStringLiteral("background-color: %1;").arg(
swatch_color.name(QColor::HexRgb))
: QString{});

const int default_font_size = defaultFontPointSize(control->type);
int font_size = default_font_size;
const auto font_size_property = control->properties.find(
HmiAppearanceProperty::kFontSize);
if (font_size_property != control->properties.cend())
{
bool ok = false;
const int parsed = fromUtf8(font_size_property->second).toInt(&ok);
if (ok)
{
font_size = parsed;
}
}
ui_.fontSizeSpinBox->setValue(std::clamp(
font_size,
ProjectLimits::kMinimumHmiFontPointSize,
ProjectLimits::kMaximumHmiFontPointSize));
const auto font_bold = control->properties.find(
HmiAppearanceProperty::kFontBold);
ui_.fontBoldCheckBox->setChecked(
font_bold != control->properties.cend() && font_bold->second == "true");
const auto font_italic = control->properties.find(
HmiAppearanceProperty::kFontItalic);
ui_.fontItalicCheckBox->setChecked(
font_italic != control->properties.cend()
&& font_italic->second == "true");
}

void PropertyPanelController::showPageProperties(const std::string &page_id)
{
ui_.propertyStack->setCurrentWidget(ui_.pagePropertiesPage);
selected_control_id_.clear();
const HmiPage *page = hmi_editor_service_.findPage(page_id);
const bool has_page = page != nullptr;
ui_.pageWidthSpinBox->setRange(
ProjectLimits::kMinimumHmiPageWidth,
ProjectLimits::kMaximumHmiPageWidth);
ui_.pageHeightSpinBox->setRange(
ProjectLimits::kMinimumHmiPageHeight,
ProjectLimits::kMaximumHmiPageHeight);
ui_.selectionValueLabel->setText(
has_page ? fromUtf8(page->name) : QObject::tr("未选择"));
ui_.pageWidthSpinBox->setEnabled(has_page);
ui_.pageHeightSpinBox->setEnabled(has_page);
ui_.applyPagePropertiesButton->setEnabled(has_page);
if (!has_page)
{
ui_.pageWidthSpinBox->setValue(ProjectLimits::kDefaultHmiPageWidth);
ui_.pageHeightSpinBox->setValue(ProjectLimits::kDefaultHmiPageHeight);
return;
}
ui_.pageWidthSpinBox->setValue(page->width);
ui_.pageHeightSpinBox->setValue(page->height);
}

void PropertyPanelController::showLogicNodeProperties(const std::string &node_id)
{
ui_.propertyStack->setCurrentWidget(ui_.logicPropertiesPage);
selected_logic_node_id_ = node_id;
const LogicNode *node = logic_editor_service_.findNode(
current_logic_id_(), node_id);
const bool has_node = node != nullptr;
ui_.selectionValueLabel->setText(
has_node ? fromUtf8(node->id) : QObject::tr("未选择"));
for (QWidget *widget : {static_cast<QWidget *>(ui_.logicNodeIdEdit),
static_cast<QWidget *>(ui_.logicAddressSpinBox),
static_cast<QWidget *>(ui_.logicModeComboBox),
static_cast<QWidget *>(ui_.logicComparisonComboBox),
static_cast<QWidget *>(ui_.logicValueSpinBox),
static_cast<QWidget *>(ui_.logicPresetSpinBox),
static_cast<QWidget *>(ui_.applyLogicPropertiesButton)})
{
widget->setEnabled(has_node);
}
ui_.logicNodeTypeLabel->setEnabled(has_node);
ui_.logicAddressAreaLabel->setEnabled(has_node);
if (!has_node)
{
ui_.logicNodeIdEdit->clear();
ui_.logicNodeTypeLabel->clear();
ui_.logicAddressAreaLabel->clear();
ui_.logicModeComboBox->clear();
ui_.logicPresetSpinBox->setValue(TonNodeConfig::kMinimumPresetMs);
return;
}

ui_.logicNodeIdEdit->setText(fromUtf8(node->id));
ui_.logicModeComboBox->clear();
ui_.logicComparisonComboBox->setEnabled(false);
ui_.logicValueSpinBox->setEnabled(false);
ui_.logicPresetSpinBox->setEnabled(false);
if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("触点"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("M"));
ui_.logicAddressSpinBox->setValue(contact->address.index());
ui_.logicModeComboBox->addItem(
QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen));
ui_.logicModeComboBox->addItem(
QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed));
ui_.logicModeComboBox->setCurrentIndex(
ui_.logicModeComboBox->findData(static_cast<int>(contact->mode)));
ui_.logicModeComboBox->setEnabled(true);
}
else if (const auto *edge = std::get_if<EdgeContactNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("边沿触点"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("M"));
ui_.logicAddressSpinBox->setValue(edge->address.index());
ui_.logicModeComboBox->addItem(
QObject::tr("上升沿"), static_cast<int>(EdgeMode::Rising));
ui_.logicModeComboBox->addItem(
QObject::tr("下降沿"), static_cast<int>(EdgeMode::Falling));
ui_.logicModeComboBox->setCurrentIndex(
ui_.logicModeComboBox->findData(static_cast<int>(edge->mode)));
ui_.logicModeComboBox->setEnabled(true);
}
else if (const auto *timer = std::get_if<TimerContactNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("T 触点"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("T"));
ui_.logicAddressSpinBox->setValue(timer->address.index());
ui_.logicModeComboBox->addItem(
QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen));
ui_.logicModeComboBox->addItem(
QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed));
ui_.logicModeComboBox->setCurrentIndex(
ui_.logicModeComboBox->findData(static_cast<int>(timer->mode)));
ui_.logicModeComboBox->setEnabled(true);
}
else if (const auto *counter_contact =
std::get_if<CounterContactNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("C 触点"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("C"));
ui_.logicAddressSpinBox->setValue(counter_contact->address.index());
ui_.logicModeComboBox->addItem(
QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen));
ui_.logicModeComboBox->addItem(
QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed));
ui_.logicModeComboBox->setCurrentIndex(ui_.logicModeComboBox->findData(
static_cast<int>(counter_contact->mode)));
ui_.logicModeComboBox->setEnabled(true);
}
else if (const auto *coil = std::get_if<CoilNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("线圈"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("M"));
ui_.logicAddressSpinBox->setValue(coil->address.index());
ui_.logicModeComboBox->addItem(
QObject::tr("普通"), static_cast<int>(CoilMode::Normal));
ui_.logicModeComboBox->addItem(
QObject::tr("置位"), static_cast<int>(CoilMode::Set));
ui_.logicModeComboBox->addItem(
QObject::tr("复位"), static_cast<int>(CoilMode::Reset));
ui_.logicModeComboBox->setCurrentIndex(
ui_.logicModeComboBox->findData(static_cast<int>(coil->mode)));
ui_.logicModeComboBox->setEnabled(true);
}
else if (const auto *compare = std::get_if<CompareNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("D 值比较"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("D"));
ui_.logicAddressSpinBox->setValue(compare->address.index());
ui_.logicModeComboBox->addItem(QObject::tr("不适用"));
ui_.logicModeComboBox->setEnabled(false);
ui_.logicComparisonComboBox->setEnabled(true);
ui_.logicComparisonComboBox->setCurrentIndex(
ui_.logicComparisonComboBox->findData(
static_cast<int>(compare->comparison)));
ui_.logicValueSpinBox->setEnabled(true);
ui_.logicValueSpinBox->setValue(compare->value);
}
else if (const auto *ton = std::get_if<TonNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("TON"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("T"));
ui_.logicAddressSpinBox->setValue(ton->address.index());
ui_.logicModeComboBox->addItem(QObject::tr("非保持接通延时"));
ui_.logicModeComboBox->setEnabled(false);
ui_.logicPresetSpinBox->setEnabled(true);
ui_.logicPresetSpinBox->setValue(ton->presetMs);
}
else if (const auto *counter = std::get_if<CounterNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(
counter->mode == CounterMode::Up ? QStringLiteral("CTU")
: QStringLiteral("CTD"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("C"));
ui_.logicAddressSpinBox->setValue(counter->address.index());
ui_.logicAddressSpinBox->setEnabled(false);
ui_.logicModeComboBox->addItem(QObject::tr("使用配置对话框编辑"));
ui_.logicModeComboBox->setEnabled(false);
}
else if (const auto *move = std::get_if<MoveNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QStringLiteral("MOVE"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("D"));
ui_.logicAddressSpinBox->setValue(move->destination.index());
ui_.logicAddressSpinBox->setEnabled(false);
ui_.logicModeComboBox->addItem(QObject::tr("使用配置对话框编辑"));
ui_.logicModeComboBox->setEnabled(false);
}
else
{
const auto &arithmetic = std::get<ArithmeticNodeConfig>(node->config);
ui_.logicNodeTypeLabel->setText(
arithmetic.operation == ArithmeticOperation::Add
? QStringLiteral("ADD") : QStringLiteral("SUB"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("D"));
ui_.logicAddressSpinBox->setValue(arithmetic.destination.index());
ui_.logicAddressSpinBox->setEnabled(false);
ui_.logicModeComboBox->addItem(QObject::tr("使用配置对话框编辑"));
ui_.logicModeComboBox->setEnabled(false);
}
if (!node->configured)
{
ui_.logicNodeTypeLabel->setText(QObject::tr("待配置:%1").arg(
ui_.logicNodeTypeLabel->text()));
}
}

void PropertyPanelController::addHmiControl(HmiControlType type)
{
const HmiEditorResult result = hmi_editor_service_.addControl(
current_page_id_(), type);
if (!result.succeeded)
{
reportFailure(QObject::tr("添加控件"), result.message);
return;
}
hmi_editor_widget_->reloadPage();
hmi_editor_widget_->selectControl(result.id);
showControlProperties(result.id);
refresh_project_ui_();
const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type);
const QString display_name = descriptor == nullptr
? QObject::tr("未知控件")
: QString::fromUtf8(descriptor->displayName);
status_reporter_(QObject::tr("已添加%1").arg(display_name), 3000);
}

void PropertyPanelController::deleteSelectedControl()
{
if (hmi_editor_widget_ == nullptr)
{
return;
}
const std::vector<std::string> selected_ids =
hmi_editor_widget_->selectedControlIds();
if (selected_ids.empty())
{
return;
}
const HmiEditorResult result = hmi_editor_service_.removeControls(
current_page_id_(), selected_ids);
if (!result.succeeded)
{
reportFailure(QObject::tr("删除控件"), result.message);
return;
}
selected_control_id_.clear();
hmi_editor_widget_->reloadPage();
showControlProperties({});
refresh_project_ui_();
}

void PropertyPanelController::applySelectedControlProperties()
{
const HmiControl *old_control = hmi_editor_service_.findControl(
current_page_id_(), selected_control_id_);
if (old_control == nullptr)
{
return;
}
HmiControl control = *old_control;
control.id = toUtf8(ui_.controlIdEdit->text());
control.text = toUtf8(ui_.controlTextEdit->text());
control.bounds.x = ui_.controlXSpinBox->value();
control.bounds.y = ui_.controlYSpinBox->value();
control.bounds.width = ui_.controlWidthSpinBox->value();
control.bounds.height = ui_.controlHeightSpinBox->value();
const HmiControlDescriptor *descriptor =
findHmiControlDescriptor(control.type);
if (descriptor == nullptr
|| descriptor->bindingKind == HmiBindingKind::None)
{
control.binding.reset();
}
else
{
const int binding_area = ui_.bindingAreaComboBox->currentData().toInt();
if (binding_area < 0)
{
control.binding.reset();
}
else
{
const RegisterArea area = binding_area == 0
? RegisterArea::M : RegisterArea::D;
control.binding = RegisterAddress{
area, ui_.bindingIndexSpinBox->value()};
}
}
if (control.type == HmiControlType::Button)
{
control.buttonOperation = static_cast<HmiButtonOperation>(
ui_.buttonOperationComboBox->currentData().toInt());
}
if (control.type == HmiControlType::PageJump)
{
control.pageJump = HmiPageJumpConfig{
toUtf8(ui_.targetPageComboBox->currentData().toString())};
}
if (control.type == HmiControlType::ProgressBar)
{
control.progressBar = HmiProgressBarConfig{
static_cast<std::int16_t>(ui_.progressMinimumSpinBox->value()),
static_cast<std::int16_t>(ui_.progressMaximumSpinBox->value()),
ui_.progressShowValueCheckBox->isChecked()};
}
const std::string text_color = toUtf8(ui_.textColorEdit->text());
if (text_color.empty())
{
control.properties.erase(HmiAppearanceProperty::kTextColor);
}
else
{
control.properties[HmiAppearanceProperty::kTextColor] = text_color;
}
control.properties[HmiAppearanceProperty::kFontSize] = std::to_string(
ui_.fontSizeSpinBox->value());
control.properties[HmiAppearanceProperty::kFontBold] =
ui_.fontBoldCheckBox->isChecked() ? "true" : "false";
control.properties[HmiAppearanceProperty::kFontItalic] =
ui_.fontItalicCheckBox->isChecked() ? "true" : "false";
const HmiEditorResult result = hmi_editor_service_.updateControl(
current_page_id_(), selected_control_id_, control);
if (!result.succeeded)
{
reportFailure(QObject::tr("应用属性"), result.message);
return;
}
const std::string updated_control_id = result.id;
hmi_editor_widget_->reloadPage();
selected_control_id_ = updated_control_id;
hmi_editor_widget_->selectControl(updated_control_id);
showControlProperties(updated_control_id);
refresh_project_ui_();
status_reporter_(QObject::tr("控件属性已更新"), 3000);
}

void PropertyPanelController::applySelectedPageProperties()
{
const std::string page_id = current_page_id_();
if (hmi_editor_service_.findPage(page_id) == nullptr)
{
return;
}
const HmiEditorResult result = hmi_editor_service_.resizePage(
page_id,
ui_.pageWidthSpinBox->value(),
ui_.pageHeightSpinBox->value());
if (!result.succeeded)
{
reportFailure(QObject::tr("应用页面尺寸"), result.message);
return;
}
if (hmi_editor_widget_ != nullptr)
{
hmi_editor_widget_->reloadPage();
}
showPageProperties(page_id);
refresh_project_ui_();
status_reporter_(QObject::tr("页面尺寸已更新"), 3000);
}

void PropertyPanelController::chooseTextColor()
{
QColor initial_color(ui_.textColorEdit->text());
if (!initial_color.isValid())
{
initial_color = QColor(QStringLiteral("#24313b"));
}
const QColor color = QColorDialog::getColor(
initial_color, &parent_, QObject::tr("选择字体颜色"));
if (color.isValid())
{
ui_.textColorEdit->setText(color.name(QColor::HexRgb));
ui_.textColorButton->setStyleSheet(
QStringLiteral("background-color: %1;").arg(
color.name(QColor::HexRgb)));
}
}

void PropertyPanelController::applySelectedLogicNodeProperties()
{
const LogicNode *node = logic_editor_service_.findNode(
current_logic_id_(), selected_logic_node_id_);
if (node == nullptr)
{
return;
}

LogicNodeConfig config = node->config;
if (std::holds_alternative<ContactNodeConfig>(config))
{
config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()},
static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())};
}
else if (std::holds_alternative<EdgeContactNodeConfig>(config))
{
config = EdgeContactNodeConfig{
RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()},
static_cast<EdgeMode>(ui_.logicModeComboBox->currentData().toInt())};
}
else if (std::holds_alternative<TimerContactNodeConfig>(config))
{
config = TimerContactNodeConfig{
TimerAddress{ui_.logicAddressSpinBox->value()},
static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())};
}
else if (std::holds_alternative<CounterContactNodeConfig>(config))
{
config = CounterContactNodeConfig{
CounterAddress{ui_.logicAddressSpinBox->value()},
static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())};
}
else if (std::holds_alternative<CoilNodeConfig>(config))
{
config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()},
static_cast<CoilMode>(ui_.logicModeComboBox->currentData().toInt())};
}
else if (std::holds_alternative<CompareNodeConfig>(config))
{
config = CompareNodeConfig{
RegisterAddress{RegisterArea::D, ui_.logicAddressSpinBox->value()},
static_cast<ComparisonOperator>(
ui_.logicComparisonComboBox->currentData().toInt()),
static_cast<std::int16_t>(ui_.logicValueSpinBox->value())};
}
else if (std::holds_alternative<TonNodeConfig>(config))
{
config = TonNodeConfig{
TimerAddress{ui_.logicAddressSpinBox->value()},
ui_.logicPresetSpinBox->value()};
}
else
{
LogicInstructionDialog dialog(node->config, &parent_);
if (dialog.exec() != QDialog::Accepted)
{
return;
}
config = dialog.config();
}

const LogicEditorResult result = logic_editor_service_.updateNodeConfig(
current_logic_id_(), selected_logic_node_id_, config);
if (!result.succeeded)
{
reportFailure(QObject::tr("应用逻辑属性"), result.message);
return;
}
logic_editor_widget_->reloadLogic();
logic_editor_widget_->selectNode(selected_logic_node_id_);
showLogicNodeProperties(selected_logic_node_id_);
refresh_project_ui_();
status_reporter_(QObject::tr("逻辑节点属性已更新,节点已配置"), 3000);
}

void PropertyPanelController::handleHmiEditorError(const QString &message) const
{
status_reporter_(message, 5000);
}

void PropertyPanelController::handleLogicEditorError(const QString &message) const
{
status_reporter_(message, 5000);
}

void PropertyPanelController::reportFailure(
const QString &action, const std::string &message) const
{
result_reporter_(action, fromUtf8(message), false);
}

+ 80
- 0
app/src/ui/property_panel_controller.h 查看文件

@@ -0,0 +1,80 @@
#pragma once

#include "domain/control_logic_model.h"
#include "domain/hmi_model.h"

#include <QString>

#include <functional>
#include <string>

class HmiEditorService;
class HmiEditorWidget;
class LogicEditorService;
class LogicEditorWidget;
class ProjectService;
class QWidget;

namespace Ui {
class MainWindow;
}

// 组织 HMI/梯形图选择状态、属性面板和编辑器信号
class PropertyPanelController final
{
public:
using ResultReporter = std::function<void(
const QString &action, const QString &message, bool succeeded)>;
using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>;

PropertyPanelController(
QWidget &parent,
Ui::MainWindow &ui,
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
std::function<std::string()> current_page_id,
std::function<std::string()> current_logic_id,
std::string &selected_control_id,
std::string &selected_logic_node_id,
std::function<void()> refresh_project_ui,
ResultReporter result_reporter,
StatusReporter status_reporter);

void configure();
void bindEditorWidgets(
HmiEditorWidget &hmi_editor_widget,
LogicEditorWidget &logic_editor_widget);

void showControlProperties(const std::string &control_id);
void showPageProperties(const std::string &page_id);
void showLogicNodeProperties(const std::string &node_id);
void addHmiControl(HmiControlType type);
void deleteSelectedControl();
void applySelectedControlProperties();
void applySelectedPageProperties();
void applySelectedLogicNodeProperties();

private:
void handleHmiEditorError(const QString &message) const;
void handleLogicEditorError(const QString &message) const;
void chooseTextColor();
void reportFailure(
const QString &action, const std::string &message) const;

QWidget &parent_;
Ui::MainWindow &ui_;
ProjectService &project_service_;
HmiEditorService &hmi_editor_service_;
LogicEditorService &logic_editor_service_;
std::function<std::string()> current_page_id_;
std::function<std::string()> current_logic_id_;
std::string &selected_control_id_;
std::string &selected_logic_node_id_;
std::function<void()> refresh_project_ui_;
ResultReporter result_reporter_;
StatusReporter status_reporter_;
HmiEditorWidget *hmi_editor_widget_ = nullptr;
LogicEditorWidget *logic_editor_widget_ = nullptr;
};

+ 155
- 0
app/src/ui/register_comment_dialog.cpp 查看文件

@@ -0,0 +1,155 @@
#include "register_comment_dialog.h"

#include "services/register_comment_service.h"
#include "ui_register_comment_dialog.h"

#include <QHeaderView>
#include <QMessageBox>
#include <QTableWidgetItem>

#include <optional>

namespace {

QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

} // namespace

RegisterCommentDialog::RegisterCommentDialog(
RegisterCommentService &service,
QWidget *parent)
: QDialog(parent),
ui_(std::make_unique<Ui::RegisterCommentDialog>()),
service_(service)
{
ui_->setupUi(this);
ui_->commentTable->horizontalHeader()->setSectionResizeMode(
0, QHeaderView::ResizeToContents);
ui_->commentTable->horizontalHeader()->setSectionResizeMode(
1, QHeaderView::Stretch);
ui_->areaComboBox->setItemData(0, static_cast<int>(RegisterArea::M));
ui_->areaComboBox->setItemData(1, static_cast<int>(RegisterArea::D));
connect(ui_->commentTable, &QTableWidget::itemSelectionChanged,
this, [this] { loadSelectedComment(); });
connect(ui_->saveButton, &QPushButton::clicked,
this, [this] { saveComment(); });
connect(ui_->removeButton, &QPushButton::clicked,
this, [this] { removeComment(); });
connect(ui_->buttonBox, &QDialogButtonBox::rejected,
this, &QDialog::reject);
reloadComments();
}

RegisterCommentDialog::~RegisterCommentDialog() = default;

void RegisterCommentDialog::reloadComments(
const RegisterAddress *selected_address)
{
const auto &comments = service_.comments();
ui_->commentTable->setRowCount(static_cast<int>(comments.size()));
int selected_row = -1;
for (std::size_t index = 0; index < comments.size(); ++index)
{
const RegisterComment &comment = comments[index];
const int row = static_cast<int>(index);
auto *address_item = new QTableWidgetItem(
QString::fromStdString(comment.address.toString()));
address_item->setData(Qt::UserRole, static_cast<int>(comment.address.area()));
address_item->setData(Qt::UserRole + 1, comment.address.index());
ui_->commentTable->setItem(row, 0, address_item);
ui_->commentTable->setItem(row, 1, new QTableWidgetItem(fromUtf8(comment.text)));
if (selected_address != nullptr && comment.address == *selected_address)
{
selected_row = row;
}
}
if (selected_row >= 0)
{
ui_->commentTable->selectRow(selected_row);
}
else
{
ui_->commentTable->clearSelection();
ui_->removeButton->setEnabled(false);
}
}

void RegisterCommentDialog::loadSelectedComment()
{
const std::optional<RegisterAddress> address = selectedAddress();
const RegisterComment *comment = address.has_value()
? service_.findComment(*address) : nullptr;
ui_->removeButton->setEnabled(comment != nullptr);
if (comment == nullptr)
{
return;
}
ui_->areaComboBox->setCurrentIndex(
comment->address.area() == RegisterArea::M ? 0 : 1);
ui_->indexSpinBox->setValue(comment->address.index());
ui_->commentEdit->setText(fromUtf8(comment->text));
}

void RegisterCommentDialog::saveComment()
{
const RegisterAddress address = addressFromInputs();
const RegisterCommentResult result = service_.setComment(
address, toUtf8(ui_->commentEdit->text()));
if (!result.succeeded)
{
QMessageBox::warning(this, tr("保存注释"), fromUtf8(result.message));
return;
}
reloadComments(&address);
}

void RegisterCommentDialog::removeComment()
{
const std::optional<RegisterAddress> address = selectedAddress();
if (!address.has_value())
{
return;
}
if (QMessageBox::question(
this, tr("删除注释"), tr("确定删除当前软元件注释吗?"))
!= QMessageBox::Yes)
{
return;
}
const RegisterCommentResult result = service_.removeComment(*address);
if (!result.succeeded)
{
QMessageBox::warning(this, tr("删除注释"), fromUtf8(result.message));
return;
}
reloadComments();
}

RegisterAddress RegisterCommentDialog::addressFromInputs() const
{
const RegisterArea area = static_cast<RegisterArea>(
ui_->areaComboBox->currentData().toInt());
return RegisterAddress{area, ui_->indexSpinBox->value()};
}

std::optional<RegisterAddress> RegisterCommentDialog::selectedAddress() const
{
const int row = ui_->commentTable->currentRow();
const QTableWidgetItem *item = row >= 0 ? ui_->commentTable->item(row, 0) : nullptr;
if (item == nullptr)
{
return std::nullopt;
}
return RegisterAddress{
static_cast<RegisterArea>(item->data(Qt::UserRole).toInt()),
item->data(Qt::UserRole + 1).toInt()};
}

+ 34
- 0
app/src/ui/register_comment_dialog.h 查看文件

@@ -0,0 +1,34 @@
#pragma once

#include "domain/register_address.h"

#include <QDialog>

#include <memory>
#include <optional>

namespace Ui {
class RegisterCommentDialog;
}

class RegisterCommentService;

class RegisterCommentDialog final : public QDialog
{
public:
explicit RegisterCommentDialog(
RegisterCommentService &service,
QWidget *parent = nullptr);
~RegisterCommentDialog() override;

private:
void reloadComments(const RegisterAddress *selected_address = nullptr);
void loadSelectedComment();
void saveComment();
void removeComment();
RegisterAddress addressFromInputs() const;
std::optional<RegisterAddress> selectedAddress() const;

std::unique_ptr<Ui::RegisterCommentDialog> ui_;
RegisterCommentService &service_;
};

+ 40
- 0
app/src/ui/register_comment_dialog.ui 查看文件

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>RegisterCommentDialog</class>
<widget class="QDialog" name="RegisterCommentDialog">
<property name="windowTitle"><string>软元件注释</string></property>
<property name="minimumSize"><size><width>620</width><height>400</height></size></property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTableWidget" name="commentTable">
<property name="selectionBehavior"><enum>QAbstractItemView::SelectRows</enum></property>
<property name="selectionMode"><enum>QAbstractItemView::SingleSelection</enum></property>
<property name="editTriggers"><set>QAbstractItemView::NoEditTriggers</set></property>
<property name="columnCount"><number>2</number></property>
<column><property name="text"><string>地址</string></property></column>
<column><property name="text"><string>注释</string></property></column>
</widget>
</item>
<item>
<layout class="QFormLayout" name="commentForm">
<item row="0" column="0"><widget class="QLabel" name="areaLabel"><property name="text"><string>地址区域</string></property></widget></item>
<item row="0" column="1"><widget class="QComboBox" name="areaComboBox"><item><property name="text"><string>M</string></property></item><item><property name="text"><string>D</string></property></item></widget></item>
<item row="1" column="0"><widget class="QLabel" name="indexLabel"><property name="text"><string>地址</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="indexSpinBox"><property name="maximum"><number>4000</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="commentLabel"><property name="text"><string>注释</string></property></widget></item>
<item row="2" column="1"><widget class="QLineEdit" name="commentEdit"><property name="placeholderText"><string>例如:启动按钮</string></property></widget></item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="operationLayout">
<item><widget class="QPushButton" name="saveButton"><property name="text"><string>保存</string></property></widget></item>
<item><widget class="QPushButton" name="removeButton"><property name="text"><string>删除</string></property></widget></item>
<item><spacer name="operationSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::Close</set></property></widget></item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

+ 28
- 5
app/src/ui/runtime_monitor_widget.cpp 查看文件

@@ -4,12 +4,15 @@
#include "hmi_editor_widget.h"
#include "logic_editor_widget.h"
#include "services/hmi_navigation_service.h"
#include "services/alarm_service.h"
#include "services/project_service.h"
#include "toolbar_icon_factory.h"
#include "ui_runtime_monitor_widget.h"

#include <QSplitter>
#include <QComboBox>
#include <QSignalBlocker>
#include <QToolButton>

#include <algorithm>

@@ -34,6 +37,7 @@ RuntimeMonitorWidget::RuntimeMonitorWidget(
LogicEditorService &logic_editor_service,
ProjectService &project_service,
HmiNavigationService &hmi_navigation_service,
AlarmService &alarm_service,
RegisterMonitorService &register_monitor_service,
QWidget *parent)
: QWidget(parent),
@@ -42,8 +46,12 @@ RuntimeMonitorWidget::RuntimeMonitorWidget(
hmi_navigation_service_(hmi_navigation_service)
{
ui_->setupUi(this);
ui_->exitRuntimeButton->setIcon(makeUiIcon(UiIcon::Exit));
hmi_view_ = new HmiEditorWidget(
hmi_editor_service, hmi_runtime_service, ui_->hmiViewContainer);
hmi_editor_service,
hmi_runtime_service,
alarm_service,
ui_->hmiViewContainer);
hmi_view_->setObjectName(QStringLiteral("runtimeHmiView"));
hmi_view_->setEditingEnabled(false);
hmi_view_->setRuntimeActive(true);
@@ -72,8 +80,9 @@ RuntimeMonitorWidget::RuntimeMonitorWidget(
return;
}
showRuntimePage(result.pageId);
emit runtimePageChanged(fromUtf8(result.pageId));
});
connect(ui_->exitRuntimeButton, &QToolButton::clicked,
this, &RuntimeMonitorWidget::exitRequested);
connect(ui_->runtimeLogicComboBox,
QOverload<int>::of(&QComboBox::currentIndexChanged),
this,
@@ -92,7 +101,7 @@ RuntimeMonitorWidget::~RuntimeMonitorWidget() = default;
void RuntimeMonitorWidget::setProjectObjects(
const std::string &page_id, const std::string &logic_id)
{
showRuntimePage(page_id);
setRuntimePage(page_id);

const QSignalBlocker blocker(ui_->runtimeLogicComboBox);
ui_->runtimeLogicComboBox->clear();
@@ -115,6 +124,11 @@ void RuntimeMonitorWidget::setProjectObjects(
: std::string{});
}

void RuntimeMonitorWidget::setRuntimePage(const std::string &page_id)
{
showRuntimePage(page_id);
}

void RuntimeMonitorWidget::setMode(
ApplicationMode mode, PlcConnectionState plc_state)
{
@@ -122,8 +136,12 @@ void RuntimeMonitorWidget::setMode(
const bool online = mode == ApplicationMode::OnlineRunning;
ui_->logicFrame->setVisible(offline);
hmi_view_->setRuntimeActive(offline || online);
hmi_view_->setRuntimeWriteEnabled(
offline || (online && plc_state == PlcConnectionState::Connected));
if (!offline && !online)
{
setHmiWriteEnabled(false);
}
setFreeMonitorWriteEnabled(offline
|| (online && plc_state == PlcConnectionState::Connected));
if (!offline)
{
logic_view_->clearRuntimeTrace();
@@ -142,6 +160,11 @@ void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled)
hmi_view_->setRuntimeWriteEnabled(enabled);
}

void RuntimeMonitorWidget::setFreeMonitorWriteEnabled(bool enabled)
{
free_monitor_widget_->setWriteEnabled(enabled);
}

void RuntimeMonitorWidget::refreshValues(
ApplicationMode mode, PlcConnectionState plc_state)
{


+ 5
- 1
app/src/ui/runtime_monitor_widget.h 查看文件

@@ -17,6 +17,7 @@ class FreeMonitorWidget;
class HmiEditorService;
class HmiEditorWidget;
class HmiRuntimeService;
class AlarmService;
class HmiNavigationService;
class LogicEditorService;
class LogicEditorWidget;
@@ -34,21 +35,24 @@ public:
LogicEditorService &logic_editor_service,
ProjectService &project_service,
HmiNavigationService &hmi_navigation_service,
AlarmService &alarm_service,
RegisterMonitorService &register_monitor_service,
QWidget *parent = nullptr);
~RuntimeMonitorWidget() override;

void setProjectObjects(const std::string &page_id, const std::string &logic_id);
void setRuntimePage(const std::string &page_id);
void setMode(ApplicationMode mode, PlcConnectionState plc_state);
void refreshValues(ApplicationMode mode, PlcConnectionState plc_state);
void setHmiWriteEnabled(bool enabled);
void setFreeMonitorWriteEnabled(bool enabled);
void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {});
FreeMonitorWidget *freeMonitorWidget() const;
std::string selectedLogicId() const;

signals:
void navigationFailed(const QString &message);
void runtimePageChanged(const QString &page_id);
void exitRequested();

private:
void showRuntimePage(const std::string &page_id);


+ 1
- 0
app/src/ui/runtime_monitor_widget.ui 查看文件

@@ -13,6 +13,7 @@
<item><widget class="QLabel" name="titleLabel"><property name="text"><string>运行监控</string></property><property name="styleSheet"><string notr="true">font-weight: 600;</string></property></widget></item>
<item><spacer name="headerSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item><widget class="QLabel" name="modeLabel"><property name="text"><string>当前未运行</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item>
<item><widget class="QToolButton" name="exitRuntimeButton"><property name="toolTip"><string>返回编辑态</string></property><property name="accessibleName"><string>返回编辑态</string></property><property name="text"><string>返回编辑态</string></property><property name="toolButtonStyle"><enum>Qt::ToolButtonIconOnly</enum></property></widget></item>
</layout>
</item>
<item>


+ 54
- 0
app/src/ui/runtime_monitor_window.cpp 查看文件

@@ -0,0 +1,54 @@
#include "runtime_monitor_window.h"

#include "ui_runtime_monitor_window.h"

#include <QCloseEvent>
#include <QWidget>

RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent)
: QMainWindow(parent, Qt::Window),
ui_(std::make_unique<Ui::RuntimeMonitorWindow>())
{
ui_->setupUi(this);
}

RuntimeMonitorWindow::~RuntimeMonitorWindow() = default;

void RuntimeMonitorWindow::setMonitorWidget(QWidget &widget)
{
widget.setParent(ui_->centralWidget);
ui_->runtimeMonitorWindowLayout->addWidget(&widget);
}

void RuntimeMonitorWindow::showForRuntime()
{
application_exit_ = false;
runtime_active_ = true;
showMaximized();
raise();
activateWindow();
}

void RuntimeMonitorWindow::hideForEditing()
{
runtime_active_ = false;
hide();
}

void RuntimeMonitorWindow::closeForApplicationExit()
{
application_exit_ = true;
runtime_active_ = false;
close();
}

void RuntimeMonitorWindow::closeEvent(QCloseEvent *event)
{
if (runtime_active_ && !application_exit_)
{
emit exitRequested();
event->ignore();
return;
}
QMainWindow::closeEvent(event);
}

+ 38
- 0
app/src/ui/runtime_monitor_window.h 查看文件

@@ -0,0 +1,38 @@
#pragma once

#include <QMainWindow>

#include <memory>

namespace Ui {
class RuntimeMonitorWindow;
}

class QCloseEvent;
class QWidget;

// 承载唯一工程师运行监控投影的独立顶层窗口
class RuntimeMonitorWindow final : public QMainWindow
{
Q_OBJECT

public:
explicit RuntimeMonitorWindow(QWidget *parent = nullptr);
~RuntimeMonitorWindow() override;

void setMonitorWidget(QWidget &widget);
void showForRuntime();
void hideForEditing();
void closeForApplicationExit();

signals:
void exitRequested();

protected:
void closeEvent(QCloseEvent *event) override;

private:
std::unique_ptr<Ui::RuntimeMonitorWindow> ui_;
bool runtime_active_ = false;
bool application_exit_ = false;
};

+ 44
- 0
app/src/ui/runtime_monitor_window.ui 查看文件

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>RuntimeMonitorWindow</class>
<widget class="QMainWindow" name="RuntimeMonitorWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1280</width>
<height>800</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>960</width>
<height>640</height>
</size>
</property>
<property name="windowTitle">
<string>运行监控 - 综合平台编程器</string>
</property>
<widget class="QWidget" name="centralWidget">
<layout class="QVBoxLayout" name="runtimeMonitorWindowLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
</layout>
</widget>
</widget>
<resources/>
<connections/>
</ui>

+ 307
- 0
app/src/ui/runtime_panel_controller.cpp 查看文件

@@ -0,0 +1,307 @@
#include "runtime_panel_controller.h"

#include "free_monitor_widget.h"
#include "hmi_editor_widget.h"
#include "logic_editor_widget.h"
#include "runtime_monitor_widget.h"
#include "runtime_monitor_window.h"
#include "services/alarm_service.h"
#include "services/hmi_editor_service.h"
#include "services/hmi_navigation_service.h"
#include "services/hmi_runtime_service.h"
#include "services/logic_editor_service.h"
#include "services/offline_simulation_service.h"
#include "services/project_service.h"
#include "services/register_monitor_service.h"
#include "services/runtime_mode_service.h"

#include <QListWidget>
#include <QLabel>
#include <QObject>
#include <QTimer>
#include <QWidget>

#include <utility>

namespace {

QString fromUtf8(const std::string &value)
{
return QString::fromUtf8(value.data(), static_cast<int>(value.size()));
}

} // namespace

RuntimePanelController::RuntimePanelController(
QWidget &parent,
RuntimeModeService &runtime_mode_service,
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
HmiRuntimeService &hmi_runtime_service,
LogicEditorService &logic_editor_service,
HmiNavigationService &hmi_navigation_service,
AlarmService &alarm_service,
RegisterMonitorService &register_monitor_service,
HmiEditorWidget &hmi_editor_widget,
LogicEditorWidget &logic_editor_widget,
QLabel &executor_status_label,
std::function<std::string()> current_logic_id,
std::function<void(const std::string &)> select_logic,
std::function<void(const std::string &)> show_logic_properties,
StatusReporter status_reporter,
OutputReporter output_reporter,
RuntimeExitRequester runtime_exit_requester)
: parent_(parent),
runtime_mode_service_(runtime_mode_service),
project_service_(project_service),
hmi_editor_service_(hmi_editor_service),
hmi_runtime_service_(hmi_runtime_service),
logic_editor_service_(logic_editor_service),
hmi_navigation_service_(hmi_navigation_service),
alarm_service_(alarm_service),
register_monitor_service_(register_monitor_service),
hmi_editor_widget_(hmi_editor_widget),
logic_editor_widget_(logic_editor_widget),
executor_status_label_(executor_status_label),
current_logic_id_(std::move(current_logic_id)),
select_logic_(std::move(select_logic)),
show_logic_properties_(std::move(show_logic_properties)),
status_reporter_(std::move(status_reporter)),
output_reporter_(std::move(output_reporter)),
runtime_exit_requester_(std::move(runtime_exit_requester))
{
}

RuntimePanelController::~RuntimePanelController() = default;

void RuntimePanelController::configure()
{
runtime_monitor_window_ = std::make_unique<RuntimeMonitorWindow>(&parent_);
runtime_monitor_window_->setObjectName(
QStringLiteral("runtimeMonitorWindow"));
runtime_monitor_widget_ = new RuntimeMonitorWidget(
hmi_editor_service_,
hmi_runtime_service_,
logic_editor_service_,
project_service_,
hmi_navigation_service_,
alarm_service_,
register_monitor_service_,
runtime_monitor_window_.get());
runtime_monitor_widget_->setObjectName(QStringLiteral("runtimeMonitorWidget"));
runtime_monitor_window_->setMonitorWidget(*runtime_monitor_widget_);
QObject::connect(runtime_monitor_widget_, &RuntimeMonitorWidget::exitRequested,
&parent_, [this]
{
if (runtime_exit_requester_)
{
runtime_exit_requester_();
}
});
QObject::connect(runtime_monitor_window_.get(),
&RuntimeMonitorWindow::exitRequested,
&parent_, [this]
{
if (runtime_exit_requester_)
{
runtime_exit_requester_();
}
});
QObject::connect(runtime_monitor_widget_->freeMonitorWidget(),
&FreeMonitorWidget::monitorAddressesChanged,
&parent_, [this]
{
runtime_mode_service_.setMonitorAddresses(
register_monitor_service_.addresses());
runtime_monitor_widget_->refreshValues(
runtime_mode_service_.mode(),
runtime_mode_service_.plcConnectionState());
});
QObject::connect(runtime_monitor_widget_->freeMonitorWidget(),
&FreeMonitorWidget::operationMessage,
&parent_, [this](const QString &message)
{
status_reporter_(message, 4000);
});
QObject::connect(runtime_monitor_widget_, &RuntimeMonitorWidget::navigationFailed,
&parent_, [this](const QString &message)
{
status_reporter_(message, 5000);
output_reporter_(QObject::tr("页面导航失败:%1").arg(message));
});
runtime_refresh_timer_ = new QTimer(&parent_);
runtime_refresh_timer_->setInterval(150);
QObject::connect(runtime_refresh_timer_, &QTimer::timeout,
&parent_, [this] { handleRuntimeTimer(); });
runtime_refresh_timer_->start();
QObject::connect(&runtime_mode_service_.offlineSimulationService(),
&OfflineSimulationService::stateChanged,
&parent_, [this] { handleSimulationStateChanged(); },
Qt::QueuedConnection);
QObject::connect(&runtime_mode_service_.offlineSimulationService(),
&OfflineSimulationService::scanCompleted,
&parent_, [this] { handleScanCompleted(); },
Qt::QueuedConnection);
}

RuntimeMonitorWidget *RuntimePanelController::runtimeMonitorWidget() const
{
return runtime_monitor_widget_;
}

void RuntimePanelController::enterRuntime(
const std::string &page_id,
const std::string &logic_id,
ApplicationMode mode,
PlcConnectionState plc_state)
{
if (!runtime_session_active_)
{
runtime_monitor_widget_->setProjectObjects(page_id, logic_id);
runtime_session_active_ = true;
}
runtime_monitor_widget_->setMode(mode, plc_state);
runtime_monitor_window_->showForRuntime();
}

void RuntimePanelController::leaveRuntime(
ApplicationMode mode, PlcConnectionState plc_state)
{
runtime_session_active_ = false;
runtime_monitor_widget_->setMode(mode, plc_state);
runtime_monitor_window_->hideForEditing();
}

void RuntimePanelController::closeForApplicationExit()
{
if (runtime_monitor_window_ != nullptr)
{
runtime_monitor_window_->closeForApplicationExit();
}
}

void RuntimePanelController::updateSimulationUi(bool report_fault)
{
const bool offline = runtime_mode_service_.mode()
== ApplicationMode::OfflineRunning;
const bool online = runtime_mode_service_.mode()
== ApplicationMode::OnlineRunning;
const bool plc_connected = runtime_mode_service_.plcConnectionState()
== PlcConnectionState::Connected;
const SimulationState state = runtime_mode_service_.simulationState();
const bool write_enabled = (online && plc_connected)
|| (offline && state == SimulationState::Running);
// 编辑画布始终只读,运行操作统一由运行监控中的 HMI 发出
hmi_editor_widget_.setRuntimeWriteEnabled(false);
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->setHmiWriteEnabled(write_enabled);
runtime_monitor_widget_->setFreeMonitorWriteEnabled(write_enabled);
runtime_monitor_widget_->refreshValues(
runtime_mode_service_.mode(),
runtime_mode_service_.plcConnectionState());
}
switch (state)
{
case SimulationState::Running:
{
executor_status_label_.setText(
QObject::tr("逻辑执行器:运行(%1 次)")
.arg(runtime_mode_service_.successfulScanCount()));
break;
}
case SimulationState::Faulted:
{
executor_status_label_.setText(QObject::tr("逻辑执行器:故障"));
if (!report_fault)
{
break;
}
const LogicScanResult &error = runtime_mode_service_.simulationError();
if (!error.logicId.empty()
&& logic_editor_service_.findLogic(error.logicId) != nullptr)
{
select_logic_(error.logicId);
}
const std::string logic_id = current_logic_id_();
logic_editor_widget_.setLogicId(logic_id);
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->setProjectObjects(
hmi_navigation_service_.currentPageId(), logic_id);
}
logic_editor_widget_.setRuntimeTrace(
runtime_mode_service_.offlineSimulationService()
.traceSnapshot().forLogic(logic_id),
error.nodeId);
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->setLogicTrace(
runtime_mode_service_.offlineSimulationService().traceSnapshot(),
error.nodeId);
}
if (!error.nodeId.empty())
{
logic_editor_widget_.selectNode(error.nodeId);
show_logic_properties_(error.nodeId);
}
QString message = QObject::tr("离线仿真异常:%1").arg(fromUtf8(error.message));
if (!error.logicId.empty())
{
message += QObject::tr(",逻辑 %1").arg(fromUtf8(error.logicId));
}
if (!error.rungId.empty())
{
message += QObject::tr(",网络 %1").arg(fromUtf8(error.rungId));
}
if (!error.nodeId.empty())
{
message += QObject::tr(",节点 %1").arg(fromUtf8(error.nodeId));
}
status_reporter_(message, 0);
output_reporter_(message);
break;
}
case SimulationState::Stopped:
default:
{
executor_status_label_.setText(QObject::tr("逻辑执行器:停止"));
break;
}
}
}

void RuntimePanelController::handleRuntimeTimer()
{
if (runtime_mode_service_.mode() == ApplicationMode::Editing)
{
return;
}
alarm_service_.refresh();
hmi_editor_widget_.refreshRuntimeValues();
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->refreshValues(
runtime_mode_service_.mode(),
runtime_mode_service_.plcConnectionState());
}
updateSimulationUi(false);
}

void RuntimePanelController::handleSimulationStateChanged()
{
updateSimulationUi(true);
}

void RuntimePanelController::handleScanCompleted()
{
const std::string logic_id = current_logic_id_();
logic_editor_widget_.setRuntimeTrace(
runtime_mode_service_.offlineSimulationService()
.traceSnapshot().forLogic(logic_id));
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->setLogicTrace(
runtime_mode_service_.offlineSimulationService().traceSnapshot());
}
}

+ 96
- 0
app/src/ui/runtime_panel_controller.h 查看文件

@@ -0,0 +1,96 @@
#pragma once

#include "domain/runtime_state.h"
#include "services/plc_communication_gateway.h"

#include <QString>

#include <functional>
#include <memory>
#include <string>

class AlarmService;
class HmiEditorService;
class HmiEditorWidget;
class HmiNavigationService;
class HmiRuntimeService;
class LogicEditorService;
class LogicEditorWidget;
class QLabel;
class ProjectService;
class RegisterMonitorService;
class RuntimeModeService;
class RuntimeMonitorWidget;
class RuntimeMonitorWindow;
class QTimer;
class QWidget;

// 组织运行监控工作台、运行刷新定时器和离线执行器反馈
class RuntimePanelController final
{
public:
using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>;
using OutputReporter = std::function<void(const QString &message)>;
using RuntimeExitRequester = std::function<void()>;

RuntimePanelController(
QWidget &parent,
RuntimeModeService &runtime_mode_service,
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
HmiRuntimeService &hmi_runtime_service,
LogicEditorService &logic_editor_service,
HmiNavigationService &hmi_navigation_service,
AlarmService &alarm_service,
RegisterMonitorService &register_monitor_service,
HmiEditorWidget &hmi_editor_widget,
LogicEditorWidget &logic_editor_widget,
QLabel &executor_status_label,
std::function<std::string()> current_logic_id,
std::function<void(const std::string &)> select_logic,
std::function<void(const std::string &)> show_logic_properties,
StatusReporter status_reporter,
OutputReporter output_reporter,
RuntimeExitRequester runtime_exit_requester);
~RuntimePanelController();

void configure();
void enterRuntime(
const std::string &page_id,
const std::string &logic_id,
ApplicationMode mode,
PlcConnectionState plc_state);
void leaveRuntime(ApplicationMode mode, PlcConnectionState plc_state);
void closeForApplicationExit();
void updateSimulationUi(bool report_fault);
RuntimeMonitorWidget *runtimeMonitorWidget() const;

private:
void handleRuntimeTimer();
void handleSimulationStateChanged();
void handleScanCompleted();

QWidget &parent_;
RuntimeModeService &runtime_mode_service_;
ProjectService &project_service_;
HmiEditorService &hmi_editor_service_;
HmiRuntimeService &hmi_runtime_service_;
LogicEditorService &logic_editor_service_;
HmiNavigationService &hmi_navigation_service_;
AlarmService &alarm_service_;
RegisterMonitorService &register_monitor_service_;
HmiEditorWidget &hmi_editor_widget_;
LogicEditorWidget &logic_editor_widget_;
QLabel &executor_status_label_;
std::function<std::string()> current_logic_id_;
std::function<void(const std::string &)> select_logic_;
std::function<void(const std::string &)> show_logic_properties_;
StatusReporter status_reporter_;
OutputReporter output_reporter_;
RuntimeExitRequester runtime_exit_requester_;
std::unique_ptr<RuntimeMonitorWindow> runtime_monitor_window_;
RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr;
QTimer *runtime_refresh_timer_ = nullptr;
bool runtime_session_active_ = false;
};

+ 551
- 0
app/src/ui/toolbar_icon_factory.cpp 查看文件

@@ -0,0 +1,551 @@
#include "toolbar_icon_factory.h"

#include <QFont>
#include <QPainter>
#include <QPainterPath>
#include <QPixmap>

#include <array>

namespace {

const QColor kInk(QStringLiteral("#263842"));
const QColor kAccent(QStringLiteral("#2f7d4a"));
const QColor kAccentFill(QStringLiteral("#dcece3"));
const QColor kWarning(QStringLiteral("#b87808"));
const QColor kDanger(QStringLiteral("#a84545"));
const QColor kPaper(QStringLiteral("#ffffff"));

QPen iconPen(const QColor &color = kInk, qreal width = 1.7)
{
QPen pen(color, width);
pen.setCapStyle(Qt::RoundCap);
pen.setJoinStyle(Qt::RoundJoin);
return pen;
}

void drawCenteredText(
QPainter &painter,
const QRectF &bounds,
const QString &text,
qreal point_size,
const QColor &color = kInk)
{
QFont font = painter.font();
font.setFamily(QStringLiteral("Arial"));
font.setPointSizeF(point_size);
font.setBold(true);
painter.setFont(font);
painter.setPen(color);
painter.drawText(bounds, Qt::AlignCenter, text);
}

void drawPlc(QPainter &painter)
{
painter.setPen(iconPen());
painter.setBrush(kPaper);
painter.drawRoundedRect(QRectF(3, 4, 18, 16), 1.5, 1.5);
painter.drawLine(QPointF(8, 4), QPointF(8, 20));
painter.setBrush(kAccent);
painter.setPen(Qt::NoPen);
painter.drawEllipse(QPointF(5.5, 8), 1.1, 1.1);
painter.drawEllipse(QPointF(5.5, 12), 1.1, 1.1);
painter.setPen(iconPen());
painter.drawLine(QPointF(11, 8), QPointF(18, 8));
painter.drawLine(QPointF(11, 12), QPointF(18, 12));
painter.drawLine(QPointF(11, 16), QPointF(16, 16));
}

void drawSave(QPainter &painter)
{
painter.setPen(iconPen());
painter.setBrush(kPaper);
painter.drawRoundedRect(QRectF(4, 3, 16, 18), 1.2, 1.2);
painter.drawRect(QRectF(7, 3, 9, 6));
painter.drawRect(QRectF(7, 13, 10, 8));
}

void drawContact(QPainter &painter, const QString &label, bool closed)
{
painter.setPen(iconPen());
painter.drawLine(QPointF(2, 12), QPointF(7, 12));
painter.drawLine(QPointF(17, 12), QPointF(22, 12));
painter.drawLine(QPointF(8, 7), QPointF(8, 17));
painter.drawLine(QPointF(16, 7), QPointF(16, 17));
if (closed)
{
painter.drawLine(QPointF(7, 17), QPointF(17, 7));
}
if (!label.isEmpty())
{
drawCenteredText(painter, QRectF(8, 7, 8, 10), label, 5.5);
}
}

void drawCoil(QPainter &painter, const QString &label)
{
painter.setPen(iconPen());
painter.drawLine(QPointF(2, 12), QPointF(6, 12));
painter.drawLine(QPointF(18, 12), QPointF(22, 12));
QPainterPath left;
left.moveTo(11, 5);
left.cubicTo(5, 6, 5, 18, 11, 19);
painter.drawPath(left);
QPainterPath right;
right.moveTo(13, 5);
right.cubicTo(19, 6, 19, 18, 13, 19);
painter.drawPath(right);
if (!label.isEmpty())
{
drawCenteredText(painter, QRectF(8, 7, 8, 10), label, 6.0);
}
}

void drawInstructionBlock(QPainter &painter, const QString &label)
{
painter.setPen(iconPen());
painter.setBrush(kPaper);
painter.drawRoundedRect(QRectF(2.5, 5, 19, 14), 1.5, 1.5);
drawCenteredText(painter, QRectF(3, 5.5, 18, 13), label, 5.5);
}

QPixmap renderIcon(UiIcon icon, int size)
{
QPixmap pixmap(size, size);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.scale(static_cast<qreal>(size) / 24.0, static_cast<qreal>(size) / 24.0);
painter.setPen(iconPen());
painter.setBrush(Qt::NoBrush);

switch (icon)
{
case UiIcon::Edit:
{
painter.drawLine(QPointF(5, 19), QPointF(16.5, 7.5));
painter.drawLine(QPointF(8, 20), QPointF(19.5, 8.5));
painter.drawLine(QPointF(16.5, 7.5), QPointF(19.5, 8.5));
painter.drawLine(QPointF(5, 19), QPointF(4, 21));
break;
}
case UiIcon::RunOffline:
{
QPainterPath path;
path.moveTo(7, 4);
path.lineTo(20, 12);
path.lineTo(7, 20);
path.closeSubpath();
painter.setPen(iconPen(kAccent));
painter.setBrush(kAccentFill);
painter.drawPath(path);
break;
}
case UiIcon::RunOnline:
{
drawPlc(painter);
painter.setPen(Qt::NoPen);
painter.setBrush(kAccent);
QPainterPath path;
path.moveTo(14, 10);
path.lineTo(19, 13);
path.lineTo(14, 16);
path.closeSubpath();
painter.drawPath(path);
break;
}
case UiIcon::NewDocument:
{
QPainterPath page;
page.moveTo(5, 2);
page.lineTo(15, 2);
page.lineTo(20, 7);
page.lineTo(20, 22);
page.lineTo(5, 22);
page.closeSubpath();
painter.setBrush(kPaper);
painter.drawPath(page);
painter.drawLine(QPointF(15, 2), QPointF(15, 7));
painter.drawLine(QPointF(15, 7), QPointF(20, 7));
painter.drawLine(QPointF(8, 15), QPointF(16, 15));
painter.drawLine(QPointF(12, 11), QPointF(12, 19));
break;
}
case UiIcon::Save:
{
drawSave(painter);
break;
}
case UiIcon::SaveAs:
{
drawSave(painter);
painter.setPen(iconPen(kAccent, 2.0));
painter.drawLine(QPointF(13, 19), QPointF(21, 11));
break;
}
case UiIcon::Open:
{
painter.setBrush(kPaper);
QPainterPath folder;
folder.moveTo(2, 7);
folder.lineTo(9, 7);
folder.lineTo(11, 10);
folder.lineTo(22, 10);
folder.lineTo(19, 20);
folder.lineTo(3, 20);
folder.closeSubpath();
painter.setBrush(kAccentFill);
painter.drawPath(folder);
break;
}
case UiIcon::Undo:
case UiIcon::Redo:
{
const bool redo = icon == UiIcon::Redo;
QPainterPath arrow;
if (redo)
{
arrow.moveTo(5, 7);
arrow.cubicTo(12, 3, 20, 7, 20, 15);
arrow.lineTo(20, 19);
painter.drawPath(arrow);
painter.drawLine(QPointF(20, 19), QPointF(16, 15));
painter.drawLine(QPointF(20, 19), QPointF(23, 15));
}
else
{
arrow.moveTo(19, 7);
arrow.cubicTo(12, 3, 4, 7, 4, 15);
arrow.lineTo(4, 19);
painter.drawPath(arrow);
painter.drawLine(QPointF(4, 19), QPointF(1, 15));
painter.drawLine(QPointF(4, 19), QPointF(8, 15));
}
break;
}
case UiIcon::Exit:
{
painter.drawRect(QRectF(4, 3, 10, 18));
painter.drawLine(QPointF(10, 12), QPointF(22, 12));
painter.drawLine(QPointF(18, 8), QPointF(22, 12));
painter.drawLine(QPointF(18, 16), QPointF(22, 12));
break;
}
case UiIcon::PlcSettings:
{
drawPlc(painter);
painter.setPen(iconPen(kAccent));
painter.drawEllipse(QPointF(17.5, 16.5), 3.0, 3.0);
painter.drawLine(QPointF(17.5, 12), QPointF(17.5, 14));
painter.drawLine(QPointF(17.5, 19), QPointF(17.5, 21));
break;
}
case UiIcon::Disconnect:
{
painter.drawLine(QPointF(3, 12), QPointF(8, 12));
painter.drawRect(QRectF(8, 8, 4, 8));
painter.drawLine(QPointF(12, 10), QPointF(15, 10));
painter.drawLine(QPointF(12, 14), QPointF(15, 14));
painter.drawRect(QRectF(16, 8, 4, 8));
painter.drawLine(QPointF(20, 12), QPointF(23, 12));
painter.setPen(iconPen(kDanger, 2.0));
painter.drawLine(QPointF(6, 20), QPointF(19, 4));
break;
}
case UiIcon::RegisterComments:
{
painter.drawRoundedRect(QRectF(3, 3, 18, 18), 1.5, 1.5);
drawCenteredText(painter, QRectF(4, 4, 8, 7), QStringLiteral("M"), 5.0);
drawCenteredText(painter, QRectF(4, 12, 8, 7), QStringLiteral("D"), 5.0);
painter.drawLine(QPointF(13, 8), QPointF(19, 8));
painter.drawLine(QPointF(13, 16), QPointF(19, 16));
break;
}
case UiIcon::HmiButton:
{
painter.setBrush(kAccentFill);
painter.drawRoundedRect(QRectF(3, 5, 18, 14), 3, 3);
painter.setBrush(kPaper);
painter.drawEllipse(QPointF(12, 12), 4, 4);
break;
}
case UiIcon::Indicator:
{
painter.setBrush(kAccentFill);
painter.drawEllipse(QPointF(12, 12), 8.5, 8.5);
painter.setBrush(kAccent);
painter.setPen(Qt::NoPen);
painter.drawEllipse(QPointF(12, 12), 4.5, 4.5);
break;
}
case UiIcon::NumericDisplay:
{
painter.drawRoundedRect(QRectF(2, 5, 20, 14), 2, 2);
drawCenteredText(painter, QRectF(3, 6, 18, 12), QStringLiteral("123"), 6.0);
break;
}
case UiIcon::NumericInput:
{
painter.drawRoundedRect(QRectF(2, 5, 20, 14), 2, 2);
drawCenteredText(painter, QRectF(3, 6, 13, 12), QStringLiteral("12"), 6.0);
painter.drawLine(QPointF(18, 7), QPointF(18, 17));
break;
}
case UiIcon::ProgressBar:
{
painter.drawRoundedRect(QRectF(2, 8, 20, 8), 2, 2);
painter.setPen(Qt::NoPen);
painter.setBrush(kAccent);
painter.drawRoundedRect(QRectF(4, 10, 11, 4), 1, 1);
break;
}
case UiIcon::Text:
{
painter.drawLine(QPointF(5, 5), QPointF(19, 5));
painter.drawLine(QPointF(12, 5), QPointF(12, 20));
drawCenteredText(painter, QRectF(7, 6, 10, 14), QStringLiteral("T"), 9.0);
break;
}
case UiIcon::PageJump:
{
painter.drawRect(QRectF(3, 3, 14, 18));
painter.drawLine(QPointF(9, 12), QPointF(22, 12));
painter.drawLine(QPointF(18, 8), QPointF(22, 12));
painter.drawLine(QPointF(18, 16), QPointF(22, 12));
break;
}
case UiIcon::AlarmList:
{
QPainterPath triangle;
triangle.moveTo(12, 3);
triangle.lineTo(22, 21);
triangle.lineTo(2, 21);
triangle.closeSubpath();
painter.setPen(iconPen(kWarning));
painter.setBrush(QColor(QStringLiteral("#fff0d2")));
painter.drawPath(triangle);
painter.drawLine(QPointF(12, 8), QPointF(12, 14));
painter.drawPoint(QPointF(12, 17));
break;
}
case UiIcon::AlarmSettings:
{
painter.setPen(iconPen(kWarning));
painter.drawArc(QRectF(4, 4, 12, 13), 0, 180 * 16);
painter.drawLine(QPointF(4, 10), QPointF(4, 17));
painter.drawLine(QPointF(16, 10), QPointF(16, 17));
painter.drawLine(QPointF(3, 17), QPointF(17, 17));
painter.drawEllipse(QPointF(18, 17), 3.0, 3.0);
painter.drawLine(QPointF(18, 12), QPointF(18, 14));
break;
}
case UiIcon::Delete:
{
painter.setPen(iconPen(kDanger));
painter.drawLine(QPointF(5, 7), QPointF(19, 7));
painter.drawLine(QPointF(9, 4), QPointF(15, 4));
painter.drawRoundedRect(QRectF(7, 7, 10, 14), 1, 1);
painter.drawLine(QPointF(10, 10), QPointF(10, 18));
painter.drawLine(QPointF(14, 10), QPointF(14, 18));
break;
}
case UiIcon::AddRung:
{
painter.drawLine(QPointF(4, 3), QPointF(4, 21));
painter.drawLine(QPointF(20, 3), QPointF(20, 21));
painter.drawLine(QPointF(4, 12), QPointF(20, 12));
painter.setPen(iconPen(kAccent, 2.0));
painter.drawLine(QPointF(12, 7), QPointF(12, 17));
painter.drawLine(QPointF(7, 12), QPointF(17, 12));
break;
}
case UiIcon::HorizontalWire:
{
painter.drawLine(QPointF(3, 12), QPointF(21, 12));
painter.setPen(iconPen(kAccent, 2.0));
painter.drawLine(QPointF(9, 7), QPointF(15, 7));
painter.drawLine(QPointF(12, 4), QPointF(12, 10));
break;
}
case UiIcon::VerticalWire:
{
painter.drawLine(QPointF(5, 7), QPointF(19, 7));
painter.drawLine(QPointF(5, 17), QPointF(19, 17));
painter.drawLine(QPointF(12, 7), QPointF(12, 17));
painter.setPen(iconPen(kAccent, 2.0));
painter.drawLine(QPointF(17, 9), QPointF(17, 15));
painter.drawLine(QPointF(14, 12), QPointF(20, 12));
break;
}
case UiIcon::ParallelBranch:
{
painter.drawLine(QPointF(2, 12), QPointF(6, 12));
painter.drawLine(QPointF(18, 12), QPointF(22, 12));
painter.drawLine(QPointF(6, 7), QPointF(6, 17));
painter.drawLine(QPointF(18, 7), QPointF(18, 17));
painter.drawLine(QPointF(6, 7), QPointF(18, 7));
painter.drawLine(QPointF(6, 17), QPointF(18, 17));
painter.setPen(iconPen(kAccent));
painter.drawLine(QPointF(12, 9), QPointF(12, 15));
painter.drawLine(QPointF(9, 12), QPointF(15, 12));
break;
}
case UiIcon::NormallyOpenContact:
{
drawContact(painter, {}, false);
break;
}
case UiIcon::NormallyClosedContact:
{
drawContact(painter, {}, true);
break;
}
case UiIcon::RisingEdgeContact:
{
drawContact(painter, QStringLiteral("P"), false);
break;
}
case UiIcon::FallingEdgeContact:
{
drawContact(painter, QStringLiteral("N"), false);
break;
}
case UiIcon::TimerContact:
{
drawContact(painter, QStringLiteral("T"), false);
break;
}
case UiIcon::CounterContact:
{
drawContact(painter, QStringLiteral("C"), false);
break;
}
case UiIcon::Coil:
{
drawCoil(painter, {});
break;
}
case UiIcon::SetCoil:
{
drawCoil(painter, QStringLiteral("S"));
break;
}
case UiIcon::ResetCoil:
{
drawCoil(painter, QStringLiteral("R"));
break;
}
case UiIcon::Ton:
{
drawInstructionBlock(painter, QStringLiteral("TON"));
break;
}
case UiIcon::Ctu:
{
drawInstructionBlock(painter, QStringLiteral("CTU"));
break;
}
case UiIcon::Ctd:
{
drawInstructionBlock(painter, QStringLiteral("CTD"));
break;
}
case UiIcon::Move:
{
painter.drawRect(QRectF(2, 6, 6, 12));
painter.drawRect(QRectF(16, 6, 6, 12));
painter.drawLine(QPointF(8, 12), QPointF(16, 12));
painter.drawLine(QPointF(13, 9), QPointF(16, 12));
painter.drawLine(QPointF(13, 15), QPointF(16, 12));
break;
}
case UiIcon::Add:
{
drawInstructionBlock(painter, QStringLiteral("+"));
break;
}
case UiIcon::Subtract:
{
drawInstructionBlock(painter, QStringLiteral("-"));
break;
}
case UiIcon::Compare:
{
drawInstructionBlock(painter, QStringLiteral("<>"));
break;
}
case UiIcon::Comment:
{
painter.drawRoundedRect(QRectF(3, 3, 18, 18), 1.5, 1.5);
painter.drawLine(QPointF(7, 8), QPointF(17, 8));
painter.drawLine(QPointF(7, 12), QPointF(17, 12));
painter.drawLine(QPointF(7, 16), QPointF(13, 16));
painter.setPen(iconPen(kAccent, 2.0));
painter.drawLine(QPointF(14, 19), QPointF(21, 12));
break;
}
case UiIcon::More:
{
painter.setPen(Qt::NoPen);
painter.setBrush(kInk);
painter.drawEllipse(QPointF(6, 12), 1.7, 1.7);
painter.drawEllipse(QPointF(12, 12), 1.7, 1.7);
painter.drawEllipse(QPointF(18, 12), 1.7, 1.7);
break;
}
case UiIcon::HmiPage:
{
painter.drawRoundedRect(QRectF(2, 4, 20, 14), 1.5, 1.5);
painter.drawLine(QPointF(8, 21), QPointF(16, 21));
painter.drawLine(QPointF(12, 18), QPointF(12, 21));
painter.setBrush(kAccentFill);
painter.drawRoundedRect(QRectF(6, 8, 12, 6), 1, 1);
break;
}
case UiIcon::Logic:
{
painter.drawLine(QPointF(3, 3), QPointF(3, 21));
painter.drawLine(QPointF(21, 3), QPointF(21, 21));
drawContact(painter, {}, false);
break;
}
case UiIcon::Runtime:
{
painter.drawRoundedRect(QRectF(2, 4, 20, 16), 1.5, 1.5);
QPainterPath pulse;
pulse.moveTo(4, 13);
pulse.lineTo(8, 13);
pulse.lineTo(10, 8);
pulse.lineTo(13, 17);
pulse.lineTo(16, 11);
pulse.lineTo(20, 11);
painter.setPen(iconPen(kAccent));
painter.drawPath(pulse);
break;
}
case UiIcon::ClearList:
{
painter.drawLine(QPointF(4, 6), QPointF(16, 6));
painter.drawLine(QPointF(4, 11), QPointF(13, 11));
painter.drawLine(QPointF(4, 16), QPointF(10, 16));
painter.setPen(iconPen(kDanger));
painter.drawLine(QPointF(14, 13), QPointF(21, 20));
painter.drawLine(QPointF(21, 13), QPointF(14, 20));
break;
}
}
return pixmap;
}

} // namespace

QIcon makeUiIcon(UiIcon icon)
{
QIcon result;
constexpr std::array<int, 3> sizes{16, 24, 32};
for (int size : sizes)
{
result.addPixmap(renderIcon(icon, size));
}
return result;
}

+ 58
- 0
app/src/ui/toolbar_icon_factory.h 查看文件

@@ -0,0 +1,58 @@
#pragma once

#include <QIcon>

enum class UiIcon
{
Edit,
RunOffline,
RunOnline,
NewDocument,
Save,
SaveAs,
Open,
Undo,
Redo,
Exit,
PlcSettings,
Disconnect,
RegisterComments,
HmiButton,
Indicator,
NumericDisplay,
NumericInput,
ProgressBar,
Text,
PageJump,
AlarmList,
AlarmSettings,
Delete,
AddRung,
HorizontalWire,
VerticalWire,
ParallelBranch,
NormallyOpenContact,
NormallyClosedContact,
RisingEdgeContact,
FallingEdgeContact,
TimerContact,
CounterContact,
Coil,
SetCoil,
ResetCoil,
Ton,
Ctu,
Ctd,
Move,
Add,
Subtract,
Compare,
Comment,
More,
HmiPage,
Logic,
Runtime,
ClearList
};

QIcon makeUiIcon(UiIcon icon);

+ 120
- 0
app/tests/alarm_service_tests.cpp 查看文件

@@ -0,0 +1,120 @@
#include "domain/project_storage.h"
#include "domain/register_repository.h"
#include "services/alarm_editor_service.h"
#include "services/alarm_service.h"
#include "services/project_service.h"
#include "support/test_support.h"

#include <algorithm>
#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

namespace {

using TestProjectStorage = TestSupport::InMemoryProjectStorage;
using TestSupport::require;

const AlarmRecord *findRecord(
const AlarmService &service, const std::string &definition_id)
{
const auto &records = service.records();
const auto record = std::find_if(
records.cbegin(), records.cend(),
[&definition_id](const AlarmRecord &candidate)
{
return candidate.definitionId == definition_id;
});
return record == records.cend() ? nullptr : &*record;
}

void testAlarmDefinitionsAndRuntimeLifecycle()
{
TestProjectStorage storage;
ProjectService project_service(storage);
AlarmEditorService editor_service(project_service);
VirtualRegisterRepository repository;
AlarmService alarm_service(project_service, repository);

AlarmDefinition m_alarm;
m_alarm.address = RegisterAddress{RegisterArea::M, 0};
m_alarm.condition = AlarmCondition::MOn;
m_alarm.message = "Emergency stop";
const AlarmEditorResult m_result = editor_service.addDefinition(m_alarm);

AlarmDefinition high_alarm;
high_alarm.address = RegisterAddress{RegisterArea::D, 0};
high_alarm.condition = AlarmCondition::DHigh;
high_alarm.threshold = 80;
high_alarm.message = "Temperature high";
const AlarmEditorResult high_result = editor_service.addDefinition(high_alarm);

AlarmDefinition low_alarm;
low_alarm.address = RegisterAddress{RegisterArea::D, 1};
low_alarm.condition = AlarmCondition::DLow;
low_alarm.threshold = 10;
low_alarm.message = "Pressure low";
const AlarmEditorResult low_result = editor_service.addDefinition(low_alarm);
require(m_result.succeeded && high_result.succeeded && low_result.succeeded,
"M, D high and D low alarm definitions must be accepted");

AlarmDefinition invalid = high_alarm;
invalid.address = RegisterAddress{RegisterArea::M, 2};
require(editor_service.addDefinition(invalid).error
== AlarmEditorError::InvalidDefinition,
"D alarm conditions must reject M addresses");

repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 20);
alarm_service.refresh();
require(alarm_service.records().empty(),
"inactive alarm conditions must not create records");

repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true);
repository.writeWord(RegisterAddress{RegisterArea::D, 0}, 80);
repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 10);
alarm_service.refresh();
require(findRecord(alarm_service, m_result.id) != nullptr
&& findRecord(alarm_service, high_result.id) != nullptr
&& findRecord(alarm_service, low_result.id) != nullptr,
"all three alarm conditions must create active records");
require(alarm_service.acknowledge(m_result.id)
&& findRecord(alarm_service, m_result.id)->acknowledged,
"an active alarm must be locally acknowledged");

repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false);
repository.writeWord(RegisterAddress{RegisterArea::D, 0}, 79);
repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 11);
alarm_service.refresh();
require(alarm_service.records().empty(),
"cleared conditions must immediately remove active alarms");

repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true);
alarm_service.refresh();
require(findRecord(alarm_service, m_result.id) != nullptr
&& alarm_service.records().size() == 1
&& !alarm_service.records().front().acknowledged,
"a repeated alarm must create a new unacknowledged occurrence");

alarm_service.reset();
require(alarm_service.records().empty(),
"leaving runtime must clear session alarm records");
}

} // namespace

int main()
{
try
{
testAlarmDefinitionsAndRuntimeLifecycle();
}
catch (const std::exception &error)
{
std::cerr << "alarm service tests failed: " << error.what() << '\n';
return 1;
}

std::cout << "alarm service tests passed\n";
return 0;
}

+ 24
- 0
app/tests/alarm_service_tests.pro 查看文件

@@ -0,0 +1,24 @@
include(pri/test_defaults.pri)
include(pri/test_layers.pri)

TARGET = alarm_service_tests
QT -= gui
CONFIG += testcase

SOURCES += \
alarm_service_tests.cpp \
$$DOMAIN_REGISTER_SOURCES \
$$DOMAIN_HMI_SOURCES \
$$DOMAIN_LOGIC_SOURCES \
$$DOMAIN_PROJECT_SOURCES \
$$SERVICE_PROJECT_SOURCES \
$$SERVICE_ALARM_SOURCES

HEADERS += \
$$DOMAIN_REGISTER_HEADERS \
$$DOMAIN_HMI_HEADERS \
$$DOMAIN_LOGIC_HEADERS \
$$DOMAIN_PROJECT_HEADERS \
$$SERVICE_PROJECT_HEADERS \
$$SERVICE_ALARM_HEADERS \
$$TEST_SUPPORT_HEADERS

+ 563
- 11
app/tests/domain_tests.cpp 查看文件

@@ -1,25 +1,24 @@
#include "domain/control_logic_model.h"
#include "domain/hmi_control_registry.h"
#include "domain/hmi_model.h"
#include "domain/project_model.h"
#include "domain/project_limits.h"
#include "domain/register_address.h"
#include "domain/register_repository.h"
#include "domain/runtime_state.h"
#include "support/test_support.h"

#include <cstdint>
#include <exception>
#include <functional>
#include <iostream>
#include <set>
#include <stdexcept>
#include <string>

namespace {

void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}
using TestSupport::require;

void testRegisterAddressBoundaries()
{
@@ -76,6 +75,166 @@ void testRegisterRepositorySeparatesAreas()
"M address must not be read as a word");
}

void testHmiControlRegistryCompleteness()
{
std::set<const HmiControlDescriptor *> descriptors;
std::set<std::string> storage_names;
std::set<std::string> id_prefixes;
const std::size_t control_type_count =
static_cast<std::size_t>(HmiControlType::Count);
for (std::size_t index = 0; index < control_type_count; ++index)
{
const HmiControlType type = static_cast<HmiControlType>(index);
const HmiControlDescriptor *descriptor =
findHmiControlDescriptor(type);
require(descriptor != nullptr,
"every HMI control type must have one descriptor");
require(descriptor->type == type,
"HMI type lookup must return the requested descriptor");
require(descriptors.emplace(descriptor).second,
"each HMI control type must resolve to a different descriptor");
require(descriptor->storageName != nullptr
&& descriptor->storageName[0] != '\0',
"HMI storage names must not be empty");
require(descriptor->displayName != nullptr
&& descriptor->displayName[0] != '\0',
"HMI display names must not be empty");
require(descriptor->idPrefix != nullptr
&& descriptor->idPrefix[0] != '\0',
"HMI id prefixes must not be empty");
require(descriptor->defaultText != nullptr,
"HMI default text must not be null");
require(descriptor->defaultBounds.width > 0
&& descriptor->defaultBounds.height > 0,
"HMI default bounds must have a positive size");
require(findHmiControlDescriptor(descriptor->storageName) == descriptor,
"HMI storage name lookup must return its registered descriptor");
require(storage_names.emplace(descriptor->storageName).second,
"HMI storage names must be unique");
require(id_prefixes.emplace(descriptor->idPrefix).second,
"HMI id prefixes must be unique");

const std::optional<RegisterArea> binding_area =
hmiBindingArea(descriptor->bindingKind);
switch (descriptor->runtimeValueKind)
{
case HmiRuntimeValueKind::Bit:
{
require(descriptor->bindingKind == HmiBindingKind::Bit
&& binding_area == RegisterArea::M,
"bit runtime controls must bind to the M area");
break;
}
case HmiRuntimeValueKind::Word:
{
require(descriptor->bindingKind == HmiBindingKind::Word
&& binding_area == RegisterArea::D,
"word runtime controls must bind to the D area");
break;
}
case HmiRuntimeValueKind::None:
{
require(descriptor->bindingKind == HmiBindingKind::None
&& !binding_area.has_value(),
"static controls must not declare a register binding");
break;
}
}

require(descriptor->requiresBindingForRunning
== (descriptor->runtimeValueKind
!= HmiRuntimeValueKind::None),
"runtime value controls must require a configured binding");
}

require(findHmiControlDescriptor(HmiControlType::Count) == nullptr,
"the HMI control count marker must not be registered");
require(findHmiControlDescriptor(static_cast<HmiControlType>(99)) == nullptr,
"unknown HMI control types must not resolve");
require(findHmiControlDescriptor("unknown") == nullptr,
"unknown HMI storage names must not resolve");
require(hmiBindingArea(HmiBindingKind::Bit) == RegisterArea::M,
"bit bindings must use the M area");
require(hmiBindingArea(HmiBindingKind::Word) == RegisterArea::D,
"word bindings must use the D area");
require(!hmiBindingArea(HmiBindingKind::None).has_value(),
"controls without bindings must not resolve a register area");
}

void testProgressBarConfigurationBoundaries()
{
const HmiProgressBarConfig config{-20, 80, true};
require(config.isValid(), "a progress bar range must accept increasing bounds");
require(config.percentageForValue(-20) == 0,
"progress bar minimum must map to zero percent");
require(config.percentageForValue(30) == 50,
"progress bar midpoint must map to fifty percent");
require(config.percentageForValue(80) == 100,
"progress bar maximum must map to one hundred percent");
require(config.percentageForValue(-100) == 0
&& config.percentageForValue(100) == 100,
"progress bar percentages must clamp values outside the range");

HmiProgressBarConfig invalid_range{10, 10, true};
require(!invalid_range.isValid()
&& invalid_range.percentageForValue(10) == 0,
"progress bar must reject an empty range defensively");

HmiControl progress;
progress.id = "progress";
progress.type = HmiControlType::ProgressBar;
progress.progressBar = config;
require(progress.validate(),
"an unbound progress bar with valid configuration must remain a valid draft");
require(!progress.isConfigured(),
"an unbound progress bar must not be ready for running");
progress.binding = RegisterAddress{RegisterArea::D, 0};
require(progress.validate() && progress.isConfigured(),
"a progress bar with a D binding must be ready for running");
progress.progressBar->maximumValue = progress.progressBar->minimumValue;
require(!progress.validate(),
"a progress bar with an invalid range must be rejected");
progress.progressBar = config;
progress.binding = RegisterAddress{RegisterArea::M, 0};
require(!progress.validate(),
"a progress bar must reject an M binding");
}

Project makeValidProject();

void testHmiAppearancePropertyBoundaries()
{
// 外观属性必须在领域层拒绝格式错误,但不能影响未知扩展属性
Project project = makeValidProject();
HmiControl &button = project.hmiPages.front().controls.front();
button.properties[HmiAppearanceProperty::kTextColor] = "#E53935";
button.properties[HmiAppearanceProperty::kFontSize] = "18";
button.properties[HmiAppearanceProperty::kFontBold] = "true";
button.properties[HmiAppearanceProperty::kFontItalic] = "false";
require(project.validate(), "valid HMI appearance properties must pass validation");

button.properties[HmiAppearanceProperty::kTextColor] = "red";
require(!project.validate(), "text colors must use the #RRGGBB format");

project = makeValidProject();
HmiControl &font_control = project.hmiPages.front().controls.front();
font_control.properties[HmiAppearanceProperty::kFontSize] = "5";
require(!project.validate(), "font sizes below the minimum must be rejected");
font_control.properties[HmiAppearanceProperty::kFontSize] = "73";
require(!project.validate(), "font sizes above the maximum must be rejected");
font_control.properties[HmiAppearanceProperty::kFontSize] = "large";
require(!project.validate(), "non-numeric font sizes must be rejected");

project = makeValidProject();
HmiControl &style_control = project.hmiPages.front().controls.front();
style_control.properties[HmiAppearanceProperty::kFontBold] = "yes";
require(!project.validate(), "font style flags must be true or false");

project = makeValidProject();
project.hmiPages.front().controls.front().properties["legacyColor"] = "green";
require(project.validate(), "unknown HMI extension properties must remain supported");
}

Project makeValidProject()
{
// 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线
@@ -169,7 +328,7 @@ void testMultiPageAndLogicDomainRules()
disabled_draft.name = "Draft logic";
disabled_draft.enabled = false;
disabled_draft.rungs.push_back(
{"rung-1", "Draft network", std::nullopt, std::nullopt});
{"rung-1", "Draft network", {}, std::nullopt, std::nullopt});
project.controlLogics.push_back(disabled_draft);
require(project.validateForRunning(),
"a disabled draft logic must not block offline running");
@@ -184,6 +343,107 @@ void testMultiPageAndLogicDomainRules()
require(!project.validate(), "unknown HMI control types must be rejected");
}

void testQuantityBoundaries()
{
Project project = makeValidProject();
for (std::size_t index = 1U; index < ProjectLimits::kMaximumHmiPages; ++index)
{
project.hmiPages.push_back({
"page-" + std::to_string(index),
"Page " + std::to_string(index),
800,
400,
{}});
}
require(project.validate(), "an HMI page count of 128 must be accepted");
project.hmiPages.push_back({"page-over", "Page over", 800, 400, {}});
require(!project.validate(), "an HMI page count of 129 must be rejected");

project = makeValidProject();
project.hmiPages.front().controls.clear();
for (std::size_t index = 0U;
index < ProjectLimits::kMaximumHmiControlsPerPage;
++index)
{
HmiControl label;
label.id = "label-" + std::to_string(index);
label.type = HmiControlType::Label;
label.bounds = {0, 0, 1, 1};
label.text = "label";
project.hmiPages.front().controls.push_back(std::move(label));
}
require(project.validate(), "a page control count of 512 must be accepted");
HmiControl extra_label;
extra_label.id = "label-over";
extra_label.type = HmiControlType::Label;
extra_label.bounds = {0, 0, 1, 1};
extra_label.text = "label";
project.hmiPages.front().controls.push_back(std::move(extra_label));
require(!project.validate(), "a page control count of 513 must be rejected");

project = makeValidProject();
project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth;
project.hmiPages.front().height = ProjectLimits::kMaximumHmiPageHeight;
require(project.validate(), "an HMI page size of 1600 by 800 must be accepted");
project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth + 1;
require(!project.validate(), "an HMI page width of 1601 must be rejected");
project.hmiPages.front().width = ProjectLimits::kMinimumHmiPageWidth - 1;
require(!project.validate(), "an HMI page width of 319 must be rejected");
project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth;
project.hmiPages.front().height = ProjectLimits::kMinimumHmiPageHeight - 1;
require(!project.validate(), "an HMI page height of 199 must be rejected");

ConditionExpression leaf = ConditionExpression::fromNode({
"depth-node-0",
ContactNodeConfig{RegisterAddress{RegisterArea::M, 0}},
true});
std::function<ConditionExpression(int, int *)> makeNested =
[&makeNested](int depth, int *next_address)
{
if (depth == 1)
{
const int address = (*next_address)++;
return ConditionExpression::fromNode({
"depth-node-" + std::to_string(address),
ContactNodeConfig{RegisterAddress{RegisterArea::M, address}},
true});
}
const int address = (*next_address)++;
ConditionExpression expression;
expression.id = "depth-expression-" + std::to_string(address);
expression.kind = depth % 2 == 0
? ConditionExpressionKind::Parallel
: ConditionExpressionKind::Series;
expression.children = {
makeNested(depth - 1, next_address),
ConditionExpression::fromNode({
"depth-node-" + std::to_string(address),
ContactNodeConfig{RegisterAddress{RegisterArea::M, address}},
true})};
return expression;
};
int next_address = 1;
ConditionExpression maximum_depth = makeNested(
static_cast<int>(ProjectLimits::kMaximumExpressionDepth), &next_address);
require(maximum_depth.validate(),
"an expression depth of 20 must be accepted");
ConditionExpression excessive_depth = makeNested(
static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1,
&next_address);
require(!excessive_depth.validate(),
"an expression depth of 21 must be rejected");

CounterNodeConfig counter_config{
CounterAddress{0},
CounterMode::Up,
RegisterAddress{RegisterArea::D, 0},
WordOperand{WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, -1},
RegisterAddress{RegisterArea::M, 0}};
LogicNode counter{"counter-negative-preset", counter_config, true};
require(!counter.validate(), "a negative constant counter preset must be rejected");
(void)leaf;
}

void testLogicNodeConfigurationBoundaries()
{
// 触点只能绑定 M 区,数值比较只能绑定 D 区
@@ -208,6 +468,245 @@ void testLogicNodeConfigurationBoundaries()
require(comparison.validate(), "comparison node bound to D address must be valid");
}

void testTimerAndCommentBoundaries()
{
LogicNode edge;
edge.id = "edge";
edge.config = EdgeContactNodeConfig{
RegisterAddress{RegisterArea::M, 0}, EdgeMode::Rising};
require(edge.validate(), "a valid rising edge contact must pass validation");

LogicNode timer_contact;
timer_contact.id = "timer-contact";
timer_contact.config = TimerContactNodeConfig{
TimerAddress{4000}, ContactMode::NormallyOpen};
require(timer_contact.validate(), "T4000 must be a valid timer contact");
timer_contact.config = TimerContactNodeConfig{
TimerAddress{-1}, ContactMode::NormallyOpen};
require(!timer_contact.validate(), "a negative T address must be rejected");

LogicNode ton;
ton.id = "ton";
ton.config = TonNodeConfig{TimerAddress{0}, TonNodeConfig::kMinimumPresetMs};
require(ton.validate(), "the minimum TON preset must be valid");
ton.config = TonNodeConfig{TimerAddress{0}, 0};
require(!ton.validate(), "a zero TON preset must be rejected");
ton.config = TonNodeConfig{
TimerAddress{0}, TonNodeConfig::kMaximumPresetMs + 1};
require(!ton.validate(), "an oversized TON preset must be rejected");

RegisterComment comment{RegisterAddress{RegisterArea::M, 0}, "启动按钮"};
require(comment.validate(), "a nonblank register comment must be valid");
comment.text.assign(ProjectLimits::kMaximumRegisterCommentBytes, 'a');
require(comment.validate(), "a register comment at the byte limit must be valid");
comment.text.push_back('a');
require(!comment.validate(), "a register comment above the byte limit must fail");
comment.text = "启动\n按钮";
require(!comment.validate(), "a multiline register comment must be rejected");
comment.text = "启动\r按钮";
require(!comment.validate(), "a register comment containing CR must be rejected");
comment.text = " \t";
require(!comment.validate(), "a blank register comment must be rejected");

LadderRung comment_rung;
comment_rung.id = "comment-rung";
comment_rung.name = "Comment rung";
comment_rung.comment.assign(ProjectLimits::kMaximumRungCommentBytes, 'a');
require(comment_rung.validate(), "a rung comment at the byte limit must be valid");
comment_rung.comment.push_back('a');
require(!comment_rung.validate(), "a rung comment above the byte limit must fail");
comment_rung.comment = "第一行\n第二行";
require(!comment_rung.validate(), "a multiline rung comment must be rejected");
comment_rung.comment = "第一行\r第二行";
require(!comment_rung.validate(), "a rung comment containing CR must be rejected");

Project project = makeValidProject();
project.registerComments = {
{RegisterAddress{RegisterArea::M, 0}, "启动按钮"},
{RegisterAddress{RegisterArea::M, 0}, "重复地址"}};
require(!project.validate(), "duplicate register comments must be rejected");
}

LadderRung makeTimerRung(
const std::string &rung_id,
const std::string &condition_id,
const std::string &output_id,
int timer_index,
int preset_ms)
{
LogicNode condition;
condition.id = condition_id;
condition.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, timer_index}, ContactMode::NormallyOpen};
LogicNode output;
output.id = output_id;
output.config = TonNodeConfig{TimerAddress{timer_index}, preset_ms};
LadderRung rung;
rung.id = rung_id;
rung.name = rung_id;
rung.condition = ConditionExpression::fromNode(condition);
rung.output = output;
return rung;
}

void testTimerReferencesForRunning()
{
ControlLogic valid;
valid.id = "timer-valid";
valid.name = "Timer valid";
valid.rungs.push_back(makeTimerRung("rung-0", "input-0", "ton-0", 0, 100));
require(validateTimerReferencesForRunning({valid}),
"a timer contact-free TON network must pass timer reference validation");

ControlLogic duplicate = valid;
duplicate.id = "timer-duplicate";
duplicate.name = "Timer duplicate";
duplicate.rungs.front().output->id = "ton-duplicate";
require(!validateTimerReferencesForRunning({valid, duplicate}),
"the same T must not have two enabled TON drivers");

ControlLogic missing;
missing.id = "timer-missing";
missing.name = "Timer missing";
LogicNode contact;
contact.id = "missing-contact";
contact.config = TimerContactNodeConfig{
TimerAddress{7}, ContactMode::NormallyOpen};
LogicNode output;
output.id = "missing-coil";
output.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal};
LadderRung missing_rung;
missing_rung.id = "missing-rung";
missing_rung.name = "Missing rung";
missing_rung.condition = ConditionExpression::fromNode(contact);
missing_rung.output = output;
missing.rungs.push_back(missing_rung);
require(!validateTimerReferencesForRunning({missing}),
"a T contact without an enabled TON driver must be rejected");

missing.enabled = false;
require(validateTimerReferencesForRunning({missing}),
"disabled timer drafts must not block runtime timer validation");
}

LadderRung makeCounterRung(
const std::string &rung_id,
const std::string &output_id,
int counter_index)
{
LogicNode condition;
condition.id = rung_id + "-input";
condition.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, counter_index},
ContactMode::NormallyOpen};
LogicNode output;
output.id = output_id;
output.config = CounterNodeConfig{
CounterAddress{counter_index},
CounterMode::Up,
RegisterAddress{RegisterArea::D, counter_index},
WordOperand{
WordOperandKind::Constant,
RegisterAddress{RegisterArea::D, 0},
10},
RegisterAddress{RegisterArea::M, counter_index + 1}};
LadderRung rung;
rung.id = rung_id;
rung.name = rung_id;
rung.condition = ConditionExpression::fromNode(condition);
rung.output = output;
return rung;
}

void testCounterAndDataInstructionBoundaries()
{
require(CounterAddress{0}.isValid() && CounterAddress{4000}.isValid(),
"C0 and C4000 must be valid counter resources");
require(!CounterAddress{-1}.isValid() && !CounterAddress{4001}.isValid(),
"counter resources outside 0 through 4000 must be rejected");

LogicNode counter;
counter.id = "counter";
counter.config = CounterNodeConfig{
CounterAddress{0},
CounterMode::Up,
RegisterAddress{RegisterArea::D, 10},
WordOperand{
WordOperandKind::Register,
RegisterAddress{RegisterArea::D, 11},
0},
RegisterAddress{RegisterArea::M, 12}};
require(counter.validate(),
"a counter with C identity and external M/D addresses must be valid");

CounterNodeConfig invalid_counter = std::get<CounterNodeConfig>(counter.config);
invalid_counter.currentValueAddress = RegisterAddress{RegisterArea::M, 10};
counter.config = invalid_counter;
require(!counter.validate(), "counter CV must reject M addresses");

LogicNode move;
move.id = "move";
move.config = MoveNodeConfig{
WordOperand{
WordOperandKind::Constant,
RegisterAddress{RegisterArea::D, 0},
-100},
RegisterAddress{RegisterArea::D, 20}};
require(move.validate() && move.isOutput(),
"MOVE with a constant source and D destination must be a valid output");

LogicNode add;
add.id = "add";
add.config = ArithmeticNodeConfig{
ArithmeticOperation::Add,
WordOperand{
WordOperandKind::Register,
RegisterAddress{RegisterArea::D, 20},
0},
WordOperand{
WordOperandKind::Constant,
RegisterAddress{RegisterArea::D, 0},
1},
RegisterAddress{RegisterArea::D, 20}};
require(add.validate() && add.isOutput(),
"ADD must allow the same D register as source and destination");

ControlLogic valid;
valid.id = "counter-valid";
valid.name = "Counter valid";
valid.rungs.push_back(makeCounterRung("counter-rung", "ctu-0", 0));
require(validateCounterReferencesForRunning({valid}),
"a counter output without contacts must pass reference validation");

ControlLogic duplicate = valid;
duplicate.id = "counter-duplicate";
duplicate.name = "Counter duplicate";
duplicate.rungs.front().output->id = "ctu-duplicate";
require(!validateCounterReferencesForRunning({valid, duplicate}),
"the same C resource must not have multiple enabled drivers");

ControlLogic missing;
missing.id = "counter-missing";
missing.name = "Counter missing";
LogicNode missing_contact;
missing_contact.id = "missing-counter-contact";
missing_contact.config = CounterContactNodeConfig{
CounterAddress{7}, ContactMode::NormallyOpen};
LogicNode output;
output.id = "missing-counter-coil";
output.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal};
LadderRung missing_rung;
missing_rung.id = "missing-counter-rung";
missing_rung.name = "Missing counter rung";
missing_rung.condition = ConditionExpression::fromNode(missing_contact);
missing_rung.output = output;
missing.rungs.push_back(missing_rung);
require(!validateCounterReferencesForRunning({missing}),
"a C contact without an enabled counter driver must be rejected");
}

void testLadderLogicBoundaries()
{
LogicNode stop;
@@ -257,6 +756,51 @@ void testLadderLogicBoundaries()
logic.rungs.push_back(rung);
require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid");

ConditionExpression wire = ConditionExpression::fromWire("wire-1", 2);
require(wire.validate() && wire.validateForRunning()
&& wire.wire->columnSpan == 2,
"a configured horizontal wire must be a valid runnable expression leaf");
ConditionExpression invalid_wire = ConditionExpression::fromWire("wire-invalid", 0);
require(!invalid_wire.validate(), "a zero-column horizontal wire must be rejected");
invalid_wire = ConditionExpression::fromWire(
"wire-too-wide", WireSegment::kMaximumColumnSpan + 1);
require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected");

ConditionExpression maximum_columns;
maximum_columns.id = "maximum-columns";
maximum_columns.kind = ConditionExpressionKind::Series;
for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
{
LogicNode node;
node.id = "column-" + std::to_string(column + 1);
node.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, column},
ContactMode::NormallyOpen};
maximum_columns.children.push_back(
ConditionExpression::fromNode(std::move(node)));
}
require(maximum_columns.validate(),
"ten condition columns must be accepted");
LogicNode extra_column;
extra_column.id = "column-11";
extra_column.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 10},
ContactMode::NormallyOpen};
maximum_columns.children.push_back(
ConditionExpression::fromNode(std::move(extra_column)));
require(!maximum_columns.validate(),
"an eleventh condition column must be rejected");

ConditionExpression wired_series;
wired_series.id = "wired-series";
wired_series.kind = ConditionExpressionKind::Series;
wired_series.children = {
ConditionExpression::fromNode(stop),
ConditionExpression::fromWire("wire-series"),
start_parallel};
require(wired_series.validateForRunning(),
"a wire must preserve a valid structured series expression");

logic.rungs.front().condition->children.front() =
ConditionExpression::fromNode(coil);
require(!logic.validate(), "a ladder condition expression must reject coils");
@@ -269,13 +813,14 @@ void testLadderLogicBoundaries()
require(!logic.validateForRunning(),
"conditions without an output must block runtime validation");

LadderRung empty_rung{"rung-empty", "Empty network", std::nullopt, std::nullopt};
LadderRung empty_rung{
"rung-empty", "Empty network", {}, std::nullopt, std::nullopt};
require(empty_rung.validate(), "an empty editing network must be valid");

empty_rung.output = coil;
require(empty_rung.validate(), "output-only network may remain in an editable draft");
require(!empty_rung.validateForRunning(),
"an output without conditions must block runtime validation");
require(empty_rung.validateForRunning(),
"an output-only network must be valid as an unconditional rung");

logic.rungs.front().output = coil;
logic.rungs.front().condition = root;
@@ -364,10 +909,17 @@ int main()
testRegisterAddressBoundaries();
testRegisterAddressParsing();
testRegisterRepositorySeparatesAreas();
testHmiControlRegistryCompleteness();
testProgressBarConfigurationBoundaries();
testHmiAppearancePropertyBoundaries();
testLogicNodeConfigurationBoundaries();
testTimerAndCommentBoundaries();
testTimerReferencesForRunning();
testCounterAndDataInstructionBoundaries();
testLadderLogicBoundaries();
testModelsValidateBindingsAndIdentifiers();
testMultiPageAndLogicDomainRules();
testQuantityBoundaries();
testRuntimeStateBoundaries();
}
catch (const std::exception &error)


+ 8
- 18
app/tests/domain_tests.pro 查看文件

@@ -1,24 +1,14 @@
TEMPLATE = app
TARGET = domain_tests

CONFIG += console c++17 testcase warn_on
CONFIG -= app_bundle qt
include(pri/test_defaults.pri)
include(pri/test_layers.pri)

INCLUDEPATH += ../src
TARGET = domain_tests
QT -= gui
CONFIG += testcase

SOURCES += \
domain_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/domain/runtime_state.cpp
$$DOMAIN_ALL_SOURCES

HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/runtime_state.h
$$DOMAIN_ALL_HEADERS \
$$TEST_SUPPORT_HEADERS

+ 189
- 25
app/tests/hmi_editor_service_tests.cpp 查看文件

@@ -4,6 +4,7 @@
#include "services/hmi_runtime_service.h"
#include "services/hmi_navigation_service.h"
#include "services/project_service.h"
#include "support/test_support.h"

#include <exception>
#include <iostream>
@@ -12,28 +13,8 @@

namespace {

class TestProjectStorage final : public ProjectStorage
{
public:
// 编辑服务测试只关注内存模型变更,不依赖真实文件系统
ProjectSaveResult save(const Project &, const std::string &) override
{
return {true, ProjectStorageError::None, {}};
}

ProjectLoadResult load(const std::string &) override
{
return {false, {}, ProjectStorageError::FileReadFailed, {}};
}
};

void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}
using TestProjectStorage = TestSupport::InMemoryProjectStorage;
using TestSupport::require;

void testControlEditing()
{
@@ -50,12 +31,26 @@ void testControlEditing()
const HmiEditorResult display = service.addControl(
page_id, HmiControlType::NumericDisplay);
const HmiEditorResult input = service.addControl(page_id, HmiControlType::NumericInput);
require(button.succeeded && indicator.succeeded && display.succeeded && input.succeeded,
"four basic HMI controls must be added");
const HmiEditorResult progress = service.addControl(
page_id, HmiControlType::ProgressBar);
require(button.succeeded && indicator.succeeded && display.succeeded
&& input.succeeded && progress.succeeded,
"basic HMI controls must be added");

const HmiPage *page = service.findPage(page_id);
require(page != nullptr && page->controls.size() == 4,
require(page != nullptr && page->controls.size() == 5,
"all added controls must be kept in the page model");
const HmiControl *progress_control = service.findControl(page_id, progress.id);
require(progress_control != nullptr
&& progress_control->id == "progress-bar-1"
&& progress_control->text == "进度"
&& progress_control->bounds.width == 240
&& progress_control->bounds.height == 36
&& progress_control->progressBar.has_value()
&& progress_control->progressBar->minimumValue == 0
&& progress_control->progressBar->maximumValue == 100
&& progress_control->progressBar->showValue,
"new progress bars must use the registered defaults");
require(service.moveControl(page_id, button.id, {120, 80, 120, 40}).succeeded,
"a valid control move must succeed");
require(!service.moveControl(page_id, button.id, {790, 460, 120, 40}).succeeded,
@@ -80,6 +75,20 @@ void testControlEditing()
"deleting a selected control must succeed");
require(service.findControl(page_id, indicator.id) == nullptr,
"deleted controls must not remain in the model");

HmiControl configured_progress = *service.findControl(page_id, progress.id);
configured_progress.binding = RegisterAddress{RegisterArea::D, 8};
configured_progress.progressBar->minimumValue = -10;
configured_progress.progressBar->maximumValue = 90;
require(service.updateControl(page_id, progress.id, configured_progress).succeeded,
"a progress bar must accept a valid D binding and value range");
configured_progress.progressBar->maximumValue = -10;
require(!service.updateControl(page_id, progress.id, configured_progress).succeeded,
"a progress bar must reject a non-increasing value range");
configured_progress.progressBar->maximumValue = 90;
configured_progress.binding = RegisterAddress{RegisterArea::M, 8};
require(!service.updateControl(page_id, progress.id, configured_progress).succeeded,
"a progress bar must reject an M binding");
}

void testRuntimeUsesRegisterRepository()
@@ -147,6 +156,110 @@ void testRuntimeUsesRegisterRepository()
const HmiRuntimeReadResult numeric_value = runtime_service.readControl(numeric_display);
require(numeric_value.succeeded && numeric_value.word_value == -18,
"numeric display must read D values through the repository");

HmiControl progress;
progress.id = "progress";
progress.type = HmiControlType::ProgressBar;
progress.binding = RegisterAddress{RegisterArea::D, 9};
progress.progressBar = HmiProgressBarConfig{-20, 80, true};
const HmiRuntimeReadResult progress_value = runtime_service.readControl(progress);
require(progress_value.succeeded && progress_value.word_value == -18,
"progress bars must read D values through the repository");
}

void testHistoryAndAtomicBatchDelete()
{
TestProjectStorage storage;
ProjectService project_service(storage);
HmiEditorService service(project_service);
const std::string page_id = service.ensureDefaultPage().id;
const HmiEditorResult first = service.addControl(page_id, HmiControlType::Label);
const HmiEditorResult second = service.addControl(page_id, HmiControlType::Label);
const HmiEditorResult third = service.addControl(page_id, HmiControlType::Label);
require(first.succeeded && second.succeeded && third.succeeded,
"controls for history testing must be created");

service.clearHistory();
require(!service.removeControls(page_id, {first.id, "missing-control"}).succeeded,
"batch deletion must reject an unknown control before changing the page");
require(service.findPage(page_id)->controls.size() == 3U
&& !service.canUndo(),
"failed batch deletion must be atomic and leave history unchanged");

require(service.removeControls(page_id, {first.id, second.id}).succeeded,
"batch deletion of valid controls must succeed");
require(service.findPage(page_id)->controls.size() == 1U
&& service.canUndo(),
"valid batch deletion must remove all selected controls in one history step");
require(service.undo().succeeded && service.findPage(page_id)->controls.size() == 3U,
"HMI undo must restore a deleted batch");
require(service.redo().succeeded && service.findPage(page_id)->controls.size() == 1U,
"HMI redo must reapply a deleted batch");

service.clearHistory();
const HmiControl *remaining = service.findControl(page_id, third.id);
require(remaining != nullptr,
"the unselected control must survive a batch deletion");
require(service.moveControl(page_id, third.id, remaining->bounds).succeeded
&& !service.canUndo(),
"a no-op control move must not consume an undo step");

service.clearHistory();
HmiControl candidate = *service.findControl(page_id, third.id);
for (int index = 1; index <= 101; ++index)
{
candidate.bounds.x = index;
require(service.updateControl(page_id, third.id, candidate).succeeded,
"repeated valid HMI edits must succeed");
}
int undo_count = 0;
while (service.undo().succeeded)
{
++undo_count;
}
require(undo_count == 100,
"HMI history must retain exactly the configured 100 most recent steps");
}

void testAppearanceEditing()
{
TestProjectStorage storage;
ProjectService project_service(storage);
HmiEditorService service(project_service);
const std::string page_id = service.ensureDefaultPage().id;
const HmiEditorResult label = service.addControl(page_id, HmiControlType::Label);
require(label.succeeded, "a label must be available for appearance editing");

HmiControl appearance = *service.findControl(page_id, label.id);
appearance.properties[HmiAppearanceProperty::kTextColor] = "#E53935";
appearance.properties[HmiAppearanceProperty::kFontSize] = "18";
appearance.properties[HmiAppearanceProperty::kFontBold] = "true";
appearance.properties[HmiAppearanceProperty::kFontItalic] = "false";
require(service.updateControl(page_id, label.id, appearance).succeeded,
"valid appearance properties must be applied atomically");
const HmiControl *updated = service.findControl(page_id, label.id);
require(updated != nullptr
&& updated->properties.at(HmiAppearanceProperty::kTextColor) == "#E53935"
&& updated->properties.at(HmiAppearanceProperty::kFontSize) == "18"
&& updated->properties.at(HmiAppearanceProperty::kFontBold) == "true",
"appearance properties must be stored on the HMI control");

HmiControl invalid = *updated;
invalid.properties[HmiAppearanceProperty::kTextColor] = "invalid";
require(!service.updateControl(page_id, label.id, invalid).succeeded,
"invalid appearance properties must be rejected without a partial update");
require(service.findControl(page_id, label.id)->properties.at(
HmiAppearanceProperty::kTextColor) == "#E53935",
"failed appearance updates must leave the old color intact");

require(service.undo().succeeded,
"appearance updates must participate in HMI undo history");
require(service.findControl(page_id, label.id)->properties.empty(),
"undo must remove the applied appearance properties");
require(service.redo().succeeded
&& service.findControl(page_id, label.id)->properties.at(
HmiAppearanceProperty::kFontSize) == "18",
"redo must restore the applied appearance properties");
}

void testPageLifecycleAndNavigation()
@@ -175,6 +288,17 @@ void testPageLifecycleAndNavigation()
require(label.succeeded
&& service.findControl(main_id, label.id)->isConfigured(),
"the editor service must expose a configured static Label control");
const HmiEditorResult alarm_list = service.addControl(
main_id, HmiControlType::AlarmList);
require(alarm_list.succeeded
&& service.findControl(main_id, alarm_list.id)->isConfigured()
&& !service.findControl(main_id, alarm_list.id)->binding.has_value(),
"AlarmList must be configured without a register binding");
HmiControl bound_alarm_list = *service.findControl(main_id, alarm_list.id);
bound_alarm_list.binding = RegisterAddress{RegisterArea::M, 20};
require(!service.updateControl(
main_id, alarm_list.id, bound_alarm_list).succeeded,
"AlarmList must reject direct register bindings");
const HmiEditorResult jump = service.addControl(main_id, HmiControlType::PageJump);
require(jump.succeeded, "the editor service must expose PageJump creation");
HmiControl jump_control = *service.findControl(main_id, jump.id);
@@ -205,6 +329,43 @@ void testPageLifecycleAndNavigation()
"stopping runtime navigation must clear session state");
}

void testPageResizeIsAtomicAndUndoable()
{
TestProjectStorage storage;
ProjectService project_service(storage);
HmiEditorService service(project_service);
const std::string page_id = service.ensureDefaultPage().id;
const HmiEditorResult label = service.addControl(page_id, HmiControlType::Label);
require(label.succeeded, "page resize fixture must add a control");

HmiControl control = *service.findControl(page_id, label.id);
control.bounds = {700, 340, 80, 32};
require(service.updateControl(page_id, label.id, control).succeeded,
"page resize fixture must place the control near the page edge");
require(service.resizePage(page_id, 1024, 600).succeeded,
"an HMI page must be resizable to a larger valid rectangle");
require(service.findPage(page_id)->width == 1024
&& service.findPage(page_id)->height == 600,
"page resize must update both dimensions");
require(service.resizePage(page_id, 700, 300).error
== HmiEditorError::InvalidPage,
"page resize must reject dimensions that clip an existing control");
require(service.findPage(page_id)->width == 1024
&& service.findPage(page_id)->height == 600,
"a rejected page resize must leave the original dimensions intact");
require(service.undo().succeeded
&& service.findPage(page_id)->width == 800
&& service.findPage(page_id)->height == 400,
"page resize must participate in HMI undo history");
require(service.redo().succeeded
&& service.findPage(page_id)->width == 1024
&& service.findPage(page_id)->height == 600,
"page resize redo must restore the new dimensions");
require(service.resizePage(page_id, 319, 600).error
== HmiEditorError::InvalidPage,
"page width below the business minimum must be rejected");
}

} // namespace

int main()
@@ -213,8 +374,11 @@ int main()
{
// 编辑和运行场景分别验证服务层两条独立职责
testControlEditing();
testHistoryAndAtomicBatchDelete();
testAppearanceEditing();
testRuntimeUsesRegisterRepository();
testPageLifecycleAndNavigation();
testPageResizeIsAtomicAndUndoable();
}
catch (const std::exception &error)
{


+ 16
- 25
app/tests/hmi_editor_service_tests.pro 查看文件

@@ -1,31 +1,22 @@
TEMPLATE = app
TARGET = hmi_editor_service_tests

CONFIG += console c++17 warn_on
CONFIG -= app_bundle
include(pri/test_defaults.pri)
include(pri/test_layers.pri)

INCLUDEPATH += ../src
TARGET = hmi_editor_service_tests

SOURCES += \
hmi_editor_service_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/services/project_service.cpp \
../src/services/hmi_editor_service.cpp \
../src/services/hmi_navigation_service.cpp \
../src/services/hmi_runtime_service.cpp
$$DOMAIN_REGISTER_SOURCES \
$$DOMAIN_HMI_SOURCES \
$$DOMAIN_LOGIC_SOURCES \
$$DOMAIN_PROJECT_SOURCES \
$$SERVICE_PROJECT_SOURCES \
$$SERVICE_HMI_SOURCES

HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/project_storage.h \
../src/services/project_service.h \
../src/services/hmi_editor_service.h \
../src/services/hmi_navigation_service.h \
../src/services/hmi_runtime_service.h
$$DOMAIN_REGISTER_HEADERS \
$$DOMAIN_HMI_HEADERS \
$$DOMAIN_LOGIC_HEADERS \
$$DOMAIN_PROJECT_HEADERS \
$$SERVICE_PROJECT_HEADERS \
$$SERVICE_HMI_HEADERS \
$$TEST_SUPPORT_HEADERS

+ 669
- 15
app/tests/logic_editor_service_tests.cpp 查看文件

@@ -1,37 +1,59 @@
#include "domain/project_storage.h"
#include "domain/project_limits.h"
#include "services/logic_editor_service.h"
#include "services/project_service.h"
#include "support/test_support.h"

#include <algorithm>
#include <iostream>
#include <stdexcept>

namespace {

class TestProjectStorage final : public ProjectStorage
using TestProjectStorage = TestSupport::InMemoryProjectStorage;
using TestSupport::require;

ContactNodeConfig contact(int address)
{
return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen};
}

int conditionColumns(const ConditionExpression &expression)
{
public:
ProjectSaveResult save(const Project &, const std::string &) override
if (expression.kind == ConditionExpressionKind::Node)
{
return {true, ProjectStorageError::None, {}};
return 1;
}

ProjectLoadResult load(const std::string &) override
if (expression.kind == ConditionExpressionKind::Wire)
{
return {false, {}, ProjectStorageError::FileReadFailed, {}};
return expression.wire->columnSpan;
}
};

void require(bool condition, const std::string &message)
{
if (!condition)
int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
for (const ConditionExpression &child : expression.children)
{
throw std::runtime_error(message);
const int child_columns = conditionColumns(child);
columns = expression.kind == ConditionExpressionKind::Series
? columns + child_columns : std::max(columns, child_columns);
}
return columns;
}

ContactNodeConfig contact(int address)
int wireColumns(const ConditionExpression &expression)
{
return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen};
if (expression.kind == ConditionExpressionKind::Node)
{
return 0;
}
if (expression.kind == ConditionExpressionKind::Wire)
{
return expression.wire->columnSpan;
}
int columns = 0;
for (const ConditionExpression &child : expression.children)
{
columns += wireColumns(child);
}
return columns;
}

void testStructuredEditingAndNormalization()
@@ -132,6 +154,488 @@ void testRangeParallelInsertion()
"a non-contiguous selection must be rejected");
}

void testStructuredWireEditing()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
service.appendCondition(logic_id, rung_id, contact(0));
service.appendCondition(logic_id, rung_id, contact(1));
service.appendCondition(logic_id, rung_id, contact(2));

const LogicEditorResult branch = service.addParallelWireBranch(
logic_id, rung_id, {"contact-2", "contact-3"});
require(branch.succeeded && branch.id == "wire-1",
"a continuous range must accept a structured horizontal bypass");
const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.at(1).kind
== ConditionExpressionKind::Parallel,
"a vertical connection must produce a parallel expression");
const ConditionExpression &wire =
rung->condition->children.at(1).children.at(1);
require(wire.kind == ConditionExpressionKind::Wire
&& wire.wire->columnSpan == 2,
"the bypass wire span must match the selected two-column range");

const LogicEditorResult replacement = service.replaceWireWithCondition(
logic_id, rung_id, branch.id, contact(3));
require(replacement.succeeded && replacement.id == "contact-4",
"a selected wire must be replaceable by a configured node type");
const LogicEditorResult extension = service.insertWireAfter(
logic_id, rung_id, replacement.id);
require(extension.succeeded && extension.id == "wire-1",
"a wire id must become reusable after its expression is replaced");

rung = service.findRung(logic_id, rung_id);
const ConditionExpression *extended_branch = service.findExpression(
logic_id, rung_id, rung->condition->children.at(1).children.at(1).id);
require(extended_branch != nullptr
&& extended_branch->kind == ConditionExpressionKind::Series
&& extended_branch->children.at(1).kind
== ConditionExpressionKind::Wire,
"inserting a horizontal wire after a branch node must preserve structure");
const std::string extended_branch_id = extended_branch->id;
require(!service.addParallelWireBranch(
logic_id, rung_id, {"contact-1", "contact-3"}).succeeded,
"non-contiguous wire connection targets must be rejected");

require(service.removeExpressions(
logic_id, rung_id, {extended_branch_id}).succeeded,
"deleting a selected vertical connection branch must succeed atomically");
rung = service.findRung(logic_id, rung_id);
require(rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.size() == 3U,
"removing a bypass branch must normalize back to the original series");
require(service.undo().succeeded
&& service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr,
"wire branch deletion must participate in ladder undo history");
}

void testBatchDeleteAllNodesInParallelBranch()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);

const LogicEditorResult first = service.appendCondition(
logic_id, rung_id, contact(0));
const LogicEditorResult second = service.appendCondition(
logic_id, rung_id, contact(1));
const LogicEditorResult third = service.appendCondition(
logic_id, rung_id, contact(2));
const LogicEditorResult fourth = service.appendCondition(
logic_id, rung_id, contact(3));
require(first.succeeded && second.succeeded && third.succeeded
&& fourth.succeeded,
"parallel batch deletion setup contacts must be created");

const LogicEditorResult branch = service.addParallelBranch(
logic_id, rung_id,
{second.id, third.id, fourth.id},
contact(10));
require(branch.succeeded,
"parallel batch deletion setup branch must be created");

require(service.removeNodes(
logic_id, {second.id, third.id, fourth.id})
.succeeded,
"deleting every node in a parallel branch as one batch must succeed");
const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->condition.has_value()
&& rung->validate(),
"batch deletion must leave a valid normalized ladder expression");
require(rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.size() == 2U
&& rung->condition->children.at(0).kind
== ConditionExpressionKind::Node
&& rung->condition->children.at(1).kind
== ConditionExpressionKind::Node,
"an empty parallel branch must collapse into the remaining branch");
require(service.findNode(logic_id, second.id) == nullptr
&& service.findNode(logic_id, third.id) == nullptr
&& service.findNode(logic_id, fourth.id) == nullptr,
"all selected parallel branch nodes must be removed");
require(service.undo().succeeded,
"parallel batch deletion must be undoable");
require(service.findNode(logic_id, second.id) != nullptr
&& service.findNode(logic_id, third.id) != nullptr
&& service.findNode(logic_id, fourth.id) != nullptr,
"undo must restore every deleted parallel branch node");
}

void testConditionColumnLimit()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);

for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column)
{
require(service.appendCondition(logic_id, rung_id, contact(column)).succeeded,
"the first ten condition columns must be editable");
}
require(project_service.saveAs("logic-editor-condition-limit.json").succeeded,
"the ten-column network must be saveable before testing overflow");
require(!project_service.isModified(),
"saving the ten-column network must clear the modified state");
const LogicEditorResult overflow = service.appendCondition(
logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns));
require(!overflow.succeeded
&& overflow.error == LogicEditorError::InvalidOperation,
"the eleventh condition column must be rejected by the editor service");
require(!project_service.isModified(),
"a failed eleventh-column edit must preserve the saved state");
const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->condition.has_value()
&& rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.size()
== static_cast<std::size_t>(
ProjectLimits::kMaximumConditionColumns),
"a rejected eleventh column must leave the ten-column network unchanged");

require(service.setOutput(
logic_id,
rung_id,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
true)
.succeeded
&& project_service.isModified(),
"a successful edit must make the project modified again");
require(!service.appendCondition(
logic_id, rung_id,
contact(ProjectLimits::kMaximumConditionColumns))
.succeeded
&& project_service.isModified(),
"a failed edit must preserve an existing modified state");
}

void testUnconditionalOutputEditing()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);

require(service.setOutput(
logic_id,
rung_id,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal},
true)
.succeeded,
"the editor must allow a coil before any condition is added");
const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && !rung->condition.has_value()
&& rung->output.has_value() && rung->validateForRunning(),
"an editor-created output-only network must be runnable as unconditional");
}

void testColumnTargetedConditionInsertion()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string first_rung_id = service.firstRungId(logic_id);

require(service.insertConditionAtColumn(
logic_id, first_rung_id, 4, contact(4)).succeeded,
"an empty network must accept a condition at the selected fifth column");
const LadderRung *rung = service.findRung(logic_id, first_rung_id);
require(rung != nullptr && rung->condition.has_value()
&& rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.size() == 2U
&& rung->condition->children.front().kind
== ConditionExpressionKind::Wire
&& rung->condition->children.front().wire->columnSpan == 4
&& rung->condition->children.back().kind
== ConditionExpressionKind::Node
&& rung->validate()
&& conditionColumns(*rung->condition) == 5,
"column insertion must preserve the requested horizontal position");

require(service.insertConditionAtColumn(
logic_id, first_rung_id, 2, contact(2)).error
== LogicEditorError::InvalidOperation,
"inserting into an already occupied column must be rejected atomically");
require(service.insertConditionAtColumn(
logic_id, first_rung_id, 10, contact(10)).error
== LogicEditorError::InvalidOperation,
"the eleventh condition column must be rejected");
require(service.insertConditionAtColumn(
logic_id, first_rung_id, -1, contact(10)).error
== LogicEditorError::InvalidOperation,
"a negative grid column must be rejected");
require(service.insertConditionAtColumn(
logic_id,
first_rung_id,
5,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal})
.error == LogicEditorError::InvalidNode,
"a condition grid slot must reject output instructions");

const LogicEditorResult second_rung = service.addRung(logic_id);
require(second_rung.succeeded,
"column insertion test must create a second empty network");
require(service.setOutput(
logic_id,
second_rung.id,
CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
true)
.succeeded,
"an output-only network must be configurable before adding a condition");
require(service.insertConditionAtColumn(
logic_id, second_rung.id, 0, contact(0)).succeeded,
"a selected first grid slot must work on an output-only network");
const LadderRung *output_rung = service.findRung(logic_id, second_rung.id);
require(output_rung != nullptr && output_rung->condition.has_value()
&& output_rung->condition->kind == ConditionExpressionKind::Node
&& output_rung->output.has_value()
&& output_rung->validate(),
"condition insertion must keep the independent output slot intact");

const LogicEditorResult last_column_rung = service.addRung(logic_id);
require(last_column_rung.succeeded
&& service.insertConditionAtColumn(
logic_id, last_column_rung.id, 9, contact(9)).succeeded,
"the tenth condition column must remain a valid insertion target");
const LadderRung *full_width = service.findRung(
logic_id, last_column_rung.id);
require(full_width != nullptr && full_width->condition.has_value()
&& full_width->condition->kind == ConditionExpressionKind::Series
&& full_width->condition->children.front().wire->columnSpan == 9
&& conditionColumns(*full_width->condition) == 10,
"last-column insertion must fill exactly the ten-column condition area");
require(!service.appendCondition(
logic_id, last_column_rung.id, contact(10)).succeeded
&& conditionColumns(*service.findRung(
logic_id, last_column_rung.id)->condition) == 10,
"a full-width grid must reject another condition without partial changes");
}

void testWireColumnReplacement()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4);
require(wire.succeeded,
"wire-column replacement test must create a four-column wire");

require(service.replaceWireColumnWithCondition(
logic_id, rung_id, wire.id, 1,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, 1},
ContactMode::NormallyClosed})
.succeeded,
"a selected wire cell must be replaceable without removing adjacent cells");
const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->condition.has_value()
&& rung->condition->kind == ConditionExpressionKind::Series
&& rung->condition->children.size() == 3U
&& rung->condition->children.front().wire->columnSpan == 1
&& rung->condition->children.at(1).kind
== ConditionExpressionKind::Node
&& std::get<ContactNodeConfig>(
rung->condition->children.at(1).node->config).mode
== ContactMode::NormallyClosed
&& rung->condition->children.back().wire->columnSpan == 2
&& conditionColumns(*rung->condition) == 4,
"wire-cell replacement must split the wire around the new contact");

const std::vector<ControlLogic> before_invalid =
project_service.project().controlLogics;
require(service.replaceWireColumnWithCondition(
logic_id,
rung_id,
rung->condition->children.front().id,
1,
contact(2))
.error == LogicEditorError::InvalidOperation,
"an out-of-range wire-cell offset must be rejected");
require(project_service.project().controlLogics.size() == before_invalid.size()
&& conditionColumns(*service.findRung(
logic_id, rung_id)->condition) == 4,
"a rejected wire-cell replacement must leave the network width unchanged");

const LogicEditorResult first_cell_rung = service.addRung(logic_id);
const LogicEditorResult first_cell_wire = service.appendWire(
logic_id, first_cell_rung.id, 4);
require(first_cell_rung.succeeded && first_cell_wire.succeeded
&& service.replaceWireColumnWithCondition(
logic_id,
first_cell_rung.id,
first_cell_wire.id,
0,
contact(10))
.succeeded,
"the first cell of a multi-column wire must be replaceable");
const LadderRung *first_cell = service.findRung(logic_id, first_cell_rung.id);
require(first_cell != nullptr && first_cell->condition.has_value()
&& first_cell->condition->kind == ConditionExpressionKind::Series
&& first_cell->condition->children.size() == 2U
&& first_cell->condition->children.front().kind
== ConditionExpressionKind::Node
&& first_cell->condition->children.back().kind
== ConditionExpressionKind::Wire
&& first_cell->condition->children.back().wire->columnSpan == 3,
"first-cell replacement must preserve the trailing wire cells");
require(service.undo().succeeded,
"wire-cell replacement must be one undoable edit");
const LadderRung *undone = service.findRung(logic_id, first_cell_rung.id);
require(undone != nullptr && undone->condition.has_value()
&& undone->condition->kind == ConditionExpressionKind::Wire
&& undone->condition->wire->columnSpan == 4,
"undo must restore the original unsplit wire");
require(service.redo().succeeded,
"wire-cell replacement must be redoable");
const LadderRung *redone = service.findRung(logic_id, first_cell_rung.id);
require(redone != nullptr && redone->condition.has_value()
&& redone->condition->kind == ConditionExpressionKind::Series
&& conditionColumns(*redone->condition) == 4,
"redo must restore the split wire without changing its width");
const std::string redone_trailing_wire_id =
redone->condition->children.back().id;

const LogicEditorResult last_cell_rung = service.addRung(logic_id);
const LogicEditorResult last_cell_wire = service.appendWire(
logic_id, last_cell_rung.id, 4);
require(last_cell_rung.succeeded && last_cell_wire.succeeded
&& service.replaceWireColumnWithCondition(
logic_id,
last_cell_rung.id,
last_cell_wire.id,
3,
contact(11))
.succeeded,
"the last cell of a multi-column wire must be replaceable");
const LadderRung *last_cell = service.findRung(logic_id, last_cell_rung.id);
require(last_cell != nullptr && last_cell->condition.has_value()
&& last_cell->condition->kind == ConditionExpressionKind::Series
&& last_cell->condition->children.size() == 2U
&& last_cell->condition->children.front().kind
== ConditionExpressionKind::Wire
&& last_cell->condition->children.front().wire->columnSpan == 3
&& last_cell->condition->children.back().kind
== ConditionExpressionKind::Node,
"last-cell replacement must preserve the leading wire cells");

const LogicEditorResult single_cell_rung = service.addRung(logic_id);
const LogicEditorResult single_cell_wire = service.appendWire(
logic_id, single_cell_rung.id, 1);
require(single_cell_rung.succeeded && single_cell_wire.succeeded
&& service.replaceWireColumnWithCondition(
logic_id,
single_cell_rung.id,
single_cell_wire.id,
0,
contact(12))
.succeeded,
"a one-column wire must use the same grid-cell replacement API");
const LadderRung *single_cell = service.findRung(
logic_id, single_cell_rung.id);
require(single_cell != nullptr && single_cell->condition.has_value()
&& single_cell->condition->kind == ConditionExpressionKind::Node,
"one-column wire replacement must normalize directly to a condition node");

require(service.replaceWireColumnWithCondition(
logic_id,
first_cell_rung.id,
redone_trailing_wire_id,
0,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, 13}, CoilMode::Set})
.error == LogicEditorError::InvalidNode,
"wire grid cells must reject output instructions");
require(service.replaceWireColumnWithCondition(
logic_id,
first_cell_rung.id,
"missing-wire",
0,
contact(13))
.error == LogicEditorError::ExpressionNotFound,
"wire grid replacement must reject an unknown wire without mutation");
}

void testSequentialConditionInsertionConsumesFollowingWire()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);
const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10);
require(wire.succeeded,
"sequential wire replacement must start with a full-width wire");

LogicEditorResult inserted = service.replaceWireColumnWithCondition(
logic_id, rung_id, wire.id, 0, contact(0));
require(inserted.succeeded,
"the first condition must replace the first full-wire cell");
std::string selected_node_id = inserted.id;
for (int address = 1; address < 10; ++address)
{
inserted = service.insertConditionAfter(
logic_id, rung_id, selected_node_id, contact(address));
require(inserted.succeeded,
"continuous condition insertion must consume the following wire cell");
selected_node_id = inserted.id;

const LadderRung *rung = service.findRung(logic_id, rung_id);
std::vector<const LogicNode *> nodes;
collectConditionNodes(*rung->condition, &nodes);
require(rung != nullptr && rung->condition.has_value()
&& conditionColumns(*rung->condition) == 10
&& nodes.size() == static_cast<std::size_t>(address + 1)
&& wireColumns(*rung->condition) == 9 - address,
"each continuous insertion must preserve width while consuming one wire cell");
}

const LadderRung *full = service.findRung(logic_id, rung_id);
require(full != nullptr && full->condition.has_value()
&& conditionColumns(*full->condition) == 10
&& wireColumns(*full->condition) == 0,
"ten continuous insertions must replace the entire wire without expanding it");
require(service.insertConditionAfter(
logic_id, rung_id, selected_node_id, contact(10))
.error == LogicEditorError::InvalidOperation,
"the eleventh condition must still be rejected after all wire cells are consumed");
require(conditionColumns(*service.findRung(
logic_id, rung_id)->condition) == 10,
"a rejected eleventh insertion must leave the full network unchanged");

require(service.undo().succeeded,
"a failed eleventh insertion must not displace the last successful undo step");
const LadderRung *undone = service.findRung(logic_id, rung_id);
std::vector<const LogicNode *> undone_nodes;
collectConditionNodes(*undone->condition, &undone_nodes);
require(undone_nodes.size() == 9U
&& wireColumns(*undone->condition) == 1
&& conditionColumns(*undone->condition) == 10,
"undo must restore nine contacts followed by one wire cell");
require(service.redo().succeeded,
"the final wire-consuming insertion must be redoable");
const LadderRung *redone = service.findRung(logic_id, rung_id);
std::vector<const LogicNode *> redone_nodes;
collectConditionNodes(*redone->condition, &redone_nodes);
require(redone_nodes.size() == 10U
&& wireColumns(*redone->condition) == 0
&& conditionColumns(*redone->condition) == 10,
"redo must restore all ten contacts without wire cells");
}

void testLogicLifecycleAndOrdering()
{
TestProjectStorage storage;
@@ -165,6 +669,147 @@ void testLogicLifecycleAndOrdering()
"the project must retain at least one control logic module");
}

void testEdgeTimerNodesAndRungComments()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string rung_id = service.firstRungId(logic_id);

const LogicEditorResult rising = service.appendCondition(
logic_id,
rung_id,
EdgeContactNodeConfig{
RegisterAddress{RegisterArea::M, 3}, EdgeMode::Rising});
require(rising.succeeded && rising.id == "edge-1",
"the editor must create rising edge nodes with a stable prefix");
const LogicEditorResult timer = service.addParallelBranch(
logic_id,
rung_id,
{rising.id},
TimerContactNodeConfig{TimerAddress{2}, ContactMode::NormallyOpen});
require(timer.succeeded && timer.id == "timer-contact-1",
"the editor must insert T contacts as conditions");
const LogicEditorResult ton = service.setOutput(
logic_id, rung_id, TonNodeConfig{TimerAddress{2}, 500});
require(ton.succeeded && ton.id == "ton-1",
"the editor must create TON outputs with a stable prefix");

require(service.updateNodeConfig(
logic_id,
rising.id,
EdgeContactNodeConfig{
RegisterAddress{RegisterArea::M, 4}, EdgeMode::Falling})
.succeeded,
"the editor must apply edge mode and M address properties");
require(service.updateNodeConfig(
logic_id,
timer.id,
TimerContactNodeConfig{TimerAddress{4}, ContactMode::NormallyClosed})
.succeeded,
"the editor must apply T contact mode and address properties");
require(service.updateNodeConfig(
logic_id,
ton.id,
TonNodeConfig{TimerAddress{4}, 750})
.succeeded,
"the editor must apply TON preset properties");
require(service.updateRungComment(logic_id, rung_id, "延时启动网络").succeeded,
"the editor must update a network comment by stable rung id");
require(!service.updateRungComment(
logic_id, rung_id, "第一行\n第二行").succeeded,
"the editor must reject multiline network comments");
require(!service.updateRungComment(
logic_id,
rung_id,
std::string(ProjectLimits::kMaximumRungCommentBytes + 1U, 'a'))
.succeeded,
"the editor must reject oversized network comments");

const LadderRung *rung = service.findRung(logic_id, rung_id);
require(rung != nullptr && rung->comment == "延时启动网络"
&& std::get<EdgeContactNodeConfig>(
rung->condition->children.front().node->config).mode
== EdgeMode::Falling
&& std::get<TimerContactNodeConfig>(
rung->condition->children.at(1).node->config).address
== TimerAddress{4}
&& std::get<TonNodeConfig>(rung->output->config).presetMs == 750,
"edge, T contact, TON and rung comment updates must remain in the model");
}

void testHistoryAndAtomicBatchDelete()
{
TestProjectStorage storage;
ProjectService project_service(storage);
LogicEditorService service(project_service);
const std::string logic_id = service.ensureDefaultLogic().id;
const std::string first_rung_id = service.firstRungId(logic_id);
const LogicEditorResult first = service.appendCondition(
logic_id, first_rung_id, contact(0));
const LogicEditorResult second = service.appendCondition(
logic_id, first_rung_id, contact(1));
const LogicEditorResult second_rung = service.addRung(logic_id);
const LogicEditorResult third = service.appendCondition(
logic_id, second_rung.id, contact(2));
require(first.succeeded && second.succeeded && second_rung.succeeded
&& third.succeeded,
"nodes for history testing must be created");

service.clearHistory();
require(!service.removeNodes(logic_id, {first.id, "missing-node"}).succeeded,
"batch node deletion must validate every id before changing the logic");
require(service.findNode(logic_id, first.id) != nullptr
&& service.findNode(logic_id, third.id) != nullptr
&& !service.canUndo(),
"failed batch node deletion must be atomic and leave history unchanged");

require(service.removeNodes(logic_id, {first.id, third.id}).succeeded,
"valid nodes across multiple rungs must be deleted together");
require(service.findNode(logic_id, first.id) == nullptr
&& service.findNode(logic_id, third.id) == nullptr,
"all selected nodes must be removed by one batch operation");
require(service.undo().succeeded
&& service.findNode(logic_id, first.id) != nullptr
&& service.findNode(logic_id, third.id) != nullptr,
"logic undo must restore a cross-rung batch deletion");
require(service.redo().succeeded
&& service.findNode(logic_id, first.id) == nullptr
&& service.findNode(logic_id, third.id) == nullptr,
"logic redo must reapply a cross-rung batch deletion");

service.clearHistory();
const ControlLogic *logic = service.findLogic(logic_id);
require(logic != nullptr && service.setLogicEnabled(logic_id, logic->enabled).succeeded
&& !service.canUndo(),
"setting an unchanged logic state must not consume history");

service.clearHistory();
for (int index = 1; index <= 101; ++index)
{
require(service.updateRungComment(
logic_id, first_rung_id, "comment-" + std::to_string(index))
.succeeded,
"repeated valid rung edits must succeed");
}
int undo_count = 0;
while (service.undo().succeeded)
{
++undo_count;
}
require(undo_count == 100,
"logic history must retain exactly the configured 100 most recent steps");

require(service.redo().succeeded,
"logic redo must be available after an undo");
require(service.appendCondition(logic_id, first_rung_id, contact(5)).succeeded,
"a new logic edit must succeed after undo");
require(!service.canRedo(),
"a new logic edit must clear the redo history");
(void)second;
}

} // namespace

int main()
@@ -173,7 +818,16 @@ int main()
{
testStructuredEditingAndNormalization();
testRangeParallelInsertion();
testStructuredWireEditing();
testBatchDeleteAllNodesInParallelBranch();
testConditionColumnLimit();
testUnconditionalOutputEditing();
testColumnTargetedConditionInsertion();
testWireColumnReplacement();
testSequentialConditionInsertionConsumesFollowingWire();
testLogicLifecycleAndOrdering();
testEdgeTimerNodesAndRungComments();
testHistoryAndAtomicBatchDelete();
}
catch (const std::exception &error)
{


+ 18
- 21
app/tests/logic_editor_service_tests.pro 查看文件

@@ -1,27 +1,24 @@
TEMPLATE = app
TARGET = logic_editor_service_tests

CONFIG += console c++17 testcase warn_on
CONFIG -= app_bundle qt
include(pri/test_defaults.pri)
include(pri/test_layers.pri)

INCLUDEPATH += ../src
TARGET = logic_editor_service_tests
QT -= gui
CONFIG += testcase

SOURCES += \
logic_editor_service_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/register_repository.cpp \
../src/services/project_service.cpp \
../src/services/logic_editor_service.cpp
$$DOMAIN_REGISTER_SOURCES \
$$DOMAIN_HMI_SOURCES \
$$DOMAIN_LOGIC_SOURCES \
$$DOMAIN_PROJECT_SOURCES \
$$SERVICE_PROJECT_SOURCES \
$$SERVICE_LOGIC_SOURCES

HEADERS += \
../src/domain/register_address.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/hmi_model.h \
../src/domain/register_repository.h \
../src/domain/project_storage.h \
../src/services/project_service.h \
../src/services/logic_editor_service.h
$$DOMAIN_REGISTER_HEADERS \
$$DOMAIN_HMI_HEADERS \
$$DOMAIN_LOGIC_HEADERS \
$$DOMAIN_PROJECT_HEADERS \
$$SERVICE_PROJECT_HEADERS \
$$SERVICE_LOGIC_HEADERS \
$$TEST_SUPPORT_HEADERS

+ 1359
- 37
app/tests/main_window_tests.cpp
文件差異過大導致無法顯示
查看文件


+ 24
- 62
app/tests/main_window_tests.pro 查看文件

@@ -1,70 +1,32 @@
QT += widgets serialport testlib
include(pri/test_defaults.pri)
include(pri/test_layers.pri)

TEMPLATE = app
TARGET = main_window_tests

CONFIG += console c++17 warn_on
CONFIG -= app_bundle

INCLUDEPATH += ../src
QT += widgets serialport testlib

SOURCES += \
main_window_tests.cpp \
../src/ui/main_window.cpp \
../src/ui/plc_connection_dialog.cpp \
../src/ui/free_monitor_widget.cpp \
../src/ui/runtime_monitor_widget.cpp \
../src/ui/hmi_editor_widget.cpp \
../src/ui/logic_editor_widget.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/active_register_repository.cpp \
../src/domain/register_monitor_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/domain/runtime_state.cpp \
../src/services/project_service.cpp \
../src/services/hmi_editor_service.cpp \
../src/services/hmi_navigation_service.cpp \
../src/services/logic_editor_service.cpp \
../src/services/hmi_runtime_service.cpp \
../src/services/software_logic_executor.cpp \
../src/services/offline_simulation_service.cpp \
../src/services/runtime_mode_service.cpp \
../src/services/register_monitor_service.cpp

HEADERS += \
../src/ui/main_window.h \
../src/ui/plc_connection_dialog.h \
../src/ui/free_monitor_widget.h \
../src/ui/runtime_monitor_widget.h \
../src/ui/hmi_editor_widget.h \
../src/ui/logic_editor_widget.h \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/active_register_repository.h \
../src/domain/register_monitor_model.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/project_storage.h \
../src/domain/runtime_state.h \
../src/services/project_service.h \
../src/services/hmi_editor_service.h \
../src/services/hmi_navigation_service.h \
../src/services/logic_editor_service.h \
../src/services/hmi_runtime_service.h \
../src/services/software_logic_executor.h \
../src/services/offline_simulation_service.h \
../src/services/runtime_mode_service.h \
../src/services/register_monitor_service.h
$$UI_MAIN_SOURCES \
$$DOMAIN_ALL_SOURCES \
$$SERVICE_PROJECT_SOURCES \
$$SERVICE_ALARM_SOURCES \
$$SERVICE_HMI_SOURCES \
$$SERVICE_LOGIC_SOURCES \
$$SERVICE_OFFLINE_SOURCES \
$$SERVICE_RUNTIME_SOURCES \
$$SERVICE_MONITOR_SOURCES

HEADERS += \
../src/services/plc_communication_gateway.h
$$UI_MAIN_HEADERS \
$$DOMAIN_ALL_HEADERS \
$$SERVICE_PROJECT_HEADERS \
$$SERVICE_ALARM_HEADERS \
$$SERVICE_HMI_HEADERS \
$$SERVICE_LOGIC_HEADERS \
$$SERVICE_OFFLINE_HEADERS \
$$SERVICE_RUNTIME_HEADERS \
$$SERVICE_MONITOR_HEADERS \
../src/services/plc_communication_gateway.h \
$$TEST_SUPPORT_HEADERS

FORMS += \
../src/ui/main_window.ui \
../src/ui/plc_connection_dialog.ui \
../src/ui/free_monitor_widget.ui \
../src/ui/runtime_monitor_widget.ui
FORMS += $$UI_MAIN_FORMS

+ 507
- 8
app/tests/offline_simulation_service_tests.cpp 查看文件

@@ -3,6 +3,7 @@
#include "services/hmi_runtime_service.h"
#include "services/offline_simulation_service.h"
#include "services/software_logic_executor.h"
#include "support/test_support.h"

#include <QCoreApplication>

@@ -14,13 +15,7 @@

namespace {

void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}
using TestSupport::require;

LogicNode contact(const std::string &id, int address,
ContactMode mode = ContactMode::NormallyOpen)
@@ -28,6 +23,100 @@ LogicNode contact(const std::string &id, int address,
return {id, ContactNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true};
}

LogicNode edgeContact(const std::string &id, int address, EdgeMode mode)
{
return {id,
EdgeContactNodeConfig{
RegisterAddress{RegisterArea::M, address}, mode},
true};
}

LogicNode timerContact(
const std::string &id,
int timer_index,
ContactMode mode = ContactMode::NormallyOpen)
{
return {id, TimerContactNodeConfig{TimerAddress{timer_index}, mode}, true};
}

LogicNode counterContact(
const std::string &id,
int counter_index,
ContactMode mode = ContactMode::NormallyOpen)
{
return {id, CounterContactNodeConfig{CounterAddress{counter_index}, mode}, true};
}

LogicNode ton(const std::string &id, int timer_index, int preset_ms)
{
return {id, TonNodeConfig{TimerAddress{timer_index}, preset_ms}, true};
}

WordOperand constantOperand(std::int16_t value)
{
return {
WordOperandKind::Constant,
RegisterAddress{RegisterArea::D, 0},
value};
}

WordOperand registerOperand(int address)
{
return {
WordOperandKind::Register,
RegisterAddress{RegisterArea::D, address},
0};
}

LogicNode counter(
const std::string &id,
int counter_index,
CounterMode mode,
int current_address,
WordOperand preset,
int reset_address)
{
return {
id,
CounterNodeConfig{
CounterAddress{counter_index},
mode,
RegisterAddress{RegisterArea::D, current_address},
preset,
RegisterAddress{RegisterArea::M, reset_address}},
true};
}

LogicNode move(
const std::string &id,
WordOperand source,
int destination)
{
return {
id,
MoveNodeConfig{
source,
RegisterAddress{RegisterArea::D, destination}},
true};
}

LogicNode arithmetic(
const std::string &id,
ArithmeticOperation operation,
WordOperand left,
WordOperand right,
int destination)
{
return {
id,
ArithmeticNodeConfig{
operation,
left,
right,
RegisterAddress{RegisterArea::D, destination}},
true};
}

LogicNode comparison(const std::string &id, int address,
ComparisonOperator operation, std::int16_t value)
{
@@ -111,6 +200,14 @@ void writeWord(RegisterRepository &repository, int address, std::int16_t value)
"test word write must succeed");
}

std::int16_t readWord(RegisterRepository &repository, int address)
{
const WordReadResult result = repository.readWord(
RegisterAddress{RegisterArea::D, address});
require(result.succeeded, "test word read must succeed");
return result.value;
}

void testNestedSeriesParallelExpression()
{
VirtualRegisterRepository repository;
@@ -155,6 +252,66 @@ void testNestedSeriesParallelExpression()
require(readBit(repository, 10), "A branch must independently energize output");
}

void testUnconditionalCoil()
{
VirtualRegisterRepository repository;
SoftwareLogicExecutor executor;
LadderRung unconditional;
unconditional.id = "unconditional-rung";
unconditional.name = "unconditional-rung";
unconditional.output = coil("unconditional-coil", 10);
const ControlLogic program = logic({unconditional});

LogicTraceSnapshot trace;
require(executor.validate({program}).succeeded,
"an output-only network must pass runtime validation");
require(executor.executeScan({program}, repository, &trace).succeeded,
"an output-only network scan must succeed");
require(readBit(repository, 10),
"an output-only network must energize its coil as a constant-true rung");
require(trace.rungValues.at("unconditional-rung")
&& trace.nodePowerValues.at("unconditional-coil"),
"an unconditional rung must report energized power flow");
}

void testWirePassThroughAndPowerTrace()
{
VirtualRegisterRepository repository;
SoftwareLogicExecutor executor;
ConditionExpression root;
root.id = "wire-series";
root.kind = ConditionExpressionKind::Series;
root.children = {
ConditionExpression::fromNode(contact("wire-input", 0)),
ConditionExpression::fromWire("wire-segment", 2),
ConditionExpression::fromNode(contact("wire-output", 1))};
LadderRung wired_rung;
wired_rung.id = "wired-rung";
wired_rung.name = "wired-rung";
wired_rung.condition = root;
wired_rung.output = coil("wired-coil", 10);
const ControlLogic program = logic({wired_rung});

writeBit(repository, 1, true);
LogicTraceSnapshot trace;
require(executor.executeScan({program}, repository, &trace).succeeded,
"wire expression scan must succeed");
require(!readBit(repository, 10),
"a horizontal wire must not bypass a false upstream series contact");
require(trace.expressionValues.at("wire-segment")
&& !trace.expressionInputValues.at("wire-segment")
&& !trace.expressionPowerValues.at("wire-segment"),
"a wire must remain logically true without showing false upstream power");

writeBit(repository, 0, true);
require(executor.executeScan({program}, repository, &trace).succeeded,
"powered wire expression scan must succeed");
require(readBit(repository, 10)
&& trace.expressionInputValues.at("wire-segment")
&& trace.expressionPowerValues.at("wire-segment"),
"a powered horizontal wire must pass current to the downstream contact");
}

void testSeriesParallelContactsAndSequentialVisibility()
{
VirtualRegisterRepository repository;
@@ -260,11 +417,346 @@ void testMultipleLogicScanOrderAndTraceIsolation()
disabled_draft.name = "Draft";
disabled_draft.enabled = false;
disabled_draft.rungs.push_back(
{"rung-1", "Draft", std::nullopt, std::nullopt});
{"rung-1", "Draft", {}, std::nullopt, std::nullopt});
require(executor.validate({first, disabled_draft}).succeeded,
"a disabled incomplete logic module must not block offline execution");
}

void testEdgeContactsAreOneScanPulsesAndAreLogicScoped()
{
VirtualRegisterRepository repository;
SoftwareLogicExecutor executor;
const ControlLogic program = logic({
rung("rising-rung", {{edgeContact("rising", 0, EdgeMode::Rising)}},
coil("rising-output", 10)),
rung("falling-rung", {{edgeContact("falling", 1, EdgeMode::Falling)}},
coil("falling-output", 11))});

writeBit(repository, 0, true);
require(executor.executeScan({program}, repository).succeeded,
"the first high scan must evaluate rising edge input");
require(readBit(repository, 10), "rising edge must pulse on OFF to ON");
require(!readBit(repository, 11), "falling edge must stay off without ON to OFF");
require(executor.executeScan({program}, repository).succeeded,
"a repeated high scan must succeed");
require(!readBit(repository, 10), "rising edge must clear after one scan");

writeBit(repository, 1, true);
require(executor.executeScan({program}, repository).succeeded,
"the falling edge input must establish its previous high state");
writeBit(repository, 1, false);
require(executor.executeScan({program}, repository).succeeded,
"the first low scan after high must succeed");
require(readBit(repository, 11), "falling edge must pulse on ON to OFF");
require(executor.executeScan({program}, repository).succeeded,
"a repeated low scan must succeed");
require(!readBit(repository, 11), "falling edge must clear after one scan");

ControlLogic first = logic({
rung("shared-rung-first", {{edgeContact("shared-edge", 2, EdgeMode::Rising)}},
coil("shared-output-first", 20))});
first.id = "logic-first";
first.name = "First";
ControlLogic second = logic({
rung("shared-rung-second", {{edgeContact("shared-edge", 3, EdgeMode::Rising)}},
coil("shared-output-second", 21))});
second.id = "logic-second";
second.name = "Second";
writeBit(repository, 2, true);
require(executor.executeScan({first, second}, repository).succeeded,
"logic-scoped edge history must support duplicate node ids");
writeBit(repository, 3, true);
require(executor.executeScan({first, second}, repository).succeeded,
"each duplicate edge node must update its own history");
require(!readBit(repository, 20) && readBit(repository, 21),
"duplicate node ids in different logic modules must not share edge history");

executor.resetRuntime();
require(executor.executeScan({first, second}, repository).succeeded,
"resetting runtime must clear edge history");
require(readBit(repository, 20) && readBit(repository, 21),
"a restarted runtime must treat current ON inputs as fresh rising edges");
}

void testTonUsesElapsedTimeAndResetsAsNonRetentive()
{
VirtualRegisterRepository repository;
SoftwareLogicExecutor executor;
const ControlLogic program = logic({
rung("ton-rung", {{contact("ton-input", 0)}}, ton("ton-0", 0, 100)),
rung("ton-feedback-rung", {{timerContact("timer-done", 0)}},
coil("ton-output", 30))});
const auto start = SoftwareLogicExecutor::TimePoint{};
LogicTraceSnapshot trace;

require(executor.executeScanAt({program}, repository, start, &trace).succeeded,
"an inactive TON must scan successfully");
require(!readBit(repository, 30)
&& trace.tonValues.at("ton-0").elapsedMs == 0
&& !trace.tonValues.at("ton-0").done,
"an inactive TON must have Q false and ET zero");

writeBit(repository, 0, true);
require(executor.executeScanAt({program}, repository, start, &trace).succeeded,
"the first active TON scan must start timing");
require(!readBit(repository, 30) && trace.tonValues.at("ton-0").elapsedMs == 0,
"TON must not complete on its starting scan");
require(executor.executeScanAt(
{program}, repository,
start + std::chrono::milliseconds{99}, &trace)
.succeeded,
"TON must use elapsed monotonic time before the preset");
require(!readBit(repository, 30)
&& trace.tonValues.at("ton-0").elapsedMs == 99,
"TON must remain false before PT");
require(executor.executeScanAt(
{program}, repository,
start + std::chrono::milliseconds{100}, &trace)
.succeeded,
"TON must complete at its preset time");
require(readBit(repository, 30)
&& trace.tonValues.at("ton-0").done,
"the T contact must observe TON Q in a later network of the same scan");

writeBit(repository, 0, false);
require(executor.executeScanAt(
{program}, repository,
start + std::chrono::milliseconds{150}, &trace)
.succeeded,
"disconnecting TON input must scan successfully");
require(!readBit(repository, 30)
&& trace.tonValues.at("ton-0").elapsedMs == 0
&& !trace.tonValues.at("ton-0").done,
"a non-retentive TON must reset ET and Q when input opens");

writeBit(repository, 0, true);
require(executor.executeScanAt(
{program}, repository,
start + std::chrono::milliseconds{200}, &trace)
.succeeded,
"TON must restart timing after an input reset");
require(!readBit(repository, 30), "TON restart must not retain the old done state");

executor.resetRuntime();
require(executor.executeScanAt(
{program}, repository,
start + std::chrono::milliseconds{1000}, &trace)
.succeeded,
"resetRuntime must clear TON state");
require(!readBit(repository, 30),
"a restarted runtime must start a currently active TON from zero");
}

void testMultipleTimersAndNetworkOrder()
{
VirtualRegisterRepository repository;
SoftwareLogicExecutor executor;
const ControlLogic independent = logic({
rung("ton-1-rung", {{contact("input-1", 1)}}, ton("ton-1", 1, 100)),
rung("ton-2-rung", {{contact("input-2", 2)}}, ton("ton-2", 2, 200))});
writeBit(repository, 1, true);
writeBit(repository, 2, true);
const auto start = SoftwareLogicExecutor::TimePoint{};
LogicTraceSnapshot trace;
require(executor.executeScanAt({independent}, repository, start, &trace).succeeded,
"multiple TON resources must start independently");
require(executor.executeScanAt(
{independent}, repository,
start + std::chrono::milliseconds{100}, &trace)
.succeeded,
"the first independent TON must reach PT without completing the second");
require(trace.tonValues.at("ton-1").done
&& !trace.tonValues.at("ton-2").done,
"different T resources must retain independent elapsed time");

VirtualRegisterRepository ordered_repository;
SoftwareLogicExecutor ordered_executor;
const ControlLogic ordered = logic({
rung("feedback-before-ton", {{timerContact("early-timer", 4)}},
coil("early-output", 40)),
rung("ton-after-feedback", {{contact("late-input", 4)}}, ton("ton-4", 4, 100))});
writeBit(ordered_repository, 4, true);
require(ordered_executor.executeScanAt(
{ordered}, ordered_repository, start, nullptr)
.succeeded,
"network order test must start TON after its earlier T contact");
require(!readBit(ordered_repository, 40),
"an earlier T contact must see the previous scan state");
require(ordered_executor.executeScanAt(
{ordered}, ordered_repository,
start + std::chrono::milliseconds{100}, nullptr)
.succeeded,
"network order test must complete TON on the next scan");
require(!readBit(ordered_repository, 40),
"the earlier T contact must remain false on the completion scan");
require(ordered_executor.executeScanAt(
{ordered}, ordered_repository,
start + std::chrono::milliseconds{101}, nullptr)
.succeeded,
"network order test must expose TON Q on the following scan");
require(readBit(ordered_repository, 40),
"a later scan must observe the completed TON through T contact");
}

void testCountersUseRisingEdgesAndExternalDValues()
{
VirtualRegisterRepository repository;
SoftwareLogicExecutor executor;
const ControlLogic up_program = logic({
rung(
"ctu-rung",
{{contact("count-input", 0)}},
counter(
"ctu-0",
0,
CounterMode::Up,
0,
constantOperand(3),
1)),
rung(
"ctu-done-rung",
{{counterContact("ctu-done", 0)}},
coil("ctu-done-output", 2))});
LogicTraceSnapshot trace;

require(executor.executeScan({up_program}, repository, &trace).succeeded,
"an inactive CTU must scan successfully");
require(readWord(repository, 0) == 0 && !readBit(repository, 2),
"an inactive CTU must keep CV zero and Q false");

writeBit(repository, 0, true);
require(executor.executeScan({up_program}, repository, &trace).succeeded,
"the first CTU rising edge must scan successfully");
require(readWord(repository, 0) == 1,
"CTU must increment the external D current value on a rising edge");
require(executor.executeScan({up_program}, repository, &trace).succeeded,
"a held CTU input must scan successfully");
require(readWord(repository, 0) == 1,
"a held CTU input must not count every scan");

for (int expected = 2; expected <= 3; ++expected)
{
writeBit(repository, 0, false);
require(executor.executeScan({up_program}, repository).succeeded,
"CTU falling preparation scan must succeed");
writeBit(repository, 0, true);
require(executor.executeScan({up_program}, repository, &trace).succeeded,
"CTU repeated rising edge must succeed");
require(readWord(repository, 0) == expected,
"CTU must increment exactly once for each rising edge");
}
require(readBit(repository, 2)
&& trace.counterValues.at("ctu-0").done,
"the C contact must observe CTU completion in a later network");

writeBit(repository, 1, true);
require(executor.executeScan({up_program}, repository, &trace).succeeded,
"CTU reset must scan successfully");
require(readWord(repository, 0) == 0 && !readBit(repository, 2),
"CTU reset must clear CV and the C completion state");

executor.resetRuntime();
repository.clear();
const ControlLogic down_program = logic({
rung(
"ctd-rung",
{{contact("down-input", 3)}},
counter(
"ctd-1",
1,
CounterMode::Down,
1,
constantOperand(2),
4)),
rung(
"ctd-done-rung",
{{counterContact("ctd-done", 1)}},
coil("ctd-done-output", 5))});
writeBit(repository, 4, true);
require(executor.executeScan({down_program}, repository).succeeded,
"CTD load reset must scan successfully");
require(readWord(repository, 1) == 2,
"CTD reset must load PV into the external D current value");
writeBit(repository, 4, false);
for (int expected = 1; expected >= 0; --expected)
{
writeBit(repository, 3, false);
require(executor.executeScan({down_program}, repository).succeeded,
"CTD falling preparation scan must succeed");
writeBit(repository, 3, true);
require(executor.executeScan({down_program}, repository).succeeded,
"CTD rising edge must scan successfully");
require(readWord(repository, 1) == expected,
"CTD must decrement exactly once for each rising edge");
}
require(readBit(repository, 5),
"CTD completion contact must turn on when CV reaches zero");
}

void testMoveAndSaturatingArithmetic()
{
VirtualRegisterRepository repository;
SoftwareLogicExecutor executor;
const ControlLogic program = logic({
rung(
"move-rung",
{{contact("execute", 0)}},
move("move", constantOperand(7), 10)),
rung(
"add-rung",
{{contact("execute-add", 0)}},
arithmetic(
"add",
ArithmeticOperation::Add,
registerOperand(10),
constantOperand(32767),
11)),
rung(
"sub-rung",
{{contact("execute-sub", 0)}},
arithmetic(
"sub",
ArithmeticOperation::Subtract,
constantOperand(-32768),
constantOperand(1),
12))});
LogicTraceSnapshot trace;

require(executor.executeScan({program}, repository, &trace).succeeded,
"inactive data instructions must scan successfully");
require(readWord(repository, 10) == 0,
"MOVE must not write while its rung is false");

writeBit(repository, 0, true);
require(executor.executeScan({program}, repository, &trace).succeeded,
"active data instructions must scan successfully");
require(readWord(repository, 10) == 7,
"MOVE must copy a constant into the destination D register");
require(readWord(repository, 11) == 32767
&& trace.wordValues.at("add").overflow,
"ADD must saturate positive overflow and expose the overflow trace");
require(readWord(repository, 12) == -32768
&& trace.wordValues.at("sub").overflow,
"SUB must saturate negative overflow and expose the overflow trace");

const ControlLogic repeated_add = logic({
rung(
"repeated-add-rung",
{{contact("repeated-add-input", 1)}},
arithmetic(
"repeated-add",
ArithmeticOperation::Add,
registerOperand(20),
constantOperand(1),
20))});
writeBit(repository, 1, true);
require(executor.executeScan({repeated_add}, repository).succeeded
&& executor.executeScan({repeated_add}, repository).succeeded,
"ADD must execute on every scan while its rung remains true");
require(readWord(repository, 20) == 2,
"ADD with the same source and destination must accumulate by scan");
}

void testSetResetPairOnSameAddress()
{
VirtualRegisterRepository repository;
@@ -425,9 +917,16 @@ int main(int argc, char *argv[])
{
testSeriesParallelContactsAndSequentialVisibility();
testNestedSeriesParallelExpression();
testUnconditionalCoil();
testWirePassThroughAndPowerTrace();
testAllComparisons();
testSetResetAndDisabledLogic();
testMultipleLogicScanOrderAndTraceIsolation();
testEdgeContactsAreOneScanPulsesAndAreLogicScoped();
testTonUsesElapsedTimeAndResetsAsNonRetentive();
testMultipleTimersAndNetworkOrder();
testCountersUseRisingEdgesAndExternalDValues();
testMoveAndSaturatingArithmetic();
testSetResetPairOnSameAddress();
testConflictingCoilsAreRejected();
testHmiSimulationClosedLoop();


+ 13
- 20
app/tests/offline_simulation_service_tests.pro 查看文件

@@ -1,29 +1,22 @@
QT += core
QT -= gui
include(pri/test_defaults.pri)
include(pri/test_layers.pri)

TEMPLATE = app
TARGET = offline_simulation_service_tests

CONFIG += console c++17 warn_on
CONFIG -= app_bundle

INCLUDEPATH += ../src
QT += core
QT -= gui

SOURCES += \
offline_simulation_service_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
$$DOMAIN_REGISTER_SOURCES \
$$DOMAIN_HMI_SOURCES \
$$DOMAIN_LOGIC_SOURCES \
../src/services/hmi_runtime_service.cpp \
../src/services/software_logic_executor.cpp \
../src/services/offline_simulation_service.cpp
$$SERVICE_OFFLINE_SOURCES

HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
$$DOMAIN_REGISTER_HEADERS \
$$DOMAIN_HMI_HEADERS \
$$DOMAIN_LOGIC_HEADERS \
../src/services/hmi_runtime_service.h \
../src/services/software_logic_executor.h \
../src/services/offline_simulation_service.h
$$SERVICE_OFFLINE_HEADERS \
$$TEST_SUPPORT_HEADERS

+ 97
- 0
app/tests/performance_tests.cpp 查看文件

@@ -0,0 +1,97 @@
#include "domain/control_logic_model.h"
#include "domain/register_repository.h"
#include "services/software_logic_executor.h"

#include <QTest>

#include <string>
#include <vector>

namespace
{

LogicNode contact(const std::string &id, int address)
{
return {id,
ContactNodeConfig{
RegisterAddress{RegisterArea::M, address},
ContactMode::NormallyOpen},
true};
}

LogicNode coil(const std::string &id, int address)
{
return {id,
CoilNodeConfig{
RegisterAddress{RegisterArea::M, address},
CoilMode::Normal},
true};
}

ControlLogic makeLogic(int index)
{
LadderRung rung;
rung.id = "rung-" + std::to_string(index);
rung.name = rung.id;
rung.condition = ConditionExpression::fromNode(
contact("contact-" + std::to_string(index), index));
rung.output = coil("coil-" + std::to_string(index), index + 100);
return {"logic-" + std::to_string(index),
"logic-" + std::to_string(index),
{rung},
true};
}

class PerformanceTests final : public QObject
{
Q_OBJECT

private slots:
void softwareExecutor_scansRepresentativeProject()
{
std::vector<ControlLogic> logics;
logics.reserve(100);
for (int index = 0; index < 100; ++index)
{
logics.push_back(makeLogic(index));
}

SoftwareLogicExecutor executor;
VirtualRegisterRepository repository;
QVERIFY(executor.validate(logics).succeeded);
QVERIFY(executor.executeScan(logics, repository).succeeded);

QBENCHMARK
{
executor.executeScan(logics, repository);
}
}

void virtualRepository_handlesBoundaryWorkload()
{
VirtualRegisterRepository repository;
for (int index = 0; index <= RegisterAddress::kMaximumIndex; ++index)
{
QVERIFY(repository.writeBit(
{RegisterArea::M, index}, (index % 2) == 0)
.succeeded);
QVERIFY(repository.readBit({RegisterArea::M, index}).succeeded);
}

QBENCHMARK
{
for (int index = 0; index <= RegisterAddress::kMaximumIndex; ++index)
{
repository.writeBit(
{RegisterArea::M, index}, (index % 2) == 0);
repository.readBit({RegisterArea::M, index});
}
}
}
};

} // namespace

QTEST_APPLESS_MAIN(PerformanceTests)

#include "performance_tests.moc"

+ 16
- 0
app/tests/performance_tests.pro 查看文件

@@ -0,0 +1,16 @@
include(pri/test_defaults.pri)
include(pri/test_layers.pri)

TARGET = performance_tests
QT += testlib

SOURCES += \
performance_tests.cpp \
$$DOMAIN_REGISTER_SOURCES \
$$DOMAIN_LOGIC_SOURCES \
../src/services/software_logic_executor.cpp

HEADERS += \
$$DOMAIN_REGISTER_HEADERS \
$$DOMAIN_LOGIC_HEADERS \
../src/services/software_logic_executor.h

+ 72
- 21
app/tests/plc_runtime_tests.cpp 查看文件

@@ -1,6 +1,9 @@
#include "domain/active_register_repository.h"
#include "domain/project_limits.h"
#include "domain/project_storage.h"
#include "infrastructure/plc_communication_error_classifier.h"
#include "infrastructure/plc_communication_service.h"
#include "support/test_support.h"
#include "infrastructure/plc_register_repository.h"
#include "services/offline_simulation_service.h"
#include "services/plc_communication_gateway.h"
@@ -14,19 +17,7 @@

namespace {

class TestProjectStorage final : public ProjectStorage
{
public:
ProjectSaveResult save(const Project &, const std::string &) override
{
return {true, ProjectStorageError::None, {}};
}

ProjectLoadResult load(const std::string &) override
{
return {false, {}, ProjectStorageError::FileReadFailed, {}};
}
};
using TestProjectStorage = TestSupport::InMemoryProjectStorage;

class FakePlcGateway final : public PlcCommunicationGateway
{
@@ -61,9 +52,11 @@ public:
}
}

void setPollAddresses(const std::vector<RegisterAddress> &addresses) override
PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses) override
{
poll_addresses = addresses;
return {true, {}};
}

PlcConnectionState state() const override { return connection_state; }
@@ -126,13 +119,7 @@ public:
std::function<void(const std::string &)> error_reported;
};

void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}
using TestSupport::require;

void testPlcCacheAndWriteForwarding()
{
@@ -311,6 +298,68 @@ void testPlcCommunicationErrorClassification()
"protocol errors must remain distinct from timeouts and disconnections");
}

void testPlcConfigurationBoundaries()
{
PlcSerialConfiguration configuration;
configuration.portName = "COM3";
require(validatePlcSerialConfiguration(configuration).succeeded,
"the standard COM3 9600 8E1 configuration must be accepted");

configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress;
require(validatePlcSerialConfiguration(configuration).succeeded,
"PLC station 247 must be accepted");
configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress + 1;
require(!validatePlcSerialConfiguration(configuration).succeeded,
"PLC station 248 must be rejected");

configuration.serverAddress = 1;
configuration.retries = ProjectLimits::kMaximumRetries;
require(validatePlcSerialConfiguration(configuration).succeeded,
"five retries must be accepted");
configuration.retries = ProjectLimits::kMaximumRetries + 1;
require(!validatePlcSerialConfiguration(configuration).succeeded,
"six retries must be rejected");

configuration.retries = 0;
configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs - 1;
require(!validatePlcSerialConfiguration(configuration).succeeded,
"a response timeout below 100 ms must be rejected");
configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs;
require(validatePlcSerialConfiguration(configuration).succeeded,
"a response timeout of 100 ms must be accepted");
}

void testPlcPollQuantityBoundaries()
{
PlcRegisterRepository repository;
PlcCommunicationService service(repository);

std::vector<RegisterAddress> too_many_addresses;
for (int index = 0; index <= RegisterAddress::kMaximumIndex; ++index)
{
too_many_addresses.push_back({RegisterArea::M, index});
}
too_many_addresses.push_back({RegisterArea::D, 0});
require(!service.setPollAddresses(too_many_addresses).succeeded,
"more than 1024 poll addresses must be rejected");

std::vector<RegisterAddress> too_many_blocks;
for (int index = 0; index < 65; ++index)
{
too_many_blocks.push_back({RegisterArea::M, index * 2});
}
require(!service.setPollAddresses(too_many_blocks).succeeded,
"more than 64 Modbus read blocks must be rejected");

std::vector<RegisterAddress> maximum_contiguous_block;
for (int index = 0; index < ProjectLimits::kMaximumModbusReadCount; ++index)
{
maximum_contiguous_block.push_back({RegisterArea::M, index});
}
require(service.setPollAddresses(maximum_contiguous_block).succeeded,
"a contiguous Modbus read block of 120 values must be accepted");
}

} // namespace

int main()
@@ -321,6 +370,8 @@ int main()
testRuntimeRepositorySwitchingAndDisconnect();
testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect();
testPlcCommunicationErrorClassification();
testPlcConfigurationBoundaries();
testPlcPollQuantityBoundaries();
}
catch (const std::exception &error)
{


+ 15
- 35
app/tests/plc_runtime_tests.pro 查看文件

@@ -1,44 +1,24 @@
QT += core serialbus
include(pri/test_defaults.pri)
include(pri/test_layers.pri)

TEMPLATE = app
TARGET = plc_runtime_tests
QT += core serialbus serialport

CONFIG += console c++17 testcase warn_on
CONFIG -= app_bundle

INCLUDEPATH += ../src
CONFIG += testcase

SOURCES += \
plc_runtime_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/active_register_repository.cpp \
../src/domain/register_monitor_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/domain/runtime_state.cpp \
../src/services/project_service.cpp \
../src/services/software_logic_executor.cpp \
../src/services/offline_simulation_service.cpp \
../src/services/runtime_mode_service.cpp \
../src/infrastructure/plc_communication_error_classifier.cpp \
../src/infrastructure/plc_register_repository.cpp
$$DOMAIN_ALL_SOURCES \
$$SERVICE_PROJECT_SOURCES \
$$SERVICE_OFFLINE_SOURCES \
$$SERVICE_RUNTIME_SOURCES \
$$INFRASTRUCTURE_PLC_SOURCES

HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/active_register_repository.h \
../src/domain/register_monitor_model.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/project_storage.h \
../src/domain/runtime_state.h \
../src/services/project_service.h \
../src/services/software_logic_executor.h \
../src/services/offline_simulation_service.h \
../src/services/runtime_mode_service.h \
$$DOMAIN_ALL_HEADERS \
$$SERVICE_PROJECT_HEADERS \
$$SERVICE_OFFLINE_HEADERS \
$$SERVICE_RUNTIME_HEADERS \
$$INFRASTRUCTURE_PLC_HEADERS \
../src/services/plc_communication_gateway.h \
../src/infrastructure/plc_communication_error_classifier.h \
../src/infrastructure/plc_register_repository.h
$$TEST_SUPPORT_HEADERS

+ 8
- 0
app/tests/pri/test_defaults.pri 查看文件

@@ -0,0 +1,8 @@
# 所有测试目标共享的 qmake 基础配置
TEMPLATE = app
CONFIG += console c++17 warn_on
CONFIG -= app_bundle

INCLUDEPATH += $$PWD/../../src $$PWD/..

TEST_SUPPORT_HEADERS = $$PWD/../support/test_support.h

+ 175
- 0
app/tests/pri/test_layers.pri 查看文件

@@ -0,0 +1,175 @@
# 测试按生产分层选择源码,避免每个 .pro 重复维护同一批路径

DOMAIN_REGISTER_SOURCES = \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp

DOMAIN_REGISTER_HEADERS = \
../src/domain/register_address.h \
../src/domain/register_repository.h

DOMAIN_HMI_SOURCES = \
../src/domain/hmi_model.cpp \
../src/domain/hmi_control_registry.cpp

DOMAIN_HMI_HEADERS = \
../src/domain/hmi_model.h \
../src/domain/hmi_control_registry.h

DOMAIN_LOGIC_SOURCES = \
../src/domain/control_logic_model.cpp

DOMAIN_LOGIC_HEADERS = \
../src/domain/control_logic_model.h

DOMAIN_PROJECT_SOURCES = \
../src/domain/alarm_model.cpp \
../src/domain/project_model.cpp

DOMAIN_PROJECT_HEADERS = \
../src/domain/alarm_model.h \
../src/domain/project_model.h \
../src/domain/project_limits.h \
../src/domain/project_storage.h

DOMAIN_RUNTIME_SOURCES = \
../src/domain/runtime_state.cpp

DOMAIN_RUNTIME_HEADERS = \
../src/domain/runtime_state.h

DOMAIN_MONITOR_SOURCES = \
../src/domain/active_register_repository.cpp \
../src/domain/register_monitor_model.cpp

DOMAIN_MONITOR_HEADERS = \
../src/domain/active_register_repository.h \
../src/domain/register_monitor_model.h

DOMAIN_ALL_SOURCES = \
$$DOMAIN_REGISTER_SOURCES \
$$DOMAIN_HMI_SOURCES \
$$DOMAIN_LOGIC_SOURCES \
$$DOMAIN_PROJECT_SOURCES \
$$DOMAIN_RUNTIME_SOURCES \
$$DOMAIN_MONITOR_SOURCES

DOMAIN_ALL_HEADERS = \
$$DOMAIN_REGISTER_HEADERS \
$$DOMAIN_HMI_HEADERS \
$$DOMAIN_LOGIC_HEADERS \
$$DOMAIN_PROJECT_HEADERS \
$$DOMAIN_RUNTIME_HEADERS \
$$DOMAIN_MONITOR_HEADERS

SERVICE_PROJECT_SOURCES = \
../src/services/project_service.cpp

SERVICE_PROJECT_HEADERS = \
../src/services/project_service.h

SERVICE_ALARM_SOURCES = \
../src/services/alarm_editor_service.cpp \
../src/services/alarm_service.cpp

SERVICE_ALARM_HEADERS = \
../src/services/alarm_editor_service.h \
../src/services/alarm_service.h

SERVICE_HMI_SOURCES = \
../src/services/hmi_editor_service.cpp \
../src/services/hmi_navigation_service.cpp \
../src/services/hmi_runtime_service.cpp

SERVICE_HMI_HEADERS = \
../src/services/editor_history.h \
../src/services/hmi_editor_service.h \
../src/services/hmi_navigation_service.h \
../src/services/hmi_runtime_service.h

SERVICE_LOGIC_SOURCES = \
../src/services/logic_editor_service.cpp

SERVICE_LOGIC_HEADERS = \
../src/services/editor_history.h \
../src/services/logic_editor_service.h

SERVICE_OFFLINE_SOURCES = \
../src/services/software_logic_executor.cpp \
../src/services/offline_simulation_service.cpp

SERVICE_OFFLINE_HEADERS = \
../src/services/software_logic_executor.h \
../src/services/offline_simulation_service.h

SERVICE_RUNTIME_SOURCES = \
../src/services/runtime_mode_service.cpp

SERVICE_RUNTIME_HEADERS = \
../src/services/runtime_mode_service.h

SERVICE_MONITOR_SOURCES = \
../src/services/register_monitor_service.cpp \
../src/services/register_comment_service.cpp

SERVICE_MONITOR_HEADERS = \
../src/services/register_monitor_service.h \
../src/services/register_comment_service.h

INFRASTRUCTURE_JSON_SOURCES = \
../src/infrastructure/json_project_storage.cpp

INFRASTRUCTURE_JSON_HEADERS = \
../src/infrastructure/json_project_storage.h

INFRASTRUCTURE_PLC_SOURCES = \
../src/infrastructure/plc_communication_error_classifier.cpp \
../src/infrastructure/plc_register_repository.cpp \
../src/infrastructure/plc_communication_service.cpp

INFRASTRUCTURE_PLC_HEADERS = \
../src/infrastructure/plc_communication_error_classifier.h \
../src/infrastructure/plc_register_repository.h \
../src/infrastructure/plc_communication_service.h

UI_MAIN_SOURCES = \
../src/ui/main_window.cpp \
../src/ui/logic_instruction_dialog.cpp \
../src/ui/alarm_configuration_dialog.cpp \
../src/ui/register_comment_dialog.cpp \
../src/ui/plc_connection_dialog.cpp \
../src/ui/free_monitor_widget.cpp \
../src/ui/runtime_monitor_window.cpp \
../src/ui/runtime_monitor_widget.cpp \
../src/ui/toolbar_icon_factory.cpp \
../src/ui/project_workspace_controller.cpp \
../src/ui/property_panel_controller.cpp \
../src/ui/runtime_panel_controller.cpp \
../src/ui/hmi_editor_widget.cpp \
../src/ui/logic_editor_widget.cpp

UI_MAIN_HEADERS = \
../src/ui/main_window.h \
../src/ui/logic_instruction_dialog.h \
../src/ui/alarm_configuration_dialog.h \
../src/ui/register_comment_dialog.h \
../src/ui/plc_connection_dialog.h \
../src/ui/free_monitor_widget.h \
../src/ui/runtime_monitor_window.h \
../src/ui/runtime_monitor_widget.h \
../src/ui/toolbar_icon_factory.h \
../src/ui/project_workspace_controller.h \
../src/ui/property_panel_controller.h \
../src/ui/runtime_panel_controller.h \
../src/ui/hmi_editor_widget.h \
../src/ui/logic_editor_widget.h

UI_MAIN_FORMS = \
../src/ui/main_window.ui \
../src/ui/logic_instruction_dialog.ui \
../src/ui/alarm_configuration_dialog.ui \
../src/ui/register_comment_dialog.ui \
../src/ui/plc_connection_dialog.ui \
../src/ui/free_monitor_widget.ui \
../src/ui/runtime_monitor_window.ui \
../src/ui/runtime_monitor_widget.ui

+ 660
- 13
app/tests/project_management_tests.cpp 查看文件

@@ -1,6 +1,10 @@
#include "domain/project_storage.h"
#include "domain/hmi_model.h"
#include "infrastructure/json_project_storage.h"
#include "support/test_support.h"
#include "services/register_comment_service.h"
#include "services/project_service.h"
#include "domain/project_limits.h"

#include <QFile>
#include <QJsonArray>
@@ -12,16 +16,11 @@
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>

namespace {

void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}
using TestSupport::require;

Project makeExampleProject()
{
@@ -34,6 +33,10 @@ Project makeExampleProject()
start_button.binding = RegisterAddress{RegisterArea::M, 0};
start_button.buttonOperation = HmiButtonOperation::SetOn;
start_button.properties.emplace("color", "green");
start_button.properties.emplace(HmiAppearanceProperty::kTextColor, "#E53935");
start_button.properties.emplace(HmiAppearanceProperty::kFontSize, "18");
start_button.properties.emplace(HmiAppearanceProperty::kFontBold, "true");
start_button.properties.emplace(HmiAppearanceProperty::kFontItalic, "false");

HmiControl running_indicator;
running_indicator.id = "running-indicator";
@@ -72,6 +75,20 @@ Project makeExampleProject()
settings_jump.text = "Settings";
settings_jump.pageJump = HmiPageJumpConfig{"settings-page"};

HmiControl alarm_list;
alarm_list.id = "alarm-list";
alarm_list.type = HmiControlType::AlarmList;
alarm_list.bounds = {10, 200, 360, 180};
alarm_list.text = "Alarms";

HmiControl progress_bar;
progress_bar.id = "progress-bar";
progress_bar.type = HmiControlType::ProgressBar;
progress_bar.bounds = {400, 20, 240, 36};
progress_bar.text = "Completion";
progress_bar.binding = RegisterAddress{RegisterArea::D, 4};
progress_bar.progressBar = HmiProgressBarConfig{-20, 80, true};

HmiPage page;
page.id = "main-page";
page.name = "Main";
@@ -81,6 +98,8 @@ Project makeExampleProject()
page.controls.push_back(target_input);
page.controls.push_back(title_label);
page.controls.push_back(settings_jump);
page.controls.push_back(alarm_list);
page.controls.push_back(progress_bar);

HmiPage settings_page;
settings_page.id = "settings-page";
@@ -118,6 +137,7 @@ Project makeExampleProject()
LadderRung rung;
rung.id = "rung-1";
rung.name = "Network 1";
rung.comment = "启动条件与温度检查";
ConditionExpression parallel;
parallel.id = "parallel-start";
parallel.kind = ConditionExpressionKind::Parallel;
@@ -129,23 +149,186 @@ Project makeExampleProject()
series.kind = ConditionExpressionKind::Series;
series.children = {
std::move(parallel),
ConditionExpression::fromWire("start-wire", 2),
ConditionExpression::fromNode(compare)};
rung.condition = std::move(series);
rung.output = coil;
logic.rungs.push_back(rung);

LogicNode rising_edge;
rising_edge.id = "rising-edge";
rising_edge.config = EdgeContactNodeConfig{
RegisterAddress{RegisterArea::M, 4}, EdgeMode::Rising};
LogicNode ton_output;
ton_output.id = "ton-output";
ton_output.config = TonNodeConfig{TimerAddress{7}, 2500};
LadderRung ton_rung;
ton_rung.id = "ton-rung";
ton_rung.name = "TON network";
ton_rung.comment = "延时启动";
ton_rung.condition = ConditionExpression::fromNode(rising_edge);
ton_rung.output = ton_output;
logic.rungs.push_back(ton_rung);

LogicNode falling_edge;
falling_edge.id = "falling-edge";
falling_edge.config = EdgeContactNodeConfig{
RegisterAddress{RegisterArea::M, 5}, EdgeMode::Falling};
LogicNode timer_contact;
timer_contact.id = "timer-contact";
timer_contact.config = TimerContactNodeConfig{
TimerAddress{7}, ContactMode::NormallyOpen};
LogicNode timer_coil;
timer_coil.id = "timer-coil";
timer_coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 6}, CoilMode::Normal};
LadderRung timer_rung;
timer_rung.id = "timer-rung";
timer_rung.name = "Timer feedback";
timer_rung.comment = "定时器完成反馈";
ConditionExpression timer_series;
timer_series.id = "timer-series";
timer_series.kind = ConditionExpressionKind::Series;
timer_series.children = {
ConditionExpression::fromNode(falling_edge),
ConditionExpression::fromNode(timer_contact)};
timer_rung.condition = std::move(timer_series);
timer_rung.output = timer_coil;
logic.rungs.push_back(timer_rung);

LogicNode counter_input;
counter_input.id = "counter-input";
counter_input.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 7}, ContactMode::NormallyOpen};
LogicNode counter_output;
counter_output.id = "counter-output";
counter_output.config = CounterNodeConfig{
CounterAddress{3},
CounterMode::Up,
RegisterAddress{RegisterArea::D, 10},
WordOperand{
WordOperandKind::Register,
RegisterAddress{RegisterArea::D, 11},
0},
RegisterAddress{RegisterArea::M, 8}};
LadderRung counter_rung;
counter_rung.id = "counter-rung";
counter_rung.name = "Counter network";
counter_rung.comment = "计数器上升沿计数";
counter_rung.condition = ConditionExpression::fromNode(counter_input);
counter_rung.output = counter_output;
logic.rungs.push_back(counter_rung);

LogicNode counter_contact;
counter_contact.id = "counter-contact";
counter_contact.config = CounterContactNodeConfig{
CounterAddress{3}, ContactMode::NormallyOpen};
LogicNode counter_coil;
counter_coil.id = "counter-coil";
counter_coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 9}, CoilMode::Normal};
LadderRung counter_feedback_rung;
counter_feedback_rung.id = "counter-feedback-rung";
counter_feedback_rung.name = "Counter feedback";
counter_feedback_rung.condition = ConditionExpression::fromNode(counter_contact);
counter_feedback_rung.output = counter_coil;
logic.rungs.push_back(counter_feedback_rung);

const auto addDataRung = [&logic](
const std::string &rung_id,
const std::string &input_id,
int input_address,
LogicNode output)
{
LogicNode input;
input.id = input_id;
input.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, input_address},
ContactMode::NormallyOpen};
LadderRung data_rung;
data_rung.id = rung_id;
data_rung.name = rung_id;
data_rung.condition = ConditionExpression::fromNode(input);
data_rung.output = std::move(output);
logic.rungs.push_back(std::move(data_rung));
};
addDataRung(
"move-rung",
"move-input",
20,
LogicNode{
"move-output",
MoveNodeConfig{
WordOperand{
WordOperandKind::Constant,
RegisterAddress{RegisterArea::D, 0},
25},
RegisterAddress{RegisterArea::D, 20}},
true});
addDataRung(
"add-rung",
"add-input",
21,
LogicNode{
"add-output",
ArithmeticNodeConfig{
ArithmeticOperation::Add,
WordOperand{
WordOperandKind::Register,
RegisterAddress{RegisterArea::D, 20},
0},
WordOperand{
WordOperandKind::Constant,
RegisterAddress{RegisterArea::D, 0},
1},
RegisterAddress{RegisterArea::D, 21}},
true});
addDataRung(
"sub-rung",
"sub-input",
22,
LogicNode{
"sub-output",
ArithmeticNodeConfig{
ArithmeticOperation::Subtract,
WordOperand{
WordOperandKind::Register,
RegisterAddress{RegisterArea::D, 21},
0},
WordOperand{
WordOperandKind::Constant,
RegisterAddress{RegisterArea::D, 0},
1},
RegisterAddress{RegisterArea::D, 22}},
true});

Project project;
project.metadata = {"example-project", "Example project", "1.0"};
project.hmiPages.push_back(page);
project.hmiPages.push_back(settings_page);
project.initialHmiPageId = page.id;
project.alarmDefinitions.push_back(
{"alarm-emergency",
RegisterAddress{RegisterArea::M, 10},
AlarmCondition::MOn,
0,
"Emergency stop"});
project.alarmDefinitions.push_back(
{"alarm-temperature",
RegisterAddress{RegisterArea::D, 2},
AlarmCondition::DHigh,
80,
"Temperature high"});
project.registerComments = {
{RegisterAddress{RegisterArea::M, 0}, "启动按钮"},
{RegisterAddress{RegisterArea::D, 2}, "当前温度"}};
project.controlLogics.push_back(logic);
ControlLogic draft_logic;
draft_logic.id = "draft-logic";
draft_logic.name = "Draft logic";
draft_logic.enabled = false;
draft_logic.rungs.push_back(
{"rung-1", "Draft network", std::nullopt, std::nullopt});
{"rung-1", "Draft network", {}, std::nullopt, std::nullopt});
project.controlLogics.push_back(draft_logic);
return project;
}
@@ -188,6 +371,41 @@ void testEmptyProjectRoundTrip()
require(service.project().hmiPages.empty(), "empty project must have no HMI pages");
require(service.project().controlLogics.empty(),
"empty project must have no control logics");
require(service.project().alarmDefinitions.empty(),
"empty project must have no alarm definitions");
}

void testUnconditionalOutputRoundTrip()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

JsonProjectStorage storage;
ProjectService service(storage);
require(service.createNewProject("Unconditional output").succeeded,
"unconditional output project creation must succeed");
LadderRung rung;
rung.id = "unconditional-rung";
rung.name = "Unconditional rung";
LogicNode coil;
coil.id = "unconditional-coil";
coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal};
rung.output = coil;
service.editProject().controlLogics.push_back(
ControlLogic{"logic-1", "Logic 1", {rung}, true});
require(service.project().validateForRunning(),
"an output-only project must be runnable before saving");

const QString path = directory.filePath("unconditional-output.json");
require(service.saveAs(path.toStdString()).succeeded,
"an output-only project must save successfully");
require(service.load(path.toStdString()).succeeded,
"an output-only project must load successfully");
const LadderRung &loaded = service.project().controlLogics.front().rungs.front();
require(!loaded.condition.has_value() && loaded.output.has_value()
&& loaded.validateForRunning(),
"JSON round trip must preserve unconditional output semantics");
}

void testExampleProjectRoundTrip()
@@ -205,6 +423,17 @@ void testExampleProjectRoundTrip()
const QString invalid_operation_path = directory.filePath("invalid-operation.json");
const QString missing_initial_path = directory.filePath("missing-initial.json");
const QString missing_target_path = directory.filePath("missing-target.json");
const QString missing_progress_range_path = directory.filePath(
"missing-progress-range.json");
const QString missing_alarms_path = directory.filePath("missing-alarms.json");
const QString missing_register_comments_path = directory.filePath(
"missing-register-comments.json");
const QString missing_rung_comment_path = directory.filePath(
"missing-rung-comment.json");
const QString multiline_register_comment_path = directory.filePath(
"multiline-register-comment.json");
const QString multiline_rung_comment_path = directory.filePath(
"multiline-rung-comment.json");
require(service.saveAs(first_path.toStdString()).succeeded,
"example project save must succeed");
const QByteArray saved_json = readBytes(first_path);
@@ -218,8 +447,26 @@ void testExampleProjectRoundTrip()
require(saved_json.contains("\"formatVersion\": \"1.0\"")
&& saved_json.contains("\"buttonOperation\": \"setOn\"")
&& saved_json.contains("\"initialHmiPageId\": \"main-page\"")
&& saved_json.contains("\"targetPageId\": \"settings-page\""),
"version 1.0 projects must persist the current multi-page schema");
&& saved_json.contains("\"targetPageId\": \"settings-page\"")
&& saved_json.contains("\"alarmDefinitions\"")
&& saved_json.contains("\"registerComments\"")
&& saved_json.contains("\"text\": \"启动按钮\"")
&& saved_json.contains("\"type\": \"edgeContact\"")
&& saved_json.contains("\"type\": \"timerContact\"")
&& saved_json.contains("\"type\": \"ton\"")
&& saved_json.contains("\"type\": \"counterContact\"")
&& saved_json.contains("\"type\": \"counter\"")
&& saved_json.contains("\"type\": \"move\"")
&& saved_json.contains("\"type\": \"arithmetic\"")
&& saved_json.contains("\"operation\": \"add\"")
&& saved_json.contains("\"operation\": \"subtract\"")
&& saved_json.contains("\"comment\": \"启动条件与温度检查\"")
&& saved_json.contains("\"type\": \"alarmList\"")
&& saved_json.contains("\"type\": \"progressBar\"")
&& saved_json.contains("\"minimumValue\": -20")
&& saved_json.contains("\"maximumValue\": 80")
&& saved_json.contains("\"showValue\": true"),
"version 1.0 projects must persist pages and alarm definitions");
require(!saved_json.contains("\"stages\"")
&& !saved_json.contains("\"branches\""),
"current project format must not contain the removed stage model");
@@ -234,8 +481,8 @@ void testExampleProjectRoundTrip()
require(project.hmiPages.size() == 2, "HMI page count must survive round trip");
require(project.initialHmiPageId == "main-page",
"the initial HMI page id must survive round trip");
require(project.hmiPages.front().controls.size() == 6,
"register, Label and PageJump controls must survive round trip");
require(project.hmiPages.front().controls.size() == 8,
"register, navigation, AlarmList and ProgressBar controls must survive round trip");
require(project.hmiPages.front().controls.front().binding->area()
== RegisterArea::M,
"HMI M binding must survive round trip");
@@ -244,6 +491,15 @@ void testExampleProjectRoundTrip()
"HMI button operation must survive round trip");
require(project.hmiPages.front().controls.front().properties.at("color") == "green",
"HMI properties must survive round trip");
require(project.hmiPages.front().controls.front().properties.at(
HmiAppearanceProperty::kTextColor) == "#E53935"
&& project.hmiPages.front().controls.front().properties.at(
HmiAppearanceProperty::kFontSize) == "18"
&& project.hmiPages.front().controls.front().properties.at(
HmiAppearanceProperty::kFontBold) == "true"
&& project.hmiPages.front().controls.front().properties.at(
HmiAppearanceProperty::kFontItalic) == "false",
"HMI appearance properties must survive round trip");
require(project.hmiPages.front().controls.at(1).type == HmiControlType::Indicator,
"indicator control type must survive round trip");
require(project.hmiPages.front().controls.at(2).binding->area() == RegisterArea::D,
@@ -254,11 +510,33 @@ void testExampleProjectRoundTrip()
&& project.hmiPages.front().controls.at(5).pageJump->targetPageId
== "settings-page",
"Label and PageJump typed data must survive round trip");
require(project.hmiPages.front().controls.at(6).type
== HmiControlType::AlarmList,
"AlarmList control type must survive round trip");
require(project.hmiPages.front().controls.at(7).type
== HmiControlType::ProgressBar
&& project.hmiPages.front().controls.at(7).progressBar.has_value()
&& project.hmiPages.front().controls.at(7).progressBar->minimumValue == -20
&& project.hmiPages.front().controls.at(7).progressBar->maximumValue == 80
&& project.hmiPages.front().controls.at(7).progressBar->showValue,
"ProgressBar configuration must survive round trip");
require(project.alarmDefinitions.size() == 2
&& project.alarmDefinitions.front().condition
== AlarmCondition::MOn
&& project.alarmDefinitions.at(1).threshold == 80,
"M and D alarm definitions must survive round trip");
require(project.registerComments.size() == 2
&& project.registerComments.front().address.area() == RegisterArea::M
&& project.registerComments.front().text == "启动按钮"
&& project.registerComments.at(1).address.area() == RegisterArea::D,
"M/D register comments must survive round trip");
require(project.controlLogics.size() == 2,
"control logic count must survive round trip");
require(!project.controlLogics.front().enabled,
"control logic enabled state must survive round trip");
const LadderRung &rung = project.controlLogics.front().rungs.front();
require(rung.comment == "启动条件与温度检查",
"rung comments must survive round trip");
require(rung.condition.has_value()
&& rung.condition->kind == ConditionExpressionKind::Series,
"series root expression must survive round trip");
@@ -266,11 +544,67 @@ void testExampleProjectRoundTrip()
== ConditionExpressionKind::Parallel
&& rung.condition->children.front().children.size() == 2U,
"parallel expression branches must survive round trip");
require(rung.condition->children.at(1).kind == ConditionExpressionKind::Wire
&& rung.condition->children.at(1).wire->columnSpan == 2,
"horizontal wire span must survive JSON round trip");
require(rung.output.has_value(),
"ladder output must survive round trip");

const LadderRung &ton_rung = project.controlLogics.front().rungs.at(1);
require(ton_rung.output.has_value()
&& std::holds_alternative<TonNodeConfig>(ton_rung.output->config)
&& std::get<TonNodeConfig>(ton_rung.output->config).address
== TimerAddress{7}
&& std::get<TonNodeConfig>(ton_rung.output->config).presetMs == 2500,
"TON timer address and preset must survive round trip");
require(std::holds_alternative<EdgeContactNodeConfig>(
ton_rung.condition->node->config)
&& std::get<EdgeContactNodeConfig>(ton_rung.condition->node->config).mode
== EdgeMode::Rising,
"rising edge configuration must survive round trip");
const LadderRung &timer_rung = project.controlLogics.front().rungs.at(2);
require(timer_rung.comment == "定时器完成反馈"
&& std::holds_alternative<TimerContactNodeConfig>(
timer_rung.condition->children.at(1).node->config)
&& std::get<TimerContactNodeConfig>(
timer_rung.condition->children.at(1).node->config).address
== TimerAddress{7}
&& std::holds_alternative<EdgeContactNodeConfig>(
timer_rung.condition->children.front().node->config)
&& std::get<EdgeContactNodeConfig>(
timer_rung.condition->children.front().node->config).mode
== EdgeMode::Falling,
"falling edge and T contact configurations must survive round trip");

const LadderRung &counter_rung = project.controlLogics.front().rungs.at(3);
const auto &counter = std::get<CounterNodeConfig>(counter_rung.output->config);
require(counter.address == CounterAddress{3}
&& counter.currentValueAddress == RegisterAddress{RegisterArea::D, 10}
&& counter.preset.kind == WordOperandKind::Register
&& counter.preset.address == RegisterAddress{RegisterArea::D, 11}
&& counter.resetAddress == RegisterAddress{RegisterArea::M, 8},
"counter resource and external M/D addresses must survive round trip");
const LadderRung &counter_feedback = project.controlLogics.front().rungs.at(4);
require(std::holds_alternative<CounterContactNodeConfig>(
counter_feedback.condition->node->config)
&& std::get<CounterContactNodeConfig>(
counter_feedback.condition->node->config).address
== CounterAddress{3},
"counter contacts must survive round trip");
require(std::get<MoveNodeConfig>(
project.controlLogics.front().rungs.at(5).output->config)
.source.constant == 25,
"MOVE operands must survive round trip");
require(std::get<ArithmeticNodeConfig>(
project.controlLogics.front().rungs.at(6).output->config)
.operation == ArithmeticOperation::Add
&& std::get<ArithmeticNodeConfig>(
project.controlLogics.front().rungs.at(7).output->config)
.operation == ArithmeticOperation::Subtract,
"ADD and SUB operations must survive round trip");

const auto &compare = std::get<CompareNodeConfig>(
rung.condition->children.at(1).node->config);
rung.condition->children.at(2).node->config);
require(compare.address.index() == 2 && compare.value == 100,
"comparison configuration must survive round trip");
require(service.saveAs(second_path.toStdString()).succeeded,
@@ -297,6 +631,80 @@ void testExampleProjectRoundTrip()
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require initialHmiPageId without migration defaults");

QJsonObject missing_alarms = QJsonDocument::fromJson(saved_json).object();
missing_alarms.remove(QStringLiteral("alarmDefinitions"));
writeText(
missing_alarms_path,
QJsonDocument(missing_alarms).toJson(QJsonDocument::Compact));
missing_result = service.load(missing_alarms_path.toStdString());
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require alarmDefinitions without migration defaults");

QJsonObject missing_register_comments = QJsonDocument::fromJson(saved_json).object();
missing_register_comments.remove(QStringLiteral("registerComments"));
writeText(
missing_register_comments_path,
QJsonDocument(missing_register_comments).toJson(QJsonDocument::Compact));
missing_result = service.load(missing_register_comments_path.toStdString());
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require registerComments without migration defaults");

QJsonObject missing_rung_comment = QJsonDocument::fromJson(saved_json).object();
QJsonArray missing_comment_logics = missing_rung_comment.value(
QStringLiteral("controlLogics")).toArray();
QJsonObject first_logic = missing_comment_logics.at(0).toObject();
QJsonArray first_rungs = first_logic.value(QStringLiteral("rungs")).toArray();
QJsonObject first_rung = first_rungs.at(0).toObject();
first_rung.remove(QStringLiteral("comment"));
first_rungs.replace(0, first_rung);
first_logic.insert(QStringLiteral("rungs"), first_rungs);
missing_comment_logics.replace(0, first_logic);
missing_rung_comment.insert(QStringLiteral("controlLogics"), missing_comment_logics);
writeText(
missing_rung_comment_path,
QJsonDocument(missing_rung_comment).toJson(QJsonDocument::Compact));
missing_result = service.load(missing_rung_comment_path.toStdString());
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require rung comments without migration defaults");

QJsonObject multiline_register_comment = QJsonDocument::fromJson(
saved_json).object();
QJsonArray comments = multiline_register_comment.value(
QStringLiteral("registerComments")).toArray();
QJsonObject first_comment = comments.at(0).toObject();
first_comment.insert(QStringLiteral("text"), QStringLiteral("第一行\n第二行"));
comments.replace(0, first_comment);
multiline_register_comment.insert(QStringLiteral("registerComments"), comments);
writeText(
multiline_register_comment_path,
QJsonDocument(multiline_register_comment).toJson(QJsonDocument::Compact));
missing_result = service.load(multiline_register_comment_path.toStdString());
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::InvalidProject,
"JSON loading must reject multiline register comments");

QJsonObject multiline_rung_comment = QJsonDocument::fromJson(saved_json).object();
QJsonArray multiline_logics = multiline_rung_comment.value(
QStringLiteral("controlLogics")).toArray();
first_logic = multiline_logics.at(0).toObject();
first_rungs = first_logic.value(QStringLiteral("rungs")).toArray();
first_rung = first_rungs.at(0).toObject();
first_rung.insert(QStringLiteral("comment"), QStringLiteral("第一行\n第二行"));
first_rungs.replace(0, first_rung);
first_logic.insert(QStringLiteral("rungs"), first_rungs);
multiline_logics.replace(0, first_logic);
multiline_rung_comment.insert(QStringLiteral("controlLogics"), multiline_logics);
writeText(
multiline_rung_comment_path,
QJsonDocument(multiline_rung_comment).toJson(QJsonDocument::Compact));
missing_result = service.load(multiline_rung_comment_path.toStdString());
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::InvalidProject,
"JSON loading must reject multiline rung comments");

QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object();
QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray();
QJsonObject main_page = pages.at(0).toObject();
@@ -314,6 +722,24 @@ void testExampleProjectRoundTrip()
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require PageJump targetPageId");

QJsonObject missing_progress_range = QJsonDocument::fromJson(saved_json).object();
pages = missing_progress_range.value(QStringLiteral("hmiPages")).toArray();
main_page = pages.at(0).toObject();
controls = main_page.value(QStringLiteral("controls")).toArray();
QJsonObject progress = controls.at(7).toObject();
progress.remove(QStringLiteral("maximumValue"));
controls.replace(7, progress);
main_page.insert(QStringLiteral("controls"), controls);
pages.replace(0, main_page);
missing_progress_range.insert(QStringLiteral("hmiPages"), pages);
writeText(
missing_progress_range_path,
QJsonDocument(missing_progress_range).toJson(QJsonDocument::Compact));
missing_result = service.load(missing_progress_range_path.toStdString());
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require the ProgressBar value range");
}

void testInvalidFiles()
@@ -350,6 +776,178 @@ void testInvalidFiles()
"unsupported versions must be rejected");
}

void testQuantityFileLimits()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

JsonProjectStorage storage;
ProjectService service(storage);
require(service.createNewProject("Quantity limits").succeeded,
"the quantity-limit fixture project must be created");

const QString oversized_path = directory.filePath("oversized.json");
const QByteArray oversized(
static_cast<int>(ProjectLimits::kMaximumProjectFileBytes + 1U), ' ');
writeText(oversized_path, oversized);
const ProjectLoadResult oversized_result =
storage.load(oversized_path.toStdString());
require(!oversized_result.succeeded
&& oversized_result.error == ProjectStorageError::InvalidJson,
"a JSON file over 16 MiB must be rejected before parsing");

const QString valid_path = directory.filePath("fixture.json");
require(service.saveAs(valid_path.toStdString()).succeeded,
"the quantity-limit fixture must be saved");
QJsonObject root = QJsonDocument::fromJson(readBytes(valid_path)).object();
QJsonArray too_many_pages;
for (int index = 0;
index <= static_cast<int>(ProjectLimits::kMaximumHmiPages);
++index)
{
too_many_pages.append(QJsonObject{});
}
root.insert(QStringLiteral("hmiPages"), too_many_pages);
const QString too_many_pages_path = directory.filePath("too-many-pages.json");
writeText(
too_many_pages_path,
QJsonDocument(root).toJson(QJsonDocument::Compact));
const ProjectLoadResult too_many_pages_result =
storage.load(too_many_pages_path.toStdString());
require(!too_many_pages_result.succeeded
&& too_many_pages_result.error == ProjectStorageError::InvalidField,
"a JSON page array over 128 items must be rejected");

service.editProject() = makeExampleProject();
require(service.saveAs(valid_path.toStdString()).succeeded,
"the nested-expression fixture must be saved");
root = QJsonDocument::fromJson(readBytes(valid_path)).object();
QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray();
QJsonObject logic = logics.at(0).toObject();
QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray();
QJsonObject rung = rungs.at(0).toObject();
QJsonObject leaf = rung.value(QStringLiteral("condition")).toObject();
while (leaf.value(QStringLiteral("kind")).toString() != QStringLiteral("node"))
{
leaf = leaf.value(QStringLiteral("children")).toArray().at(0).toObject();
}
QJsonObject nested = leaf;
for (int depth = 1;
depth <= static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1;
++depth)
{
QJsonObject sibling = leaf;
sibling.insert(
QStringLiteral("id"),
QStringLiteral("json-depth-sibling-") + QString::number(depth));
QJsonArray children;
children.append(nested);
children.append(sibling);
QJsonObject parent;
parent.insert(
QStringLiteral("id"),
QStringLiteral("json-depth-") + QString::number(depth));
parent.insert(
QStringLiteral("kind"),
depth % 2 == 0 ? QStringLiteral("parallel") : QStringLiteral("series"));
parent.insert(QStringLiteral("children"), children);
nested = parent;
}
rung.insert(QStringLiteral("condition"), nested);
rungs[0] = rung;
logic.insert(QStringLiteral("rungs"), rungs);
logics[0] = logic;
root.insert(QStringLiteral("controlLogics"), logics);
const QString deep_path = directory.filePath("too-deep.json");
writeText(deep_path, QJsonDocument(root).toJson(QJsonDocument::Compact));
const ProjectLoadResult deep_result = storage.load(deep_path.toStdString());
require(!deep_result.succeeded
&& deep_result.error == ProjectStorageError::InvalidField,
"a condition expression deeper than 20 levels must be rejected while parsing");
}

void testHmiPageJsonBoundaries()
{
// JSON 入口必须和页面领域校验使用同一组业务边界
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

JsonProjectStorage storage;
ProjectService service(storage);
Project boundary_project = makeExampleProject();
boundary_project.hmiPages.front().controls.clear();
service.editProject() = std::move(boundary_project);

const QString fixture_path = directory.filePath("hmi-page-boundary-fixture.json");
require(service.saveAs(fixture_path.toStdString()).succeeded,
"the HMI boundary fixture must be saved");

const QJsonObject original_root =
QJsonDocument::fromJson(readBytes(fixture_path)).object();

const auto loadWithSize = [&](int width, int height, const QString &name)
{
QJsonObject root = original_root;
QJsonArray pages = root.value(QStringLiteral("hmiPages")).toArray();
QJsonObject page = pages.at(0).toObject();
page.insert(QStringLiteral("width"), width);
page.insert(QStringLiteral("height"), height);
pages.replace(0, page);
root.insert(QStringLiteral("hmiPages"), pages);

const QString path = directory.filePath(name);
writeText(path, QJsonDocument(root).toJson(QJsonDocument::Compact));
return storage.load(path.toStdString());
};

require(!loadWithSize(
ProjectLimits::kMinimumHmiPageWidth - 1,
ProjectLimits::kDefaultHmiPageHeight,
QStringLiteral("width-below-minimum.json"))
.succeeded,
"JSON must reject an HMI page width below 320");
require(!loadWithSize(
ProjectLimits::kMaximumHmiPageWidth + 1,
ProjectLimits::kDefaultHmiPageHeight,
QStringLiteral("width-above-maximum.json"))
.succeeded,
"JSON must reject an HMI page width above 1600");
require(!loadWithSize(
ProjectLimits::kDefaultHmiPageWidth,
ProjectLimits::kMinimumHmiPageHeight - 1,
QStringLiteral("height-below-minimum.json"))
.succeeded,
"JSON must reject an HMI page height below 200");
require(!loadWithSize(
ProjectLimits::kDefaultHmiPageWidth,
ProjectLimits::kMaximumHmiPageHeight + 1,
QStringLiteral("height-above-maximum.json"))
.succeeded,
"JSON must reject an HMI page height above 800");

const ProjectLoadResult minimum_result = loadWithSize(
ProjectLimits::kMinimumHmiPageWidth,
ProjectLimits::kMinimumHmiPageHeight,
QStringLiteral("minimum-size.json"));
require(minimum_result.succeeded
&& minimum_result.project.hmiPages.front().width
== ProjectLimits::kMinimumHmiPageWidth
&& minimum_result.project.hmiPages.front().height
== ProjectLimits::kMinimumHmiPageHeight,
"JSON must accept the minimum HMI page size 320x200");

const ProjectLoadResult maximum_result = loadWithSize(
ProjectLimits::kMaximumHmiPageWidth,
ProjectLimits::kMaximumHmiPageHeight,
QStringLiteral("maximum-size.json"));
require(maximum_result.succeeded
&& maximum_result.project.hmiPages.front().width
== ProjectLimits::kMaximumHmiPageWidth
&& maximum_result.project.hmiPages.front().height
== ProjectLimits::kMaximumHmiPageHeight,
"JSON must accept the maximum HMI page size 1600x800");
}

void testServiceStateAndSaveErrors()
{
// 保存路径和修改标记只在成功持久化后更新
@@ -380,6 +978,51 @@ void testServiceStateAndSaveErrors()
"failed save as must keep the previous current path");
}

void testRegisterCommentService()
{
JsonProjectStorage storage;
ProjectService project_service(storage);
RegisterCommentService service(project_service);

require(service.setComment(
RegisterAddress{RegisterArea::D, 3}, " 目标温度 ").succeeded,
"register comment service must create a trimmed D comment");
require(service.setComment(
RegisterAddress{RegisterArea::M, 2}, "启动信号").succeeded,
"register comment service must create an M comment");
require(service.comments().size() == 2U
&& service.comments().front().address.area() == RegisterArea::M
&& service.comments().front().address.index() == 2
&& service.comments().back().text == "目标温度",
"register comments must be sorted by area and address");
require(service.setComment(
RegisterAddress{RegisterArea::M, 2}, "新的启动信号").succeeded
&& service.comments().size() == 2U
&& service.findComment(RegisterAddress{RegisterArea::M, 2})->text
== "新的启动信号",
"setting an existing address must update instead of duplicating it");
require(!service.setComment(RegisterAddress{RegisterArea::M, 4}, " \t").succeeded,
"blank register comments must be rejected by the service");
require(service.setComment(
RegisterAddress{RegisterArea::M, 4},
std::string(ProjectLimits::kMaximumRegisterCommentBytes, 'a'))
.succeeded,
"a register comment at the byte limit must be accepted");
require(!service.setComment(
RegisterAddress{RegisterArea::M, 5},
std::string(ProjectLimits::kMaximumRegisterCommentBytes + 1U, 'a'))
.succeeded,
"an oversized register comment must be rejected by the service");
require(!service.setComment(
RegisterAddress{RegisterArea::M, 5}, "第一行\n第二行").succeeded,
"a multiline register comment must be rejected by the service");
require(service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded
&& service.findComment(RegisterAddress{RegisterArea::M, 2}) == nullptr,
"register comments must be removable");
require(!service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded,
"removing a missing register comment must fail explicitly");
}

} // namespace

int main()
@@ -388,8 +1031,12 @@ int main()
{
// 工程服务和 JSON 存储在同一测试进程中验证完整闭环
testEmptyProjectRoundTrip();
testUnconditionalOutputRoundTrip();
testExampleProjectRoundTrip();
testRegisterCommentService();
testInvalidFiles();
testQuantityFileLimits();
testHmiPageJsonBoundaries();
testServiceStateAndSaveErrors();
}
catch (const std::exception &error)


+ 13
- 24
app/tests/project_management_tests.pro 查看文件

@@ -1,31 +1,20 @@
TEMPLATE = app
TARGET = project_management_tests

CONFIG += console c++17 testcase warn_on
CONFIG -= app_bundle
include(pri/test_defaults.pri)
include(pri/test_layers.pri)

TARGET = project_management_tests
QT += core

INCLUDEPATH += ../src
CONFIG += testcase

SOURCES += \
project_management_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/domain/runtime_state.cpp \
../src/services/project_service.cpp \
../src/infrastructure/json_project_storage.cpp
$$DOMAIN_ALL_SOURCES \
$$SERVICE_PROJECT_SOURCES \
$$SERVICE_MONITOR_SOURCES \
$$INFRASTRUCTURE_JSON_SOURCES

HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/runtime_state.h \
../src/domain/project_storage.h \
../src/services/project_service.h \
../src/infrastructure/json_project_storage.h
$$DOMAIN_ALL_HEADERS \
$$SERVICE_PROJECT_HEADERS \
$$SERVICE_MONITOR_HEADERS \
$$INFRASTRUCTURE_JSON_HEADERS \
$$TEST_SUPPORT_HEADERS

+ 32
- 7
app/tests/register_monitor_service_tests.cpp 查看文件

@@ -2,6 +2,7 @@
#include "domain/register_monitor_model.h"
#include "domain/register_repository.h"
#include "services/register_monitor_service.h"
#include "support/test_support.h"

#include <iostream>
#include <stdexcept>
@@ -9,13 +10,7 @@

namespace {

void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}
using TestSupport::require;

void testRangeManagement()
{
@@ -76,6 +71,35 @@ void testSharedActiveRepositoryValues()
"valid cached values must be marked stale during a communication fault");
}

void testRegisterWrites()
{
VirtualRegisterRepository repository;
RegisterMonitorService service(repository);
const RegisterAddress m0{RegisterArea::M, 0};
const RegisterAddress d0{RegisterArea::D, 0};

const RegisterMonitorWriteResult bit_write = service.writeBit(m0, true);
require(bit_write.succeeded && repository.readBit(m0).value,
"monitor service must write M values through the active repository");

const RegisterMonitorWriteResult word_write = service.writeWord(d0, -123);
require(word_write.succeeded && repository.readWord(d0).value == -123,
"monitor service must write signed D values through the active repository");

const RegisterMonitorWriteResult wrong_bit = service.writeBit(d0, true);
require(!wrong_bit.succeeded && wrong_bit.error == RegisterError::AreaMismatch,
"monitor bit writes must reject D addresses");
const RegisterMonitorWriteResult wrong_word = service.writeWord(m0, 123);
require(!wrong_word.succeeded && wrong_word.error == RegisterError::AreaMismatch,
"monitor word writes must reject M addresses");

require(service.writeWord(d0, static_cast<std::int16_t>(-32768)).succeeded
&& repository.readWord(d0).value == -32768
&& service.writeWord(d0, static_cast<std::int16_t>(32767)).succeeded
&& repository.readWord(d0).value == 32767,
"monitor word writes must preserve signed 16-bit boundary values");
}

} // namespace

int main()
@@ -84,6 +108,7 @@ int main()
{
testRangeManagement();
testSharedActiveRepositoryValues();
testRegisterWrites();
}
catch (const std::exception &error)
{


+ 9
- 16
app/tests/register_monitor_service_tests.pro 查看文件

@@ -1,24 +1,17 @@
QT -= gui
include(pri/test_defaults.pri)
include(pri/test_layers.pri)

TEMPLATE = app
TARGET = register_monitor_service_tests

CONFIG += console c++17 warn_on
CONFIG -= app_bundle

INCLUDEPATH += ../src
QT -= gui

SOURCES += \
register_monitor_service_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/active_register_repository.cpp \
../src/domain/register_monitor_model.cpp \
$$DOMAIN_REGISTER_SOURCES \
$$DOMAIN_MONITOR_SOURCES \
../src/services/register_monitor_service.cpp

HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/active_register_repository.h \
../src/domain/register_monitor_model.h \
../src/services/register_monitor_service.h
$$DOMAIN_REGISTER_HEADERS \
$$DOMAIN_MONITOR_HEADERS \
../src/services/register_monitor_service.h \
$$TEST_SUPPORT_HEADERS

+ 158
- 21
app/tests/runtime_mode_service_tests.cpp 查看文件

@@ -4,6 +4,7 @@
#include "domain/active_register_repository.h"
#include "domain/project_storage.h"
#include "domain/register_repository.h"
#include "support/test_support.h"

#include <functional>
#include <iostream>
@@ -13,19 +14,7 @@

namespace {

class TestProjectStorage final : public ProjectStorage
{
public:
ProjectSaveResult save(const Project &, const std::string &) override
{
return {true, ProjectStorageError::None, {}};
}

ProjectLoadResult load(const std::string &) override
{
return {false, {}, ProjectStorageError::FileReadFailed, {}};
}
};
using TestProjectStorage = TestSupport::InMemoryProjectStorage;

class ReadyPlcGateway final : public PlcCommunicationGateway
{
@@ -46,7 +35,12 @@ public:
initial_read = false;
}

void setPollAddresses(const std::vector<RegisterAddress> &) override {}
PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses) override
{
poll_addresses = addresses;
return {true, {}};
}
PlcConnectionState state() const override { return connection_state; }
bool initialReadCompleted() const override { return initial_read; }
PlcCommunicationError lastErrorType() const override
@@ -76,6 +70,11 @@ public:
}
}

const std::vector<RegisterAddress> &pollAddresses() const
{
return poll_addresses;
}

private:
PlcConnectionState connection_state = PlcConnectionState::Disconnected;
bool initial_read = false;
@@ -84,15 +83,10 @@ private:
std::function<void(bool)> initial_read_changed;
std::function<void()> cache_updated;
std::function<void(const std::string &)> error_reported;
std::vector<RegisterAddress> poll_addresses;
};

void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}
using TestSupport::require;

void testModeTransitions()
{
@@ -108,6 +102,137 @@ void testModeTransitions()
service.configurePlc(
gateway, active_repository, virtual_repository, plc_repository);

Project &project = project_service.editProject();
project.alarmDefinitions.push_back(
{"alarm-m", {RegisterArea::M, 12}, AlarmCondition::MOn, 0, "M alarm"});
project.alarmDefinitions.push_back(
{"alarm-d", {RegisterArea::D, 34}, AlarmCondition::DHigh, 100, "D alarm"});
project.registerComments = {
{RegisterAddress{RegisterArea::M, 3999}, "只用于说明"},
{RegisterAddress{RegisterArea::D, 3999}, "只用于说明"}};

ControlLogic logic;
logic.id = "poll-logic";
logic.name = "Poll logic";
LogicNode edge;
edge.id = "poll-edge";
edge.config = EdgeContactNodeConfig{
RegisterAddress{RegisterArea::M, 20}, EdgeMode::Rising};
LogicNode edge_coil;
edge_coil.id = "poll-edge-coil";
edge_coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 21}, CoilMode::Normal};
LadderRung edge_rung;
edge_rung.id = "poll-edge-rung";
edge_rung.name = "Poll edge";
edge_rung.condition = ConditionExpression::fromNode(edge);
edge_rung.output = edge_coil;
logic.rungs.push_back(edge_rung);
LogicNode comparison;
comparison.id = "poll-comparison";
comparison.config = CompareNodeConfig{
RegisterAddress{RegisterArea::D, 35}, ComparisonOperator::GreaterThan, 0};
LogicNode comparison_coil;
comparison_coil.id = "poll-comparison-coil";
comparison_coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 22}, CoilMode::Normal};
LadderRung comparison_rung;
comparison_rung.id = "poll-comparison-rung";
comparison_rung.name = "Poll comparison";
comparison_rung.condition = ConditionExpression::fromNode(comparison);
comparison_rung.output = comparison_coil;
logic.rungs.push_back(comparison_rung);
LogicNode timer_input;
timer_input.id = "poll-timer-input";
timer_input.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 23}, ContactMode::NormallyOpen};
LogicNode ton;
ton.id = "poll-ton";
ton.config = TonNodeConfig{TimerAddress{4}, 100};
LadderRung ton_rung;
ton_rung.id = "poll-ton-rung";
ton_rung.name = "Poll TON";
ton_rung.condition = ConditionExpression::fromNode(timer_input);
ton_rung.output = ton;
logic.rungs.push_back(ton_rung);
LogicNode timer_contact;
timer_contact.id = "poll-timer-contact";
timer_contact.config = TimerContactNodeConfig{
TimerAddress{4}, ContactMode::NormallyOpen};
LogicNode timer_coil;
timer_coil.id = "poll-timer-coil";
timer_coil.config = CoilNodeConfig{
RegisterAddress{RegisterArea::M, 24}, CoilMode::Normal};
LadderRung timer_rung;
timer_rung.id = "poll-timer-rung";
timer_rung.name = "Poll timer contact";
timer_rung.condition = ConditionExpression::fromNode(timer_contact);
timer_rung.output = timer_coil;
logic.rungs.push_back(timer_rung);
LogicNode counter_input;
counter_input.id = "poll-counter-input";
counter_input.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 25}, ContactMode::NormallyOpen};
LogicNode counter_output;
counter_output.id = "poll-counter";
counter_output.config = CounterNodeConfig{
CounterAddress{2},
CounterMode::Up,
RegisterAddress{RegisterArea::D, 36},
WordOperand{
WordOperandKind::Register,
RegisterAddress{RegisterArea::D, 37},
0},
RegisterAddress{RegisterArea::M, 26}};
LadderRung counter_rung;
counter_rung.id = "poll-counter-rung";
counter_rung.name = "Poll counter";
counter_rung.condition = ConditionExpression::fromNode(counter_input);
counter_rung.output = counter_output;
logic.rungs.push_back(counter_rung);
LogicNode move_input;
move_input.id = "poll-move-input";
move_input.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 27}, ContactMode::NormallyOpen};
LogicNode move_output;
move_output.id = "poll-move";
move_output.config = MoveNodeConfig{
WordOperand{
WordOperandKind::Register,
RegisterAddress{RegisterArea::D, 38},
0},
RegisterAddress{RegisterArea::D, 39}};
LadderRung move_rung;
move_rung.id = "poll-move-rung";
move_rung.name = "Poll MOVE";
move_rung.condition = ConditionExpression::fromNode(move_input);
move_rung.output = move_output;
logic.rungs.push_back(move_rung);
LogicNode add_input;
add_input.id = "poll-add-input";
add_input.config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 28}, ContactMode::NormallyOpen};
LogicNode add_output;
add_output.id = "poll-add";
add_output.config = ArithmeticNodeConfig{
ArithmeticOperation::Add,
WordOperand{
WordOperandKind::Register,
RegisterAddress{RegisterArea::D, 40},
0},
WordOperand{
WordOperandKind::Constant,
RegisterAddress{RegisterArea::D, 0},
1},
RegisterAddress{RegisterArea::D, 41}};
LadderRung add_rung;
add_rung.id = "poll-add-rung";
add_rung.name = "Poll ADD";
add_rung.condition = ConditionExpression::fromNode(add_input);
add_rung.output = add_output;
logic.rungs.push_back(add_rung);
project.controlLogics.push_back(logic);

require(service.mode() == ApplicationMode::Editing,
"service must start in editing mode");
require(service.policy().allowsProjectEditing,
@@ -134,6 +259,18 @@ void testModeTransitions()
require(service.connectPlc(
{"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
"PLC connection must be established before its initial read can complete");
require(gateway.pollAddresses()
== std::vector<RegisterAddress>({
{RegisterArea::M, 12}, {RegisterArea::M, 20},
{RegisterArea::M, 21}, {RegisterArea::M, 22},
{RegisterArea::M, 23}, {RegisterArea::M, 24},
{RegisterArea::M, 25}, {RegisterArea::M, 26},
{RegisterArea::M, 27}, {RegisterArea::M, 28},
{RegisterArea::D, 34}, {RegisterArea::D, 35},
{RegisterArea::D, 36}, {RegisterArea::D, 37},
{RegisterArea::D, 38}, {RegisterArea::D, 39},
{RegisterArea::D, 40}, {RegisterArea::D, 41}}),
"all instruction M/D references must be polled while T/C resources and comments stay offline-only");
gateway.completeInitialRead();
require(service.initialPlcReadCompleted(),
"service must retain the initial PLC read state");


+ 12
- 32
app/tests/runtime_mode_service_tests.pro 查看文件

@@ -1,39 +1,19 @@
QT -= gui
include(pri/test_defaults.pri)
include(pri/test_layers.pri)

TEMPLATE = app
TARGET = runtime_mode_service_tests

CONFIG += console c++17 warn_on
CONFIG -= app_bundle

INCLUDEPATH += ../src
QT -= gui

SOURCES += \
runtime_mode_service_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/active_register_repository.cpp \
../src/domain/register_monitor_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/domain/runtime_state.cpp \
../src/services/project_service.cpp \
../src/services/software_logic_executor.cpp \
../src/services/offline_simulation_service.cpp \
../src/services/runtime_mode_service.cpp
$$DOMAIN_ALL_SOURCES \
$$SERVICE_PROJECT_SOURCES \
$$SERVICE_OFFLINE_SOURCES \
$$SERVICE_RUNTIME_SOURCES

HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/active_register_repository.h \
../src/domain/register_monitor_model.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/project_storage.h \
../src/domain/runtime_state.h \
../src/services/project_service.h \
../src/services/software_logic_executor.h \
../src/services/offline_simulation_service.h \
../src/services/runtime_mode_service.h
$$DOMAIN_ALL_HEADERS \
$$SERVICE_PROJECT_HEADERS \
$$SERVICE_OFFLINE_HEADERS \
$$SERVICE_RUNTIME_HEADERS \
$$TEST_SUPPORT_HEADERS

+ 34
- 0
app/tests/support/test_support.h 查看文件

@@ -0,0 +1,34 @@
#pragma once

#include "domain/project_storage.h"

#include <stdexcept>
#include <string>

namespace TestSupport
{

inline void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}

// 编辑服务测试只验证内存模型和服务契约,不应依赖真实文件系统
class InMemoryProjectStorage final : public ProjectStorage
{
public:
ProjectSaveResult save(const Project &, const std::string &) override
{
return {true, ProjectStorageError::None, {}};
}

ProjectLoadResult load(const std::string &) override
{
return {false, {}, ProjectStorageError::FileReadFailed, {}};
}
};

} // namespace TestSupport

+ 28
- 0
app/tests/tests.pro 查看文件

@@ -0,0 +1,28 @@
# 测试总入口:只负责编排目标,不把不同环境的测试揉成一个可执行文件
TEMPLATE = subdirs
CONFIG += ordered

SUBDIRS += \
domain \
alarm_service \
hmi_editor_service \
logic_editor_service \
offline_simulation \
project_management \
register_monitor \
runtime_mode \
plc_runtime \
main_window \
performance

domain.file = domain_tests.pro
alarm_service.file = alarm_service_tests.pro
hmi_editor_service.file = hmi_editor_service_tests.pro
logic_editor_service.file = logic_editor_service_tests.pro
offline_simulation.file = offline_simulation_service_tests.pro
project_management.file = project_management_tests.pro
register_monitor.file = register_monitor_service_tests.pro
runtime_mode.file = runtime_mode_service_tests.pro
plc_runtime.file = plc_runtime_tests.pro
main_window.file = main_window_tests.pro
performance.file = performance_tests.pro

部分文件因文件數量過多而無法顯示

Loading…
取消
儲存