diff --git a/.gitignore b/.gitignore index 7f5b708..dfa5ab5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ ui_*.h *.ilk *.exp *.manifest +*.autosave # Qt Creator local user settings *.pro.user @@ -63,3 +64,5 @@ Desktop.ini 设计方案书.doc /学习 /json +/代码 +/other_version diff --git a/app/src/domain/alarm_model.h b/app/src/domain/alarm_model.h index a5a2465..570456d 100644 --- a/app/src/domain/alarm_model.h +++ b/app/src/domain/alarm_model.h @@ -8,19 +8,19 @@ // 报警触发条件:M 位为 1,或 D 字高于/低于阈值 enum class AlarmCondition { - MOn, - DHigh, - DLow + MOn, // M 位为 1 时触发 + DHigh, // D 寄存器值高于阈值时触发 + DLow // D 寄存器值低于阈值时触发 }; // 可保存的报警定义;运行时 AlarmService 根据此定义生成报警记录 struct AlarmDefinition { - std::string id; - RegisterAddress address{RegisterArea::M, 0}; - AlarmCondition condition = AlarmCondition::MOn; - std::int16_t threshold = 0; - std::string message; + std::string id; // 报警定义的唯一 ID + RegisterAddress address{RegisterArea::M, 0}; // 报警监控的 M 或 D 地址 + AlarmCondition condition = AlarmCondition::MOn; // 报警触发条件 + std::int16_t threshold = 0; // D 地址比较时使用的阈值 + std::string message; // 报警触发后显示的提示文字 // 校验标识、地址区域、消息长度和条件之间的组合是否有效 bool validate(std::string *error = nullptr) const; diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp index e1b61d3..b678da4 100644 --- a/app/src/domain/control_logic_model.cpp +++ b/app/src/domain/control_logic_model.cpp @@ -604,6 +604,7 @@ bool LogicNode::isConfigured() const return configured; } +// 判断当前节点是不是条件触点类节点 bool LogicNode::isCondition() const { return std::holds_alternative(config) @@ -613,6 +614,7 @@ bool LogicNode::isCondition() const || std::holds_alternative(config); } +// 判断当前节点是不是输出执行类节点 bool LogicNode::isOutput() const { return std::holds_alternative(config) diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h index 2b4fd12..bb1f9fb 100644 --- a/app/src/domain/control_logic_model.h +++ b/app/src/domain/control_logic_model.h @@ -12,103 +12,114 @@ // 普通触点的导通方式;常闭触点会对读取到的位值取反 enum class ContactMode { - NormallyOpen, - NormallyClosed + NormallyOpen, // 常开:位值为 1 时触点导通 + NormallyClosed // 常闭:位值为 0 时触点导通 }; // 线圈写入方式:普通写入、置位保持、复位清零 enum class CoilMode { - Normal, - Set, - Reset + Normal, // 普通写入:直接写入当前逻辑结果 + Set, // 置位:逻辑结果为 1 时保持为 1 + Reset // 复位:逻辑结果为 1 时清零 }; // 上升沿/下降沿触点的边沿方向 enum class EdgeMode { - Rising, - Falling + Rising, // 上升沿:信号从 0 变成 1 + Falling // 下降沿:信号从 1 变成 0 }; -// D 字比较节点支持的比较运算 +// D 寄存器字比较,等于、不等于、大于、小于等 6 种比较 enum class ComparisonOperator { - Equal, - NotEqual, - LessThan, - LessThanOrEqual, - GreaterThan, - GreaterThanOrEqual + Equal, // 等于 + NotEqual, // 不等于 + LessThan, // 小于 + LessThanOrEqual, // 小于或等于 + GreaterThan, // 大于 + GreaterThanOrEqual // 大于或等于 }; // 字操作数可以来自常量,也可以来自 D 寄存器 enum class WordOperandKind { - Constant, - Register + Constant, // 使用固定数值 + Register // 使用 D 寄存器中的数值 }; // MOVE、ADD/SUB、计数器预置值共用的字操作数 struct WordOperand { - WordOperandKind kind = WordOperandKind::Constant; - RegisterAddress address{RegisterArea::D, 0}; - std::int16_t constant = 0; + WordOperandKind kind = WordOperandKind::Constant; // 操作数来源 + RegisterAddress address{RegisterArea::D, 0}; // kind 为 Register 时使用的 D 地址 + std::int16_t constant = 0; // kind 为 Constant 时使用的数值 // 常量始终有效;寄存器操作数必须是有效 D 地址 bool validate(std::string *error = nullptr) const; }; +// 普通 M 寄存器触点,地址 + 常开常闭 struct ContactNodeConfig { - RegisterAddress address{RegisterArea::M, 0}; - ContactMode mode = ContactMode::NormallyOpen; + RegisterAddress address{RegisterArea::M, 0}; // 触点读取的 M 地址 + ContactMode mode = ContactMode::NormallyOpen; // 常开或常闭 }; +// 边沿触点,地址 + 上升 / 下降沿 struct EdgeContactNodeConfig { + // 用于检测 M 寄存器的上升沿或下降沿 RegisterAddress address{RegisterArea::M, 0}; EdgeMode mode = EdgeMode::Rising; }; +// 线圈输出配置,决定如何写入 M 寄存器 struct CoilNodeConfig { + // 线圈要写入的 M 寄存器地址 RegisterAddress address{RegisterArea::M, 0}; CoilMode mode = CoilMode::Normal; }; +// D 寄存器比较配置 struct CompareNodeConfig { + // 要参与比较的 D 寄存器地址 RegisterAddress address{RegisterArea::D, 0}; + // 比较运算符 ComparisonOperator comparison = ComparisonOperator::Equal; + // 与寄存器值比较的常量 std::int16_t value = 0; }; -// 离线执行器内部使用的 T 区地址,不会加入 PLC M/D 轮询 +// 定时器 T 编号,内部使用,不映射 M/D 寄存器;限制编号范围,判断是否合法,可转字符串 class TimerAddress { public: - static constexpr int kMinimumIndex = 0; - static constexpr int kMaximumIndex = 4000; + static constexpr int kMinimumIndex = 0; // 允许使用的最小 T 编号 + static constexpr int kMaximumIndex = 4000; // 允许使用的最大 T 编号 - explicit TimerAddress(int index = 0); + explicit TimerAddress(int index = 0); // 使用编号创建 T 地址 - int index() const; - bool isValid() const; - std::string toString() const; + int index() const; // 返回地址编号 + bool isValid() const; // 判断地址编号是否在有效范围内 + std::string toString() const; // 转成 T0 这样的显示文本 - bool operator==(const TimerAddress &other) const; - bool operator!=(const TimerAddress &other) const; + bool operator==(const TimerAddress &other) const; // 判断两个 T 地址是否相同 + bool operator!=(const TimerAddress &other) const; // 判断两个 T 地址是否不同 private: - int index_ = 0; + int index_ = 0; // 保存 T 地址编号 }; // 读取 TON 完成状态的条件触点 struct TimerContactNodeConfig { + // 要读取完成状态的定时器地址 TimerAddress address; + // 按常开或常闭方式判断完成状态 ContactMode mode = ContactMode::NormallyOpen; }; @@ -116,32 +127,34 @@ struct TimerContactNodeConfig class CounterAddress { public: - static constexpr int kMinimumIndex = 0; - static constexpr int kMaximumIndex = 4000; + static constexpr int kMinimumIndex = 0; // 允许使用的最小 C 编号 + static constexpr int kMaximumIndex = 4000; // 允许使用的最大 C 编号 - explicit CounterAddress(int index = 0); + explicit CounterAddress(int index = 0); // 使用编号创建 C 地址 - int index() const; - bool isValid() const; - std::string toString() const; + int index() const; // 返回地址编号 + bool isValid() const; // 判断地址编号是否在有效范围内 + std::string toString() const; // 转成 C0 这样的显示文本 - bool operator==(const CounterAddress &other) const; - bool operator!=(const CounterAddress &other) const; + bool operator==(const CounterAddress &other) const; // 判断两个 C 地址是否相同 + bool operator!=(const CounterAddress &other) const; // 判断两个 C 地址是否不同 private: - int index_ = 0; + int index_ = 0; // 保存 C 地址编号 }; enum class CounterMode { - Up, - Down + Up, // 加计数 + Down // 减计数 }; // 读取计数器完成状态的条件触点 struct CounterContactNodeConfig { + // 要读取完成状态的计数器地址 CounterAddress address; + // 按常开或常闭方式判断完成状态 ContactMode mode = ContactMode::NormallyOpen; }; @@ -151,54 +164,54 @@ struct TonNodeConfig static constexpr int kMinimumPresetMs = 1; static constexpr int kMaximumPresetMs = 86400000; - TimerAddress address; - int presetMs = 1000; + TimerAddress address; // 定时器地址 + int presetMs = 1000; // 延时时间,单位为毫秒 }; // CTU/CTD 输出配置;当前值、预置值和复位信号都可引用 M/D struct CounterNodeConfig { - CounterAddress address; - CounterMode mode = CounterMode::Up; - RegisterAddress currentValueAddress{RegisterArea::D, 0}; - WordOperand preset; - RegisterAddress resetAddress{RegisterArea::M, 0}; + CounterAddress address; // 计数器地址 + CounterMode mode = CounterMode::Up; // 加计数或减计数 + RegisterAddress currentValueAddress{RegisterArea::D, 0}; // 当前值保存到的 D 地址 + WordOperand preset; // 计数预置值 + RegisterAddress resetAddress{RegisterArea::M, 0}; // 复位输入使用的 M 地址 }; // MOVE 输出:把一个字操作数写入目标 D 地址 struct MoveNodeConfig { - WordOperand source; - RegisterAddress destination{RegisterArea::D, 0}; + WordOperand source; // 要写入的源操作数 + RegisterAddress destination{RegisterArea::D, 0}; // 接收数据的 D 地址 }; enum class ArithmeticOperation { - Add, - Subtract + Add, // 加法 + Subtract // 减法 }; // ADD/SUB 输出:计算两个字操作数并写入目标 D 地址 struct ArithmeticNodeConfig { - ArithmeticOperation operation = ArithmeticOperation::Add; - WordOperand left; - WordOperand right; - RegisterAddress destination{RegisterArea::D, 0}; + ArithmeticOperation operation = ArithmeticOperation::Add; // 加法或减法 + WordOperand left; // 左操作数 + WordOperand right; // 右操作数 + RegisterAddress destination{RegisterArea::D, 0}; // 运算结果写入的 D 地址 }; // 所有梯形图指令的强类型配置联合,避免用无关字段拼装指令 using LogicNodeConfig = std::variant< - ContactNodeConfig, - EdgeContactNodeConfig, - TimerContactNodeConfig, - CounterContactNodeConfig, - CoilNodeConfig, - CompareNodeConfig, - TonNodeConfig, - CounterNodeConfig, - MoveNodeConfig, - ArithmeticNodeConfig>; + ContactNodeConfig, // 普通常开或常闭触点 + EdgeContactNodeConfig, // 上升沿或下降沿触点 + TimerContactNodeConfig, // 定时器完成触点 + CounterContactNodeConfig, // 计数器完成触点 + CoilNodeConfig, // 普通、置位或复位线圈 + CompareNodeConfig, // D 寄存器比较节点 + TonNodeConfig, // TON 定时器输出 + CounterNodeConfig, // CTU 或 CTD 计数器输出 + MoveNodeConfig, // MOVE 数据传送输出 + ArithmeticNodeConfig>; // ADD 或 SUB 算术输出 // 返回节点最主要的 M/D 地址;复合输出请使用下面的收集函数 std::optional registerAddressForLogicNode( @@ -211,9 +224,9 @@ void collectRegisterAddressesForLogicNode( // 表达式中的一个指令节点;configured=false 表示编辑中的未完成草稿 struct LogicNode { - std::string id; - LogicNodeConfig config; - bool configured = true; + std::string id; // 节点唯一 ID + LogicNodeConfig config; // 节点的具体指令配置 + bool configured = true; // 是否已完成配置 // 校验节点 ID、配置类型和配置内容 bool validate(std::string *error = nullptr) const; @@ -225,10 +238,10 @@ struct LogicNode enum class ConditionExpressionKind { - Node, - Wire, - Series, - Parallel + Node, // 一个实际的条件节点 + Wire, // 一段横线 + Series, // 多个条件串联,必须全部满足 + Parallel // 多个条件并联,满足任意一条即可 }; // 条件区中的持久化横线;columnSpan 表示跨越的网格列数 @@ -238,7 +251,7 @@ struct WireSegment static constexpr int kMaximumColumnSpan = ProjectLimits::kMaximumConditionColumns; - int columnSpan = 1; + int columnSpan = 1; // 横线占用的网格列数 bool validate(std::string *error = nullptr) const; }; @@ -246,13 +259,15 @@ struct WireSegment // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 struct ConditionExpression { - std::string id; - ConditionExpressionKind kind = ConditionExpressionKind::Node; - std::optional node; - std::optional wire; - std::vector children; + std::string id; // 表达式唯一 ID + ConditionExpressionKind kind = ConditionExpressionKind::Node; // 表达式类型 + std::optional node; // kind 为 Node 时保存的节点 + std::optional wire; // kind 为 Wire 时保存的横线 + std::vector children; // 串联或并联的子表达式 + // 把一个逻辑节点包装成条件表达式 static ConditionExpression fromNode(LogicNode node); + // 创建一段指定列数的横线表达式 static ConditionExpression fromWire(std::string id, int column_span = 1); // 编辑态校验允许空配置节点,但必须保持树结构合法 bool validate(std::string *error = nullptr) const; @@ -260,29 +275,41 @@ struct ConditionExpression bool validateForRunning(std::string *error = nullptr) const; }; -// 删除后折叠单子项容器并合并相邻同类容器,保持表达式的规范形态 +// 删除节点后整理表达式结构 void normalizeConditionExpression(std::optional *expression); + +// 按节点 ID 查找条件节点,只读版本 const LogicNode *findConditionNode( const ConditionExpression &expression, const std::string &node_id); + +// 按节点 ID 查找条件节点,可修改版本 LogicNode *findConditionNode( ConditionExpression &expression, const std::string &node_id); + +// 按表达式 ID 查找子表达式,只读版本 const ConditionExpression *findConditionExpression( const ConditionExpression &expression, const std::string &expression_id); + +// 按表达式 ID 查找子表达式,可修改版本 ConditionExpression *findConditionExpression( ConditionExpression &expression, const std::string &expression_id); + +// 收集表达式中的所有条件节点 void collectConditionNodes( const ConditionExpression &expression, std::vector *nodes); + +// 收集表达式和子表达式的所有 ID void collectConditionExpressionIds( const ConditionExpression &expression, std::vector *ids); // 条件表达式为空时表示恒真网络,输出指令固定在最右侧 struct LadderRung { - std::string id; - std::string name; - std::string comment; - std::optional condition; - std::optional output; + std::string id; // 网络唯一 ID + std::string name; // 网络名称 + std::string comment; // 网络注释 + std::optional condition; // 条件表达式,没有时表示恒真 + std::optional output; // 网络右侧的输出指令 // 编辑态允许没有条件或输出,便于逐步搭建网络 bool validate(std::string *error = nullptr) const; @@ -295,10 +322,10 @@ struct LadderRung // 一组按顺序扫描的梯形图网络;enabled=false 时运行校验会跳过它 struct ControlLogic { - std::string id; - std::string name; - std::vector rungs; - bool enabled = true; + std::string id; // 控制逻辑唯一 ID + std::string name; // 控制逻辑名称 + std::vector rungs; // 很多条 LadderRung(梯形图网络/梯级) + bool enabled = true; // 是否参与运行扫描 // 校验可保存结构 bool validate(std::string *error = nullptr) const; diff --git a/app/src/domain/hmi_control_registry.h b/app/src/domain/hmi_control_registry.h index 5b5bfad..86ca98e 100644 --- a/app/src/domain/hmi_control_registry.h +++ b/app/src/domain/hmi_control_registry.h @@ -17,9 +17,9 @@ */ enum class HmiBindingKind { - None, - Bit, - Word + None, // 不支持寄存器绑定 + Bit, // 绑定 M 区位地址 + Word // 绑定 D 区字地址 }; /** @@ -27,9 +27,9 @@ enum class HmiBindingKind */ enum class HmiRuntimeValueKind { - None, - Bit, - Word + None, // 运行态不读取寄存器值 + Bit, // 运行态读取绑定的 M 位值 + Word // 运行态读取绑定的 D 字值 }; /** @@ -37,15 +37,15 @@ enum class HmiRuntimeValueKind */ struct HmiControlDescriptor { - HmiControlType type; - const char *storageName; - const char *displayName; - const char *idPrefix; - const char *defaultText; - HmiRect defaultBounds; - HmiBindingKind bindingKind; - HmiRuntimeValueKind runtimeValueKind; - bool requiresBindingForRunning; + HmiControlType type; // 控件的领域类型 + const char *storageName; // 工程 JSON 中持久化的稳定类型名 + const char *displayName; // 编辑器和属性面板使用的显示名称 + const char *idPrefix; // 自动生成控件 ID 时使用的前缀 + const char *defaultText; // 新建控件时使用的默认显示文本 + HmiRect defaultBounds; // 新建控件时使用的默认位置和尺寸 + HmiBindingKind bindingKind; // 编辑态允许的寄存器绑定类型 + HmiRuntimeValueKind runtimeValueKind; // 运行态读取的寄存器值类型 + bool requiresBindingForRunning; // 运行前是否必须完成有效寄存器绑定 }; /** diff --git a/app/src/domain/project_limits.h b/app/src/domain/project_limits.h index d403c58..43158a3 100644 --- a/app/src/domain/project_limits.h +++ b/app/src/domain/project_limits.h @@ -5,62 +5,62 @@ // 所有跨层共享的数量和范围边界集中在这里,避免 UI、服务和 JSON 各自写数字 namespace ProjectLimits { -constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U; +constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U; // 一个工程文件最大 16 MiB,防止异常大文件占满内存 -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 kMaximumHmiPages = 128U; // 一个工程最多放 128 个 HMI 页面 +constexpr std::size_t kMaximumHmiControlsPerPage = 512U; // 一个 HMI 页面最多放 512 个控件 +constexpr std::size_t kMaximumAlarmDefinitions = 512U; // 一个工程最多配置 512 条报警 +constexpr std::size_t kMaximumRegisterComments = 8002U; // M0~M4000 和 D0~D4000 最多各写一条注释 +constexpr std::size_t kMaximumControlLogics = 128U; // 一个工程最多放 128 组梯形图控制逻辑 +constexpr std::size_t kMaximumRungsPerLogic = 1024U; // 一组控制逻辑最多放 1024 个网络 -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; +constexpr std::size_t kMaximumExpressionNodesPerRung = 4096U; // 一个网络里的触点、横线和内部结构最多共 4096 个 +constexpr std::size_t kMaximumExpressionDepth = 20U; // 并联套并联时最多套 20 层,防止结构无限变复杂 +constexpr std::size_t kMaximumExpressionChildren = 256U; // 一个串联组或并联组最多放 256 个子项 +constexpr int kMaximumConditionColumns = 10; // 一个网络前面的条件区最多 10 列 +constexpr int kMaximumLadderColumns = 11; // 一个网络总共 11 列,最后 1 列专门放输出 +constexpr int kMaximumLogicRows = 256; // 一个网络上下最多 256 行并联支路 -static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); +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 std::size_t kMaximumHmiProperties = 64U; // 一个 HMI 控件最多保存 64 对扩展属性 +constexpr std::size_t kMaximumIdBytes = 128U; // ID 最多 128 个 UTF-8 字节,够用来做稳定标识 +constexpr std::size_t kMaximumTextBytes = 4096U; // 工程名称等普通文本最多 4096 个 UTF-8 字节 +constexpr std::size_t kMaximumHmiControlTextCharacters = 12U; // HMI 控件显示文字最多输入 12 个字符 +constexpr std::size_t kMaximumAlarmTitleCharacters = 12U; // 报警列表标题最多输入 12 个字符 +constexpr std::size_t kMaximumAlarmMessageCharacters = 20U; // 一条报警内容最多输入 20 个字符 +constexpr std::size_t kMaximumPropertyKeyBytes = 128U; // HMI 属性名最多 128 个 UTF-8 字节 +constexpr std::size_t kMaximumPropertyValueBytes = 4096U; // HMI 属性值最多 4096 个 UTF-8 字节 +constexpr std::size_t kMaximumRegisterCommentBytes = 64U; // M/D 注释最多 64 个 UTF-8 字节,保证节点下方放得下 +constexpr std::size_t kMaximumRungCommentBytes = 128U; // 网络注释最多 128 个 UTF-8 字节,并且保持单行显示 -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 int kMinimumHmiPageWidth = 320; // HMI 页面最窄 320 像素,太窄会不好编辑 +constexpr int kMaximumHmiPageWidth = 1600; // HMI 页面最宽 1600 像素,避免画布无限变大 +constexpr int kMinimumHmiPageHeight = 200; // HMI 页面最矮 200 像素,保证基本可用空间 +constexpr int kMaximumHmiPageHeight = 800; // HMI 页面最高 800 像素,适合当前桌面编辑器 +constexpr int kDefaultHmiPageWidth = 800; // 新建 HMI 页面默认宽度 800 像素 +constexpr int kDefaultHmiPageHeight = 400; // 新建 HMI 页面默认高度 400 像素 +constexpr int kMaximumHmiControlWidth = 8192; // 单个 HMI 控件最大宽度,防止异常尺寸撑坏画布 +constexpr int kMaximumHmiControlHeight = 8192; // 单个 HMI 控件最大高度,防止异常尺寸撑坏画布 +constexpr int kMinimumHmiFontPointSize = 6; // HMI 字体最小 6 磅,太小基本看不清 +constexpr int kMaximumHmiFontPointSize = 72; // HMI 字体最大 72 磅,防止文字撑坏布局 +constexpr int kAlarmDefaultFontPointReduction = 2; // 报警列表默认比普通文字小 2 磅,给表头留空间 +constexpr int kMaximumVisibleAlarmRows = 5; // 报警列表默认最多同时显示 5 行,更多内容通过翻页查看 -constexpr std::size_t kMaximumPollAddresses = 1024U; -constexpr std::size_t kMaximumPollBlocks = 64U; -constexpr int kMaximumModbusReadCount = 120; +constexpr std::size_t kMaximumPollAddresses = 1024U; // PLC 一轮轮询最多读取 1024 个去重后的地址 +constexpr std::size_t kMaximumPollBlocks = 64U; // PLC 一轮轮询最多拆成 64 个读块 +constexpr int kMaximumModbusReadCount = 120; // 一条 Modbus 读指令最多读 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 int kMinimumPlcServerAddress = 1; // Modbus 站号从 1 开始 +constexpr int kMaximumPlcServerAddress = 247; // 站号最大 247,避开 Modbus 保留站号 +constexpr int kMinimumResponseTimeoutMs = 100; // PLC 最短等待回复 100 毫秒,避免过早判定超时 +constexpr int kMaximumResponseTimeoutMs = 30000; // PLC 最长等待回复 30 秒,避免故障时一直卡住 +constexpr int kMinimumRetries = 0; // 最少可以不重试,收到失败就直接返回 +constexpr int kMaximumRetries = 5; // PLC 通信最多自动重试 5 次 +constexpr int kMinimumPollIntervalMs = 50; // PLC 最快每 50 毫秒轮询一次,避免压满串口 +constexpr int kMaximumPollIntervalMs = 10000; // PLC 最慢每 10 秒轮询一次,避免画面长时间不刷新 -constexpr std::size_t kMaximumPendingWrites = 1U; -constexpr int kMaximumOutputMessages = 1000; +constexpr std::size_t kMaximumPendingWrites = 1U; // 同时只允许等待 1 个 PLC 写入,避免写入顺序混乱 +constexpr int kMaximumOutputMessages = 1000; // 输出日志最多保留 1000 条,超过后删除最旧的一条 -} // namespace ProjectLimits +} diff --git a/app/src/infrastructure/plc_communication_service.cpp b/app/src/infrastructure/plc_communication_service.cpp index 48f6412..9ff6ff0 100644 --- a/app/src/infrastructure/plc_communication_service.cpp +++ b/app/src/infrastructure/plc_communication_service.cpp @@ -15,26 +15,31 @@ namespace { +// 故障恢复时,两次轻量探测之间等待的时间 constexpr int kRecoveryProbeIntervalMs = 2000; +// Qt 使用 QString,项目内部错误信息统一使用 UTF-8 std::string std::string toUtf8(const QString &value) { const QByteArray bytes = value.toUtf8(); return std::string(bytes.constData(), static_cast(bytes.size())); } +// M 区在 Modbus 中按线圈读取,D 区按保持寄存器读取 QModbusDataUnit::RegisterType registerType(RegisterArea area) { return area == RegisterArea::M ? QModbusDataUnit::Coils : QModbusDataUnit::HoldingRegisters; } +// 只有超时和 PLC 不响应属于可以自动探测恢复的故障 bool isRecoverableTimeout(PlcCommunicationError error) { return error == PlcCommunicationError::PlcNotResponding || error == PlcCommunicationError::CommunicationTimeout; } +// Connected 正常轮询,Recovering 正在恢复后的重新首读 bool isReadingState(PlcConnectionState state) { return state == PlcConnectionState::Connected @@ -46,6 +51,7 @@ bool normalizePollAddresses( std::vector *normalized, std::string *error) { + // 先复制,后续排序和去重不能改变调用方传入的地址列表 *normalized = addresses; if (std::any_of( normalized->cbegin(), normalized->cend(), @@ -76,6 +82,7 @@ std::size_t pollBlockCount(const std::vector &addresses) { if (addresses.empty()) { + // 空集合也会保留 M0、D0 两个默认读块 return 2U; } std::size_t blocks = 0U; @@ -84,6 +91,7 @@ std::size_t pollBlockCount(const std::vector &addresses) int count = 0; for (const RegisterAddress &address : addresses) { + // 跨区域、出现地址间隔或达到 Modbus 单次上限时,必须开始新读块 if (count == 0 || address.area() != current_area || address.index() > start_address + count @@ -111,6 +119,7 @@ PlcCommunicationService::PlcCommunicationService( repository_(repository), master_(std::make_unique()) { + // 仓库只负责缓存;写入请求通过这里注入的回调转给通信服务 repository_.setWriteHandlers( [this](const RegisterAddress &address, bool value) { @@ -120,6 +129,7 @@ PlcCommunicationService::PlcCommunicationService( { return sendWordWrite(address, value); }); + // 轮询定时器每次只推动一个读块,避免一次压入大量异步请求 connect(&poll_timer_, &QTimer::timeout, this, &PlcCommunicationService::pollNextBlock); recovery_timer_.setSingleShot(true); connect( @@ -127,12 +137,14 @@ PlcCommunicationService::PlcCommunicationService( &QTimer::timeout, this, &PlcCommunicationService::probeRecovery); + // Qt 串口状态变化是异步通知,所有连接状态都从这里统一处理 connect(master_.get(), &QModbusClient::stateChanged, this, [this](QModbusDevice::State device_state) { if (device_state == QModbusDevice::ConnectedState) { + // 串口连接成功后立即开始轮询,并不代表首读已经完成 serial_session_opened_ = true; setState(PlcConnectionState::Connected); poll_timer_.start(configuration_.pollIntervalMs); @@ -145,6 +157,7 @@ PlcCommunicationService::PlcCommunicationService( } else if (device_state == QModbusDevice::UnconnectedState) { + // 主动断开和意外断线要区分,后者需要报告故障 if (disconnecting_) { serial_session_opened_ = false; @@ -159,6 +172,7 @@ PlcCommunicationService::PlcCommunicationService( } } }); + // Qt 报告底层错误时,转换为项目自己的错误和状态 connect(master_.get(), &QModbusClient::errorOccurred, this, [this](QModbusDevice::Error error) @@ -198,6 +212,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( received_valid_response_ = false; last_error_type_ = PlcCommunicationError::None; last_error_.clear(); + // 把项目配置转换成 Qt 串口参数 master_->setConnectionParameter( QModbusDevice::SerialPortNameParameter, QString::fromStdString(configuration.portName)); @@ -234,6 +249,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( void PlcCommunicationService::disconnectDevice() { + // 关闭串口、清除缓存有效标记,防止断开后继续使用旧值 closeSerialSession(); repository_.invalidate(); updateInitialReadCompleted(false, true); @@ -258,6 +274,7 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( // 当前读请求完成前暂存新集合,避免按半旧半新的地址解析回复 if (pending_reply_ != nullptr) { + // 当前回复仍在解析旧集合,等它结束后再切换到新集合 pending_poll_addresses_ = std::move(normalized); poll_update_pending_ = true; return {true, {}}; @@ -297,10 +314,11 @@ void PlcCommunicationService::setCallbacks( std::function cache_updated, std::function error_reported) { - state_changed_callback_ = std::move(state_changed); - initial_read_changed_callback_ = std::move(initial_read_changed); - cache_updated_callback_ = std::move(cache_updated); - error_reported_callback_ = std::move(error_reported); + // 保存外部通知函数,通信事件发生时再调用对应函数 + state_changed_callback_ = std::move(state_changed); // 通信状态变化时通知外部 + initial_read_changed_callback_ = std::move(initial_read_changed); // 首读资格变化时通知外部 + cache_updated_callback_ = std::move(cache_updated); // PLC 缓存更新后通知外部 + error_reported_callback_ = std::move(error_reported); // 发生通信错误时通知外部 } void PlcCommunicationService::rebuildPollBlocks() @@ -308,6 +326,7 @@ void PlcCommunicationService::rebuildPollBlocks() std::vector addresses = poll_addresses_; if (addresses.empty()) { + // 没有指定地址时保留 M0/D0,保证连接后仍能验证设备是否响应 addresses = { RegisterAddress{RegisterArea::M, 0}, RegisterAddress{RegisterArea::D, 0}}; @@ -349,6 +368,7 @@ void PlcCommunicationService::rebuildPollBlocks() next_poll_block_ = 0; if (!initial_read_completed_) { + // 地址集合变化或重新连接后,首读资格从头统计 initial_blocks_read_.assign(poll_blocks_.size(), false); } else @@ -360,6 +380,7 @@ void PlcCommunicationService::rebuildPollBlocks() void PlcCommunicationService::applyPollAddresses( const std::vector &addresses) { + // 只有在没有读请求占用时,新的地址集合才会真正生效 poll_addresses_ = addresses; rebuildPollBlocks(); poll_update_pending_ = false; @@ -390,6 +411,7 @@ void PlcCommunicationService::pollNextBlock() return; } pending_reply_ = reply; + // 把当前连接代次带进回调,防止断线重连后旧回复误更新新连接 const std::uint64_t generation = connection_generation_; connect(reply, &QModbusReply::finished, this, @@ -424,6 +446,7 @@ void PlcCommunicationService::pollNextBlock() } if (pending_reply_ == reply) { + // 只有当前指针仍指向本次回复时才清空,避免误清新请求 pending_reply_ = nullptr; } reply->deleteLater(); @@ -437,6 +460,7 @@ void PlcCommunicationService::pollNextBlock() void PlcCommunicationService::probeRecovery() { + // 只在可恢复的超时故障中探测;串口拔出等故障不会反复探测 if (state_ != PlcConnectionState::Faulted || !isRecoverableTimeout(last_error_type_)) { @@ -448,6 +472,7 @@ void PlcCommunicationService::probeRecovery() } if (pending_reply_ != nullptr) { + // 已有读请求在途,稍后再探测,保证同一时间只有一个读回复 recovery_timer_.start(kRecoveryProbeIntervalMs); return; } @@ -455,6 +480,7 @@ void PlcCommunicationService::probeRecovery() { return; } + // 恢复探测只读取一个地址,确认链路恢复后再进行完整首读 const PollBlock block = poll_blocks_.front(); const QModbusDataUnit request( registerType(block.area), block.startAddress, 1); @@ -521,6 +547,7 @@ void PlcCommunicationService::handleRecoveryProbeFailure(QModbusDevice::Error er void PlcCommunicationService::restoreCommunication() { + // 探测成功只说明链路恢复,仍要重新读取全部读块才能恢复真机资格 received_valid_response_ = true; last_error_type_ = PlcCommunicationError::None; last_error_.clear(); @@ -568,6 +595,7 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock RegisterWriteResult PlcCommunicationService::sendBitWrite( const RegisterAddress &address, bool value) { + // 写入只允许在正常 Connected 状态进行,Recovering/Faulted 都拒绝 if (state_ != PlcConnectionState::Connected) { return {false, RegisterError::Unavailable}; @@ -576,6 +604,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( { return {false, RegisterError::WriteRejected}; } + // M 区对应 Modbus Coils,单次只写一个地址 QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); unit.setValue(0, value ? 1U : 0U); QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); @@ -585,6 +614,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( return {false, RegisterError::WriteRejected}; } pending_write_reply_ = reply; + // 写回复完成后只处理成功或错误,不直接改缓存;后续轮询负责确认真实值 connect(reply, &QModbusReply::finished, this, [this, reply, generation = connection_generation_] @@ -610,6 +640,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( RegisterWriteResult PlcCommunicationService::sendWordWrite( const RegisterAddress &address, std::int16_t value) { + // D 区写入流程与 M 区相同,只是 Modbus 类型不同 if (state_ != PlcConnectionState::Connected) { return {false, RegisterError::Unavailable}; @@ -618,6 +649,7 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( { return {false, RegisterError::WriteRejected}; } + // D 区对应 Modbus HoldingRegisters,单次只写一个字 QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); unit.setValue(0, static_cast(value)); QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); @@ -656,6 +688,7 @@ void PlcCommunicationService::updateInitialReadCompleted( { return; } + // 只有状态真的变化,或调用方明确要求通知时才发出信号 initial_read_completed_ = completed; emit initialReadCompletedChanged(completed); if (initial_read_changed_callback_) @@ -666,6 +699,7 @@ void PlcCommunicationService::updateInitialReadCompleted( void PlcCommunicationService::handleUnexpectedDisconnect() { + // 这里表示设备原本连上过,后来串口意外断开 serial_session_opened_ = false; const QString port_name = QString::fromStdString(configuration_.portName).trimmed(); setError({ @@ -677,6 +711,7 @@ void PlcCommunicationService::handleUnexpectedDisconnect() void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) { + // 主动断开、已处理的故障和旧回复错误都不重复上报 if (disconnecting_ || (error == QModbusDevice::ReplyAbortedError && state_ == PlcConnectionState::Disconnected) @@ -695,6 +730,7 @@ void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) void PlcCommunicationService::closeSerialSession() { + // 先递增代次并停止定时器,再断开串口;旧异步回调会因此失效 ++connection_generation_; disconnecting_ = true; poll_timer_.stop(); @@ -718,6 +754,7 @@ void PlcCommunicationService::setState(PlcConnectionState state) { return; } + // 状态集中从这里修改,确保 Qt 信号和 std::function 回调同步触发 state_ = state; emit stateChanged(); if (state_changed_callback_) @@ -728,6 +765,7 @@ void PlcCommunicationService::setState(PlcConnectionState state) void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) { + // 统一保存错误、停止正常轮询、撤销首读资格并通知 UI last_error_type_ = failure.type; last_error_ = toUtf8(failure.message); poll_timer_.stop(); @@ -754,3 +792,5 @@ void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) recovery_timer_.start(kRecoveryProbeIntervalMs); } } + // 按区域和地址排序,方便后面合并连续读块 + // 相同区域和编号的地址只保留一个 diff --git a/app/src/infrastructure/plc_communication_service.h b/app/src/infrastructure/plc_communication_service.h index 1258cd7..ac8859e 100644 --- a/app/src/infrastructure/plc_communication_service.h +++ b/app/src/infrastructure/plc_communication_service.h @@ -24,22 +24,33 @@ class PlcCommunicationService final : public QObject, public PlcCommunicationGat Q_OBJECT public: + // 创建通信服务,并把 PLC 缓存仓库交给它管理 explicit PlcCommunicationService( PlcRegisterRepository &repository, QObject *parent = nullptr); + // 释放 Modbus 主站和未完成的通信资源 ~PlcCommunicationService() override; + // 按给定串口参数连接 PLC,并开始异步轮询 PlcCommunicationResult connectDevice( const PlcSerialConfiguration &configuration) override; + // 停止轮询并断开 PLC void disconnectDevice() override; + // 设置需要周期性读取的 M/D 地址集合 PlcCommunicationResult setPollAddresses( const std::vector &addresses) override; + // 返回当前连接状态 PlcConnectionState state() const override; + // 判断本次连接是否已经完成所有轮询块的首次读取 bool initialReadCompleted() const override; + // 返回最近一次通信错误的类型 PlcCommunicationError lastErrorType() const override; + // 返回最近一次通信错误的文字 const std::string &lastError() const override; + // 返回当前使用的串口和 Modbus 参数 const PlcSerialConfiguration &configuration() const; + // 注册状态、首读、缓存更新和错误通知回调 void setCallbacks( std::function state_changed, std::function initial_read_changed, @@ -47,18 +58,22 @@ public: std::function error_reported) override; signals: + // 连接状态发生变化 void stateChanged(); + // 首次完整读取资格发生变化 void initialReadCompletedChanged(bool completed); + // PLC 缓存收到新的成功读数 void cacheUpdated(); + // 通信错误的可读提示文字 void communicationError(const QString &message); private: struct PollBlock { // 同一区域的一段连续 Modbus 原始地址 - RegisterArea area = RegisterArea::M; - int startAddress = 0; - int count = 1; + RegisterArea area = RegisterArea::M; // M 区或 D 区 + int startAddress = 0; // 读块的第一个原始地址 + int count = 1; // 从起始地址连续读取的数量 }; // 把去重后的地址集合压缩为有限数量的连续读块 @@ -69,7 +84,9 @@ private: void pollNextBlock(); // Faulted 状态下用轻量探测判断通信是否恢复 void probeRecovery(); + // 处理恢复探测失败,并安排下一次探测 void handleRecoveryProbeFailure(QModbusDevice::Error error); + // 探测成功后恢复轮询,但仍需重新完成首读 void restoreCommunication(); // 处理读回复并把值写入 PLC 缓存 void handleReadFinished(QModbusReply *reply, PollBlock block); @@ -79,36 +96,41 @@ private: const RegisterAddress &address, std::int16_t value); // 更新“所有轮询块均成功读取”的真机进入资格 void updateInitialReadCompleted(bool completed, bool force_notification = false); + // 处理没有主动断开时发生的串口断线 void handleUnexpectedDisconnect(); + // 把 Qt Modbus 错误转换成项目自己的通信错误 void handleModbusError(QModbusDevice::Error error); + // 关闭串口并清理当前连接的请求和状态 void closeSerialSession(); + // 修改连接状态并通知外部观察者 void setState(PlcConnectionState state); + // 保存错误信息、更新状态并通知外部观察者 void setError(const PlcCommunicationFailure &failure); - PlcRegisterRepository &repository_; - std::unique_ptr master_; - QTimer poll_timer_; - QTimer recovery_timer_; - PlcSerialConfiguration configuration_; - std::vector poll_addresses_; - std::vector pending_poll_addresses_; - std::vector 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; - bool received_valid_response_ = false; - std::uint64_t connection_generation_ = 0; - PlcConnectionState state_ = PlcConnectionState::Disconnected; - bool initial_read_completed_ = false; + PlcRegisterRepository &repository_; // 用于保存 PLC 最近一次成功读回的 M/D 值 + std::unique_ptr master_; // Qt Modbus RTU 主站对象 + QTimer poll_timer_; // 周期性触发下一轮轮询 + QTimer recovery_timer_; // Faulted 状态下定时发起恢复探测 + PlcSerialConfiguration configuration_; // 当前串口和站号配置 + std::vector poll_addresses_; // 当前生效的轮询地址 + std::vector pending_poll_addresses_; // 请求在途时暂存的新地址 + std::vector 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; // 本次串口会话是否曾经成功打开 + bool received_valid_response_ = false; // 本次连接是否收到过有效 PLC 回复 + std::uint64_t connection_generation_ = 0; // 连接代次,用于丢弃旧连接的异步回复 + PlcConnectionState state_ = PlcConnectionState::Disconnected; // 当前通信状态 + bool initial_read_completed_ = false; // 是否已完成首读,可作为真机运行门槛 // 首读阶段每个读块的完成标记,全部为 true 后才允许进入真机 std::vector initial_blocks_read_; - PlcCommunicationError last_error_type_ = PlcCommunicationError::None; - std::string last_error_; - std::function state_changed_callback_; - std::function initial_read_changed_callback_; - std::function cache_updated_callback_; - std::function error_reported_callback_; + PlcCommunicationError last_error_type_ = PlcCommunicationError::None; // 最近一次错误类型 + std::string last_error_; // 最近一次错误的可读文字 + std::function state_changed_callback_; // 状态变化时调用 + std::function initial_read_changed_callback_; // 首读资格变化时调用 + std::function cache_updated_callback_; // 缓存更新后调用 + std::function error_reported_callback_; // 发生错误时调用 }; diff --git a/app/src/infrastructure/plc_register_repository.h b/app/src/infrastructure/plc_register_repository.h index 5b0ad18..9ea5f42 100644 --- a/app/src/infrastructure/plc_register_repository.h +++ b/app/src/infrastructure/plc_register_repository.h @@ -31,12 +31,13 @@ public: bool hasAnyValidValue() const; private: + // M/D 区共用的数组长度,覆盖 0 到最大地址 static constexpr std::size_t kRegisterCount = static_cast(RegisterAddress::kMaximumIndex + 1); - std::array bits_{}; - std::array words_{}; - std::array valid_bits_{}; - std::array valid_words_{}; - std::function bit_handler_; - std::function word_handler_; + std::array bits_{}; // 最近一次读到的 M 区位值 + std::array words_{}; // 最近一次读到的 D 区字值 + std::array valid_bits_{}; // 对应 M 地址是否读到过有效值 + std::array valid_words_{}; // 对应 D 地址是否读到过有效值 + std::function bit_handler_; // M 区异步写入回调 + std::function word_handler_; // D 区异步写入回调 }; diff --git a/app/src/main.cpp b/app/src/main.cpp index b115119..ede1187 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -26,36 +26,59 @@ #include "services/register_comment_service.h" #include "ui/main_window.h" +// 程序从这里开始,argc 和 argv 保存用户启动程序时附带的命令行参数 int main(int argc, char *argv[]) { + // 创建整个 Qt 应用,后面的窗口、按钮和消息循环都要依靠它 QApplication application(argc, argv); + // 设置程序名称,Qt 的窗口信息和配置中会用到这个名字 application.setApplicationName(QObject::tr("综合平台编程器")); + // 设置组织名称,用来区分这个程序保存的 Qt 配置 application.setOrganizationName(QStringLiteral("QtProXinJe")); - // 组合根负责创建具体实现并将抽象依赖注入服务和 UI + // 负责把工程保存到 JSON 文件,也负责从 JSON 文件读取工程 JsonProjectStorage project_storage; + // 统一管理新建、打开、保存和校验工程,实际读写文件交给上面的对象 ProjectService project_service(project_storage); + // 处理 HMI 页面和控件的添加、修改、删除、撤销与重做 HmiEditorService hmi_editor_service(project_service); + // 处理运行画面在不同 HMI 页面之间的跳转 HmiNavigationService hmi_navigation_service(project_service); + // 处理梯形图网络和节点的编辑、校验、撤销与重做 LogicEditorService logic_editor_service(project_service); - // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 + + // 在内存中保存离线仿真使用的 M、D 寄存器值,不会写入真实 PLC VirtualRegisterRepository virtual_register_repository; + // 保存从真实 PLC 最近一次读取到的 M、D 值,并转交写入请求 PlcRegisterRepository plc_register_repository; + // 给界面提供统一的寄存器入口,启动时先使用上面的离线寄存器 ActiveRegisterRepository active_register_repository(virtual_register_repository); + // 负责通过串口异步连接、读取和写入真实 PLC 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); + // 管理工程中 M、D 地址对应的文字备注 RegisterCommentService register_comment_service(project_service); + // 处理自由监控列表中的寄存器读取和单点写入 RegisterMonitorService register_monitor_service(active_register_repository); + // 用虚拟寄存器执行本地梯形图,实现不连接 PLC 的离线仿真 OfflineSimulationService offline_simulation_service(virtual_register_repository); + // 管理编辑、离线运行和真机运行三种模式,并控制离线仿真的启停 RuntimeModeService runtime_mode_service( project_service, offline_simulation_service); + // 把 PLC 通信和两套寄存器接入运行模式,切换模式时才能切换数据来源 runtime_mode_service.configurePlc( plc_communication_service, active_register_repository, virtual_register_repository, plc_register_repository); + + // 创建主窗口,并把界面操作需要的各项服务交给它使用 MainWindow main_window( runtime_mode_service, project_service, @@ -67,7 +90,9 @@ int main(int argc, char *argv[]) hmi_navigation_service, register_comment_service, register_monitor_service); + // 把主窗口显示到屏幕上 main_window.show(); + // 启动 Qt 消息循环,持续响应鼠标、键盘、定时器和串口事件,窗口关闭后才返回 return application.exec(); } diff --git a/app/src/services/alarm_service.cpp b/app/src/services/alarm_service.cpp index 0f1d92d..a6ab385 100644 --- a/app/src/services/alarm_service.cpp +++ b/app/src/services/alarm_service.cpp @@ -21,11 +21,13 @@ void AlarmService::refresh() for (const AlarmDefinition &definition : project_service_.project().alarmDefinitions) { + // 空值表示寄存器读取失败;保留原记录,等待下一轮成功读回 const std::optional active = evaluate(definition); if (!active.has_value()) { continue; } + // 同一报警定义在当前会话中最多保留一条活动记录 const auto current = std::find_if( records_.begin(), records_.end(), [&definition](const AlarmRecord &record) @@ -34,12 +36,14 @@ void AlarmService::refresh() }); 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); } } @@ -76,21 +80,27 @@ std::optional AlarmService::evaluate( { if (definition.condition == AlarmCondition::MOn) { + // MOn 报警直接读取 M 位;读取失败返回空值,交由 refresh 保留旧记录 const BitReadResult value = repository_.readBit(definition.address); return value.succeeded ? std::optional{value.value} : std::nullopt; } + // DHigh 和 DLow 报警读取 D 字,比较方向由定义中的条件决定 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; } diff --git a/app/src/services/alarm_service.h b/app/src/services/alarm_service.h index e0a83c2..b3dc57c 100644 --- a/app/src/services/alarm_service.h +++ b/app/src/services/alarm_service.h @@ -9,36 +9,66 @@ class ProjectService; class RegisterRepository; struct AlarmDefinition; -// 运行时产生的报警记录,不会写入工程 JSON +// 运行时产生的报警记录,只存在当前会话,不会写入工程 JSON struct AlarmRecord { - std::string definitionId; - std::string message; - bool acknowledged = false; - std::chrono::system_clock::time_point occurredAt; + std::string definitionId; // 对应 AlarmDefinition 的稳定 ID + std::string message; // 触发时复制的报警提示文本 + bool acknowledged = false; // 当前记录是否已被操作员确认 + std::chrono::system_clock::time_point occurredAt; // 本次触发时间 }; -// 根据当前活动寄存器仓库评估报警,并维护确认状态 +/** + * @brief 根据当前活动寄存器仓库评估报警并维护会话记录 + * + * 服务只读取仓库,不修改工程中的报警定义;仓库可以由运行模式注入虚拟或 PLC 数据源 + */ class AlarmService { public: + /** + * @brief 创建报警服务 + * @param project_service 只读工程服务,提供报警定义集合 + * @param repository 当前运行模式使用的寄存器仓库 + */ AlarmService( const ProjectService &project_service, RegisterRepository &repository); - // 重新评估全部定义;仍处于触发状态的记录会保留确认状态 + /** + * @brief 重新评估全部报警定义 + * + * 仍处于触发状态的记录会保留确认状态;读取失败时跳过该定义并保留原记录。 + * 新触发记录插入列表头部,解除触发的记录立即移除 + */ void refresh(); - // 确认指定报警;不存在时返回 false + /** + * @brief 确认当前会话中的指定报警记录 + * @param definition_id 报警定义的稳定 ID + * @return 找到当前活动记录并完成确认时返回 true,否则返回 false + */ bool acknowledge(const std::string &definition_id); - // 清空当前运行会话的报警记录 + /** + * @brief 清空当前运行会话的全部报警记录和确认状态 + */ void reset(); - // 返回按触发时间保存的报警记录 + /** + * @brief 返回当前会话的报警记录 + * @return 按触发时间倒序排列的只读记录列表,列表由服务持有 + */ const std::vector &records() const; private: + /** + * @brief 使用当前寄存器值评估单条报警定义 + * @param definition 待评估的报警定义 + * @return true 表示触发,false 表示未触发;读取失败时返回空值 + * + * MOn 比较 M 位是否为 1;DHigh 使用大于等于阈值,DLow 使用小于等于阈值 + */ std::optional evaluate(const AlarmDefinition &definition) const; - const ProjectService &project_service_; - RegisterRepository &repository_; - std::vector records_; + const ProjectService &project_service_; // 不拥有的只读工程服务 + RegisterRepository &repository_; // 当前运行模式的活动寄存器仓库 + std::vector records_; // 当前会话记录,最新触发项位于列表头部 }; diff --git a/app/src/services/editor_history.h b/app/src/services/editor_history.h index aef409d..cd7d97e 100644 --- a/app/src/services/editor_history.h +++ b/app/src/services/editor_history.h @@ -8,16 +8,27 @@ /** * @brief 保存有限数量的编辑前快照并提供撤销、重做 * - * 历史只存在当前编辑会话内。新编辑会清空重做栈,超过容量时丢弃最早记录。 + * State 由具体编辑服务决定,可以是 HMI 页面集合或控制逻辑集合 + * 历史只存在当前编辑会话内,不写入工程文件 + * 新编辑会清空重做栈,超过容量时丢弃最早记录 */ template class EditorHistory final { public: + // 单个编辑服务最多保留 100 条撤销和重做历史 static constexpr std::size_t kMaximumEntries = 100U; template - // 只有前后状态不同才记录;新编辑会清空重做栈 + /** + * @brief 记录一次编辑前状态 + * @param before 编辑前状态 + * @param after 编辑完成后的当前状态 + * @param equal 比较前后状态是否相同的可调用对象 + * + * 状态没有变化时不创建空历史 + * 新编辑成功记录后清空重做栈 + */ void record(State before, const State &after, Equal equal) { if (equal(before, after)) @@ -29,7 +40,13 @@ public: redo_states_.clear(); } - // 返回撤销后的目标状态,并把当前状态放入重做栈 + /** + * @brief 撤销最近一次编辑 + * @param current 当前状态 + * @return 撤销后的目标状态;没有可撤销历史时返回空值 + * + * 当前状态会先进入重做栈,之后取出撤销栈顶的编辑前快照 + */ std::optional undo(State current) { if (undo_states_.empty()) @@ -43,7 +60,13 @@ public: return target; } - // 返回重做后的目标状态,并把当前状态放回撤销栈 + /** + * @brief 重做最近一次被撤销的编辑 + * @param current 当前状态 + * @return 重做后的目标状态;没有可重做历史时返回空值 + * + * 当前状态会先放回撤销栈,之后取出重做栈顶的目标状态 + */ std::optional redo(State current) { if (redo_states_.empty()) @@ -57,23 +80,33 @@ public: return target; } + /** + * @brief 清空撤销栈和重做栈 + */ void clear() { undo_states_.clear(); redo_states_.clear(); } + /** + * @brief 判断是否存在可撤销历史 + */ bool canUndo() const { return !undo_states_.empty(); } + /** + * @brief 判断是否存在可重做历史 + */ bool canRedo() const { return !redo_states_.empty(); } private: + // 保持历史不超过上限,超出时从最早的记录开始丢弃 static void trim(std::vector *states) { while (states->size() > kMaximumEntries) @@ -82,6 +115,6 @@ private: } } - std::vector undo_states_; - std::vector redo_states_; + std::vector undo_states_; // 编辑前快照栈,末尾是下一次撤销要恢复的状态 + std::vector redo_states_; // 撤销后状态栈,末尾是下一次重做要恢复的状态 }; diff --git a/app/src/services/hmi_editor_service.h b/app/src/services/hmi_editor_service.h index 4e16049..d65f190 100644 --- a/app/src/services/hmi_editor_service.h +++ b/app/src/services/hmi_editor_service.h @@ -21,17 +21,17 @@ struct HmiControlDescriptor; */ enum class HmiEditorError { - None, - PageNotFound, - ControlNotFound, - DuplicateId, - DuplicateName, - InvalidPage, - InvalidControl, - LastPageRequired, - InitialPageCannotBeRemoved, - PageReferenced, - InvalidOperation + None, // 操作成功或没有错误 + PageNotFound, // 目标页面不存在 + ControlNotFound, // 目标控件不存在 + DuplicateId, // 控件 ID 与同页其他控件重复 + DuplicateName, // 页面名称与其他页面重复 + InvalidPage, // 页面名称、尺寸或页面配置无效 + InvalidControl, // 控件属性、绑定或页面边界无效 + LastPageRequired, // 删除后不能少于一个 HMI 页面 + InitialPageCannotBeRemoved, // 初始页面仍未切换,不能删除 + PageReferenced, // 页面仍被页面跳转控件引用 + InvalidOperation // 操作参数或撤销/重做状态不允许 }; /** @@ -41,10 +41,10 @@ enum class HmiEditorError */ struct HmiEditorResult { - bool succeeded = false; - HmiEditorError error = HmiEditorError::None; - std::string message; - std::string id; + bool succeeded = false; // 操作是否成功 + HmiEditorError error = HmiEditorError::None; // 失败时的分类 + std::string message; // 面向用户的 UTF-8 成功说明或失败原因 + std::string id; // 成功时返回新建或更新后的页面/控件 ID }; /** @@ -55,6 +55,10 @@ struct HmiEditorResult class HmiEditorService { public: + /** + * @brief 创建 HMI 编辑服务 + * @param project_service 用于读取和原子修改当前工程的项目服务 + */ explicit HmiEditorService(ProjectService &project_service); /** @@ -82,13 +86,52 @@ public: * @return 已有或新建页面的成功结果及其页面标识 */ HmiEditorResult ensureDefaultPage(); + /** + * @brief 添加一个 HMI 页面 + * @param name 页面显示名称,不能为空且必须在工程内唯一 + * @return 成功时返回新页面 ID + */ HmiEditorResult addPage(const std::string &name); + + /** + * @brief 调整页面尺寸 + * @param page_id 目标页面唯一标识 + * @param width 页面宽度,必须满足页面边界且不能使已有控件越界 + * @param height 页面高度,必须满足页面边界且不能使已有控件越界 + * @return 页面不存在或新尺寸无效时返回失败结果 + */ HmiEditorResult resizePage( const std::string &page_id, int width, int height); + + /** + * @brief 修改页面显示名称 + * @param page_id 目标页面唯一标识 + * @param name 新页面名称,不能为空且必须在工程内唯一 + * @return 页面不存在或名称无效/重复时返回失败结果 + */ HmiEditorResult renamePage( const std::string &page_id, const std::string &name); + + /** + * @brief 删除一个 HMI 页面 + * @param page_id 待删除页面唯一标识 + * @return 页面不存在、页面是唯一页面、仍为初始页面或仍被引用时返回失败结果 + */ HmiEditorResult removePage(const std::string &page_id); + + /** + * @brief 将页面在工程页面列表中上移或下移一位 + * @param page_id 目标页面唯一标识 + * @param offset 只能为 -1(上移)或 1(下移) + * @return 页面不存在或已经位于目标边界时返回失败结果 + */ HmiEditorResult movePage(const std::string &page_id, int offset); + + /** + * @brief 设置工程的初始 HMI 页面 + * @param page_id 要作为启动页面的页面唯一标识 + * @return 页面不存在时返回 PageNotFound + */ HmiEditorResult setInitialPage(const std::string &page_id); /** * @brief 向指定页面添加待配置的基础控件 @@ -108,6 +151,13 @@ public: */ HmiEditorResult removeControl( const std::string &page_id, const std::string &control_id); + + /** + * @brief 一次删除同一页面中的多个控件 + * @param page_id 所属页面唯一标识 + * @param control_ids 待删除控件 ID 列表,不能为空且不能包含重复 ID + * @return 页面、控件不存在或 ID 列表无效时返回失败结果 + */ HmiEditorResult removeControls( const std::string &page_id, const std::vector &control_ids); @@ -136,26 +186,51 @@ public: const std::string &control_id, const HmiControl &control); + /** @brief 判断是否存在可撤销的 HMI 编辑操作 */ bool canUndo() const; + /** @brief 判断是否存在可重做的 HMI 编辑操作 */ bool canRedo() const; + + /** + * @brief 撤销最近一次成功的编辑操作 + * @return 没有可撤销操作时返回 InvalidOperation + */ HmiEditorResult undo(); + + /** + * @brief 重做最近一次被撤销的编辑操作 + * @return 没有可重做操作时返回 InvalidOperation + */ HmiEditorResult redo(); + + /** + * @brief 清空撤销和重做历史 + * + * 不修改当前工程内容 + */ void clearHistory(); private: + // 撤销/重做需要保存的完整 HMI 编辑状态 struct HistoryState { - std::vector pages; - std::string initial_page_id; + std::vector pages; // 页面及其控件的深拷贝 + std::string initial_page_id; // 工程初始页面 ID }; + // 捕获当前工程状态,作为一次编辑操作的前后快照 HistoryState captureState() const; + // 比较编辑前后状态并记录实际发生的修改 void recordHistory(HistoryState before); + // 比较两个历史快照是否完全相同 static bool statesEqual( const HistoryState &left, const HistoryState &right); + // 比较两个 HMI 控件的全部可保存属性 static bool controlsEqual( const HmiControl &left, const HmiControl &right); + // 比较两个 HMI 页面及其控件集合 static bool pagesEqual(const HmiPage &left, const HmiPage &right); + // 生成统一的历史操作失败结果 static HmiEditorResult historyFailure(const std::string &message); /** @@ -183,7 +258,7 @@ private: /** * @brief 按控件类型创建带默认属性的新控件 * @param page 新控件所属页面,用于确定初始位置和唯一标识 - * @param type 新控件类型 + * @param descriptor 新控件类型对应的注册描述 * @return 未绑定寄存器的默认控件配置 */ static HmiControl makeControl( diff --git a/app/src/services/hmi_runtime_service.h b/app/src/services/hmi_runtime_service.h index c4753bf..bea4040 100644 --- a/app/src/services/hmi_runtime_service.h +++ b/app/src/services/hmi_runtime_service.h @@ -18,10 +18,10 @@ enum class HmiRuntimeError { None, // 无错误 - UnsupportedControl, // 控件类型不支持寄存器读写 + UnsupportedControl, // 控件类型不支持寄存器读写或写入操作 MissingBinding, // 控件未配置寄存器绑定 InvalidBinding, // 控件绑定地址无效或地址区域不匹配 - RepositoryFailure // 寄存器仓库读取或写入失败 + RepositoryFailure // 寄存器仓库不可用或拒绝读写 }; /** @@ -31,10 +31,10 @@ enum class HmiRuntimeError */ struct HmiRuntimeReadResult { - bool succeeded = false; - HmiRuntimeError error = HmiRuntimeError::None; - bool bit_value = false; - std::int16_t word_value = 0; + bool succeeded = false; // 是否成功读到控件绑定值 + HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 + bool bit_value = false; // 位控件的读回值;字控件读取成功时无效 + std::int16_t word_value = 0; // 字控件的读回值;位控件读取成功时无效 }; /** @@ -42,8 +42,8 @@ struct HmiRuntimeReadResult */ struct HmiRuntimeWriteResult { - bool succeeded = false; - HmiRuntimeError error = HmiRuntimeError::None; + bool succeeded = false; // 写入请求是否被仓库接受 + HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 }; /** @@ -51,8 +51,8 @@ struct HmiRuntimeWriteResult */ enum class HmiButtonEvent { - Pressed, - Released + Pressed, // 按钮按下事件 + Released // 按钮释放事件 }; /** @@ -64,6 +64,10 @@ enum class HmiButtonEvent class HmiRuntimeService { public: + /** + * @brief 创建 HMI 运行态读写服务 + * @param repository 当前运行模式提供的寄存器仓库,不由本服务拥有 + */ explicit HmiRuntimeService(RegisterRepository &repository); /** @@ -79,6 +83,9 @@ public: * @param control 按钮控件,必须绑定有效 M 地址 * @param event 当前输入事件 * @return 无需写入的事件返回成功,配置无效或仓库拒绝写入时返回失败 + * + * SetOn、SetOff 和 Toggle 只在按下时写入;MomentaryOn 在按下时写 1、 + * 释放时写 0。写入结果以仓库返回值为准,不直接假定缓存已经更新 */ HmiRuntimeWriteResult operateButton( const HmiControl &control, HmiButtonEvent event); @@ -87,14 +94,29 @@ public: * @param control 数值输入控件,必须绑定有效 D 地址 * @param value 要写入的 D 字值 * @return 非数值输入、绑定无效或仓库拒绝写入时返回失败结果 + * + * 该服务只负责提交写入请求,实际读回值由后续仓库轮询确认 */ HmiRuntimeWriteResult writeNumericInput( const HmiControl &control, std::int16_t value); + + /** + * @brief 读取指定 M 位 + * @param address 必须是有效的 M 区地址 + * @return 仓库原始读取结果,不转换为 HMI 错误分类 + */ BitReadResult readBit(const RegisterAddress &address) const; + + /** + * @brief 读取指定 D 字 + * @param address 必须是有效的 D 区地址 + * @return 仓库原始读取结果,不转换为 HMI 错误分类 + */ WordReadResult readWord(const RegisterAddress &address) const; private: + // 将寄存器仓库错误统一转换为 HMI 运行态错误 static HmiRuntimeError repositoryError(RegisterError error); - RegisterRepository &repository_; + RegisterRepository &repository_; // 当前运行模式使用的寄存器仓库,不由本服务拥有 }; diff --git a/app/src/services/logic_editor_service.h b/app/src/services/logic_editor_service.h index a74f376..1fb4b06 100644 --- a/app/src/services/logic_editor_service.h +++ b/app/src/services/logic_editor_service.h @@ -12,164 +12,233 @@ class ProjectService; // 梯形图编辑失败分类 enum class LogicEditorError { - None, - LogicNotFound, - RungNotFound, - ExpressionNotFound, - NodeNotFound, - InvalidNode, - InvalidOperation, - UnsupportedNodeChange, - DuplicateName, - LastLogicRequired + None, // 操作成功或没有错误 + LogicNotFound, // 控制逻辑不存在 + RungNotFound, // 网络不存在 + ExpressionNotFound, // 条件表达式不存在 + NodeNotFound, // 节点不存在 + InvalidNode, // 节点配置或节点位置无效 + InvalidOperation, // 操作参数或当前结构不允许该操作 + UnsupportedNodeChange, // 不允许把条件节点改成输出节点,或反向修改 + DuplicateName, // 控制逻辑名称与其他逻辑重复 + LastLogicRequired // 删除后不能少于一个控制逻辑 }; // 梯形图编辑结果;成功时 id 通常是新建或更新对象的稳定 ID struct LogicEditorResult { - bool succeeded = false; - LogicEditorError error = LogicEditorError::None; - std::string message; - std::string id; + bool succeeded = false; // 操作是否成功 + LogicEditorError error = LogicEditorError::None; // 失败时的分类 + std::string message; // 面向用户的 UTF-8 成功说明或失败原因 + std::string id; // 成功时返回新建或更新对象的稳定 ID }; // 负责把 UI 编辑命令转换为结构化表达式树操作,并维护撤销/重做 class LogicEditorService { public: + /** + * @brief 创建梯形图编辑服务 + * @param project_service 用于读取和修改当前工程的项目服务 + */ explicit LogicEditorService(ProjectService &project_service); // 以下查询接口只读工程模型,供编辑器投影和属性面板使用 + /** @brief 按 ID 查找控制逻辑,未找到时返回空指针 */ const ControlLogic *findLogic(const std::string &logic_id) const; + /** @brief 在指定逻辑中按 ID 查找网络,未找到时返回空指针 */ const LadderRung *findRung( const std::string &logic_id, const std::string &rung_id) const; + /** @brief 在指定逻辑的所有网络中按 ID 查找节点,未找到时返回空指针 */ const LogicNode *findNode( const std::string &logic_id, const std::string &node_id) const; + /** @brief 在指定网络中按 ID 查找条件表达式,未找到时返回空指针 */ const ConditionExpression *findExpression( const std::string &logic_id, const std::string &rung_id, const std::string &expression_id) const; + /** @brief 返回工程中第一个控制逻辑 ID,没有逻辑时返回空字符串 */ std::string firstLogicId() const; + /** @brief 返回指定逻辑中第一个网络 ID,没有网络或逻辑不存在时返回空字符串 */ std::string firstRungId(const std::string &logic_id) const; + /** @brief 查找节点所属网络 ID,未找到时返回空字符串 */ std::string rungIdForNode( const std::string &logic_id, const std::string &node_id) const; + /** @brief 返回指定 M/D 地址的工程注释,没有注释时返回空字符串 */ std::string registerCommentFor(const RegisterAddress &address) const; - // 确保新工程至少有一组可编辑逻辑;逻辑组初始允许没有网络 + /** + * @brief 确保工程至少有一组可编辑逻辑 + * @return 已有或新建逻辑的成功结果及其 ID + * + * 新建逻辑时允许暂时没有网络,第一次实际编辑网络内容时再创建网络 + */ LogicEditorResult ensureDefaultLogic(); - // 逻辑组管理 + /** @brief 添加控制逻辑;名称不能为空且必须唯一 */ LogicEditorResult addLogic(const std::string &name); + /** @brief 修改控制逻辑名称;名称不能为空且必须唯一 */ LogicEditorResult renameLogic( const std::string &logic_id, const std::string &name); + /** @brief 删除控制逻辑;工程至少保留一组逻辑 */ LogicEditorResult removeLogic(const std::string &logic_id); + /** @brief 将逻辑在工程列表中上移或下移一位,offset 只能为 -1 或 1 */ LogicEditorResult moveLogic(const std::string &logic_id, int offset); + /** @brief 启用或停用指定控制逻辑 */ LogicEditorResult setLogicEnabled(const std::string &logic_id, bool enabled); - // 网络管理 + /** @brief 在指定逻辑末尾添加空网络 */ LogicEditorResult addRung(const std::string &logic_id); + /** @brief 删除指定网络 */ LogicEditorResult removeRung( const std::string &logic_id, const std::string &rung_id); + /** @brief 修改网络注释;注释长度和换行规则由领域校验约束 */ LogicEditorResult updateRungComment( const std::string &logic_id, const std::string &rung_id, const std::string &comment); // 条件区编辑:串联、按列插入、横线和并联分支 + /** @brief 在网络条件末尾追加一个条件节点 */ LogicEditorResult appendCondition( const std::string &logic_id, const std::string &rung_id, const LogicNodeConfig &config); + /** + * @brief 按绝对条件列插入条件节点 + * @param column 从 0 开始的条件列号;插入位置必须位于允许的条件区 + */ LogicEditorResult insertConditionAtColumn( const std::string &logic_id, const std::string &rung_id, int column, const LogicNodeConfig &config); + /** + * @brief 在直属并联分支的指定视觉列插入条件节点 + * + * 目标列可以是分支已有横线或 UI 投影出的补线格,服务会原子补齐必要横线 + */ LogicEditorResult insertConditionInBranchAtColumn( const std::string &logic_id, const std::string &rung_id, const std::string &branch_expression_id, int column, const LogicNodeConfig &config); + /** @brief 在网络条件末尾追加指定列宽的横线 */ LogicEditorResult appendWire( const std::string &logic_id, const std::string &rung_id, int column_span = 1); + /** @brief 在目标节点后串联插入条件节点 */ LogicEditorResult insertConditionAfter( const std::string &logic_id, const std::string &rung_id, const std::string &target_node_id, const LogicNodeConfig &config); + /** @brief 在目标表达式后串联插入横线 */ LogicEditorResult insertWireAfter( const std::string &logic_id, const std::string &rung_id, const std::string &target_expression_id, int column_span = 1); + /** @brief 用条件节点整体替换一条横线表达式 */ LogicEditorResult replaceWireWithCondition( const std::string &logic_id, const std::string &rung_id, const std::string &wire_expression_id, const LogicNodeConfig &config); + /** @brief 只替换横线表达式中的一个指定列单元格 */ LogicEditorResult replaceWireColumnWithCondition( const std::string &logic_id, const std::string &rung_id, const std::string &wire_expression_id, int column_offset, const LogicNodeConfig &config); + /** + * @brief 将选中的条件节点建立为并联分支 + * @param selected_node_ids 同一网络中按视觉连续范围选择的条件节点 ID + */ LogicEditorResult addParallelBranch( const std::string &logic_id, const std::string &rung_id, const std::vector &selected_node_ids, const LogicNodeConfig &config); + /** @brief 将选中的横线表达式建立为并联旁路 */ LogicEditorResult addParallelWireBranch( const std::string &logic_id, const std::string &rung_id, const std::vector &selected_expression_ids); - // 按选中的横线网格建立旁路;网格必须来自同一条横线且列号连续 + /** + * @brief 按选中的横线网格建立并联旁路 + * + * 所有网格必须来自同一条横线,且列号连续;操作失败时不保留部分修改 + */ LogicEditorResult addParallelWireBranchAtCells( const std::string &logic_id, const std::string &rung_id, const std::vector> &selected_wire_cells); - // 输出槽编辑;每个网络最多一个输出节点 + /** + * @brief 设置网络唯一的输出节点 + * @param configured 是否将新输出标记为已完成配置 + * + * 输出节点固定位于网络输出槽,替换已有输出时保持一次原子编辑 + */ LogicEditorResult setOutput( const std::string &logic_id, const std::string &rung_id, const LogicNodeConfig &config, bool configured = false); // 节点属性和删除操作 + /** @brief 更新节点配置;条件节点和输出节点不能互相改型 */ LogicEditorResult updateNodeConfig( const std::string &logic_id, const std::string &node_id, const LogicNodeConfig &config); + /** @brief 删除指定节点并归一化受影响的表达式树 */ LogicEditorResult removeNode( const std::string &logic_id, const std::string &node_id); + /** + * @brief 批量删除节点 + * @param node_ids 节点 ID 列表,不能为空且不能包含重复或不存在的 ID + * @return 成功时作为一次编辑记录,失败时整体回滚 + */ LogicEditorResult removeNodes( const std::string &logic_id, const std::vector &node_ids); + /** @brief 删除指定条件表达式并归一化表达式树 */ LogicEditorResult removeExpression( const std::string &logic_id, const std::string &rung_id, const std::string &expression_id); + /** @brief 批量删除条件表达式,失败时整体回滚 */ LogicEditorResult removeExpressions( const std::string &logic_id, const std::string &rung_id, const std::vector &expression_ids); // 当前编辑会话的撤销/重做 + /** @brief 判断是否存在可撤销的梯形图编辑操作 */ bool canUndo() const; + /** @brief 判断是否存在可重做的梯形图编辑操作 */ bool canRedo() const; + /** @brief 撤销最近一次成功的梯形图编辑操作 */ LogicEditorResult undo(); + /** @brief 重做最近一次被撤销的梯形图编辑操作 */ LogicEditorResult redo(); + /** @brief 清空撤销/重做历史,不修改当前工程内容 */ void clearHistory(); private: // 只保存逻辑集合快照,当前选择等 UI 会话状态不进入历史 struct HistoryState { - std::vector logics; + std::vector 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( @@ -183,17 +252,25 @@ private: 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); + // 返回节点配置对应的稳定 ID 前缀 static std::string nodePrefix(const LogicNodeConfig &config); + // 在一个控制逻辑的全部网络中生成唯一节点 ID static std::string makeUniqueNodeId( const ControlLogic &logic, const std::string &prefix); + // 生成唯一横线表达式 ID static std::string makeUniqueWireId(const ControlLogic &logic); + // 生成唯一容器表达式 ID static std::string makeUniqueExpressionId(const ControlLogic &logic); + // 生成唯一网络 ID static std::string makeUniqueRungId(const ControlLogic &logic); + // 创建统一的失败结果 static LogicEditorResult failure( LogicEditorError error, const std::string &message); - ProjectService &project_service_; - EditorHistory history_; + ProjectService &project_service_; // 不拥有的工程服务依赖 + EditorHistory history_; // 当前编辑会话的撤销/重做历史 }; diff --git a/app/src/services/offline_simulation_service.h b/app/src/services/offline_simulation_service.h index c0a04cb..9ce46f8 100644 --- a/app/src/services/offline_simulation_service.h +++ b/app/src/services/offline_simulation_service.h @@ -12,25 +12,25 @@ // 离线扫描器的生命周期状态 enum class SimulationState { - Stopped, - Running, - Faulted + Stopped, // 未运行或已主动停止 + Running, // 定时器正在驱动离线扫描 + Faulted // 最近一轮扫描失败,定时器已停止 }; // 启动离线仿真失败的原因 enum class SimulationStartError { - None, - AlreadyRunning, - InvalidLogic + None, // 启动成功 + AlreadyRunning, // 当前已经处于运行态 + InvalidLogic // 启动前逻辑校验失败 }; // 离线仿真启动结果;detail 保存领域执行器的具体校验错误 struct SimulationStartResult { - bool succeeded = false; - SimulationStartError error = SimulationStartError::None; - LogicScanResult detail; + bool succeeded = false; // 是否成功启动仿真 + SimulationStartError error = SimulationStartError::None; // 启动失败分类 + LogicScanResult detail; // 领域执行器的详细校验或执行结果 }; // 管理离线逻辑快照、固定周期扫描和实际执行状态 @@ -39,40 +39,54 @@ class OfflineSimulationService final : public QObject Q_OBJECT public: + // 默认离线扫描周期,单位为毫秒 static constexpr int kDefaultScanIntervalMs = 50; + /** + * @brief 创建离线仿真服务 + * @param repository 离线模式使用的虚拟 M/D 寄存器仓库 + * @param parent Qt 对象父级 + */ explicit OfflineSimulationService( VirtualRegisterRepository &repository, QObject *parent = nullptr); - // 复制工程逻辑作为本次会话快照并启动定时扫描 + // 复制工程逻辑作为本次会话快照并启动定时扫描;启动失败时不进入运行态 SimulationStartResult start(const std::vector &logics); - // 停止扫描但保留最近一次轨迹供 UI 显示 + // 停止扫描并清除运行快照,但保留最近一次轨迹供 UI 显示 void stop(); - // 立即执行一轮扫描,主要供定时器和调试调用 + // 立即执行一轮扫描;非 Running 状态调用会返回失败结果 LogicScanResult executeOnce(); + // 返回当前离线仿真生命周期状态 SimulationState state() const; + // 返回当前定时器扫描周期,单位为毫秒 int scanIntervalMs() const; + // 返回本次会话成功完成的扫描轮数 std::uint64_t successfulScanCount() const; + // 返回最近一次扫描错误;无错误时为成功结果 const LogicScanResult &lastError() const; // 返回最近一轮按逻辑 ID 隔离的运行轨迹 const LogicTraceSnapshot &traceSnapshot() const; signals: + // 仿真状态从 Stopped、Running 或 Faulted 之一切换时发出 void stateChanged(); + // 一轮扫描成功完成并更新轨迹后发出 void scanCompleted(); private: + // 定时器到期时触发一轮扫描 void handleTimeout(); + // 保存错误、停止定时器并切换到 Faulted void enterFault(const LogicScanResult &error); - VirtualRegisterRepository &repository_; - SoftwareLogicExecutor executor_; - QTimer timer_; - std::vector logic_snapshot_; - SimulationState state_ = SimulationState::Stopped; - std::uint64_t successful_scan_count_ = 0; - LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}}; - LogicTraceSnapshot trace_snapshot_; + VirtualRegisterRepository &repository_; // 不拥有的虚拟寄存器仓库 + SoftwareLogicExecutor executor_; // 执行逻辑校验和扫描的领域执行器 + QTimer timer_; // 按固定周期触发离线扫描 + std::vector logic_snapshot_; // 本次仿真使用的逻辑快照 + SimulationState state_ = SimulationState::Stopped; // 当前仿真状态 + std::uint64_t successful_scan_count_ = 0; // 当前会话成功扫描次数 + LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}}; // 最近一次错误 + LogicTraceSnapshot trace_snapshot_; // 最近一次扫描的运行轨迹 }; diff --git a/app/src/services/plc_communication_gateway.h b/app/src/services/plc_communication_gateway.h index fc2a55d..f5376e3 100644 --- a/app/src/services/plc_communication_gateway.h +++ b/app/src/services/plc_communication_gateway.h @@ -12,52 +12,58 @@ // PLC 串口和轮询参数;parity 使用 Qt 约定值:0 无、2 偶、3 奇 struct PlcSerialConfiguration { - std::string portName; - int serverAddress = 1; - int baudRate = 9600; - int dataBits = 8; - int parity = 2; - int stopBits = 1; - int responseTimeoutMs = 1000; - int retries = 2; - int pollIntervalMs = 200; + std::string portName; // 串口名称,例如 COM3 + int serverAddress = 1; // Modbus 从站地址,范围为 1~247 + int baudRate = 9600; // 串口波特率,支持 9600、19200、38400、57600 和 115200 + int dataBits = 8; // 数据位,支持 7 或 8 + int parity = 2; // 校验方式,0 为无校验、2 为偶校验、3 为奇校验 + int stopBits = 1; // 停止位,支持 1 或 2 + int responseTimeoutMs = 1000; // 单次 Modbus 请求的响应超时时间,单位为毫秒 + int retries = 2; // 请求失败后的重试次数,范围为 0~5 + int pollIntervalMs = 200; // 轮询周期,单位为毫秒,范围为 50~10000 }; // PLC 异步通信生命周期 enum class PlcConnectionState { - Disconnected, - Connecting, - Connected, - Recovering, - Faulted + Disconnected, // 未建立 PLC 连接 + Connecting, // 正在异步建立 PLC 连接 + Connected, // 串口已连接并进行正常轮询,不代表首读已完成 + Recovering, // 从可恢复通信故障中探测恢复并重新进行首读 + Faulted // 当前通信发生故障,等待恢复探测或重新连接 }; // 通信故障分类,用于状态栏提示和真机运行资格撤销 enum class PlcCommunicationError { - None, - SerialPortOpenFailed, - PlcNotResponding, - UsbSerialAdapterRemoved, - SerialConnectionLost, - CommunicationTimeout, - ProtocolError, - ReadFailed, - WriteFailed, - ConfigurationError, - RequestAborted, - Unknown + None, // 没有待报告的通信错误 + SerialPortOpenFailed, // 串口打开失败 + PlcNotResponding, // PLC 未响应请求 + UsbSerialAdapterRemoved, // USB 转串口设备被移除 + SerialConnectionLost, // 已建立的串口连接意外中断 + CommunicationTimeout, // 请求等待响应超时 + ProtocolError, // 收到的 Modbus 数据不符合协议 + ReadFailed, // 读取请求失败 + WriteFailed, // 写入请求失败 + ConfigurationError, // 串口或通信参数无效 + RequestAborted, // 请求在完成前被中止 + Unknown // 未分类的通信错误 }; // 通信命令的受理结果;真正的读写完成由回调和缓存更新通知 struct PlcCommunicationResult { - bool succeeded = false; - std::string message; + bool succeeded = false; // 命令是否被接受或参数校验是否通过 + std::string message; // 失败原因或补充说明,成功时通常为空 }; -// 校验串口配置,不打开串口也不产生副作用 +/** + * @brief 校验 PLC 串口、Modbus 和轮询参数 + * + * 此函数只检查配置,不打开串口、不启动通信,也不会修改传入对象 + * @param configuration 待校验的连接配置 + * @return 校验通过时返回 succeeded 为 true,否则返回 false 和 UTF-8 错误说明 + */ inline PlcCommunicationResult validatePlcSerialConfiguration( const PlcSerialConfiguration &configuration) { @@ -117,19 +123,56 @@ inline PlcCommunicationResult validatePlcSerialConfiguration( class PlcCommunicationGateway { public: + // 允许通过网关基类指针安全释放具体通信实现 virtual ~PlcCommunicationGateway() = default; + /** + * @brief 校验串口配置并启动异步 PLC 连接 + * @param configuration 本次连接使用的串口、Modbus 和轮询参数 + * @return true 仅表示连接请求已受理,实际连接结果通过状态回调通知 + */ virtual PlcCommunicationResult connectDevice( const PlcSerialConfiguration &configuration) = 0; + + /** + * @brief 停止轮询并断开当前 PLC 连接 + * + * 断开后当前缓存视为无效,首读资格同时清除 + */ virtual void disconnectDevice() = 0; - // 设置后续轮询的 M/D 地址集合;实现可以合并相邻地址块 + + /** + * @brief 设置后续轮询的 M/D 地址集合 + * + * 实现可以校验、排序、去重并合并相邻地址块;已有读请求进行时, + * 新集合可以延后到当前请求完成后生效 + * @param addresses 需要周期性读回的 M/D 地址集合,空集合使用实现的默认探测地址 + * @return true 表示集合已接受,false 表示地址或轮询资源校验失败 + */ virtual PlcCommunicationResult setPollAddresses( const std::vector &addresses) = 0; + + // 返回当前连接生命周期状态;Connected 不代表首读资格已经完成 virtual PlcConnectionState state() const = 0; + + // 返回本次连接是否已完成全部轮询块的成功首读 virtual bool initialReadCompleted() const = 0; + + // 返回最近一次通信错误的分类;没有错误或错误已清除时返回 None virtual PlcCommunicationError lastErrorType() const = 0; + + // 返回最近一次通信错误的 UTF-8 可读文本;没有错误时返回空字符串 virtual const std::string &lastError() const = 0; - // 注册通信状态、首读资格、缓存更新和错误通知 + + /** + * @brief 替换异步通信事件回调 + * + * 传入空 std::function 可取消对应通知;回调由实现在线程或事件循环中按事件发生时调用 + * @param state_changed 连接状态发生变化时调用 + * @param initial_read_changed 首读资格发生变化时调用,参数表示当前是否已完成首读 + * @param cache_updated 任一轮询块成功更新 PLC 缓存后调用 + * @param error_reported 发生通信错误时调用,参数为 UTF-8 可读错误文本 + */ virtual void setCallbacks( std::function state_changed, std::function initial_read_changed, diff --git a/app/src/services/register_monitor_service.h b/app/src/services/register_monitor_service.h index 5540c6f..90d86e4 100644 --- a/app/src/services/register_monitor_service.h +++ b/app/src/services/register_monitor_service.h @@ -11,53 +11,96 @@ // 自由监控列表编辑的失败分类 enum class RegisterMonitorError { - None, - InvalidAddress, - RangeOverflow, - LimitExceeded, - NoChange + None, // 操作成功 + InvalidAddress, // 起始地址格式或区域不支持 + RangeOverflow, // 连续范围超出 M/D 地址上限 + LimitExceeded, // 监控地址数量超过会话上限 + NoChange // 操作没有产生任何列表变化 }; // 监控列表编辑结果;affectedCount 表示本次受影响的地址数量 struct RegisterMonitorResult { - bool succeeded = false; - RegisterMonitorError error = RegisterMonitorError::None; - std::string message; - int affectedCount = 0; + bool succeeded = false; // 列表编辑是否成功 + RegisterMonitorError error = RegisterMonitorError::None; // 失败时的分类 + std::string message; // 面向用户的 UTF-8 成功说明或失败原因 + int affectedCount = 0; // 本次新增或删除的地址数量 }; // 自由监控写入结果;底层 RegisterError 会原样保留 struct RegisterMonitorWriteResult { - bool succeeded = false; - RegisterError error = RegisterError::Unavailable; - std::string message; + bool succeeded = false; // 写入请求是否成功提交 + RegisterError error = RegisterError::Unavailable; // 底层仓库错误分类 + std::string message; // 面向用户的 UTF-8 错误说明 }; -// 编排监控模型和寄存器仓库,地址列表属于当前会话 +/** + * @brief 编排自由监控地址列表和寄存器仓库读写 + * + * 地址列表只属于当前运行会话,不写入工程文件;仓库由当前运行模式注入 + */ class RegisterMonitorService { public: + /** + * @brief 创建自由监控服务 + * @param repository 当前运行模式使用的寄存器仓库,不由服务拥有 + */ explicit RegisterMonitorService(RegisterRepository &repository); - // 从起始地址连续添加 count 个同区域地址 + /** + * @brief 从起始地址连续添加同一区域的多个地址 + * @param start_address 文本地址,例如 M0 或 D100 + * @param count 连续地址数量,必须为正且不能越过 4000 + * @return 去重后实际新增数量;格式、范围、容量或无变化时返回失败 + */ RegisterMonitorResult addRange(const std::string &start_address, int count); - // 删除一批地址并通知 UI 刷新轮询集合 + /** + * @brief 删除一批监控地址 + * @param addresses 待删除地址;不存在的地址会被忽略 + * @return 实际删除数量,并在发生变化时通知轮询集合更新 + */ RegisterMonitorResult remove(const std::vector &addresses); - // 清空监控列表 + /** + * @brief 清空当前会话的监控地址列表 + * @return 实际清除数量;列表本来为空时返回 NoChange + */ RegisterMonitorResult clear(); - // 对单个 M 地址写位 + /** + * @brief 对单个 M 地址提交位写入 + * @param address 必须是有效的 M 区地址 + * @param value 要写入的位值 + * @return 成功或包含底层 RegisterError 和用户可读文本的失败结果 + */ RegisterMonitorWriteResult writeBit(const RegisterAddress &address, bool value); - // 对单个 D 地址写字 + /** + * @brief 对单个 D 地址提交有符号 16 位字写入 + * @param address 必须是有效的 D 区地址 + * @param value 要写入的 D 字值 + * @return 成功或包含底层 RegisterError 和用户可读文本的失败结果 + */ RegisterMonitorWriteResult writeWord( const RegisterAddress &address, std::int16_t value); + /** + * @brief 返回当前会话的监控地址列表 + * @return 按服务内部顺序保存的只读地址列表 + */ const std::vector &addresses() const; + /** + * @brief 读取监控列表的当前值并生成 UI 展示模型 + * @param communication_fault 通信处于故障态时,将成功读回值标记为通信故障 + * @return 每个监控地址对应一个值和可用性状态 + */ std::vector values(bool communication_fault) const; + /** + * @brief 设置监控地址变化通知回调 + * @param callback 地址列表新增、删除或清空后调用;可传空函数取消通知 + */ void setAddressesChangedCallback(std::function callback); private: - RegisterRepository &repository_; - RegisterMonitorModel model_; - std::function addresses_changed_callback_; + RegisterRepository &repository_; // 当前运行模式的寄存器仓库,不由服务拥有 + RegisterMonitorModel model_; // 当前会话的去重监控地址列表 + std::function addresses_changed_callback_; // 地址变化后的轮询刷新通知 }; diff --git a/app/src/services/runtime_mode_service.h b/app/src/services/runtime_mode_service.h index 010edeb..27dae7e 100644 --- a/app/src/services/runtime_mode_service.h +++ b/app/src/services/runtime_mode_service.h @@ -24,11 +24,21 @@ class RegisterRepository; class RuntimeModeService { public: + /** + * @brief 创建运行模式服务 + * @param project_service 只读工程服务,用于运行前校验和收集轮询地址 + * @param offline_simulation_service 离线仿真服务,由应用层负责其生命周期 + */ RuntimeModeService( const ProjectService &project_service, OfflineSimulationService &offline_simulation_service); + + /** + * @brief 解除 PLC 网关回调绑定 + */ ~RuntimeModeService(); + /** @brief 获取当前应用运行模式 */ ApplicationMode mode() const; /** * @brief 获取当前模式下允许的工程编辑和寄存器使用策略 @@ -65,35 +75,71 @@ public: */ bool initialPlcReadCompleted() const; + /** @brief 获取离线仿真当前状态 */ SimulationState simulationState() const; + /** @brief 获取离线仿真成功完成的扫描轮数 */ std::uint64_t successfulScanCount() const; + /** @brief 获取离线仿真最近一次扫描错误 */ const LogicScanResult &simulationError() const; + /** @brief 返回服务持有的离线仿真服务引用 */ OfflineSimulationService &offlineSimulationService(); - // 注入 PLC 网关、活动仓库和两种实际数据源;应用生命周期内只调用一次 + + /** + * @brief 注入 PLC 网关、活动仓库和两种实际数据源 + * + * 应用生命周期内只调用一次;真机模式使用 PLC 仓库,编辑/离线模式使用虚拟仓库 + */ void configurePlc( PlcCommunicationGateway &gateway, ActiveRegisterRepository &active_repository, RegisterRepository &virtual_repository, RegisterRepository &plc_repository); - // 连接 PLC 并开始异步通信,不阻塞 UI 等待首读 + /** + * @brief 连接 PLC 并开始异步通信 + * @param configuration 串口、Modbus 和轮询配置 + * @return 连接请求受理结果,不阻塞 UI 等待首读完成 + */ PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration); + + /** + * @brief 设置自由监控引用的地址并刷新 PLC 轮询集合 + * @param addresses 自由监控当前需要读取的 M/D 地址 + */ void setMonitorAddresses(const std::vector &addresses); + + /** + * @brief 汇总工程和自由监控引用并刷新 PLC 轮询地址 + * @return 网关未配置或地址数量超限时返回失败结果 + */ PlcCommunicationResult refreshPlcPollAddresses(); + + /** + * @brief 退出真机运行、断开 PLC 并清除首读资格 + */ void disconnectPlc(); + + /** @brief 获取当前 PLC 连接状态;网关未配置时返回 Disconnected */ PlcConnectionState plcConnectionState() const; + + /** @brief 获取 PLC 最近一次通信错误文本;网关未配置时返回空字符串 */ const std::string &plcError() const; + + /** + * @brief 设置 PLC 状态变化通知回调 + * @param callback 状态、首读资格或通信错误变化时调用的函数;可传空函数取消通知 + */ void setPlcStatusChangedCallback(std::function callback); private: - const ProjectService &project_service_; - OfflineSimulationService &offline_simulation_service_; - RuntimeState state_; - // 表示 PLC 缓存是否已通过至少一次有效读取建立 + const ProjectService &project_service_; // 不拥有的只读工程服务 + OfflineSimulationService &offline_simulation_service_; // 不拥有的离线仿真服务 + RuntimeState state_; // 编辑、离线和真机模式状态机 + // PLC 缓存是否已通过有效首读建立,通信故障时清除 bool initial_plc_read_completed_ = false; - PlcCommunicationGateway *plc_gateway_ = nullptr; - ActiveRegisterRepository *active_repository_ = nullptr; - RegisterRepository *virtual_repository_ = nullptr; - RegisterRepository *plc_repository_ = nullptr; - std::function plc_status_changed_callback_; - std::vector monitor_addresses_; + PlcCommunicationGateway *plc_gateway_ = nullptr; // 不拥有的 PLC 通信网关 + ActiveRegisterRepository *active_repository_ = nullptr; // 当前模式使用的仓库代理 + RegisterRepository *virtual_repository_ = nullptr; // 离线/编辑模式的虚拟仓库 + RegisterRepository *plc_repository_ = nullptr; // 真机模式的 PLC 缓存仓库 + std::function plc_status_changed_callback_; // PLC 状态变化通知 + std::vector monitor_addresses_; // 自由监控额外引用的地址 }; diff --git a/app/src/services/software_logic_executor.cpp b/app/src/services/software_logic_executor.cpp index 964ba39..6d1d98c 100644 --- a/app/src/services/software_logic_executor.cpp +++ b/app/src/services/software_logic_executor.cpp @@ -383,6 +383,8 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( RegisterRepository &repository, bool *value) { + // 根据条件节点的具体配置读取 M/D 或内部 T/C 状态,并计算触点结果 + // 边沿触点使用 logic_id 和 node.id 组成运行时状态键,避免同名节点互相影响 if (value == nullptr) { return failure( @@ -400,6 +402,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( using Config = std::decay_t; if constexpr (std::is_same_v) { + // 普通触点直接读取 M 位;常闭触点使用读回值的反值 const BitReadResult read = repository.readBit(config.address); if (!read.succeeded) { @@ -416,6 +419,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( } else if constexpr (std::is_same_v) { + // 边沿触点需要比较本轮与上一轮的 M 位值,只在目标边沿出现时导通 const BitReadResult read = repository.readBit(config.address); if (!read.succeeded) { @@ -435,18 +439,21 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( } else if constexpr (std::is_same_v) { + // 定时器触点不读 PLC 地址,而是读取本地 TON 的完成状态 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) { + // 计数器触点读取本地 CTU/CTD 的完成状态 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) { + // 字比较触点读取 D 字,再按配置的比较运算符与目标值比较 const WordReadResult read = repository.readWord(config.address); if (!read.succeeded) { @@ -462,6 +469,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( } else { + // 输出节点只能由 executeOutput 处理,不能作为条件触点计算 return failure( LogicScanError::InvalidLogic, "条件节点不能包含输出节点", diff --git a/app/src/services/software_logic_executor.h b/app/src/services/software_logic_executor.h index f94bc93..d51369f 100644 --- a/app/src/services/software_logic_executor.h +++ b/app/src/services/software_logic_executor.h @@ -11,75 +11,78 @@ #include #include -// 一轮离线扫描可能返回的错误 +// 一轮软件逻辑扫描可能返回的错误 enum class LogicScanError { - None, - InvalidLogic, - ConflictingOutput, - RegisterReadFailed, - RegisterWriteFailed + None, // 扫描成功 + InvalidLogic, // 逻辑结构或运行配置无效 + ConflictingOutput, // 多个输出以不兼容的线圈模式写入同一地址 + RegisterReadFailed, // 扫描读取寄存器失败 + RegisterWriteFailed // 扫描写入寄存器失败 }; // 扫描结果;失败时携带出错的逻辑、网络和节点 ID struct LogicScanResult { - bool succeeded = false; - LogicScanError error = LogicScanError::None; - std::string message; - std::string logicId; - std::string rungId; - std::string nodeId; + bool succeeded = false; // 本轮扫描是否完成 + LogicScanError error = LogicScanError::None; // 失败时的分类 + std::string message; // UTF-8 可读结果或失败原因 + std::string logicId; // 失败关联的控制逻辑 ID + std::string rungId; // 失败关联的网络 ID + std::string nodeId; // 失败关联的节点 ID }; // TON 节点在最近一轮扫描中的可视化状态 struct TonTraceValue { - bool input = false; - bool done = false; - std::int64_t elapsedMs = 0; - int presetMs = 0; + bool input = false; // 本轮 TON 输入状态 + bool done = false; // 当前是否已经到达预置时间 + std::int64_t elapsedMs = 0; // 已累计时间,单位为毫秒 + int presetMs = 0; // 预置时间,单位为毫秒 }; // CTU/CTD 节点在最近一轮扫描中的可视化状态 struct CounterTraceValue { - bool input = false; - bool reset = false; - bool done = false; - std::int16_t value = 0; - std::int16_t preset = 0; + bool input = false; // 本轮计数输入状态 + bool reset = false; // 本轮复位输入状态 + bool done = false; // 当前计数值是否达到预置值 + std::int16_t value = 0; // 当前计数值 + std::int16_t preset = 0; // 计数预置值 }; // MOVE/ADD/SUB 节点的结果值和溢出标记 struct WordTraceValue { - std::int16_t value = 0; - bool overflow = false; + std::int16_t value = 0; // MOVE/ADD/SUB 的结果值 + bool overflow = false; // ADD/SUB 是否发生饱和溢出 }; -// 一条逻辑或一个网络的运行轨迹快照 +// 一条控制逻辑的运行轨迹快照;各映射使用节点、表达式或网络 ID 作为键 struct LogicTraceValues { - std::unordered_map nodeValues; - std::unordered_map nodePowerValues; - std::unordered_map expressionValues; - std::unordered_map expressionInputValues; - std::unordered_map expressionPowerValues; - std::unordered_map rungValues; - std::unordered_map tonValues; - std::unordered_map counterValues; - std::unordered_map wordValues; - + std::unordered_map nodeValues; // 节点最终逻辑值 + std::unordered_map nodePowerValues; // 节点输入电源状态 + std::unordered_map expressionValues; // 条件表达式最终值 + std::unordered_map expressionInputValues; // 表达式输入状态 + std::unordered_map expressionPowerValues; // 表达式输出电源状态 + std::unordered_map rungValues; // 网络最终逻辑值 + std::unordered_map tonValues; // TON 节点状态 + std::unordered_map counterValues; // CTU/CTD 节点状态 + std::unordered_map wordValues; // MOVE/ADD/SUB 节点状态 + + // 清除当前逻辑或网络的全部轨迹 void clear(); }; -// 全工程轨迹;logicValues 按逻辑 ID 隔离,避免节点 ID 重复互相覆盖 +// 全工程轨迹;logicValues 按逻辑 ID 隔离,避免不同逻辑中的节点 ID 互相覆盖 struct LogicTraceSnapshot : LogicTraceValues { - std::unordered_map logicValues; + std::unordered_map logicValues; // 按逻辑 ID 保存的轨迹 + // 清除全工程轨迹及按逻辑分组的轨迹 void clear(); + // 返回指定逻辑的轨迹投影;不存在时返回空轨迹 LogicTraceSnapshot forLogic(const std::string &logic_id) const; }; @@ -90,16 +93,41 @@ public: using Clock = std::chrono::steady_clock; using TimePoint = Clock::time_point; - // 在启动仿真前检查所有启用逻辑及 T/C 资源引用 + /** + * @brief 检查逻辑是否满足软件扫描的运行要求 + * @param logics 待检查的控制逻辑集合 + * @return 成功或包含逻辑、网络、节点定位信息的失败结果 + * + * 检查逻辑结构、启用逻辑的运行配置、共享输出冲突以及 T/C 资源引用 + */ LogicScanResult validate(const std::vector &logics) const; - // 清除沿触发、定时器和计数器的跨扫描运行状态 + + /** + * @brief 清除所有跨扫描运行状态 + * + * 重置边沿触发、TON 定时器和 CTU/CTD 计数器;不修改寄存器仓库和轨迹对象 + */ void resetRuntime(); - // 使用当前 steady_clock 执行一轮扫描 + + /** + * @brief 使用当前 steady_clock 执行一轮完整扫描 + * @param logics 按工程顺序扫描的控制逻辑集合 + * @param repository 扫描读取和写入的寄存器仓库 + * @param trace 可选的轨迹输出;非空时会先清空再写入本轮结果 + * @return 扫描结果,失败时不会返回部分成功状态作为成功结果 + */ LogicScanResult executeScan( const std::vector &logics, RegisterRepository &repository, LogicTraceSnapshot *trace = nullptr); - // 使用指定时间执行扫描,便于稳定验证 TON 等时间逻辑 + /** + * @brief 使用指定时间执行一轮扫描 + * @param logics 按工程顺序扫描的控制逻辑集合 + * @param repository 扫描读取和写入的寄存器仓库 + * @param now 本轮扫描使用的单调时钟时间点 + * @param trace 可选的轨迹输出;适合测试 TON 等时间相关逻辑 + * @return 扫描结果 + */ LogicScanResult executeScanAt( const std::vector &logics, RegisterRepository &repository, @@ -107,19 +135,38 @@ public: LogicTraceSnapshot *trace = nullptr); private: + // 单个 TON 定时器跨扫描保存的计时状态 struct TonRuntimeState { - bool timing = false; - bool done = false; - TimePoint startedAt{}; - std::chrono::milliseconds elapsed{0}; + bool timing = false; // 是否正在累计输入有效时间 + bool done = false; // 是否已经完成预置时间 + TimePoint startedAt{}; // 本次计时开始时间 + std::chrono::milliseconds elapsed{0}; // 最近一次计算的累计时间 }; + /** + * @brief 计算单个条件节点的导通结果 + * @param logic_id 所属控制逻辑 ID,用于隔离边沿触点的跨扫描状态 + * @param node 待读取和计算的条件节点 + * @param repository 提供 M/D 值的寄存器仓库;T/C 条件读取执行器内部状态 + * @param value 输出节点导通结果,不能为空 + * @return 成功结果,或包含节点 ID 的逻辑/寄存器读取失败结果 + */ LogicScanResult evaluateCondition( const std::string &logic_id, const LogicNode &node, RegisterRepository &repository, bool *value); + /** + * @brief 递归计算串联、并联或叶子条件表达式 + * @param logic_id 所属控制逻辑 ID,用于传递给叶子节点状态计算 + * @param expression 待读取的条件表达式树 + * @param repository 提供 M/D 值的寄存器仓库 + * @param trace 可选的轨迹输出,用于记录表达式值、电源输入和电源输出 + * @param input_power 进入当前表达式的电源状态 + * @param value 输出表达式最终值,不能为空 + * @return 成功结果,或包含失败节点 ID 的扫描错误 + */ LogicScanResult evaluateExpression( const std::string &logic_id, const ConditionExpression &expression, @@ -127,6 +174,17 @@ private: LogicTraceValues *trace, bool input_power, bool *value); + /** + * @brief 在网络结果驱动下执行单个输出节点 + * @param logic_id 所属控制逻辑 ID,用于隔离计数器等跨扫描状态 + * @param node 待读取配置并执行的输出节点 + * @param rung_value 当前网络的逻辑结果,决定输出是否动作 + * @param repository 提供输出读写所需的 M/D 寄存器仓库 + * @param now 本轮扫描的单调时钟时间点,TON 使用它计算累计时间 + * @param trace 可选的轨迹输出,用于记录 TON、计数器和字操作结果 + * @param output_value 输出节点最终逻辑值,不能为空 + * @return 成功结果,或包含节点 ID 的逻辑/寄存器读写失败结果 + */ LogicScanResult executeOutput( const std::string &logic_id, const LogicNode &node, @@ -135,18 +193,30 @@ private: TimePoint now, LogicTraceValues *trace, bool *output_value); + /** + * @brief 解析并读取一个字操作数 + * @param operand 常量或 D 寄存器操作数配置 + * @param repository 读取寄存器操作数时使用的仓库 + * @param value 输出解析后的 int16 数值,不能为空 + * @param node_id 关联节点 ID,用于构造可定位的失败结果 + * @return 常量读取成功,或 D 寄存器读取/操作数类型校验失败 + */ LogicScanResult readWordOperand( const WordOperand &operand, RegisterRepository &repository, std::int16_t *value, const std::string &node_id) const; + // 按逻辑 ID 和节点 ID 保存上一轮边沿输入 std::map, bool> previous_edge_inputs_; + // 按逻辑 ID 和节点 ID 保存上一轮计数输入 std::map, bool> previous_counter_inputs_; + // 按定时器编号保存跨扫描状态 std::unordered_map ton_states_; + // 单个计数器跨扫描保存的完成状态 struct CounterRuntimeState { - bool done = false; + bool done = false; // 是否已经达到预置值 }; - std::unordered_map counter_states_; + std::unordered_map counter_states_; // 按计数器编号保存状态 }; diff --git a/app/src/ui/alarm_configuration_dialog.h b/app/src/ui/alarm_configuration_dialog.h index aee7304..cac77ce 100644 --- a/app/src/ui/alarm_configuration_dialog.h +++ b/app/src/ui/alarm_configuration_dialog.h @@ -20,13 +20,15 @@ class AlarmConfigurationDialog; class AlarmEditorService; struct AlarmDefinition; -/** 报警定义编辑对话框;保存和删除都委托 AlarmEditorService */ +/** 报警定义编辑对话框;保存、更新和删除都委托 AlarmEditorService */ class AlarmConfigurationDialog final : public QDialog { public: + /** 创建报警配置对话框并绑定报警编辑服务 */ explicit AlarmConfigurationDialog( AlarmEditorService &service, QWidget *parent = nullptr); + /** 释放 Designer 界面对象 */ ~AlarmConfigurationDialog() override; private: diff --git a/app/src/ui/free_monitor_widget.h b/app/src/ui/free_monitor_widget.h index 6c00a53..5ecd095 100644 --- a/app/src/ui/free_monitor_widget.h +++ b/app/src/ui/free_monitor_widget.h @@ -27,9 +27,11 @@ class FreeMonitorWidget final : public QWidget Q_OBJECT public: + /** 创建自由监控控件并绑定监控服务 */ explicit FreeMonitorWidget( RegisterMonitorService &service, QWidget *parent = nullptr); + /** 释放 Designer 界面对象 */ ~FreeMonitorWidget() override; /** 真机未连接、通信故障或编辑态时应关闭写入入口 */ diff --git a/app/src/ui/logic_editor_widget.h b/app/src/ui/logic_editor_widget.h index aaf2129..23f9af2 100644 --- a/app/src/ui/logic_editor_widget.h +++ b/app/src/ui/logic_editor_widget.h @@ -27,6 +27,7 @@ class LogicEditorWidget final : public QGraphicsView Q_OBJECT public: + /** 梯形图中的条件、输出、横线和辅助选择图元类型 */ class NodeItem; class WireItem; class WireCellItem; @@ -34,11 +35,12 @@ public: class RungItem; class EmptySlotItem; + /** 创建梯形图画布并绑定编辑服务 */ explicit LogicEditorWidget( LogicEditorService &editor_service, QWidget *parent = nullptr); - /** 切换当前显示的控制逻辑 */ + /** 切换当前显示的控制逻辑;不存在时显示空场景 */ void setLogicId(const std::string &logic_id); /** 设置是否允许编辑梯形图 */ void setEditingEnabled(bool enabled); diff --git a/app/src/ui/logic_instruction_dialog.h b/app/src/ui/logic_instruction_dialog.h index e0b2837..05c982b 100644 --- a/app/src/ui/logic_instruction_dialog.h +++ b/app/src/ui/logic_instruction_dialog.h @@ -27,9 +27,11 @@ class LogicInstructionDialog final : public QDialog Q_OBJECT public: + /** 使用已有节点配置初始化指令参数表单 */ explicit LogicInstructionDialog( const LogicNodeConfig &config, QWidget *parent = nullptr); + /** 释放 Designer 界面对象 */ ~LogicInstructionDialog() override; /** 返回当前表单配置,调用方仍需交给 LogicEditorService 校验 */ diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index 02cb29f..dd23a37 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -1,7 +1,7 @@ /** * @file main_window.h * @brief 定义综合平台编程器主窗口及运行模式界面协调逻辑 - * @version 0.2.0 + * @version 1.0.0 * @author suyu * @date 2026-08-08 */ @@ -76,6 +76,11 @@ public: RegisterCommentService ®ister_comment_service, RegisterMonitorService ®ister_monitor_service, QWidget *parent = nullptr); + /** + * @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务 + * + * 两个构造函数的区别只在导航服务的所有权;其余服务均由应用入口管理 + */ MainWindow( RuntimeModeService &runtime_mode_service, ProjectService &project_service, @@ -89,6 +94,7 @@ public: RegisterMonitorService ®ister_monitor_service, QWidget *parent = nullptr); + /** 释放窗口拥有的控制器和可选导航服务 */ ~MainWindow() override; /** 返回当前选中的 HMI 页面标识 */ diff --git a/app/src/ui/plc_connection_dialog.h b/app/src/ui/plc_connection_dialog.h index 2927f27..4662780 100644 --- a/app/src/ui/plc_connection_dialog.h +++ b/app/src/ui/plc_connection_dialog.h @@ -24,9 +24,11 @@ class PlcConnectionDialog final : public QDialog Q_OBJECT public: + /** 使用现有配置初始化 PLC 参数表单 */ explicit PlcConnectionDialog( const PlcSerialConfiguration &configuration, QWidget *parent = nullptr); + /** 释放 Designer 界面对象 */ ~PlcConnectionDialog() override; /** 返回表单中的串口配置,连接前由网关再次校验 */ diff --git a/app/src/ui/project_workspace_controller.h b/app/src/ui/project_workspace_controller.h index 9255c62..f191f90 100644 --- a/app/src/ui/project_workspace_controller.h +++ b/app/src/ui/project_workspace_controller.h @@ -48,6 +48,7 @@ public: /** 通知主窗口编辑状态已经发生变化 */ using EditStateChanged = std::function; + /** 创建工程树控制器并绑定主窗口服务和编辑器 */ ProjectWorkspaceController( QWidget &parent, Ui::MainWindow &ui, @@ -97,9 +98,9 @@ private: /** 工程树项目的三种业务类型 */ enum class ItemKind { - Root = 0, - HmiPage = 1, - ControlLogic = 2 + Root = 0, // 工程树根节点 + HmiPage = 1, // HMI 页面节点 + ControlLogic = 2 // 控制逻辑节点 }; /** 处理工程树选择变化并刷新当前编辑器 */ diff --git a/app/src/ui/property_panel_controller.h b/app/src/ui/property_panel_controller.h index 6ebf256..f843f53 100644 --- a/app/src/ui/property_panel_controller.h +++ b/app/src/ui/property_panel_controller.h @@ -42,6 +42,7 @@ public: using StatusReporter = std::function; + /** 创建属性面板控制器并注入编辑服务和选择状态 */ PropertyPanelController( QWidget &parent, Ui::MainWindow &ui, diff --git a/app/src/ui/register_comment_dialog.h b/app/src/ui/register_comment_dialog.h index 1b7eb30..6463e74 100644 --- a/app/src/ui/register_comment_dialog.h +++ b/app/src/ui/register_comment_dialog.h @@ -25,9 +25,11 @@ class RegisterCommentService; class RegisterCommentDialog final : public QDialog { public: + /** 创建寄存器注释对话框并绑定注释服务 */ explicit RegisterCommentDialog( RegisterCommentService &service, QWidget *parent = nullptr); + /** 释放 Designer 界面对象 */ ~RegisterCommentDialog() override; private: diff --git a/app/src/ui/runtime_monitor_widget.h b/app/src/ui/runtime_monitor_widget.h index 429c917..9f4d374 100644 --- a/app/src/ui/runtime_monitor_widget.h +++ b/app/src/ui/runtime_monitor_widget.h @@ -38,6 +38,7 @@ class RuntimeMonitorWidget final : public QWidget Q_OBJECT public: + /** 创建组合 HMI、梯形图和自由监控的运行投影 */ RuntimeMonitorWidget( HmiEditorService &hmi_editor_service, HmiRuntimeService &hmi_runtime_service, @@ -47,6 +48,7 @@ public: AlarmService &alarm_service, RegisterMonitorService ®ister_monitor_service, QWidget *parent = nullptr); + /** 释放组合的运行监控子控件 */ ~RuntimeMonitorWidget() override; /** 将当前页面和控制逻辑绑定到运行监控界面 */ diff --git a/app/src/ui/runtime_monitor_window.h b/app/src/ui/runtime_monitor_window.h index e1e21a0..cc7450b 100644 --- a/app/src/ui/runtime_monitor_window.h +++ b/app/src/ui/runtime_monitor_window.h @@ -25,7 +25,9 @@ class RuntimeMonitorWindow final : public QMainWindow Q_OBJECT public: + /** 创建运行监控顶层窗口 */ explicit RuntimeMonitorWindow(QWidget *parent = nullptr); + /** 释放窗口和 Designer 界面对象 */ ~RuntimeMonitorWindow() override; /** 将运行监控控件放入窗口中央区域 */ diff --git a/app/src/ui/runtime_panel_controller.h b/app/src/ui/runtime_panel_controller.h index da99424..bddff79 100644 --- a/app/src/ui/runtime_panel_controller.h +++ b/app/src/ui/runtime_panel_controller.h @@ -45,6 +45,7 @@ public: /** 请求上层退出当前运行态 */ using RuntimeExitRequester = std::function; + /** 创建运行面板控制器并注入运行模式、编辑器和监控窗口依赖 */ RuntimePanelController( QWidget &parent, RuntimeModeService &runtime_mode_service, @@ -64,6 +65,7 @@ public: StatusReporter status_reporter, OutputReporter output_reporter, RuntimeExitRequester runtime_exit_requester); + /** 停止刷新并释放控制器拥有的运行监控窗口 */ ~RuntimePanelController(); /** 连接运行监控相关信号并完成初始配置 */ diff --git a/app/src/ui/toolbar_icon_factory.h b/app/src/ui/toolbar_icon_factory.h index 871c5e6..bf63ab2 100644 --- a/app/src/ui/toolbar_icon_factory.h +++ b/app/src/ui/toolbar_icon_factory.h @@ -13,55 +13,55 @@ /** 工具栏中使用的语义图标类型 */ 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 + Edit, // 编辑模式 + RunOffline, // 离线运行 + RunOnline, // 真机运行 + NewDocument, // 新建工程 + Save, // 保存工程 + SaveAs, // 另存工程 + Open, // 打开工程 + Undo, // 撤销 + Redo, // 重做 + Exit, // 退出程序 + PlcSettings, // PLC 连接设置 + Disconnect, // 断开 PLC + RegisterComments, // 寄存器注释 + HmiButton, // HMI 按钮 + Indicator, // HMI 指示灯 + NumericDisplay, // HMI 数值显示 + NumericInput, // HMI 数值输入 + ProgressBar, // HMI 进度条 + Text, // HMI 文本 + PageJump, // HMI 页面跳转 + AlarmList, // HMI 报警列表 + AlarmSettings, // 报警配置 + Delete, // 删除 + AddRung, // 新增网络 + HorizontalWire, // 横线 + VerticalWire, // 竖线 + ParallelBranch, // 并联支路 + NormallyOpenContact, // 常开触点 + NormallyClosedContact, // 常闭触点 + RisingEdgeContact, // 上升沿触点 + FallingEdgeContact, // 下降沿触点 + TimerContact, // 定时器触点 + CounterContact, // 计数器触点 + Coil, // 普通线圈 + SetCoil, // 置位线圈 + ResetCoil, // 复位线圈 + Ton, // TON 定时器 + Ctu, // CTU 加计数器 + Ctd, // CTD 减计数器 + Move, // MOVE 指令 + Add, // ADD 指令 + Subtract, // SUB 指令 + Compare, // 比较指令 + Comment, // 注释 + More, // 更多操作 + HmiPage, // HMI 页面 + Logic, // 控制逻辑 + Runtime, // 运行监控 + ClearList // 清空列表 }; /** diff --git a/app/tests/main_window_tests.cpp b/app/tests/main_window_tests.cpp deleted file mode 100644 index a7230f6..0000000 --- a/app/tests/main_window_tests.cpp +++ /dev/null @@ -1,2689 +0,0 @@ -#include "domain/active_register_repository.h" -#include "domain/project_storage.h" -#include "domain/project_limits.h" -#include "domain/register_repository.h" -#include "domain/virtual_register_repository.h" -#include "services/alarm_editor_service.h" -#include "services/alarm_service.h" -#include "services/hmi_editor_service.h" -#include "services/hmi_runtime_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/alarm_configuration_dialog.h" -#include "ui/hmi_editor_widget.h" -#include "ui/free_monitor_widget.h" -#include "ui/logic_editor_widget.h" -#include "ui/main_window.h" -#include "ui/plc_connection_dialog.h" -#include "ui/runtime_monitor_widget.h" -#include "ui/runtime_monitor_window.h" -#include "support/test_support.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -namespace { - -using TestProjectStorage = TestSupport::InMemoryProjectStorage; - -class RepeatedStatusGateway final : public PlcCommunicationGateway -{ -public: - PlcCommunicationResult connectDevice( - const PlcSerialConfiguration &) override - { - connection_state = PlcConnectionState::Connecting; - notifyStateChanged(); - connection_state = PlcConnectionState::Connected; - notifyStateChanged(); - notifyStateChanged(); - return {true, {}}; - } - - void disconnectDevice() override - { - connection_state = PlcConnectionState::Disconnected; - notifyStateChanged(); - } - - PlcCommunicationResult setPollAddresses( - const std::vector &) override - { - return {true, {}}; - } - PlcConnectionState state() const override { return connection_state; } - bool initialReadCompleted() const override { return false; } - PlcCommunicationError lastErrorType() const override - { - return PlcCommunicationError::None; - } - const std::string &lastError() const override { return last_error; } - - void setCallbacks( - std::function state_callback, - std::function initial_callback, - std::function cache_callback, - std::function error_callback) override - { - state_changed = std::move(state_callback); - initial_read_changed = std::move(initial_callback); - cache_updated = std::move(cache_callback); - error_reported = std::move(error_callback); - } - -private: - void notifyStateChanged() - { - if (state_changed) - { - state_changed(); - } - } - - PlcConnectionState connection_state = PlcConnectionState::Disconnected; - std::string last_error; - std::function state_changed; - std::function initial_read_changed; - std::function cache_updated; - std::function error_reported; -}; - -class OnlineReadyGateway final : public PlcCommunicationGateway -{ -public: - PlcCommunicationResult connectDevice(const PlcSerialConfiguration &) override - { - connection_state = PlcConnectionState::Connected; - if (state_changed) - { - state_changed(); - } - return {true, {}}; - } - - void disconnectDevice() override - { - connection_state = PlcConnectionState::Disconnected; - initial_read = false; - if (initial_read_changed) - { - initial_read_changed(false); - } - if (state_changed) - { - state_changed(); - } - } - - PlcCommunicationResult setPollAddresses( - const std::vector &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 { return last_error_type; } - const std::string &lastError() const override { return last_error; } - - void setCallbacks( - std::function state_callback, - std::function initial_callback, - std::function cache_callback, - std::function error_callback) override - { - state_changed = std::move(state_callback); - initial_read_changed = std::move(initial_callback); - cache_updated = std::move(cache_callback); - error_reported = std::move(error_callback); - } - - void completeInitialRead() - { - initial_read = true; - last_error_type = PlcCommunicationError::None; - last_error.clear(); - if (connection_state == PlcConnectionState::Recovering) - { - connection_state = PlcConnectionState::Connected; - if (state_changed) - { - state_changed(); - } - } - if (initial_read_changed) - { - initial_read_changed(true); - } - } - - void timeoutCommunication(const std::string &message) - { - initial_read = false; - last_error_type = PlcCommunicationError::CommunicationTimeout; - last_error = message; - connection_state = PlcConnectionState::Faulted; - if (initial_read_changed) - { - initial_read_changed(false); - } - if (state_changed) - { - state_changed(); - } - if (error_reported) - { - error_reported(last_error); - } - } - - void recoverCommunication() - { - initial_read = false; - last_error_type = PlcCommunicationError::None; - last_error.clear(); - connection_state = PlcConnectionState::Recovering; - if (state_changed) - { - state_changed(); - } - } - - void loseSerialConnection(const std::string &message) - { - initial_read = false; - last_error_type = PlcCommunicationError::SerialConnectionLost; - last_error = message; - connection_state = PlcConnectionState::Disconnected; - if (initial_read_changed) - { - initial_read_changed(false); - } - if (state_changed) - { - state_changed(); - } - if (error_reported) - { - error_reported(last_error); - } - } - - PlcConnectionState connection_state = PlcConnectionState::Disconnected; - bool initial_read = false; - PlcCommunicationError last_error_type = PlcCommunicationError::None; - std::string last_error; - std::vector poll_addresses; - std::function state_changed; - std::function initial_read_changed; - std::function cache_updated; - std::function error_reported; -}; - -using TestSupport::require; - -template -ObjectType *requiredChild(MainWindow &window, const char *name) -{ - // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息 - ObjectType *child = window.findChild(QString::fromLatin1(name)); - require(child != nullptr, std::string("missing UI object ") + name); - return child; -} - -QColor renderedColorAt(HmiEditorWidget &view, const QPoint &viewport_position) -{ - QImage image(view.viewport()->size(), QImage::Format_ARGB32_Premultiplied); - image.fill(Qt::transparent); - QPainter painter(&image); - view.viewport()->render(&painter); - return image.pixelColor(viewport_position); -} - -ContactNodeConfig logicContact(int address) -{ - return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen}; -} - -std::string makeEmptyRung(LogicEditorService &service, const std::string &logic_id) -{ - const LogicEditorResult result = service.addRung(logic_id); - require(result.succeeded, "UI test fixture must create an empty network"); - return result.id; -} - -void testLogicEditorBatchDeletesWholeParallelRow() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - - std::vector top_ids; - for (int address = 0; address < 4; ++address) - { - const LogicEditorResult result = service.appendCondition( - logic_id, rung_id, logicContact(address)); - require(result.succeeded, - "logic UI batch deletion setup contacts must be created"); - top_ids.push_back(result.id); - } - const LogicEditorResult lower_first = service.addParallelBranch( - logic_id, rung_id, top_ids, logicContact(10)); - require(lower_first.succeeded, - "logic UI batch deletion setup branch must be created"); - std::string lower_tail = lower_first.id; - for (int address = 11; address < 14; ++address) - { - const LogicEditorResult result = service.insertConditionAfter( - logic_id, rung_id, lower_tail, logicContact(address)); - require(result.succeeded, - "logic UI batch deletion lower branch contacts must be created"); - lower_tail = result.id; - } - - LogicEditorWidget view(service); - view.setLogicId(logic_id); - qreal lower_row_y = 0.0; - for (QGraphicsItem *item : view.scene()->items()) - { - if (qFuzzyCompare(item->zValue(), 3.0)) - { - lower_row_y = std::max(lower_row_y, item->scenePos().y()); - } - } - int selected_count = 0; - for (QGraphicsItem *item : view.scene()->items()) - { - if (qFuzzyCompare(item->zValue(), 3.0) - && qFuzzyCompare(item->scenePos().y(), lower_row_y)) - { - item->setSelected(true); - ++selected_count; - } - } - require(selected_count == 4, - "logic UI batch deletion must select every contact in the lower branch"); - require(view.deleteSelected().succeeded, - "logic UI must batch delete a complete parallel row"); - - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() && rung->validate(), - "logic UI batch deletion must leave a valid ladder expression"); - require(rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.size() == 4U, - "logic UI batch deletion must preserve only the original series row"); - const QList rendered_items = view.scene()->items(); - const bool has_vertical_connector = std::any_of( - rendered_items.cbegin(), - rendered_items.cend(), - [](const QGraphicsItem *item) - { - return qFuzzyCompare(item->zValue(), 2.5); - }); - require(!has_vertical_connector, - "logic UI must not render a vertical connector after removing the branch"); -} - -void testRuntimeButtonMouseInteraction() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - VirtualRegisterRepository repository; - HmiRuntimeService runtime_service(repository); - AlarmService alarm_service(project_service, repository); - const HmiEditorResult page_result = editor_service.ensureDefaultPage(); - require(page_result.succeeded, "runtime button test must create an HMI page"); - const HmiEditorResult button_result = editor_service.addControl( - page_result.id, HmiControlType::Button); - require(button_result.succeeded, "runtime button test must create a button"); - - HmiControl button = *editor_service.findControl(page_result.id, button_result.id); - button.binding = RegisterAddress{RegisterArea::M, 0}; - button.buttonOperation = HmiButtonOperation::MomentaryOn; - require(editor_service.updateControl(page_result.id, button.id, button).succeeded, - "runtime button test must bind the button to M0"); - - HmiEditorWidget view(editor_service, runtime_service, alarm_service); - view.resize(900, 600); - view.setPageId(page_result.id); - view.setEditingEnabled(false); - view.setRuntimeActive(true); - view.setRuntimeWriteEnabled(true); - view.show(); - QApplication::processEvents(); - - const QPoint center = view.mapFromScene(QPointF( - button.bounds.x + button.bounds.width / 2.0, - button.bounds.y + button.bounds.height / 2.0)); - const QPoint color_sample = view.mapFromScene(QPointF( - button.bounds.x + 8.0, button.bounds.y + 8.0)); - const QColor normal_color = renderedColorAt(view, color_sample); - - QTest::mouseMove(view.viewport(), center); - QApplication::processEvents(); - QGraphicsItem *button_item = view.itemAt(center); - require(button_item != nullptr, - "runtime button must be hit-testable while writes are enabled"); - require(button_item->cursor().shape() == Qt::PointingHandCursor, - "runtime button must use a pointing-hand cursor"); - const QColor hovered_color = renderedColorAt(view, color_sample); - require(hovered_color != normal_color, - "hovering a runtime button must change its visual state"); - - QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, center); - QApplication::processEvents(); - require(repository.readBit({RegisterArea::M, 0}).value, - "pressing a momentary runtime button must write M0 ON"); - require(view.scene()->selectedItems().isEmpty(), - "pressing a runtime button must not show an editing selection"); - const QColor pressed_color = renderedColorAt(view, color_sample); - require(pressed_color != hovered_color, - "pressing a runtime button must change its visual state"); - - QTest::mouseRelease(view.viewport(), Qt::LeftButton, Qt::NoModifier, center); - QApplication::processEvents(); - require(!repository.readBit({RegisterArea::M, 0}).value, - "releasing a momentary runtime button must write M0 OFF"); - require(renderedColorAt(view, color_sample) == hovered_color, - "releasing a runtime button must restore its hovered visual state"); - - view.setRuntimeWriteEnabled(false); - QApplication::processEvents(); - const QColor disabled_color = renderedColorAt(view, color_sample); - require(disabled_color != hovered_color, - "a non-writable runtime button must use its disabled visual state"); - QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, center); - require(!repository.readBit({RegisterArea::M, 0}).value, - "a disabled runtime button must not write its register"); -} - -void testRuntimeProgressBarRendering() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - VirtualRegisterRepository repository; - HmiRuntimeService runtime_service(repository); - AlarmService alarm_service(project_service, repository); - const std::string page_id = editor_service.ensureDefaultPage().id; - const HmiEditorResult progress_result = editor_service.addControl( - page_id, HmiControlType::ProgressBar); - require(progress_result.succeeded, - "runtime progress test must create a ProgressBar control"); - - HmiControl progress = *editor_service.findControl( - page_id, progress_result.id); - progress.binding = RegisterAddress{RegisterArea::D, 0}; - progress.progressBar = HmiProgressBarConfig{0, 100, true}; - require(editor_service.updateControl( - page_id, progress.id, progress).succeeded, - "runtime progress test must configure a D value range"); - require(repository.writeWord( - {RegisterArea::D, 0}, static_cast(25)).succeeded, - "runtime progress test must seed D0"); - - HmiEditorWidget view(editor_service, runtime_service, alarm_service); - view.resize(900, 560); - view.setPageId(page_id); - view.setEditingEnabled(false); - view.setRuntimeActive(true); - view.refreshRuntimeValues(); - view.show(); - QApplication::processEvents(); - - const QPoint sample = view.mapFromScene(QPointF( - progress.bounds.x + progress.bounds.width * 0.6, - progress.bounds.y + 6.0)); - const QColor background_color = renderedColorAt(view, sample); - QGraphicsItem *progress_item = view.itemAt(sample); - require(progress_item != nullptr - && progress_item->acceptedMouseButtons() == Qt::NoButton, - "runtime ProgressBar must be visible and read-only"); - - require(repository.writeWord( - {RegisterArea::D, 0}, static_cast(75)).succeeded, - "runtime progress test must update D0"); - view.refreshRuntimeValues(); - QApplication::processEvents(); - const QColor fill_color = renderedColorAt(view, sample); - require(fill_color != background_color, - "ProgressBar fill must expand when the D value increases"); - - require(repository.writeWord( - {RegisterArea::D, 0}, static_cast(200)).succeeded, - "runtime progress test must accept an out-of-range source value"); - view.refreshRuntimeValues(); - QApplication::processEvents(); - const QPoint near_end = view.mapFromScene(QPointF( - progress.bounds.x + progress.bounds.width * 0.95, - progress.bounds.y + 6.0)); - require(renderedColorAt(view, near_end) == fill_color, - "ProgressBar rendering must clamp values above its maximum"); -} - -void testRuntimePageJumpDoesNotRequireRegisterWritePermission() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - const std::string source_page = editor_service.ensureDefaultPage().id; - const std::string target_page = editor_service.addPage("Target").id; - const HmiEditorResult jump_result = editor_service.addControl( - source_page, HmiControlType::PageJump); - HmiControl jump = *editor_service.findControl(source_page, jump_result.id); - jump.pageJump = HmiPageJumpConfig{target_page}; - require(editor_service.updateControl( - source_page, jump_result.id, jump).succeeded, - "PageJump fixture setup must succeed"); - - VirtualRegisterRepository repository; - HmiRuntimeService runtime_service(repository); - AlarmService alarm_service(project_service, repository); - HmiEditorWidget view(editor_service, runtime_service, alarm_service); - view.setPageId(source_page); - view.setEditingEnabled(false); - view.setRuntimeActive(true); - view.setRuntimeWriteEnabled(false); - view.refreshRuntimeValues(); - view.resize(900, 560); - view.show(); - QApplication::processEvents(); - - QString requested_page; - QObject::connect( - &view, &HmiEditorWidget::pageNavigationRequested, - [&requested_page](const QString &page_id) - { - requested_page = page_id; - }); - QGraphicsItem *jump_item = nullptr; - for (QGraphicsItem *item : view.scene()->items()) - { - if (item->zValue() >= 0.0) - { - jump_item = item; - break; - } - } - require(jump_item != nullptr, "PageJump graphics item must be rendered"); - const QPoint center = view.mapFromScene( - jump_item->mapToScene(jump_item->boundingRect().center())); - QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, center); - require(requested_page == QString::fromStdString(target_page), - "PageJump must remain active when register writes are disabled"); -} - -void testRuntimeAlarmListInteraction() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - AlarmEditorService alarm_editor_service(project_service); - VirtualRegisterRepository repository; - HmiRuntimeService runtime_service(repository); - AlarmService alarm_service(project_service, repository); - - const std::string page_id = editor_service.ensureDefaultPage().id; - const HmiEditorResult list_result = editor_service.addControl( - page_id, HmiControlType::AlarmList); - require(list_result.succeeded, - "runtime alarm test must create an AlarmList control"); - const HmiControl *alarm_list = editor_service.findControl( - page_id, list_result.id); - require(alarm_list != nullptr && !alarm_list->binding.has_value(), - "AlarmList must not require a register binding"); - - std::vector alarm_ids; - for (int index = 0; index <= ProjectLimits::kMaximumVisibleAlarmRows; ++index) - { - AlarmDefinition definition; - definition.address = RegisterAddress{RegisterArea::M, index}; - definition.condition = AlarmCondition::MOn; - definition.message = "Alarm " + std::to_string(index); - const AlarmEditorResult alarm_result = - alarm_editor_service.addDefinition(definition); - require(alarm_result.succeeded, - "runtime alarm test must create paged M alarm definitions"); - alarm_ids.push_back(alarm_result.id); - } - - AlarmConfigurationDialog configuration_dialog(alarm_editor_service); - QLineEdit *message_edit = configuration_dialog.findChild( - QStringLiteral("messageEdit")); - require(message_edit != nullptr - && message_edit->maxLength() - == static_cast( - ProjectLimits::kMaximumAlarmMessageCharacters), - "alarm configuration must limit messages to 20 characters"); - - HmiEditorWidget view(editor_service, runtime_service, alarm_service); - view.setPageId(page_id); - QGraphicsItem *alarm_item = nullptr; - for (QGraphicsItem *item : view.scene()->items()) - { - if (item->zValue() >= 0.0) - { - alarm_item = item; - break; - } - } - require(alarm_item != nullptr && alarm_item->isVisible(), - "AlarmList must remain visible while editing"); - - view.setEditingEnabled(false); - view.setRuntimeActive(true); - view.setRuntimeWriteEnabled(false); - view.refreshRuntimeValues(); - view.resize(900, 560); - view.show(); - QApplication::processEvents(); - require(!alarm_item->isVisible(), - "AlarmList must be hidden when runtime has no active alarm"); - - repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true); - alarm_service.refresh(); - view.refreshRuntimeValues(); - QApplication::processEvents(); - require(alarm_service.records().size() == 1, - "an active M alarm must be available to AlarmList"); - require(alarm_item->isVisible() && alarm_item->zValue() > 0.0, - "AlarmList must appear above the page for an active alarm"); - require(alarm_item->boundingRect().height() < alarm_list->bounds.height, - "a runtime AlarmList with one record must remove unused row space"); - - const QPoint header_position = view.mapFromScene(QPointF( - alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 10.0)); - QTest::mouseClick( - view.viewport(), Qt::LeftButton, Qt::NoModifier, header_position); - require(!alarm_service.records().front().acknowledged, - "clicking the AlarmList header must not acknowledge an alarm"); - - const QPoint first_row_position = view.mapFromScene(QPointF( - alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 34.0)); - QTest::mouseClick( - view.viewport(), Qt::LeftButton, Qt::NoModifier, first_row_position); - require(alarm_service.records().front().acknowledged, - "clicking an active AlarmList row must acknowledge the alarm"); - - for (int index = 1; index <= ProjectLimits::kMaximumVisibleAlarmRows; ++index) - { - repository.writeBit(RegisterAddress{RegisterArea::M, index}, true); - } - alarm_service.reset(); - alarm_service.refresh(); - view.refreshRuntimeValues(); - QApplication::processEvents(); - require(alarm_service.records().size() - == static_cast( - ProjectLimits::kMaximumVisibleAlarmRows + 1), - "AlarmList pagination must retain records beyond the visible row limit"); - require(alarm_item->boundingRect().height() == alarm_list->bounds.height, - "a full AlarmList page must use its configured maximum height"); - - const QPoint next_page_position = view.mapFromScene(QPointF( - alarm_list->bounds.x + alarm_list->bounds.width - 14.0, - alarm_list->bounds.y + 10.0)); - QTest::mouseClick( - view.viewport(), Qt::LeftButton, Qt::NoModifier, next_page_position); - QApplication::processEvents(); - require(alarm_item->boundingRect().height() < alarm_list->bounds.height, - "the final partial alarm page must shrink to its visible rows"); - - QTest::mouseClick( - view.viewport(), Qt::LeftButton, Qt::NoModifier, first_row_position); - require(alarm_service.records().back().definitionId == alarm_ids.front() - && alarm_service.records().back().acknowledged, - "the next AlarmList page must acknowledge its own first record"); - - for (int index = 0; index <= ProjectLimits::kMaximumVisibleAlarmRows; ++index) - { - repository.writeBit(RegisterAddress{RegisterArea::M, index}, false); - } - alarm_service.refresh(); - view.refreshRuntimeValues(); - QApplication::processEvents(); - require(alarm_service.records().empty(), - "clearing the M condition must remove the alarm immediately"); - require(!alarm_item->isVisible(), - "AlarmList must hide after the final alarm recovers"); - - view.setRuntimeActive(false); - view.setEditingEnabled(true); - require(alarm_item->isVisible(), - "AlarmList must become visible again after returning to editing"); -} - -void testLogicEmptyGridSlotInsertion() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - LogicEditorWidget editor(service); - editor.setLogicId(logic_id); - editor.resize(1400, 360); - editor.show(); - QApplication::processEvents(); - - auto insertionGridItems = [&editor] - { - QList items; - for (QGraphicsItem *item : editor.scene()->items()) - { - if (item->toolTip().startsWith(QStringLiteral("空条件网格")) - || item->toolTip().startsWith(QStringLiteral("横线网格"))) - { - items.push_back(item); - } - } - std::sort( - items.begin(), items.end(), - [](const QGraphicsItem *left, const QGraphicsItem *right) - { - return left->scenePos().x() < right->scenePos().x(); - }); - return items; - }; - - QList empty_items = insertionGridItems(); - require(empty_items.size() == ProjectLimits::kMaximumConditionColumns - && !service.findRung(logic_id, rung_id)->condition.has_value(), - "a new network must expose ten selectable grid slots without persisting placeholders"); - - const QPoint fifth_slot = editor.mapFromScene(empty_items.at(4)->scenePos()); - QTest::mouseClick( - editor.viewport(), Qt::LeftButton, Qt::NoModifier, fifth_slot); - require(editor.scene()->selectedItems().size() == 1, - "clicking an empty grid cell must select one insertion position"); - require(editor.addCondition(ContactNodeConfig{ - RegisterAddress{RegisterArea::M, 4}, - ContactMode::NormallyOpen}) - .succeeded, - "the toolbar condition path must insert at the selected fifth column"); - - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.front().kind - == ConditionExpressionKind::Wire - && rung->condition->children.front().wire->columnSpan == 4, - "a fifth-column click must become four wire columns followed by the contact"); - - empty_items = insertionGridItems(); - require(empty_items.size() == 9, - "every unoccupied column must stay clickable after the first insertion"); - const QPoint second_slot = editor.mapFromScene(empty_items.at(1)->scenePos()); - QTest::mouseClick( - editor.viewport(), Qt::LeftButton, Qt::NoModifier, second_slot); - require(editor.addCondition(ContactNodeConfig{ - RegisterAddress{RegisterArea::M, 1}, - ContactMode::NormallyClosed}) - .succeeded, - "a grid cell inside a multi-column wire must accept a contact"); - rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->condition->kind == ConditionExpressionKind::Series - && rung->condition->children.size() == 4U - && rung->condition->children.front().kind - == ConditionExpressionKind::Wire - && rung->condition->children.front().wire->columnSpan == 1 - && rung->condition->children.at(2).kind - == ConditionExpressionKind::Wire - && rung->condition->children.at(2).wire->columnSpan == 2 - && std::get( - rung->condition->children.at(1).node->config).mode - == ContactMode::NormallyClosed, - "wire splitting must preserve both surrounding gaps and contact order"); - - empty_items = insertionGridItems(); - require(empty_items.size() == 8, - "two occupied columns must leave eight selectable grid positions"); - const QPoint eighth_slot = editor.mapFromScene(empty_items.at(5)->scenePos()); - QTest::mouseClick( - editor.viewport(), Qt::LeftButton, Qt::NoModifier, eighth_slot); - require(editor.addCondition(ContactNodeConfig{ - RegisterAddress{RegisterArea::M, 7}, - ContactMode::NormallyOpen}) - .succeeded, - "a trailing empty grid cell must remain a valid insertion target"); - - require(service.undo().succeeded, - "a grid-targeted insertion must remain one undoable edit"); - editor.reloadLogic(); - require(insertionGridItems().size() == 8, - "undo must restore the prior condition layout and empty grid slots"); - - const LogicEditorResult added_rung = service.addRung(logic_id); - require(added_rung.succeeded, - "the grid-slot UI test must create another network"); - editor.reloadLogic(); - require(insertionGridItems().size() == 18 - && !service.findRung(logic_id, added_rung.id)->condition.has_value(), - "every newly added network must expose its own ten empty insertion slots"); -} - -void testLogicParallelPaddingGridInsertion() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - std::vector top_ids; - for (int address = 0; address < 3; ++address) - { - const LogicEditorResult result = service.appendCondition( - logic_id, rung_id, logicContact(address)); - require(result.succeeded, - "parallel grid UI setup must create the upper branch"); - top_ids.push_back(result.id); - } - const LogicEditorResult lower = service.addParallelBranch( - logic_id, rung_id, top_ids, logicContact(10)); - require(lower.succeeded, - "parallel grid UI setup must create a short lower branch"); - - LogicEditorWidget editor(service); - editor.setLogicId(logic_id); - editor.resize(1700, 480); - editor.show(); - QApplication::processEvents(); - - QList branch_slots; - for (QGraphicsItem *item : editor.scene()->items()) - { - if (item->toolTip().startsWith(QStringLiteral("并联空网格"))) - { - branch_slots.push_back(item); - } - } - std::sort( - branch_slots.begin(), branch_slots.end(), - [](const QGraphicsItem *left, const QGraphicsItem *right) - { - return left->scenePos().x() < right->scenePos().x(); - }); - require(branch_slots.size() == 2, - "every visible padding cell in a short parallel branch must be selectable"); - - QTest::mouseClick( - editor.viewport(), - Qt::LeftButton, - Qt::NoModifier, - editor.mapFromScene(branch_slots.back()->scenePos())); - require(editor.scene()->selectedItems().size() == 1 - && editor.scene()->selectedItems().front()->toolTip().startsWith( - QStringLiteral("并联空网格")), - "clicking a parallel padding line must select its grid cell, not the rung"); - QImage selected_branch_cell(128, 120, QImage::Format_ARGB32_Premultiplied); - selected_branch_cell.fill(Qt::white); - { - QPainter painter(&selected_branch_cell); - const QPointF center = branch_slots.back()->scenePos(); - editor.scene()->render( - &painter, - QRectF(0, 0, 128, 120), - QRectF(center.x() - 64, center.y() - 60, 128, 120)); - } - bool selected_line_visible = false; - for (int x = 12; x < selected_branch_cell.width() - 12 && !selected_line_visible; - ++x) - { - for (int y = 58; y <= 62; ++y) - { - const QColor pixel = selected_branch_cell.pixelColor(x, y); - if (pixel.red() < 100 && pixel.green() < 120 && pixel.blue() < 130) - { - selected_line_visible = true; - break; - } - } - } - require(selected_line_visible, - "selecting a parallel padding cell must keep its horizontal wire visible"); - const LogicEditorResult inserted = editor.addCondition(logicContact(12)); - require(inserted.succeeded, - "the normal condition action must insert into a selected parallel grid cell"); - - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->condition->kind == ConditionExpressionKind::Parallel - && rung->condition->children.at(1).kind - == ConditionExpressionKind::Series - && rung->condition->children.at(1).children.size() == 3U, - "parallel grid insertion must update only the selected branch"); - int remaining_branch_slots = 0; - int persisted_wire_cells = 0; - for (QGraphicsItem *item : editor.scene()->items()) - { - remaining_branch_slots += item->toolTip().startsWith( - QStringLiteral("并联空网格")) ? 1 : 0; - persisted_wire_cells += item->toolTip().startsWith( - QStringLiteral("横线网格")) ? 1 : 0; - } - require(remaining_branch_slots == 0 && persisted_wire_cells == 1, - "using a distant branch cell must retain the skipped cell as a replaceable wire"); -} - -void testLogicParallelActionsRespectGridSelectionType() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - - std::vector top_ids; - for (int address = 0; address < 3; ++address) - { - const LogicEditorResult result = service.appendCondition( - logic_id, rung_id, logicContact(address)); - require(result.succeeded, - "grid selection type setup must create the upper branch"); - top_ids.push_back(result.id); - } - require(service.addParallelBranch( - logic_id, rung_id, top_ids, logicContact(10)) - .succeeded, - "grid selection type setup must create a short parallel branch"); - - LogicEditorWidget editor(service); - editor.setLogicId(logic_id); - editor.resize(1400, 480); - editor.show(); - QApplication::processEvents(); - - QList branch_slots; - for (QGraphicsItem *item : editor.scene()->items()) - { - if (item->toolTip().startsWith(QStringLiteral("并联空网格"))) - { - branch_slots.push_back(item); - } - } - std::sort( - branch_slots.begin(), branch_slots.end(), - [](const QGraphicsItem *left, const QGraphicsItem *right) - { - return left->scenePos().x() < right->scenePos().x(); - }); - require(branch_slots.size() == 2, - "empty parallel branch must expose its two grid cells"); - branch_slots.at(0)->setSelected(true); - branch_slots.at(1)->setSelected(true); - service.clearHistory(); - - const LogicEditorResult parallel = editor.addParallelBranch(logicContact(20)); - require(!parallel.succeeded - && parallel.message.find("空白网格没有可并联的逻辑") - != std::string::npos - && !service.canUndo(), - "parallel insertion must reject a selection made only of empty branch cells"); - const LogicEditorResult vertical = editor.addVerticalWire(); - require(!vertical.succeeded - && vertical.message.find("空白网格没有可连接的逻辑") - != std::string::npos - && !service.canUndo(), - "vertical insertion must reject a selection made only of empty branch cells"); -} - -void testLogicVerticalWireUsesSelectedWireCells() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - const LogicEditorResult source = service.appendWire( - logic_id, rung_id, 3); - require(source.succeeded, - "wire-cell UI setup must create a three-column wire"); - - LogicEditorWidget editor(service); - editor.setLogicId(logic_id); - editor.resize(1400, 360); - editor.show(); - QApplication::processEvents(); - - QList wire_cells; - for (QGraphicsItem *item : editor.scene()->items()) - { - if (item->toolTip().startsWith(QStringLiteral("横线网格"))) - { - wire_cells.push_back(item); - } - } - std::sort( - wire_cells.begin(), wire_cells.end(), - [](const QGraphicsItem *left, const QGraphicsItem *right) - { - return left->scenePos().x() < right->scenePos().x(); - }); - require(wire_cells.size() == 3, - "a three-column wire must expose three selectable cells"); - wire_cells.at(0)->setSelected(true); - wire_cells.at(1)->setSelected(true); - - const LogicEditorResult branch = editor.addVerticalWire(); - require(branch.succeeded, - "vertical insertion must accept two selected wire 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.front().kind - == ConditionExpressionKind::Parallel - && rung->condition->children.front().children.back().kind - == ConditionExpressionKind::Wire - && rung->condition->children.front().children.back() - .wire->columnSpan == 2 - && rung->condition->children.back().kind - == ConditionExpressionKind::Wire - && rung->condition->children.back().wire->columnSpan == 1, - "vertical insertion must persist exactly the selected two wire columns"); -} - -void testLogicVerticalWireUsesAdjacentWireCells() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - for (int index = 0; index < 3; ++index) - { - require(service.appendWire(logic_id, rung_id).succeeded, - "adjacent wire UI setup must create one-column segments"); - } - - LogicEditorWidget editor(service); - editor.setLogicId(logic_id); - editor.resize(1400, 360); - editor.show(); - QApplication::processEvents(); - - QList wire_cells; - for (QGraphicsItem *item : editor.scene()->items()) - { - if (item->toolTip().startsWith(QStringLiteral("横线网格"))) - { - wire_cells.push_back(item); - } - } - std::sort( - wire_cells.begin(), wire_cells.end(), - [](const QGraphicsItem *left, const QGraphicsItem *right) - { - return left->scenePos().x() < right->scenePos().x(); - }); - require(wire_cells.size() == 3, - "adjacent one-column wires must expose three selectable cells"); - for (QGraphicsItem *cell : wire_cells) - { - cell->setSelected(true); - } - - const LogicEditorResult branch = editor.addVerticalWire(); - require(branch.succeeded, - "vertical insertion must accept adjacent cells from separate wire expressions"); - const LadderRung *rung = service.findRung(logic_id, rung_id); - require(rung != nullptr && rung->condition.has_value() - && rung->condition->kind == ConditionExpressionKind::Parallel - && rung->condition->children.size() == 2U - && rung->condition->children.front().wire->columnSpan == 3 - && rung->condition->children.back().wire->columnSpan == 3, - "adjacent wire cells must persist a three-column bypass in the UI path"); -} - -void testLogicParallelBranchConnectsShortBranchToRightJoin() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - std::vector selected_ids; - for (int address = 0; address < 5; ++address) - { - const LogicEditorResult result = service.appendCondition( - logic_id, rung_id, logicContact(address)); - require(result.succeeded, - "short parallel branch rendering setup must create the upper series"); - selected_ids.push_back(result.id); - } - const LogicEditorResult branch = service.addParallelBranch( - logic_id, rung_id, selected_ids, logicContact(10)); - require(branch.succeeded, - "parallel branch rendering setup must create a one-column lower branch"); - - LogicEditorWidget editor(service); - editor.setLogicId(logic_id); - editor.resize(1700, 480); - editor.show(); - QApplication::processEvents(); - - // The lower one-column node must be visibly wired to the parallel right join - const qreal expected_lower_y = 28.0 + 52.0 + 1.5 * 120.0; - const qreal expected_branch_output_x = 68.0 + 128.0; - const qreal expected_right_join_x = 68.0 + 5.0 * 128.0; - bool has_right_join_wire = false; - for (QGraphicsItem *item : editor.scene()->items()) - { - const QGraphicsLineItem *line = dynamic_cast(item); - if (line == nullptr || line->zValue() <= -1.0) - { - continue; - } - const QLineF segment = line->line(); - if (qAbs(segment.y1() - expected_lower_y) < 0.1 - && qAbs(segment.y2() - expected_lower_y) < 0.1 - && qAbs(segment.x1() - expected_branch_output_x) < 0.1 - && qAbs(segment.x2() - expected_right_join_x) < 0.1) - { - has_right_join_wire = true; - break; - } - } - require(has_right_join_wire, - "a short parallel branch must connect its output to the right join"); -} - -void testLogicContinuousInsertionConsumesFullWire() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = makeEmptyRung(service, logic_id); - LogicEditorWidget editor(service); - editor.setLogicId(logic_id); - editor.resize(1400, 360); - editor.show(); - QApplication::processEvents(); - - for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) - { - require(editor.addHorizontalWire().succeeded, - "the UI fixture must fill all ten condition columns with wires"); - } - - QList wire_cells; - for (QGraphicsItem *item : editor.scene()->items()) - { - if (item->toolTip().startsWith(QStringLiteral("横线网格"))) - { - wire_cells.push_back(item); - } - } - std::sort( - wire_cells.begin(), wire_cells.end(), - [](const QGraphicsItem *left, const QGraphicsItem *right) - { - return left->scenePos().x() < right->scenePos().x(); - }); - require(wire_cells.size() == ProjectLimits::kMaximumConditionColumns, - "a full wire network must expose ten replaceable wire cells"); - QTest::mouseClick( - editor.viewport(), - Qt::LeftButton, - Qt::NoModifier, - editor.mapFromScene(wire_cells.front()->scenePos())); - - for (int address = 0; address < ProjectLimits::kMaximumConditionColumns; ++address) - { - require(editor.addCondition(logicContact(address)).succeeded, - "repeated toolbar conditions must consume the next wire cell"); - int remaining_wire_cells = 0; - for (QGraphicsItem *item : editor.scene()->items()) - { - remaining_wire_cells += item->toolTip().startsWith( - QStringLiteral("横线网格")) ? 1 : 0; - } - require(remaining_wire_cells - == ProjectLimits::kMaximumConditionColumns - address - 1, - "each toolbar condition must consume exactly one visible wire cell"); - } - - const LadderRung *rung = service.findRung(logic_id, rung_id); - std::vector nodes; - collectConditionNodes(*rung->condition, &nodes); - require(rung != nullptr && rung->condition.has_value() - && nodes.size() == static_cast( - ProjectLimits::kMaximumConditionColumns), - "ten repeated toolbar conditions must replace the full wire with ten contacts"); - require(!editor.addCondition(logicContact(10)).succeeded, - "the toolbar must reject only the eleventh condition after all wires are consumed"); -} - -void testLogicHorizontalWireCanBeInsertedAgainAfterUndoingFirstAutoRung() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - LogicEditorService service(project_service); - const std::string logic_id = service.ensureDefaultLogic().id; - LogicEditorWidget editor(service); - editor.setLogicId(logic_id); - editor.resize(1400, 360); - editor.show(); - QApplication::processEvents(); - - require(editor.addHorizontalWire().succeeded, - "the first horizontal wire must create the initial ladder network"); - const ControlLogic *logic = service.findLogic(logic_id); - require(logic != nullptr && logic->rungs.size() == 1U, - "the first horizontal wire must leave one network in the model"); - - require(service.undo().succeeded, - "undo must remove the automatically created network"); - editor.reloadLogic(); - logic = service.findLogic(logic_id); - require(logic != nullptr && logic->rungs.empty(), - "undo must restore the initially empty ladder logic"); - - require(editor.addHorizontalWire().succeeded, - "a horizontal wire must be insertable again after undoing the first one"); - logic = service.findLogic(logic_id); - require(logic != nullptr && logic->rungs.size() == 1U - && logic->rungs.front().condition.has_value(), - "reinserting the horizontal wire must create a fresh network"); -} - -void testWindowTitleTracksUnsavedProjectChanges() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - LogicEditorService logic_editor_service(project_service); - require(editor_service.ensureDefaultPage().succeeded, - "window-title test must create a default HMI page"); - require(logic_editor_service.ensureDefaultLogic().succeeded, - "window-title test must create a default control logic"); - require(project_service.saveAs("window-title-test.json").succeeded, - "window-title fixture must start from a saved project"); - - VirtualRegisterRepository repository; - HmiRuntimeService runtime_service(repository); - AlarmEditorService alarm_editor_service(project_service); - AlarmService alarm_service(project_service, repository); - RegisterCommentService register_comment_service(project_service); - OfflineSimulationService simulation_service(repository); - RuntimeModeService mode_service(project_service, simulation_service); - RegisterMonitorService monitor_service(repository); - MainWindow window( - mode_service, - project_service, - editor_service, - logic_editor_service, - runtime_service, - alarm_editor_service, - alarm_service, - register_comment_service, - monitor_service); - - require(!window.windowTitle().endsWith(QLatin1Char('*')), - "a saved project title must not show an unsaved marker"); - requiredChild(window, "addLabelAction")->trigger(); - require(project_service.isModified() - && window.windowTitle().endsWith(QLatin1Char('*')), - "editing the project must immediately add an unsaved marker"); - - requiredChild(window, "saveProjectAction")->trigger(); - require(!project_service.isModified() - && !window.windowTitle().endsWith(QLatin1Char('*')), - "saving the project must immediately remove the unsaved marker"); -} - -void testModeActionsControlEditingAvailability() -{ - // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择 - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - LogicEditorService logic_editor_service(project_service); - VirtualRegisterRepository repository; - HmiRuntimeService runtime_service(repository); - AlarmEditorService alarm_editor_service(project_service); - AlarmService alarm_service(project_service, repository); - RegisterCommentService register_comment_service(project_service); - OfflineSimulationService simulation_service(repository); - RuntimeModeService mode_service(project_service, simulation_service); - RegisterMonitorService monitor_service(repository); - MainWindow window( - mode_service, - project_service, - editor_service, - logic_editor_service, - runtime_service, - alarm_editor_service, - alarm_service, - register_comment_service, - monitor_service); - window.resize(1000, 640); - window.show(); - QApplication::processEvents(); - - require(window.findChild(QStringLiteral("dataPointDock")) == nullptr, - "the removed data point dock must not remain in the main window"); - require(window.findChild(QStringLiteral("hmiDataPointComboBox")) == nullptr - && window.findChild(QStringLiteral("logicDataPointComboBox")) == nullptr, - "HMI and ladder properties must use direct M/D address inputs"); - - QAction *editing_action = requiredChild(window, "editingModeAction"); - QAction *offline_action = requiredChild(window, "offlineModeAction"); - QAction *online_action = requiredChild(window, "onlineModeAction"); - QAction *add_button_action = requiredChild(window, "addButtonAction"); - QAction *add_indicator_action = requiredChild(window, "addIndicatorAction"); - QAction *add_progress_bar_action = requiredChild( - window, "addProgressBarAction"); - QAction *delete_control_action = requiredChild(window, "deleteControlAction"); - QAction *undo_action = requiredChild(window, "undoAction"); - QAction *redo_action = requiredChild(window, "redoAction"); - QAction *delete_selection_action = requiredChild( - window, "deleteSelectionAction"); - QAction *toggle_logic_enabled_action = requiredChild( - window, "toggleLogicEnabledAction"); - QToolButton *hmi_more_controls = requiredChild( - window, "hmiMoreControlsButton"); - QToolButton *logic_contact_menu = requiredChild( - window, "logicContactMenuButton"); - QToolButton *logic_output_menu = requiredChild( - window, "logicOutputMenuButton"); - QToolButton *logic_timer_counter_menu = requiredChild( - window, "logicTimerCounterMenuButton"); - QToolButton *logic_data_menu = requiredChild( - window, "logicDataMenuButton"); - QAction *add_normally_open_action = requiredChild( - window, "addNormallyOpenAction"); - QAction *add_normal_coil_action = requiredChild( - window, "addNormalCoilAction"); - QAction *add_set_coil_action = requiredChild( - window, "addSetCoilAction"); - QAction *add_reset_coil_action = requiredChild( - window, "addResetCoilAction"); - QAction *parallel_insert_action = requiredChild( - window, "parallelInsertAction"); - QAction *insert_horizontal_wire_action = requiredChild( - window, "insertHorizontalWireAction"); - QAction *insert_vertical_wire_action = requiredChild( - window, "insertVerticalWireAction"); - QAction *delete_horizontal_wire_action = requiredChild( - window, "deleteHorizontalWireAction"); - QAction *delete_vertical_wire_action = requiredChild( - window, "deleteVerticalWireAction"); - QDockWidget *project_dock = requiredChild(window, "projectDock"); - QDockWidget *properties_dock = requiredChild(window, "propertiesDock"); - QDockWidget *output_dock = requiredChild(window, "outputDock"); - QLabel *selection = requiredChild(window, "selectionValueLabel"); - QLineEdit *text_edit = requiredChild(window, "controlTextEdit"); - QLineEdit *text_color_edit = requiredChild(window, "textColorEdit"); - QComboBox *button_operation = requiredChild( - window, "buttonOperationComboBox"); - QComboBox *binding_area = requiredChild( - window, "bindingAreaComboBox"); - QSpinBox *binding_index = requiredChild( - window, "bindingIndexSpinBox"); - QSpinBox *progress_minimum = requiredChild( - window, "progressMinimumSpinBox"); - QSpinBox *progress_maximum = requiredChild( - window, "progressMaximumSpinBox"); - QSpinBox *font_size = requiredChild(window, "fontSizeSpinBox"); - QCheckBox *font_bold = requiredChild(window, "fontBoldCheckBox"); - QCheckBox *font_italic = requiredChild(window, "fontItalicCheckBox"); - QCheckBox *progress_show_value = requiredChild( - window, "progressShowValueCheckBox"); - QPushButton *apply_properties = requiredChild( - window, "applyPropertiesButton"); - HmiEditorWidget *hmi_editor = requiredChild( - window, "hmiEditorWidget"); - LogicEditorWidget *logic_editor = requiredChild( - window, "logicEditorWidget"); - QLabel *executor_status = requiredChild(window, "executorStatusLabel"); - RuntimeMonitorWindow *runtime_window = requiredChild( - window, "runtimeMonitorWindow"); - QWidget *runtime_hmi = requiredChild(window, "runtimeHmiView"); - QWidget *runtime_logic = requiredChild(window, "runtimeLogicView"); - QWidget *free_monitor = requiredChild(window, "freeMonitorWidget"); - QTabWidget *editor_tabs = requiredChild(window, "editorTabWidget"); - QTreeWidget *project_tree = requiredChild(window, "projectTree"); - const bool initially_maximized = window.isMaximized(); - const bool project_dock_initially_visible = project_dock->isVisible(); - const bool properties_dock_initially_visible = properties_dock->isVisible(); - const bool output_dock_initially_visible = output_dock->isVisible(); - require(!runtime_window->isVisible() - && window.findChild(QStringLiteral("runtimeMonitorTab")) - == nullptr, - "runtime monitor window must be hidden while editing and absent from editor tabs"); - for (const char *action_name : { - "exitAction", - "newProjectAction", - "saveProjectAction", - "saveAsProjectAction", - "loadProjectAction", - "undoAction", - "redoAction", - "deleteSelectionAction", - "clearSelectionAction", - "configurePlcAction", - "configureRegisterCommentsAction", - "disconnectPlcAction", - "addButtonAction", - "addIndicatorAction", - "addNumericDisplayAction", - "addNumericInputAction", - "addProgressBarAction", - "addLabelAction", - "addPageJumpAction", - "addAlarmListAction", - "configureAlarmsAction", - "deleteControlAction", - "addRungAction", - "insertHorizontalWireAction", - "insertVerticalWireAction", - "deleteHorizontalWireAction", - "deleteVerticalWireAction", - "parallelInsertAction", - "addNormallyOpenAction", - "addNormallyClosedAction", - "addRisingEdgeAction", - "addFallingEdgeAction", - "addTimerContactAction", - "addCounterContactAction", - "addNormalCoilAction", - "addSetCoilAction", - "addResetCoilAction", - "addTonAction", - "addCtuAction", - "addCtdAction", - "addMoveAction", - "addAddAction", - "addSubAction", - "addCompareAction", - "editRungCommentAction", - "deleteLogicAction", - "editingModeAction", - "offlineModeAction", - "onlineModeAction"}) - { - require(!requiredChild(window, action_name)->icon().isNull(), - std::string("UI action must have a semantic icon: ") + action_name); - } - require(!hmi_more_controls->icon().isNull() - && !logic_contact_menu->icon().isNull() - && !logic_output_menu->icon().isNull() - && !logic_timer_counter_menu->icon().isNull() - && !logic_data_menu->icon().isNull(), - "every grouped toolbar menu must have an icon"); - require(editor_tabs->count() == 2 - && !editor_tabs->tabIcon(0).isNull() - && !editor_tabs->tabIcon(1).isNull(), - "the two editor tabs must remain present and have icons"); - QToolButton *monitor_remove = free_monitor->findChild( - QStringLiteral("removeButton")); - QToolButton *monitor_clear = free_monitor->findChild( - QStringLiteral("clearButton")); - require(monitor_remove != nullptr && monitor_clear != nullptr - && !monitor_remove->icon().isNull() - && !monitor_clear->icon().isNull(), - "free-monitor delete and clear commands must have icons"); - require(hmi_more_controls->menu() != nullptr - && hmi_more_controls->menu()->actions().contains(add_progress_bar_action), - "advanced HMI controls must remain reachable from the more-controls menu"); - require(logic_contact_menu->menu() != nullptr - && logic_contact_menu->menu()->actions().contains( - requiredChild(window, "addRisingEdgeAction")), - "edge and resource contacts must remain reachable from the contact menu"); - require(logic_output_menu->menu() != nullptr - && logic_output_menu->menu()->actions().contains( - requiredChild(window, "addResetCoilAction")), - "set/reset coils must remain reachable from the output menu"); - require(logic_timer_counter_menu->menu() != nullptr - && logic_timer_counter_menu->menu()->actions().contains( - requiredChild(window, "addCtdAction")), - "timer and counter instructions must remain reachable from their menu"); - require(logic_data_menu->menu() != nullptr - && logic_data_menu->menu()->actions().contains( - requiredChild(window, "addSubAction")), - "data instructions must remain reachable from the data menu"); - - const qreal compact_scale = hmi_editor->transform().m11(); - window.resize(1600, 900); - QApplication::processEvents(); - require(hmi_editor->transform().m11() > compact_scale, - "HMI page must refit when the window becomes larger"); - - require(editing_action->isChecked(), "editing action must be selected initially"); - require(undo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Z) - && redo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Y) - && delete_selection_action->shortcut() == QKeySequence(Qt::Key_Delete) - && insert_horizontal_wire_action->shortcut() - == QKeySequence(Qt::Key_F11) - && insert_vertical_wire_action->shortcut() - == QKeySequence(Qt::Key_F12) - && delete_horizontal_wire_action->shortcut() - == QKeySequence(Qt::SHIFT | Qt::Key_F11) - && delete_vertical_wire_action->shortcut() - == QKeySequence(Qt::SHIFT | Qt::Key_F12), - "core editor actions must expose the agreed keyboard shortcuts"); - require(project_dock->isEnabled(), "project dock must be enabled while editing"); - require(properties_dock->isEnabled(), "properties dock must be enabled while editing"); - - const std::string default_logic_id = logic_editor_service.firstLogicId(); - project_tree->setCurrentItem(project_tree->topLevelItem(1)->child(0)); - QApplication::processEvents(); - const bool default_logic_enabled = - logic_editor_service.findLogic(default_logic_id)->enabled; - toggle_logic_enabled_action->trigger(); - require(undo_action->isEnabled() - && logic_editor_service.findLogic(default_logic_id)->enabled - != default_logic_enabled, - "project-tree edits must immediately enable ladder undo"); - undo_action->trigger(); - require(logic_editor_service.findLogic(default_logic_id)->enabled - == default_logic_enabled, - "ladder undo must restore a project-tree edit"); - editor_tabs->setCurrentIndex(0); - QApplication::processEvents(); - - add_button_action->trigger(); - const std::string page_id = editor_service.firstPageId(); - require(editor_service.findPage(page_id)->controls.size() == 1, - "adding a control must update the HMI page model"); - require(selection->text() == QStringLiteral("button-1(未绑定)"), - "an unbound added control must be marked in the property panel"); - require(text_edit->text() == QStringLiteral("按钮"), - "property panel must show the control text"); - require(text_edit->maxLength() - == static_cast( - ProjectLimits::kMaximumHmiControlTextCharacters), - "all HMI control text inputs must be limited to twelve characters"); - require(button_operation->currentText() == QStringLiteral("瞬时 ON"), - "new HMI buttons must default to momentary ON"); - - text_edit->setFocus(); - text_edit->selectAll(); - QTest::keyClicks(text_edit, "modified"); - QTest::keyClick(text_edit, Qt::Key_Z, Qt::ControlModifier); - require(text_edit->text() == QStringLiteral("按钮") - && editor_service.findControl(page_id, "button-1") != nullptr, - "Ctrl+Z in a property input must undo text without undoing the HMI model"); - - const QList unbound_items = hmi_editor->scene()->selectedItems(); - require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0, - "an unbound HMI control must not reserve an address label area"); - button_operation->setCurrentIndex( - button_operation->findData(static_cast(HmiButtonOperation::Toggle))); - text_color_edit->setText(QStringLiteral("#E53935")); - font_size->setValue(18); - font_bold->setChecked(true); - font_italic->setChecked(true); - apply_properties->click(); - const HmiControl *styled_button = editor_service.findControl(page_id, "button-1"); - require(styled_button != nullptr - && styled_button->buttonOperation == HmiButtonOperation::Toggle - && styled_button->properties.at(HmiAppearanceProperty::kTextColor) - == "#E53935" - && styled_button->properties.at(HmiAppearanceProperty::kFontSize) - == "18" - && styled_button->properties.at(HmiAppearanceProperty::kFontBold) - == "true" - && styled_button->properties.at(HmiAppearanceProperty::kFontItalic) - == "true", - "the property panel must update HMI appearance properties"); - - HmiControl bound_button = *editor_service.findControl(page_id, "button-1"); - bound_button.binding = RegisterAddress{RegisterArea::M, 0}; - require(editor_service.updateControl(page_id, bound_button.id, bound_button).succeeded, - "binding an HMI button to M0 must succeed"); - hmi_editor->reloadPage(); - hmi_editor->selectControl(bound_button.id); - const QList bound_items = hmi_editor->scene()->selectedItems(); - require(bound_items.size() == 1 && bound_items.front()->boundingRect().top() < 0, - "a bound HMI control must include its address label above the control body"); - - delete_control_action->trigger(); - require(editor_service.findPage(page_id)->controls.empty(), - "deleting a selected control must update the HMI page model"); - - QAction *add_label_action = requiredChild(window, "addLabelAction"); - add_label_action->trigger(); - add_label_action->trigger(); - add_label_action->trigger(); - hmi_editor->scene()->clearSelection(); - for (QGraphicsItem *item : hmi_editor->scene()->items()) - { - if (item->zValue() >= 0.0) - { - item->setSelected(true); - } - } - delete_selection_action->trigger(); - require(editor_service.findPage(page_id)->controls.empty(), - "Delete must remove every selected HMI control in one operation"); - undo_action->trigger(); - require(editor_service.findPage(page_id)->controls.size() == 3U, - "HMI undo must restore a batch deletion"); - redo_action->trigger(); - require(editor_service.findPage(page_id)->controls.empty(), - "HMI redo must reapply a batch deletion"); - - add_progress_bar_action->trigger(); - const HmiControl *default_progress = editor_service.findControl( - page_id, "progress-bar-1"); - require(default_progress != nullptr - && default_progress->progressBar.has_value() - && progress_minimum->isVisible() - && progress_maximum->isVisible() - && progress_show_value->isVisible() - && progress_minimum->value() == 0 - && progress_maximum->value() == 100 - && progress_show_value->isChecked(), - "ProgressBar properties must expose the registered defaults"); - progress_minimum->setValue(-20); - progress_maximum->setValue(80); - progress_show_value->setChecked(false); - binding_area->setCurrentIndex(binding_area->findData(1)); - binding_index->setValue(4); - apply_properties->click(); - const HmiControl *configured_progress = editor_service.findControl( - page_id, "progress-bar-1"); - require(configured_progress != nullptr - && configured_progress->binding - == RegisterAddress{RegisterArea::D, 4} - && configured_progress->progressBar->minimumValue == -20 - && configured_progress->progressBar->maximumValue == 80 - && !configured_progress->progressBar->showValue, - "the property panel must update ProgressBar binding and range settings"); - delete_control_action->trigger(); - require(editor_service.findPage(page_id)->controls.empty(), - "deleting a ProgressBar must remove it from the HMI page"); - - requiredChild(window, "addAlarmListAction")->trigger(); - const HmiControl *default_alarm_list = editor_service.findControl( - page_id, "alarm-list-1"); - const int expected_alarm_font_size = std::clamp( - QApplication::font().pointSize() - - ProjectLimits::kAlarmDefaultFontPointReduction, - ProjectLimits::kMinimumHmiFontPointSize, - ProjectLimits::kMaximumHmiFontPointSize); - require(default_alarm_list != nullptr - && default_alarm_list->bounds.width == 360 - && default_alarm_list->bounds.height == 136 - && text_edit->maxLength() - == static_cast( - ProjectLimits::kMaximumAlarmTitleCharacters) - && font_size->value() == expected_alarm_font_size, - "AlarmList defaults must expose five rows and the reduced font size"); - delete_control_action->trigger(); - require(editor_service.findPage(page_id)->controls.empty(), - "deleting an AlarmList must remove it from the HMI page"); - - add_indicator_action->trigger(); - HmiControl runtime_indicator = *editor_service.findControl(page_id, "indicator-1"); - runtime_indicator.binding = RegisterAddress{RegisterArea::M, 3}; - require(editor_service.updateControl( - page_id, runtime_indicator.id, runtime_indicator).succeeded, - "a configured HMI control must be available for the runtime projection"); - hmi_editor->reloadPage(); - - add_normally_open_action->trigger(); - editor_tabs->setCurrentIndex(1); - QApplication::processEvents(); - undo_action->trigger(); - require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) - ->rungs.empty(), - "logic undo must use the ladder history when the ladder tab is active"); - redo_action->trigger(); - const ControlLogic *restored_logic = logic_editor_service.findLogic( - logic_editor_service.firstLogicId()); - require(restored_logic != nullptr && restored_logic->rungs.size() == 1U, - "logic redo must restore the automatically created network"); - const LadderRung &restored_rung = restored_logic->rungs.front(); - require(restored_rung.condition.has_value(), - "logic redo must restore the ladder edit"); - std::vector restored_nodes; - collectConditionNodes(*restored_rung.condition, &restored_nodes); - require(restored_nodes.size() == 1U, - "logic redo must restore the original condition node"); - const std::string restored_node_id = restored_nodes.front()->id; - - QGraphicsItem *fifth_column_slot = nullptr; - for (QGraphicsItem *item : logic_editor->scene()->items()) - { - if (item->toolTip().startsWith(QStringLiteral("空条件网格")) - && item->toolTip().contains(QStringLiteral("第 5 列"))) - { - fifth_column_slot = item; - break; - } - } - require(fifth_column_slot != nullptr, - "the main-window ladder must expose the fifth condition-grid slot"); - QTest::mouseClick( - logic_editor->viewport(), - Qt::LeftButton, - Qt::NoModifier, - logic_editor->mapFromScene(fifth_column_slot->scenePos())); - add_normally_open_action->trigger(); - const LadderRung &action_inserted_rung = logic_editor_service.findLogic( - logic_editor_service.firstLogicId())->rungs.front(); - require(action_inserted_rung.condition.has_value() - && action_inserted_rung.condition->kind - == ConditionExpressionKind::Series - && action_inserted_rung.condition->children.size() == 3U - && action_inserted_rung.condition->children.at(1).kind - == ConditionExpressionKind::Wire - && action_inserted_rung.condition->children.at(1).wire->columnSpan == 3 - && action_inserted_rung.condition->children.at(2).kind - == ConditionExpressionKind::Node, - "the real contact QAction must insert at the clicked fifth grid column"); - undo_action->trigger(); - require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) - ->rungs.front().condition->kind == ConditionExpressionKind::Node, - "undo after a grid-targeted QAction must restore the prior network"); - logic_editor->selectNode(restored_node_id); - - insert_horizontal_wire_action->trigger(); - const LadderRung &wired_rung = logic_editor_service.findLogic( - logic_editor_service.firstLogicId())->rungs.front(); - require(wired_rung.condition->kind == ConditionExpressionKind::Series - && wired_rung.condition->children.at(1).kind - == ConditionExpressionKind::Wire, - "F11 must insert a selectable horizontal wire after the current condition"); - delete_horizontal_wire_action->trigger(); - require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) - ->rungs.front().condition->kind == ConditionExpressionKind::Node, - "Shift+F11 must delete the selected horizontal wire"); - logic_editor->selectNode(restored_node_id); - insert_horizontal_wire_action->trigger(); - add_normally_open_action->trigger(); - const LadderRung &replaced_wire_rung = logic_editor_service.findLogic( - logic_editor_service.firstLogicId())->rungs.front(); - require(replaced_wire_rung.condition->kind == ConditionExpressionKind::Series - && replaced_wire_rung.condition->children.at(1).kind - == ConditionExpressionKind::Node, - "adding a contact on a selected wire must replace the wire in place"); - undo_action->trigger(); - undo_action->trigger(); - - logic_editor->selectNode(restored_node_id); - insert_vertical_wire_action->trigger(); - const LadderRung &vertical_rung = logic_editor_service.findLogic( - logic_editor_service.firstLogicId())->rungs.front(); - require(vertical_rung.condition->kind == ConditionExpressionKind::Parallel - && vertical_rung.condition->children.at(1).kind - == ConditionExpressionKind::Wire, - "F12 must create a structured wire bypass with vertical connectors"); - logic_editor->scene()->clearSelection(); - for (QGraphicsItem *item : logic_editor->scene()->items()) - { - if (qFuzzyCompare(item->zValue(), 2.5)) - { - item->setSelected(true); - } - } - delete_vertical_wire_action->trigger(); - require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) - ->rungs.front().condition->kind == ConditionExpressionKind::Node, - "Shift+F12 must remove the selected vertical connection branch"); - - logic_editor->selectNode(restored_node_id); - parallel_insert_action->trigger(); - add_normal_coil_action->trigger(); - const ControlLogic *logic = logic_editor_service.findLogic( - logic_editor_service.firstLogicId()); - require(logic != nullptr && logic->rungs.size() == 1, - "logic actions must edit the default ladder rung"); - require(logic->rungs.front().condition.has_value() - && logic->rungs.front().condition->kind - == ConditionExpressionKind::Parallel - && logic->rungs.front().condition->children.size() == 2U, - "parallel branch action must create a parallel expression"); - require(logic->rungs.front().output.has_value(), - "coil action must set the fixed ladder output"); - const std::string output_node_id = logic->rungs.front().output->id; - add_set_coil_action->trigger(); - logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId()); - require(logic->rungs.front().output.has_value() - && logic->rungs.front().output->id == output_node_id - && std::get( - logic->rungs.front().output->config).mode == CoilMode::Set, - "the set-coil action must switch the existing fixed output in place"); - add_reset_coil_action->trigger(); - logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId()); - require(logic->rungs.front().output.has_value() - && logic->rungs.front().output->id == output_node_id - && std::get( - logic->rungs.front().output->config).mode == CoilMode::Reset, - "the reset-coil action must reuse the same fixed output slot"); - add_normal_coil_action->trigger(); - logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId()); - require(logic->rungs.front().output.has_value() - && logic->rungs.front().output->id == output_node_id - && std::get( - logic->rungs.front().output->config).mode == CoilMode::Normal, - "switching back to a normal coil must preserve the output node identity"); - offline_action->trigger(); - require(mode_service.mode() == ApplicationMode::Editing, - "unconfigured ladder nodes must block offline running"); - const LadderRung &rung = logic->rungs.front(); - std::vector condition_nodes; - collectConditionNodes(*rung.condition, &condition_nodes); - for (const LogicNode *node : condition_nodes) - { - require(logic_editor_service.updateNodeConfig( - logic_editor_service.firstLogicId(), - node->id, - ContactNodeConfig{ - RegisterAddress{RegisterArea::M, 0}, - ContactMode::NormallyOpen}) - .succeeded, - "applying a contact configuration must complete the ladder node"); - } - require(logic_editor_service.updateNodeConfig( - logic_editor_service.firstLogicId(), - rung.output->id, - CoilNodeConfig{ - RegisterAddress{RegisterArea::M, 1}, - CoilMode::Normal}) - .succeeded, - "applying a coil configuration must complete the ladder node"); - offline_action->trigger(); - require(mode_service.mode() == ApplicationMode::OfflineRunning, - "offline action must enter offline running"); - require(simulation_service.state() == SimulationState::Running, - "offline action must start the actual simulation service"); - require(executor_status->text().contains(QStringLiteral("运行")), - "executor status label must report the actual running state"); - require(!project_dock->isEnabled(), - "project dock must be disabled while running"); - require(!properties_dock->isEnabled(), - "properties dock must be disabled while running"); - require(runtime_window->isVisible() && runtime_window->isMaximized() - && project_dock->isVisible() == project_dock_initially_visible - && properties_dock->isVisible() - == properties_dock_initially_visible - && output_dock->isVisible() == output_dock_initially_visible - && editor_tabs->tabBar()->isVisible() - && window.isMaximized() == initially_maximized, - "runtime monitoring must open maximized without changing the editor layout"); - require(!add_button_action->isEnabled(), - "HMI add controls must be disabled while running"); - require(!add_progress_bar_action->isEnabled(), - "ProgressBar creation must be disabled while running"); - require(!add_normally_open_action->isEnabled(), - "logic add nodes must be disabled while running"); - require(!undo_action->isEnabled() && !redo_action->isEnabled() - && !delete_selection_action->isEnabled(), - "undo, redo and delete must be disabled while running"); - require(runtime_window->isVisible() && runtime_hmi->isVisible() - && runtime_logic->isVisible() && free_monitor->isVisible(), - "offline running must show HMI, ladder trace and free monitor together"); - auto *runtime_hmi_view = qobject_cast(runtime_hmi); - require(runtime_hmi_view != nullptr - && runtime_hmi_view->scene()->items().size() == 2, - "offline runtime HMI must project the configured page control"); - QLineEdit *monitor_address = requiredChild(window, "addressEdit"); - QPushButton *monitor_add = requiredChild(window, "addButton"); - QTableWidget *monitor_table = requiredChild(window, "monitorTable"); - monitor_address->setText(QStringLiteral("M0")); - monitor_add->click(); - repository.writeBit({RegisterArea::M, 0}, true); - auto *monitor_widget = requiredChild(window, "freeMonitorWidget"); - monitor_widget->refreshValues( - ApplicationMode::OfflineRunning, PlcConnectionState::Disconnected); - require(monitor_table->rowCount() == 1 - && monitor_table->item(0, 2)->text() == QStringLiteral("ON"), - "offline free monitor must read the same virtual M/D repository as HMI"); - - online_action->trigger(); - require(mode_service.mode() == ApplicationMode::OfflineRunning, - "running modes must not switch directly through the UI"); - require(offline_action->isChecked(), - "failed mode changes must restore the active action"); - - editing_action->trigger(); - require(mode_service.mode() == ApplicationMode::Editing, - "editing action must return to editing"); - require(simulation_service.state() == SimulationState::Stopped, - "editing action must stop the simulation service"); - require(executor_status->text() == QStringLiteral("逻辑执行器:停止"), - "executor status label must report the actual stopped state"); - require(project_dock->isEnabled(), - "project dock must be restored after returning to editing"); - require(properties_dock->isEnabled(), - "properties dock must be restored after returning to editing"); - require(project_dock->isVisible() == project_dock_initially_visible - && properties_dock->isVisible() - == properties_dock_initially_visible - && output_dock->isVisible() == output_dock_initially_visible - && editor_tabs->tabBar()->isVisible() - && window.isMaximized() == initially_maximized, - "returning to editing must restore the previous workspace layout"); - require(add_button_action->isEnabled(), - "HMI add controls must be restored after returning to editing"); - require(add_progress_bar_action->isEnabled(), - "ProgressBar creation must be restored after returning to editing"); - require(add_normally_open_action->isEnabled(), - "logic add nodes must be restored after returning to editing"); - require(!runtime_window->isVisible(), - "returning to editing must hide the runtime monitor window"); - - online_action->trigger(); - require(mode_service.mode() == ApplicationMode::Editing, - "online action must require an initial PLC read"); - require(editing_action->isChecked(), - "rejected online running must restore the editing action"); -} - -void testRuntimeMonitorWindowLifecycle() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - LogicEditorService logic_editor_service(project_service); - const std::string main_page_id = editor_service.ensureDefaultPage().id; - const std::string settings_page_id = editor_service.addPage("Settings").id; - const HmiEditorResult jump_result = editor_service.addControl( - main_page_id, HmiControlType::PageJump); - HmiControl jump = *editor_service.findControl(main_page_id, jump_result.id); - jump.pageJump = HmiPageJumpConfig{settings_page_id}; - require(editor_service.updateControl( - main_page_id, jump_result.id, jump).succeeded, - "runtime-workspace fixture must configure a PageJump target"); - const HmiEditorResult button_result = editor_service.addControl( - main_page_id, HmiControlType::Button); - HmiControl button = *editor_service.findControl( - main_page_id, button_result.id); - button.binding = RegisterAddress{RegisterArea::M, 0}; - button.buttonOperation = HmiButtonOperation::Toggle; - button.bounds = HmiRect{200, 20, 120, 40}; - require(editor_service.updateControl( - main_page_id, button_result.id, button).succeeded, - "runtime-workspace fixture must configure an interactive HMI button"); - const HmiEditorResult return_jump_result = editor_service.addControl( - settings_page_id, HmiControlType::PageJump); - HmiControl return_jump = *editor_service.findControl( - settings_page_id, return_jump_result.id); - return_jump.pageJump = HmiPageJumpConfig{main_page_id}; - require(editor_service.updateControl( - settings_page_id, return_jump_result.id, return_jump).succeeded, - "runtime-workspace fixture must configure a PageJump back to the initial page"); - VirtualRegisterRepository repository; - HmiRuntimeService runtime_service(repository); - AlarmEditorService alarm_editor_service(project_service); - AlarmService alarm_service(project_service, repository); - RegisterCommentService register_comment_service(project_service); - OfflineSimulationService simulation_service(repository); - RuntimeModeService mode_service(project_service, simulation_service); - RegisterMonitorService monitor_service(repository); - HmiNavigationService navigation_service(project_service); - MainWindow window( - mode_service, - project_service, - editor_service, - logic_editor_service, - runtime_service, - alarm_editor_service, - alarm_service, - navigation_service, - register_comment_service, - monitor_service); - window.show(); - QApplication::processEvents(); - - requiredChild(window, "offlineModeAction")->trigger(); - QApplication::processEvents(); - require(mode_service.mode() == ApplicationMode::OfflineRunning, - "offline mode must remain the authoritative runtime state"); - RuntimeMonitorWindow *runtime_window = requiredChild( - window, "runtimeMonitorWindow"); - HmiEditorWidget *runtime_hmi = requiredChild( - window, "runtimeHmiView"); - require(runtime_window->isWindow() && runtime_window->isVisible() - && runtime_window->isMaximized(), - "offline running must show a maximized runtime monitor window"); - require(window.findChildren().size() == 1, - "the application must keep exactly one runtime monitor projection"); - require(window.findChild(QStringLiteral("runtimeMonitorTab")) == nullptr, - "the editor must not retain an embedded runtime monitor tab"); - - QTest::mouseClick( - runtime_hmi->viewport(), Qt::LeftButton, Qt::NoModifier, - runtime_hmi->mapFromScene(QPointF( - button.bounds.x + button.bounds.width / 2.0, - button.bounds.y + button.bounds.height / 2.0))); - QApplication::processEvents(); - require(repository.readBit({RegisterArea::M, 0}).succeeded - && repository.readBit({RegisterArea::M, 0}).value, - "the unified runtime HMI must write to the active virtual repository"); - - QTest::mouseClick(runtime_hmi->viewport(), Qt::LeftButton, Qt::NoModifier, - runtime_hmi->mapFromScene(QPointF( - jump.bounds.x + jump.bounds.width / 2.0, - jump.bounds.y + jump.bounds.height / 2.0))); - QApplication::processEvents(); - require(navigation_service.currentPageId() == settings_page_id, - "PageJump in the unified runtime HMI must update the navigation session"); - require(requiredChild(window, "runtimePageLabel")->text() - == QStringLiteral("Settings"), - "the runtime workspace header must follow the navigated HMI page"); - QTest::mouseClick(runtime_hmi->viewport(), Qt::LeftButton, Qt::NoModifier, - runtime_hmi->mapFromScene(QPointF( - return_jump.bounds.x + return_jump.bounds.width / 2.0, - return_jump.bounds.y + return_jump.bounds.height / 2.0))); - QApplication::processEvents(); - require(navigation_service.currentPageId() == main_page_id, - "the unified runtime HMI must return to the initial page before editing"); - - requiredChild(window, "exitRuntimeButton")->click(); - QApplication::processEvents(); - require(mode_service.mode() == ApplicationMode::Editing, - "the runtime monitor exit button must return to editing"); - require(!runtime_window->isVisible(), - "returning to editing must hide the runtime monitor window"); - - requiredChild(window, "addLabelAction")->trigger(); - QApplication::processEvents(); - require(editor_service.findPage(main_page_id)->controls.size() == 3U, - "editing after a runtime session must add the new control to the page"); - - requiredChild(window, "offlineModeAction")->trigger(); - QApplication::processEvents(); - runtime_hmi = requiredChild(window, "runtimeHmiView"); - require(runtime_window->isVisible() && runtime_window->isMaximized() - && runtime_hmi->scene()->items().size() == 4, - "a later runtime session must reopen and refresh the monitor window"); - - const Qt::WindowFlags runtime_window_flags = runtime_window->windowFlags(); - require(!(runtime_window_flags & Qt::WindowMinimizeButtonHint) - && !(runtime_window_flags & Qt::WindowMaximizeButtonHint) - && !(runtime_window_flags & Qt::WindowCloseButtonHint), - "runtime monitor must not expose native window control buttons"); - - runtime_window->close(); - QApplication::processEvents(); - require(mode_service.mode() == ApplicationMode::OfflineRunning, - "closing the runtime monitor window must not leave offline running mode"); - require(runtime_window->isVisible(), - "closing the runtime monitor window must not hide the runtime surface"); - - requiredChild(window, "exitRuntimeButton")->click(); - QApplication::processEvents(); - require(mode_service.mode() == ApplicationMode::Editing, - "the runtime monitor exit button must be the editing transition"); - require(!runtime_window->isVisible(), - "the runtime monitor exit button must hide the runtime surface"); -} - -void testApplicationExitClosesRuntimeMonitorWindow() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - LogicEditorService logic_editor_service(project_service); - VirtualRegisterRepository repository; - HmiRuntimeService runtime_service(repository); - AlarmEditorService alarm_editor_service(project_service); - AlarmService alarm_service(project_service, repository); - RegisterCommentService register_comment_service(project_service); - OfflineSimulationService simulation_service(repository); - RuntimeModeService mode_service(project_service, simulation_service); - RegisterMonitorService monitor_service(repository); - MainWindow window( - mode_service, - project_service, - editor_service, - logic_editor_service, - runtime_service, - alarm_editor_service, - alarm_service, - register_comment_service, - monitor_service); - window.show(); - QApplication::processEvents(); - require(project_service.saveAs("runtime-window-exit-test.json").succeeded, - "application-exit fixture must clear the unsaved-project prompt"); - - requiredChild(window, "offlineModeAction")->trigger(); - QApplication::processEvents(); - RuntimeMonitorWindow *runtime_window = requiredChild( - window, "runtimeMonitorWindow"); - require(runtime_window->isVisible(), - "application-exit fixture must start with a visible runtime window"); - - requiredChild(window, "exitAction")->trigger(); - QApplication::processEvents(); - require(!window.isVisible() && !runtime_window->isVisible(), - "application exit must close both programmer and runtime windows"); -} - -void testEdgeTimerPropertyEditingAndParallelMenu() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - LogicEditorService logic_editor_service(project_service); - VirtualRegisterRepository repository; - HmiRuntimeService runtime_service(repository); - AlarmEditorService alarm_editor_service(project_service); - AlarmService alarm_service(project_service, repository); - RegisterCommentService register_comment_service(project_service); - OfflineSimulationService simulation_service(repository); - RuntimeModeService mode_service(project_service, simulation_service); - RegisterMonitorService monitor_service(repository); - MainWindow window( - mode_service, - project_service, - editor_service, - logic_editor_service, - runtime_service, - alarm_editor_service, - alarm_service, - register_comment_service, - monitor_service); - window.show(); - QApplication::processEvents(); - - QAction *parallel_action = requiredChild(window, "parallelInsertAction"); - require(parallel_action->menu() != nullptr, - "parallel insertion must expose its node type menu"); - QStringList parallel_labels; - for (QAction *action : parallel_action->menu()->actions()) - { - parallel_labels.push_back(action->text()); - } - require(parallel_labels.contains(QStringLiteral("并联上升沿触点")) - && parallel_labels.contains(QStringLiteral("并联下降沿触点")) - && parallel_labels.contains(QStringLiteral("并联 T 触点")), - "parallel insertion must include edge and T contact choices"); - - QAction *rising_action = requiredChild(window, "addRisingEdgeAction"); - QAction *falling_action = requiredChild(window, "addFallingEdgeAction"); - QAction *timer_contact_action = requiredChild( - window, "addTimerContactAction"); - QAction *ton_action = requiredChild(window, "addTonAction"); - requiredChild(window, "editRungCommentAction"); - requiredChild(window, "configureRegisterCommentsAction"); - QSpinBox *address = requiredChild(window, "logicAddressSpinBox"); - QSpinBox *preset = requiredChild(window, "logicPresetSpinBox"); - QComboBox *mode = requiredChild(window, "logicModeComboBox"); - QPushButton *apply = requiredChild( - window, "applyLogicPropertiesButton"); - const std::string logic_id = logic_editor_service.firstLogicId(); - - rising_action->trigger(); - address->setValue(8); - mode->setCurrentIndex(mode->findData(static_cast(EdgeMode::Falling))); - apply->click(); - const LogicNode *edge = logic_editor_service.findNode(logic_id, "edge-1"); - require(edge != nullptr && edge->configured - && std::get(edge->config).address.index() == 8 - && std::get(edge->config).mode - == EdgeMode::Falling, - "edge properties must update M address and edge mode"); - - timer_contact_action->trigger(); - address->setValue(9); - mode->setCurrentIndex(mode->findData( - static_cast(ContactMode::NormallyClosed))); - apply->click(); - const LogicNode *timer_contact = logic_editor_service.findNode( - logic_id, "timer-contact-1"); - require(timer_contact != nullptr && timer_contact->configured - && std::get(timer_contact->config).address - == TimerAddress{9} - && std::get(timer_contact->config).mode - == ContactMode::NormallyClosed, - "T contact properties must update T address and contact mode"); - - ton_action->trigger(); - require(preset->isEnabled() && !mode->isEnabled(), - "TON properties must enable PT and disable irrelevant mode editing"); - address->setValue(9); - preset->setValue(1234); - apply->click(); - const LogicNode *ton = logic_editor_service.findNode(logic_id, "ton-1"); - require(ton != nullptr && ton->configured - && std::get(ton->config).address == TimerAddress{9} - && std::get(ton->config).presetMs == 1234, - "TON properties must update T address and preset milliseconds"); - - falling_action->trigger(); - const LogicNode *falling = logic_editor_service.findNode(logic_id, "edge-2"); - require(falling != nullptr - && std::get(falling->config).mode - == EdgeMode::Falling, - "falling edge action must create a falling-edge condition"); -} - -void testPlcConfigurationUsesDialog() -{ - PlcSerialConfiguration initial; - initial.portName = "COM17"; - initial.serverAddress = 12; - initial.baudRate = 38400; - initial.dataBits = 7; - initial.parity = 3; - initial.stopBits = 2; - initial.responseTimeoutMs = 2500; - initial.retries = 4; - initial.pollIntervalMs = 350; - PlcConnectionDialog configuration_dialog(initial); - const PlcSerialConfiguration actual = configuration_dialog.configuration(); - require(actual.portName == initial.portName, - "PLC dialog must preserve a manually entered serial port"); - require(actual.serverAddress == initial.serverAddress - && actual.baudRate == initial.baudRate - && actual.dataBits == initial.dataBits - && actual.parity == initial.parity - && actual.stopBits == initial.stopBits, - "PLC dialog must preserve Modbus RTU serial parameters"); - require(actual.responseTimeoutMs == initial.responseTimeoutMs - && actual.retries == initial.retries - && actual.pollIntervalMs == initial.pollIntervalMs, - "PLC dialog must preserve communication timing parameters"); - - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - LogicEditorService logic_editor_service(project_service); - VirtualRegisterRepository repository; - HmiRuntimeService runtime_service(repository); - AlarmEditorService alarm_editor_service(project_service); - AlarmService alarm_service(project_service, repository); - RegisterCommentService register_comment_service(project_service); - OfflineSimulationService simulation_service(repository); - RuntimeModeService mode_service(project_service, simulation_service); - RegisterMonitorService monitor_service(repository); - MainWindow window( - mode_service, - project_service, - editor_service, - logic_editor_service, - runtime_service, - alarm_editor_service, - alarm_service, - register_comment_service, - monitor_service); - require(window.findChild(QStringLiteral("serialPortComboBox")) == nullptr, - "serial configuration controls must not be embedded in the main window"); - QAction *configure_action = requiredChild(window, "configurePlcAction"); - bool dialog_opened = false; - QTimer::singleShot( - 0, - [&dialog_opened] - { - auto *dialog = qobject_cast( - QApplication::activeModalWidget()); - dialog_opened = dialog != nullptr; - if (dialog != nullptr) - { - dialog->reject(); - } - }); - configure_action->trigger(); - require(dialog_opened, - "PLC configuration action must open the dedicated configuration dialog"); -} - -void testRepeatedPlcStatusNotificationsAreCoalesced() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - LogicEditorService logic_editor_service(project_service); - VirtualRegisterRepository virtual_repository; - VirtualRegisterRepository plc_repository; - ActiveRegisterRepository active_repository(virtual_repository); - HmiRuntimeService runtime_service(active_repository); - AlarmEditorService alarm_editor_service(project_service); - AlarmService alarm_service(project_service, active_repository); - RegisterCommentService register_comment_service(project_service); - OfflineSimulationService simulation_service(virtual_repository); - RepeatedStatusGateway gateway; - RuntimeModeService mode_service(project_service, simulation_service); - RegisterMonitorService monitor_service(active_repository); - mode_service.configurePlc( - gateway, active_repository, virtual_repository, plc_repository); - MainWindow window( - mode_service, - project_service, - editor_service, - logic_editor_service, - runtime_service, - alarm_editor_service, - alarm_service, - register_comment_service, - monitor_service); - QListWidget *output = requiredChild(window, "outputList"); - const int initial_count = output->count(); - - const PlcCommunicationResult result = mode_service.connectPlc( - {"COM3", 1, 9600, 8, 2, 1, 1000, 2, 200}); - require(result.succeeded, "PLC connection setup must succeed"); - QApplication::processEvents(); - - const QString expected = QStringLiteral("PLC 已连接,正在读取工程使用的 M/D 地址"); - int matching_count = 0; - for (int index = initial_count; index < output->count(); ++index) - { - if (output->item(index)->text() == expected) - { - ++matching_count; - } - } - require(matching_count == 1, - "repeated PLC status callbacks must append one coalesced output message"); -} - -void testFreeMonitorWritesSingleValues() -{ - VirtualRegisterRepository repository; - RegisterMonitorService monitor_service(repository); - FreeMonitorWidget widget(monitor_service); - widget.show(); - QApplication::processEvents(); - - QLineEdit *address_edit = widget.findChild("addressEdit"); - QPushButton *add_button = widget.findChild("addButton"); - QTableWidget *table = widget.findChild("monitorTable"); - require(address_edit != nullptr && add_button != nullptr && table != nullptr, - "free monitor UI test requires the address, add and table controls"); - address_edit->setText(QStringLiteral("M0")); - add_button->click(); - require(table->rowCount() == 1, "free monitor UI must add an M row before writing"); - - auto *bit_target = qobject_cast(table->cellWidget(0, 3)); - auto *bit_write = qobject_cast(table->cellWidget(0, 4)); - require(bit_target != nullptr && bit_write != nullptr && !bit_write->isEnabled(), - "free monitor writes must be disabled outside a running mode"); - - widget.setWriteEnabled(true); - bit_target->setCurrentIndex(bit_target->findData(true)); - bit_write->click(); - require(repository.readBit({RegisterArea::M, 0}).succeeded - && repository.readBit({RegisterArea::M, 0}).value, - "free monitor UI must write the selected M value"); - - address_edit->setText(QStringLiteral("D0")); - add_button->click(); - require(table->rowCount() == 2, "free monitor UI must add a D row before writing"); - auto *word_target = qobject_cast(table->cellWidget(1, 3)); - auto *word_write = qobject_cast(table->cellWidget(1, 4)); - require(word_target != nullptr && word_write != nullptr && word_write->isEnabled(), - "D monitor rows must expose an enabled write action when permitted"); - word_target->setText(QStringLiteral("-321")); - word_write->click(); - require(repository.readWord({RegisterArea::D, 0}).succeeded - && repository.readWord({RegisterArea::D, 0}).value == -321, - "free monitor UI must write signed D values"); -} - -void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - LogicEditorService logic_editor_service(project_service); - const std::string page_id = editor_service.ensureDefaultPage().id; - const HmiEditorResult button_result = editor_service.addControl( - page_id, HmiControlType::Button); - HmiControl button = *editor_service.findControl(page_id, button_result.id); - button.binding = RegisterAddress{RegisterArea::M, 5}; - button.buttonOperation = HmiButtonOperation::Toggle; - require(editor_service.updateControl( - page_id, button_result.id, button).succeeded, - "online workspace fixture must configure an interactive HMI button"); - VirtualRegisterRepository virtual_repository; - VirtualRegisterRepository plc_repository; - ActiveRegisterRepository active_repository(virtual_repository); - HmiRuntimeService runtime_service(active_repository); - AlarmEditorService alarm_editor_service(project_service); - AlarmService alarm_service(project_service, active_repository); - RegisterCommentService register_comment_service(project_service); - OfflineSimulationService simulation_service(virtual_repository); - OnlineReadyGateway gateway; - RuntimeModeService mode_service(project_service, simulation_service); - RegisterMonitorService monitor_service(active_repository); - mode_service.configurePlc( - gateway, active_repository, virtual_repository, plc_repository); - MainWindow window( - mode_service, - project_service, - editor_service, - logic_editor_service, - runtime_service, - alarm_editor_service, - alarm_service, - register_comment_service, - monitor_service); - window.resize(1200, 760); - window.show(); - QApplication::processEvents(); - - require(mode_service.connectPlc( - {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, - "online workspace test must connect the fake PLC gateway"); - gateway.completeInitialRead(); - QAction *online_action = requiredChild(window, "onlineModeAction"); - online_action->trigger(); - QApplication::processEvents(); - - RuntimeMonitorWindow *runtime_window = requiredChild( - window, "runtimeMonitorWindow"); - QWidget *runtime_hmi = requiredChild(window, "runtimeHmiView"); - QWidget *runtime_logic = requiredChild(window, "runtimeLogicView"); - QWidget *free_monitor = requiredChild(window, "freeMonitorWidget"); - require(mode_service.mode() == ApplicationMode::OnlineRunning, - "completed PLC initial read must allow online running"); - require(runtime_window->isVisible() && runtime_window->isMaximized() - && runtime_hmi->isVisible() - && free_monitor->isVisible(), - "online running must show HMI and free monitor in the maximized window"); - require(!runtime_logic->isVisible(), - "online running must not show a misleading local ladder runtime trace"); - auto *runtime_hmi_view = qobject_cast(runtime_hmi); - require(runtime_hmi_view != nullptr, - "online running must expose the unified interactive HMI view"); - QTest::mouseClick( - runtime_hmi_view->viewport(), Qt::LeftButton, Qt::NoModifier, - runtime_hmi_view->mapFromScene(QPointF( - button.bounds.x + button.bounds.width / 2.0, - button.bounds.y + button.bounds.height / 2.0))); - QApplication::processEvents(); - require(plc_repository.readBit({RegisterArea::M, 5}).succeeded - && plc_repository.readBit({RegisterArea::M, 5}).value, - "the unified online HMI must write through the active PLC repository"); - QLineEdit *monitor_address = requiredChild(window, "addressEdit"); - QPushButton *monitor_add = requiredChild(window, "addButton"); - QTableWidget *monitor_table = requiredChild(window, "monitorTable"); - monitor_address->setText(QStringLiteral("D9")); - monitor_add->click(); - require(std::find( - gateway.poll_addresses.begin(), - gateway.poll_addresses.end(), - RegisterAddress{RegisterArea::D, 9}) != gateway.poll_addresses.end(), - "online free monitor addresses must join the active PLC poll set immediately"); - auto *monitor_write = qobject_cast(monitor_table->cellWidget(0, 4)); - require(monitor_write != nullptr && monitor_write->isEnabled(), - "online Connected mode must enable free monitor writes"); - - QAction *configure_action = requiredChild(window, "configurePlcAction"); - QAction *disconnect_action = requiredChild(window, "disconnectPlcAction"); - QListWidget *output = requiredChild(window, "outputList"); - gateway.timeoutCommunication( - "PLC 通信超时,本地串口 COM9 仍处于打开状态;请检查 PLC 供电和 RS-485 接线"); - QApplication::processEvents(); - - require(mode_service.mode() == ApplicationMode::Editing, - "a PLC timeout must return online running to editing"); - require(!runtime_window->isVisible(), - "a PLC timeout must hide the runtime monitor window"); - require(!monitor_write->isEnabled(), - "a PLC communication fault must disable free monitor writes"); - require(disconnect_action->isEnabled(), - "a timeout must keep disconnect available while the local port remains open"); - require(configure_action->isEnabled() - && configure_action->text() == QStringLiteral("PLC 重新配置"), - "a timeout must keep PLC reconfiguration available"); - - gateway.recoverCommunication(); - QApplication::processEvents(); - require(mode_service.mode() == ApplicationMode::Editing, - "automatic communication recovery must remain in editing mode"); - require(output->count() > 0 - && output->item(output->count() - 1)->text().contains( - QStringLiteral("通信已恢复")), - "automatic recovery must be retained in the output log"); - online_action->trigger(); - require(mode_service.mode() == ApplicationMode::Editing, - "recovered communication must complete a fresh initial read before online mode"); - - gateway.completeInitialRead(); - QApplication::processEvents(); - online_action->trigger(); - require(mode_service.mode() == ApplicationMode::OnlineRunning, - "users must be able to re-enter online mode after the recovered initial read"); - - gateway.loseSerialConnection( - "PLC 串口 COM9 连接已中断;请检查 USB 转串口是否被拔出或已经失效"); - QApplication::processEvents(); - - require(mode_service.mode() == ApplicationMode::Editing, - "a communication fault must return the UI from online running to editing"); - require(configure_action->isEnabled() - && configure_action->text() == QStringLiteral("PLC 配置"), - "a lost serial connection must expose direct configuration"); - require(!disconnect_action->isEnabled(), - "a lost serial connection must disable redundant disconnect actions"); - require(output->count() > 0 - && output->item(output->count() - 1)->text().contains( - QStringLiteral("连接已中断")), - "communication fault details must remain in the output log"); - - online_action->trigger(); - require(mode_service.mode() == ApplicationMode::Editing, - "faulted PLC state must not re-enter online running without a fresh read"); -} - -void testMultiPageAndLogicMainWindowIntegration() -{ - TestProjectStorage storage; - ProjectService project_service(storage); - HmiEditorService editor_service(project_service); - LogicEditorService logic_editor_service(project_service); - const std::string main_page_id = editor_service.ensureDefaultPage().id; - const std::string settings_page_id = editor_service.addPage("Settings").id; - const std::string first_logic_id = logic_editor_service.ensureDefaultLogic().id; - const std::string second_logic_id = - logic_editor_service.addLogic("Safety logic").id; - - const HmiEditorResult jump_result = editor_service.addControl( - main_page_id, HmiControlType::PageJump); - HmiControl jump = *editor_service.findControl(main_page_id, jump_result.id); - jump.pageJump = HmiPageJumpConfig{settings_page_id}; - require(editor_service.updateControl( - main_page_id, jump_result.id, jump).succeeded, - "the integration fixture must configure PageJump"); - - VirtualRegisterRepository repository; - HmiRuntimeService runtime_service(repository); - AlarmEditorService alarm_editor_service(project_service); - AlarmService alarm_service(project_service, repository); - RegisterCommentService register_comment_service(project_service); - OfflineSimulationService simulation_service(repository); - RuntimeModeService mode_service(project_service, simulation_service); - RegisterMonitorService monitor_service(repository); - HmiNavigationService navigation_service(project_service); - MainWindow window( - mode_service, - project_service, - editor_service, - logic_editor_service, - runtime_service, - alarm_editor_service, - alarm_service, - navigation_service, - register_comment_service, - monitor_service); - window.resize(1400, 820); - window.show(); - QApplication::processEvents(); - - QTreeWidget *tree = requiredChild(window, "projectTree"); - require(tree->topLevelItemCount() == 2 - && tree->topLevelItem(0)->childCount() == 2 - && tree->topLevelItem(1)->childCount() == 2, - "the project tree must list every page and logic module"); - - tree->setCurrentItem(tree->topLevelItem(0)->child(1)); - QApplication::processEvents(); - require(window.currentHmiPageId() == settings_page_id, - "selecting a page tree node must change the current HMI page id"); - QSpinBox *page_width = requiredChild(window, "pageWidthSpinBox"); - QSpinBox *page_height = requiredChild(window, "pageHeightSpinBox"); - QPushButton *apply_page_properties = requiredChild( - window, "applyPagePropertiesButton"); - require(page_width->isEnabled() && page_height->isEnabled() - && page_width->value() == 800 && page_height->value() == 400, - "selecting an HMI page must expose its page dimensions"); - page_width->setValue(1024); - page_height->setValue(600); - apply_page_properties->click(); - require(editor_service.findPage(settings_page_id)->width == 1024 - && editor_service.findPage(settings_page_id)->height == 600 - && requiredChild(window, "hmiPageSizeLabel")->text() - == QStringLiteral("1024 x 600"), - "applying page properties must update the model and page summary"); - QAction *add_label = requiredChild(window, "addLabelAction"); - add_label->trigger(); - require(editor_service.findPage(settings_page_id)->controls.size() == 1U - && editor_service.findPage(main_page_id)->controls.size() == 1U, - "HMI toolbar actions must edit the selected page rather than the first page"); - QComboBox *binding_area = requiredChild(window, "bindingAreaComboBox"); - QComboBox *target_page = requiredChild(window, "targetPageComboBox"); - require(!binding_area->isVisible() && !target_page->isVisible(), - "Label properties must hide both register binding and PageJump target fields"); - - tree->setCurrentItem(tree->topLevelItem(1)->child(1)); - QApplication::processEvents(); - require(window.currentLogicId() == second_logic_id, - "selecting a logic tree node must change the current logic id"); - requiredChild(window, "addRungAction")->trigger(); - require(logic_editor_service.findLogic(second_logic_id)->rungs.size() == 1U - && logic_editor_service.findLogic(first_logic_id)->rungs.empty(), - "logic toolbar actions must edit the selected logic module"); - - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - QApplication::processEvents(); - HmiEditorWidget *editor = requiredChild(window, "hmiEditorWidget"); - editor->selectControl(jump_result.id); - QApplication::processEvents(); - require(target_page->isVisible() && !binding_area->isVisible() - && target_page->currentData().toString() - == QString::fromStdString(settings_page_id), - "PageJump properties must show a target-page selector without register fields"); - - requiredChild(window, "offlineModeAction")->trigger(); - QApplication::processEvents(); - require(mode_service.mode() == ApplicationMode::OfflineRunning - && navigation_service.currentPageId() == main_page_id, - "runtime HMI navigation must start on the persisted initial page"); - HmiEditorWidget *runtime_hmi = requiredChild( - window, "runtimeHmiView"); - QGraphicsItem *jump_item = nullptr; - for (QGraphicsItem *item : runtime_hmi->scene()->items()) - { - if (item->zValue() >= 0.0) - { - jump_item = item; - break; - } - } - require(jump_item != nullptr, "runtime HMI must render the PageJump control"); - const QPoint jump_center = runtime_hmi->mapFromScene( - jump_item->mapToScene(jump_item->boundingRect().center())); - QTest::mouseClick(runtime_hmi->viewport(), Qt::LeftButton, - Qt::NoModifier, jump_center); - QApplication::processEvents(); - require(navigation_service.currentPageId() == settings_page_id - && requiredChild(window, "runtimePageLabel")->text() - == QStringLiteral("Settings"), - "clicking PageJump at runtime must navigate locally to its target page"); - - QComboBox *logic_selector = requiredChild( - window, "runtimeLogicComboBox"); - require(logic_selector->count() == 2, - "runtime logic selector must list all enabled logic modules"); - logic_selector->setCurrentIndex( - logic_selector->findData(QString::fromStdString(second_logic_id))); - QApplication::processEvents(); - auto *runtime_monitor = requiredChild( - window, "runtimeMonitorWidget"); - require(runtime_monitor->selectedLogicId() == second_logic_id, - "runtime logic selection must change only the visible trace projection"); - - requiredChild(window, "editingModeAction")->trigger(); -} - -} // namespace - -int main(int argc, char *argv[]) -{ - // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行 - qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); - QApplication application(argc, argv); - - try - { - testLogicEditorBatchDeletesWholeParallelRow(); - testRuntimeButtonMouseInteraction(); - testRuntimeProgressBarRendering(); - testRuntimePageJumpDoesNotRequireRegisterWritePermission(); - testRuntimeAlarmListInteraction(); - testLogicEmptyGridSlotInsertion(); - testLogicParallelPaddingGridInsertion(); - testLogicParallelActionsRespectGridSelectionType(); - testLogicVerticalWireUsesSelectedWireCells(); - testLogicVerticalWireUsesAdjacentWireCells(); - testLogicParallelBranchConnectsShortBranchToRightJoin(); - testLogicContinuousInsertionConsumesFullWire(); - testLogicHorizontalWireCanBeInsertedAgainAfterUndoingFirstAutoRung(); - testWindowTitleTracksUnsavedProjectChanges(); - testModeActionsControlEditingAvailability(); - testRuntimeMonitorWindowLifecycle(); - testEdgeTimerPropertyEditingAndParallelMenu(); - testPlcConfigurationUsesDialog(); - testRepeatedPlcStatusNotificationsAreCoalesced(); - testFreeMonitorWritesSingleValues(); - testOnlineWorkspaceShowsHmiAndFreeMonitorOnly(); - testMultiPageAndLogicMainWindowIntegration(); - testApplicationExitClosesRuntimeMonitorWindow(); - } - catch (const std::exception &error) - { - std::cerr << "main window tests failed: " << error.what() << '\n'; - return 1; - } - - std::cout << "main window tests passed\n"; - return 0; -} diff --git a/app/tests/main_window_tests.pro b/app/tests/main_window_tests.pro deleted file mode 100644 index 3d0e6b1..0000000 --- a/app/tests/main_window_tests.pro +++ /dev/null @@ -1,32 +0,0 @@ -include(pri/test_defaults.pri) -include(pri/test_layers.pri) - -TARGET = main_window_tests -QT += widgets serialport testlib - -SOURCES += \ - main_window_tests.cpp \ - $$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 += \ - $$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 += $$UI_MAIN_FORMS diff --git a/app/tests/pri/test_layers.pri b/app/tests/pri/test_layers.pri index e87a2d2..f96a8d6 100644 --- a/app/tests/pri/test_layers.pri +++ b/app/tests/pri/test_layers.pri @@ -132,45 +132,3 @@ 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 diff --git a/app/tests/tests.pro b/app/tests/tests.pro index df5f80b..25f7250 100644 --- a/app/tests/tests.pro +++ b/app/tests/tests.pro @@ -1,4 +1,4 @@ -# 测试总入口:只负责编排目标,不把不同环境的测试揉成一个可执行文件 +# 功能测试总入口:性能测试使用 performance_tests.pro 单独运行 TEMPLATE = subdirs CONFIG += ordered @@ -11,9 +11,7 @@ SUBDIRS += \ project_management \ register_monitor \ runtime_mode \ - plc_runtime \ - main_window \ - performance + plc_runtime domain.file = domain_tests.pro alarm_service.file = alarm_service_tests.pro @@ -24,5 +22,3 @@ 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 diff --git a/scripts/run_qt_tests.ps1 b/scripts/run_qt_tests.ps1 index dcae291..5e915d8 100644 --- a/scripts/run_qt_tests.ps1 +++ b/scripts/run_qt_tests.ps1 @@ -2,7 +2,8 @@ param( [ValidateSet('Debug', 'Release')] [string]$Configuration = 'Debug', - [switch]$SkipPerformance + [ValidateSet('Functional', 'Performance', 'All')] + [string]$Suite = 'Functional' ) Set-StrictMode -Version Latest @@ -23,7 +24,7 @@ foreach ($requiredPath in @($qmakePath, $makePath, $testSourceRoot)) { } } -$targets = @( +$functionalTargets = @( 'domain_tests', 'alarm_service_tests', 'hmi_editor_service_tests', @@ -32,17 +33,20 @@ $targets = @( 'project_management_tests', 'register_monitor_service_tests', 'runtime_mode_service_tests', - 'plc_runtime_tests', - 'main_window_tests' + 'plc_runtime_tests' ) -if (-not $SkipPerformance) { - $targets += 'performance_tests' +$performanceTargets = @('performance_tests') + +$targets = switch ($Suite) { + 'Functional' { $functionalTargets } + 'Performance' { $performanceTargets } + 'All' { $functionalTargets + $performanceTargets } } $originalPath = $env:Path -$originalPlatform = $env:QT_QPA_PLATFORM $env:Path = "$qtRoot\bin;$mingwBin;$env:Path" -$env:QT_QPA_PLATFORM = 'offscreen' + +Write-Host "测试类型:$Suite" try { foreach ($target in $targets) { @@ -68,10 +72,19 @@ try { } Write-Host "运行 $target" - & $executablePath + if ($target -eq 'performance_tests') { + $benchmarkResultPath = Join-Path $buildDirectory 'benchmark.csv' + & $executablePath '-o' "$benchmarkResultPath,csv" + } else { + & $executablePath + } if ($LASTEXITCODE -ne 0) { throw "测试失败:$target,退出码:$LASTEXITCODE" } + if ($target -eq 'performance_tests') { + Write-Host "性能结果:$benchmarkResultPath" + Get-Content -LiteralPath $benchmarkResultPath -Encoding utf8 + } } finally { Pop-Location @@ -80,9 +93,4 @@ try { } finally { $env:Path = $originalPath - if ($null -eq $originalPlatform) { - Remove-Item Env:QT_QPA_PLATFORM -ErrorAction SilentlyContinue - } else { - $env:QT_QPA_PLATFORM = $originalPlatform - } }