From 88cacc0f57db522c7ca22bb25c27b953c1e04fa0 Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Sat, 22 Aug 2026 14:11:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=A2=AF=E5=BD=A2?= =?UTF-8?q?=E5=9B=BE=E7=BD=91=E6=A0=BC=E7=BC=96=E8=BE=91=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/domain/hmi_model.cpp | 7 +- app/src/services/logic_editor_service.cpp | 230 +++++++++++++++++++--- app/src/services/logic_editor_service.h | 8 +- app/src/ui/alarm_configuration_dialog.h | 21 +- app/src/ui/free_monitor_widget.h | 25 ++- app/src/ui/hmi_editor_widget.h | 21 +- app/src/ui/logic_editor_widget.cpp | 126 ++++++++---- app/src/ui/logic_editor_widget.h | 56 +++++- app/src/ui/logic_instruction_dialog.h | 20 +- app/src/ui/main_window.h | 98 ++++++--- app/src/ui/plc_connection_dialog.h | 13 +- app/src/ui/project_workspace_controller.h | 58 +++++- app/src/ui/property_panel_controller.h | 45 ++++- app/src/ui/register_comment_dialog.h | 18 +- app/src/ui/runtime_monitor_widget.h | 35 +++- app/src/ui/runtime_monitor_window.h | 22 ++- app/src/ui/runtime_panel_controller.h | 46 ++++- app/src/ui/toolbar_icon_factory.h | 16 +- app/tests/logic_editor_service_tests.cpp | 154 +++++++++++++-- app/tests/main_window_tests.cpp | 159 ++++++++++++++- 20 files changed, 1035 insertions(+), 143 deletions(-) diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp index f20cea3..50d77ef 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -271,11 +271,14 @@ bool HmiPage::validate(std::string *error) const setError(error, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); return false; } - if (width <= 0 || height <= 0 + if (width < ProjectLimits::kMinimumHmiPageWidth + || height < ProjectLimits::kMinimumHmiPageHeight || width > ProjectLimits::kMaximumHmiPageWidth || height > ProjectLimits::kMaximumHmiPageHeight) { - setError(error, "HMI 页面宽度和高度必须在 1~8192 范围内"); + setError( + error, + "HMI 页面宽度必须在 320~1600、高度必须在 200~800 范围内"); return false; } if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage) diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index 170f312..16f630a 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -80,6 +80,24 @@ ConditionExpression *findParentExpression( return nullptr; } +const ConditionExpression *findParentExpression( + const ConditionExpression &expression, const std::string &child_id) +{ + for (const ConditionExpression &child : expression.children) + { + if (child.id == child_id) + { + return &expression; + } + if (const ConditionExpression *parent = findParentExpression( + child, child_id)) + { + return parent; + } + } + return nullptr; +} + using NodeIdSet = std::unordered_set; NodeIdSet conditionLeafIds(const ConditionExpression &expression) @@ -652,7 +670,6 @@ LogicEditorResult LogicEditorService::ensureDefaultLogic() ControlLogic logic; logic.id = "logic-1"; logic.name = "控制逻辑 1"; - logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt, std::nullopt}); Project &project = project_service_.editProject(); project.controlLogics.push_back(std::move(logic)); return {true, LogicEditorError::None, {}, project.controlLogics.back().id}; @@ -684,7 +701,6 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name) ControlLogic logic; logic.id = makeUniqueLogicId(current); logic.name = name; - logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt, std::nullopt}); HistoryState before = captureState(); Project &project = project_service_.editProject(); project.controlLogics.push_back(std::move(logic)); @@ -850,10 +866,6 @@ LogicEditorResult LogicEditorService::removeRung( { return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); } - if (logic->rungs.size() == 1U) - { - return failure(LogicEditorError::InvalidOperation, "控制逻辑至少需要保留一个网络"); - } HistoryState before = captureState(); Project &project = project_service_.editProject(); auto target = std::find_if( @@ -908,11 +920,19 @@ LogicEditorResult LogicEditorService::appendCondition( const LogicNodeConfig &config) { const ControlLogic *logic = findLogic(logic_id); - if (logic == nullptr || findRung(logic_id, rung_id) == nullptr) + if (logic == nullptr) { - return failure( - logic == nullptr ? LogicEditorError::LogicNotFound : LogicEditorError::RungNotFound, - logic == nullptr ? "未找到控制逻辑" : "未找到梯形图网络"); + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); + } + const bool create_rung = rung_id.empty(); + if (!create_rung && findRung(logic_id, rung_id) == nullptr) + { + return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); + } + if (create_rung + && logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic) + { + return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络"); } if (!isConditionConfig(config)) { @@ -920,11 +940,25 @@ LogicEditorResult LogicEditorService::appendCondition( } // 先生成稳定节点 ID,再把新节点接到已有表达式的串联末尾 const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); + const std::string target_rung_id = create_rung + ? makeUniqueRungId(*logic) : rung_id; ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); - LadderRung *rung = findEditableRung(project, logic_id, rung_id); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); + if (create_rung) + { + editable_logic->rungs.push_back({ + target_rung_id, + "网络 " + std::to_string(editable_logic->rungs.size() + 1U), + {}, + std::nullopt, + std::nullopt}); + } + LadderRung *rung = findEditableRung(project, logic_id, target_rung_id); if (!rung->condition.has_value()) { rung->condition = std::move(leaf); @@ -1048,31 +1082,152 @@ LogicEditorResult LogicEditorService::insertConditionAtColumn( return {true, LogicEditorError::None, {}, node_id}; } -LogicEditorResult LogicEditorService::appendWire( +LogicEditorResult LogicEditorService::insertConditionInBranchAtColumn( const std::string &logic_id, const std::string &rung_id, - int column_span) + const std::string &branch_expression_id, + int column, + const LogicNodeConfig &config) { const ControlLogic *logic = findLogic(logic_id); - if (logic == nullptr || findRung(logic_id, rung_id) == nullptr) + const LadderRung *existing_rung = findRung(logic_id, rung_id); + if (logic == nullptr || existing_rung == nullptr + || !existing_rung->condition.has_value()) { return failure( logic == nullptr ? LogicEditorError::LogicNotFound : LogicEditorError::RungNotFound, logic == nullptr ? "未找到控制逻辑" : "未找到梯形图网络"); } + if (!isConditionConfig(config)) + { + return failure(LogicEditorError::InvalidNode, "并联空网格只能插入条件节点"); + } + + const ConditionExpression *branch = findExpression( + logic_id, rung_id, branch_expression_id); + const ConditionExpression *branch_parent = findParentExpression( + *existing_rung->condition, branch_expression_id); + if (branch == nullptr || branch_parent == nullptr + || branch_parent->kind != ConditionExpressionKind::Parallel) + { + return failure( + LogicEditorError::ExpressionNotFound, + "未找到并联分支空网格"); + } + + const int branch_columns = expressionColumns(*branch); + const int parallel_columns = expressionColumns(*branch_parent); + if (column < branch_columns || column >= parallel_columns) + { + return failure( + LogicEditorError::InvalidOperation, + "所选位置不是并联分支中的可用空网格"); + } + + const int leading_wire_columns = column - branch_columns; + const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); + const std::string wire_id = leading_wire_columns > 0 + ? makeUniqueWireId(*logic) : std::string{}; + const std::string series_id = makeUniqueExpressionId(*logic); + ConditionExpression leaf = ConditionExpression::fromNode( + makeNode(node_id, config)); + HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + LadderRung *rung = findEditableRung( + project_service_.editProject(), logic_id, rung_id); + ConditionExpression *editable_branch = findConditionExpression( + *rung->condition, branch_expression_id); + + if (editable_branch->kind == ConditionExpressionKind::Series) + { + if (leading_wire_columns > 0) + { + editable_branch->children.push_back(ConditionExpression::fromWire( + wire_id, leading_wire_columns)); + } + editable_branch->children.push_back(std::move(leaf)); + } + else + { + ConditionExpression series; + series.id = series_id; + series.kind = ConditionExpressionKind::Series; + series.children.push_back(std::move(*editable_branch)); + if (leading_wire_columns > 0) + { + series.children.push_back(ConditionExpression::fromWire( + wire_id, leading_wire_columns)); + } + series.children.push_back(std::move(leaf)); + *editable_branch = std::move(series); + } + + std::string validation_error; + if (!rung->validate(&validation_error)) + { + rollbackEdit(std::move(before), modified_before); + return failure(LogicEditorError::InvalidOperation, validation_error); + } + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, node_id}; +} + +LogicEditorResult LogicEditorService::appendWire( + const std::string &logic_id, + const std::string &rung_id, + int column_span) +{ + const ControlLogic *logic = findLogic(logic_id); + if (logic == nullptr) + { + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); + } WireSegment wire{column_span}; std::string error; if (!wire.validate(&error)) { return failure(LogicEditorError::InvalidOperation, error); } + const LadderRung *existing_rung = rung_id.empty() + ? nullptr : findRung(logic_id, rung_id); + if (!rung_id.empty() && existing_rung == nullptr) + { + return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); + } + bool create_rung = rung_id.empty(); + if (!create_rung && column_span == 1 && existing_rung->condition.has_value() + && expressionColumns(*existing_rung->condition) + >= ProjectLimits::kMaximumConditionColumns) + { + // 无选中目标的连续追加达到十列后,原子切换到下一个网络 + create_rung = true; + } + if (create_rung + && logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic) + { + return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络"); + } + const std::string target_rung_id = create_rung + ? makeUniqueRungId(*logic) : rung_id; const std::string wire_id = makeUniqueWireId(*logic); ConditionExpression leaf = ConditionExpression::fromWire(wire_id, column_span); HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); - LadderRung *rung = findEditableRung( - project_service_.editProject(), logic_id, rung_id); + Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); + if (create_rung) + { + editable_logic->rungs.push_back({ + target_rung_id, + "网络 " + std::to_string(editable_logic->rungs.size() + 1U), + {}, + std::nullopt, + std::nullopt}); + } + LadderRung *rung = findEditableRung(project, logic_id, target_rung_id); if (!rung->condition.has_value()) { rung->condition = std::move(leaf); @@ -1488,14 +1643,31 @@ LogicEditorResult LogicEditorService::setOutput( bool configured) { const ControlLogic *logic = findLogic(logic_id); - const LadderRung *existing_rung = findRung(logic_id, rung_id); - if (logic == nullptr || existing_rung == nullptr || !isOutputConfig(config)) + const LadderRung *existing_rung = rung_id.empty() + ? nullptr : findRung(logic_id, rung_id); + if (logic == nullptr || (!rung_id.empty() && existing_rung == nullptr) + || !isOutputConfig(config)) { return failure( - LogicEditorError::InvalidNode, - "梯形图输出必须使用有效的输出指令"); + logic == nullptr ? LogicEditorError::LogicNotFound + : existing_rung == nullptr && !rung_id.empty() + ? LogicEditorError::RungNotFound + : LogicEditorError::InvalidNode, + logic == nullptr ? "未找到控制逻辑" + : existing_rung == nullptr && !rung_id.empty() + ? "未找到梯形图网络" + : "梯形图输出必须使用有效的输出指令"); } - const std::string node_id = existing_rung->output.has_value() + if (rung_id.empty() + && logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic) + { + return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络"); + } + const bool create_rung = rung_id.empty(); + const std::string target_rung_id = create_rung + ? makeUniqueRungId(*logic) : rung_id; + const std::string node_id = existing_rung != nullptr + && existing_rung->output.has_value() ? existing_rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config)); LogicNode node = makeNode(node_id, config); node.configured = configured; @@ -1504,14 +1676,26 @@ LogicEditorResult LogicEditorService::setOutput( { return failure(LogicEditorError::InvalidNode, validation_error); } - if (existing_rung->output.has_value() + if (existing_rung != nullptr && existing_rung->output.has_value() && nodesEqual(*existing_rung->output, node)) { return {true, LogicEditorError::None, {}, node_id}; } HistoryState before = captureState(); Project &project = project_service_.editProject(); - findEditableRung(project, logic_id, rung_id)->output = std::move(node); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); + if (create_rung) + { + editable_logic->rungs.push_back({ + target_rung_id, + "网络 " + std::to_string(editable_logic->rungs.size() + 1U), + {}, + std::nullopt, + std::nullopt}); + } + findEditableRung(project, logic_id, target_rung_id)->output = std::move(node); recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } diff --git a/app/src/services/logic_editor_service.h b/app/src/services/logic_editor_service.h index de1a048..5aab3a1 100644 --- a/app/src/services/logic_editor_service.h +++ b/app/src/services/logic_editor_service.h @@ -54,7 +54,7 @@ public: const std::string &logic_id, const std::string &node_id) const; std::string registerCommentFor(const RegisterAddress &address) const; - // 确保新工程至少有一组可编辑逻辑 + // 确保新工程至少有一组可编辑逻辑;逻辑组初始允许没有网络 LogicEditorResult ensureDefaultLogic(); // 逻辑组管理 LogicEditorResult addLogic(const std::string &name); @@ -81,6 +81,12 @@ public: const std::string &rung_id, int column, const LogicNodeConfig &config); + LogicEditorResult insertConditionInBranchAtColumn( + const std::string &logic_id, + const std::string &rung_id, + const std::string &branch_expression_id, + int column, + const LogicNodeConfig &config); LogicEditorResult appendWire( const std::string &logic_id, const std::string &rung_id, diff --git a/app/src/ui/alarm_configuration_dialog.h b/app/src/ui/alarm_configuration_dialog.h index 5473a1b..aee7304 100644 --- a/app/src/ui/alarm_configuration_dialog.h +++ b/app/src/ui/alarm_configuration_dialog.h @@ -1,3 +1,11 @@ +/** + * @file alarm_configuration_dialog.h + * @brief 定义报警定义配置对话框 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include @@ -12,7 +20,7 @@ class AlarmConfigurationDialog; class AlarmEditorService; struct AlarmDefinition; -// 报警定义编辑对话框;保存和删除都委托 AlarmEditorService +/** 报警定义编辑对话框;保存和删除都委托 AlarmEditorService */ class AlarmConfigurationDialog final : public QDialog { public: @@ -22,16 +30,25 @@ public: ~AlarmConfigurationDialog() override; private: - // 刷新左侧列表并尽量恢复原选中项 + /** 刷新左侧列表并尽量恢复原选中项 */ void reloadDefinitions(const std::string &selected_id = {}); + /** 将当前选中报警加载到编辑区 */ void loadSelectedDefinition(); + /** 根据报警类型更新条件输入项 */ void updateConditionOptions(); + /** 新增一条报警定义 */ void addDefinition(); + /** 更新当前选中的报警定义 */ void updateDefinition(); + /** 删除当前选中的报警定义 */ void removeDefinition(); + /** 从输入框读取报警定义 */ AlarmDefinition definitionFromInputs() const; + /** 返回当前选中的报警标识 */ std::string selectedId() const; + /** Qt Designer 生成的界面对象 */ std::unique_ptr ui_; + /** 报警定义服务,不由对话框拥有 */ AlarmEditorService &service_; }; diff --git a/app/src/ui/free_monitor_widget.h b/app/src/ui/free_monitor_widget.h index f894a5c..6c00a53 100644 --- a/app/src/ui/free_monitor_widget.h +++ b/app/src/ui/free_monitor_widget.h @@ -1,3 +1,11 @@ +/** + * @file free_monitor_widget.h + * @brief 定义自由寄存器监控控件 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include "domain/runtime_state.h" @@ -13,7 +21,7 @@ class FreeMonitorWidget; class RegisterMonitorService; -// 自由监控 UI:地址列表属于当前会话,读写统一委托 RegisterMonitorService +/** 自由监控 UI:地址列表属于当前会话,读写统一委托 RegisterMonitorService */ class FreeMonitorWidget final : public QWidget { Q_OBJECT @@ -24,24 +32,37 @@ public: QWidget *parent = nullptr); ~FreeMonitorWidget() override; - // 真机未连接、通信故障或编辑态时应关闭写入入口 + /** 真机未连接、通信故障或编辑态时应关闭写入入口 */ void setWriteEnabled(bool enabled); + /** 根据运行模式和 PLC 状态刷新所有监控值 */ void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); + /** 从服务重新加载当前会话中的地址列表 */ void reloadAddresses(); signals: + /** 监控地址列表发生变化时发出 */ void monitorAddressesChanged(); + /** 操作完成后发出可直接显示的消息 */ void operationMessage(const QString &message); private: + /** 从输入框添加一个或多个监控地址 */ void addAddresses(); + /** 删除当前选中的监控地址 */ void removeSelectedAddresses(); + /** 清空当前会话中的监控地址 */ void clearAddresses(); + /** 写入指定行中的寄存器值 */ void writeRow(int row); + /** 根据当前权限刷新写入控件状态 */ void updateWriteControls(); + /** 统一处理监控服务返回结果 */ void handleResult(const QString &action, bool succeeded, const std::string &message); + /** Qt Designer 生成的界面对象 */ std::unique_ptr ui_; + /** 寄存器监控服务,不由控件拥有 */ RegisterMonitorService &service_; + /** 当前是否允许执行寄存器写入 */ bool write_enabled_ = false; }; diff --git a/app/src/ui/hmi_editor_widget.h b/app/src/ui/hmi_editor_widget.h index c1d32bf..afe23a9 100644 --- a/app/src/ui/hmi_editor_widget.h +++ b/app/src/ui/hmi_editor_widget.h @@ -1,6 +1,7 @@ /** * @file hmi_editor_widget.h * @brief 定义基于 QGraphicsScene 的 HMI 页面编辑和运行画布 + * @version 1.0.0 * @author suyu * @date 2026-08-10 */ @@ -56,8 +57,7 @@ public: * @param active 为 true 时允许按钮和数值输入调用运行时服务 */ void setRuntimeActive(bool active); - // 故障态保持运行值可见,但禁止按钮和数值输入继续写入 - // 故障态保持运行值可见,但禁止按钮和数值输入继续写入 + /** 故障态保持运行值可见,但禁止按钮和数值输入继续写入 */ void setRuntimeWriteEnabled(bool enabled); /** * @brief 使用当前页面模型完全重建场景 @@ -70,7 +70,9 @@ public: * @param control_id 待选中控件标识,不存在时不改变当前选择 */ void selectControl(const std::string &control_id); + /** 返回当前选中的单个控件标识 */ std::string selectedControlId() const; + /** 返回当前选中的多个控件标识 */ std::vector selectedControlIds() const; /** * @brief 从统一寄存器仓库读取当前值并刷新画布显示 @@ -95,6 +97,7 @@ signals: * @param message 可直接显示的错误信息 */ void editorError(const QString &message); + /** 运行态请求跳转到指定 HMI 页面 */ void pageNavigationRequested(const QString &target_page_id); protected: @@ -115,18 +118,20 @@ private: // 在运行态处理数值输入激活 void handleNumericInputActivated(const std::string &control_id); - // 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 + /** 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 */ HmiEditorService &editor_service_; - // 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 + /** 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 */ HmiRuntimeService &runtime_service_; + /** 报警查询服务,不由控件拥有 */ AlarmService &alarm_service_; - // 持有所有页面图元和页面边框 + /** 持有所有页面图元和页面边框 */ QGraphicsScene *scene_ = nullptr; - // 当前画布投影的页面标识 + /** 当前画布投影的页面标识 */ std::string page_id_; - // 控制是否允许拖动和编辑控件 + /** 控制是否允许拖动和编辑控件 */ bool editing_enabled_ = true; - // 控制是否刷新运行值并响应运行态控件操作 + /** 控制是否刷新运行值并响应运行态控件操作 */ bool runtime_active_ = false; + /** 控制运行态按钮和数值输入是否允许写入 */ bool runtime_write_enabled_ = false; }; diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp index 2967e10..1f2d413 100644 --- a/app/src/ui/logic_editor_widget.cpp +++ b/app/src/ui/logic_editor_widget.cpp @@ -649,14 +649,24 @@ public: const std::string &rung_id, int column, const QPointF ¢er, - bool show_label) - : rung_id_(rung_id), column_(column), show_label_(show_label) + bool show_label, + std::string branch_expression_id = {}) + : rung_id_(rung_id), + branch_expression_id_(std::move(branch_expression_id)), + column_(column), + show_label_(show_label) { setPos(center); setFlag(ItemIsSelectable, true); setZValue(2.0); - setToolTip(LogicEditorWidget::tr("空条件网格:第 %1 列,点击后可插入触点") - .arg(column + 1)); + setToolTip( + branch_expression_id_.empty() + ? LogicEditorWidget::tr( + "空条件网格:第 %1 列,点击后可插入触点") + .arg(column + 1) + : LogicEditorWidget::tr( + "并联空网格:第 %1 格,点击后可插入触点") + .arg(column + 1)); } QRectF boundingRect() const override @@ -676,9 +686,6 @@ public: painter->setPen(QPen(kSelectionBorderColor, 1.4, Qt::DashLine)); painter->drawRect(boundingRect().adjusted(3, 3, -3, -3)); } - painter->setPen(ladderPen(false)); - painter->drawLine( - QPointF(-kCellWidth / 2.0, 0), QPointF(kCellWidth / 2.0, 0)); if (show_label_) { painter->setPen(kPlaceholderColor); @@ -690,10 +697,15 @@ public: } const std::string &rungId() const { return rung_id_; } + const std::string &branchExpressionId() const + { + return branch_expression_id_; + } int column() const { return column_; } private: std::string rung_id_; + std::string branch_expression_id_; int column_ = 0; bool show_label_ = false; }; @@ -782,6 +794,13 @@ public: QRectF boundingRect() const override { return {kSceneMargin, 0, width_, height_}; } + QPainterPath shape() const override + { + QPainterPath path; + path.addRect(QRectF(kSceneMargin, 0, width_, kRungHeaderHeight)); + return path; + } + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *) override { const bool selected = (option->state & QStyle::State_Selected) != 0; @@ -1006,9 +1025,29 @@ RenderResult renderExpression( scene.addLine( QLineF(QPointF(left_join, branches[index].input.y()), branches[index].input), ladderPen(branch_active)); - scene.addLine( - QLineF(branches[index].output, QPointF(right_join, branches[index].output.y())), - ladderPen(branch_active)); + if (branches[index].output.x() < right_join - 0.1) + { + // 分支宽度不足时补出到右侧汇合点的结构连接,不写入隐式 Wire + scene.addLine( + QLineF( + branches[index].output, + QPointF(right_join, branches[index].output.y())), + ladderPen(branch_active)); + } + const int branch_columns = measureExpression( + expression.children[index]).columns; + for (int column = branch_columns; column < metrics.columns; ++column) + { + scene.addItem(new LogicEditorWidget::EmptySlotItem( + rung_id, + column, + QPointF( + top_left.x() + + (static_cast(column) + 0.5) * kCellWidth, + branches[index].output.y()), + false, + expression.children[index].id)); + } } return {QPointF(left_join, top_y), QPointF(right_join, top_y)}; } @@ -1142,14 +1181,14 @@ void LogicEditorWidget::reloadLogic() column == 0 && !rung.output.has_value())); } - const bool rung_active = runtime_trace_enabled_ && traceValue( - trace_, &LogicTraceSnapshot::rungValues, rung.id); const qreal output_left = right_rail_x - kCellWidth; - scene_->addLine( - QLineF(expression_output, QPointF(output_left, main_y)), - ladderPen(rung_active)); if (rung.output.has_value()) { + const bool rung_active = runtime_trace_enabled_ && traceValue( + trace_, &LogicTraceSnapshot::rungValues, rung.id); + scene_->addLine( + QLineF(expression_output, QPointF(output_left, main_y)), + ladderPen(rung_active)); scene_->addLine( QLineF(QPointF(output_left, main_y), QPointF(right_rail_x, main_y)), ladderPen(rung_active)); @@ -1186,11 +1225,6 @@ void LogicEditorWidget::reloadLogic() *scene_, QRectF(output_left, grid_top, kCellWidth, kCellHeight), tr("输出线圈")); - scene_->addLine( - QLineF( - QPointF(output_left + kCellWidth, main_y), - QPointF(right_rail_x, main_y)), - ladderPen(rung_active)); } top += rung_height + kRungGap; ++number; @@ -1353,17 +1387,17 @@ std::vector LogicEditorWidget::selectedWireIds() const return ids; } -std::vector LogicEditorWidget::selectedEmptySlotColumns() const +std::vector> LogicEditorWidget::selectedEmptySlots() const { - std::vector columns; + std::vector> targets; for (QGraphicsItem *item : scene_->selectedItems()) { if (const EmptySlotItem *slot = dynamic_cast(item)) { - columns.push_back(slot->column()); + targets.emplace_back(slot->branchExpressionId(), slot->column()); } } - return columns; + return targets; } std::vector> LogicEditorWidget::selectedWireCells() const @@ -1456,6 +1490,18 @@ std::string LogicEditorWidget::selectedRungId() const return rung_id; } +bool LogicEditorWidget::hasSelectedRungItem() const +{ + for (QGraphicsItem *item : scene_->selectedItems()) + { + if (dynamic_cast(item) != nullptr) + { + return true; + } + } + return false; +} + LogicEditorResult LogicEditorWidget::addRung() { const LogicEditorResult result = editor_service_.addRung(logic_id_); @@ -1474,15 +1520,16 @@ LogicEditorResult LogicEditorWidget::addRung() LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) { - const std::vector selected_empty_columns = selectedEmptySlotColumns(); + const std::vector> selected_empty_slots = + selectedEmptySlots(); const std::vector> selected_wire_cells = selectedWireCells(); const std::vector selected_expressions = selectedExpressionIds(); const std::vector selected_wires = selectedWireIds(); const std::vector selected_ids = selectedNodeIds(); LogicEditorResult result; - if (selected_empty_columns.size() > 1U || selected_wire_cells.size() > 1U - || (!selected_empty_columns.empty() + if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U + || (!selected_empty_slots.empty() && (!selected_wire_cells.empty() || !selected_expressions.empty() || !selected_ids.empty())) || (!selected_wire_cells.empty() @@ -1491,13 +1538,14 @@ LogicEditorResult LogicEditorWidget::addCondition(const LogicNodeConfig &config) result = {false, LogicEditorError::InvalidOperation, "插入条件时只能选择一个网格或条件对象", {}}; } - else if (selected_empty_columns.size() == 1U) + else if (selected_empty_slots.size() == 1U) { - result = editor_service_.insertConditionAtColumn( - logic_id_, - currentRungId(), - selected_empty_columns.front(), - config); + const auto &slot = selected_empty_slots.front(); + result = slot.first.empty() + ? editor_service_.insertConditionAtColumn( + logic_id_, currentRungId(), slot.second, config) + : editor_service_.insertConditionInBranchAtColumn( + logic_id_, currentRungId(), slot.first, slot.second, config); } else if (selected_wire_cells.size() == 1U) { @@ -1762,20 +1810,20 @@ LogicEditorResult LogicEditorWidget::deleteSelected() { result = editor_service_.removeNodes(logic_id_, node_ids); } - else + else if (hasSelectedRungItem()) { const std::string rung_id = selectedRungId(); - if (rung_id.empty()) - { - return {false, LogicEditorError::InvalidOperation, - "请先选择要删除的逻辑节点或网络", {}}; - } result = editor_service_.removeRung(logic_id_, rung_id); if (result.succeeded && current_rung_id_ == rung_id) { current_rung_id_ = editor_service_.firstRungId(logic_id_); } } + else + { + result = {false, LogicEditorError::InvalidOperation, + "请先选择要删除的逻辑节点或网络", {}}; + } if (result.succeeded) { reloadLogic(); diff --git a/app/src/ui/logic_editor_widget.h b/app/src/ui/logic_editor_widget.h index 8cff549..aaf2129 100644 --- a/app/src/ui/logic_editor_widget.h +++ b/app/src/ui/logic_editor_widget.h @@ -1,3 +1,11 @@ +/** + * @file logic_editor_widget.h + * @brief 定义结构化梯形图编辑和运行轨迹画布 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include "domain/control_logic_model.h" @@ -13,7 +21,7 @@ class QGraphicsScene; class QResizeEvent; -// 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 +/** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */ class LogicEditorWidget final : public QGraphicsView { Q_OBJECT @@ -30,57 +38,97 @@ public: LogicEditorService &editor_service, QWidget *parent = nullptr); + /** 切换当前显示的控制逻辑 */ void setLogicId(const std::string &logic_id); + /** 设置是否允许编辑梯形图 */ void setEditingEnabled(bool enabled); - // 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 + /** 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 */ void setRuntimeTrace( const LogicTraceSnapshot &trace, const std::string &fault_node_id = {}); + /** 清除当前运行轨迹和故障节点标记 */ void clearRuntimeTrace(); + /** 根据当前逻辑模型重建梯形图场景 */ void reloadLogic(); + /** 选中指定节点并滚动到可见区域 */ void selectNode(const std::string &node_id); + /** 返回当前选中的单个节点标识 */ std::string selectedNodeId() const; + /** 返回当前选中的多个节点标识 */ std::vector selectedNodeIds() const; + /** 返回当前选中的网络标识 */ std::string selectedRungId() const; + /** 新增一个空的梯形图网络 */ LogicEditorResult addRung(); - // 下列编辑命令只负责把当前选择翻译成服务调用 + /** 将当前选择转换为服务调用并新增串联条件 */ LogicEditorResult addCondition(const LogicNodeConfig &config); + /** 将当前选择转换为服务调用并新增并联支路 */ LogicEditorResult addParallelBranch(const LogicNodeConfig &config); + /** 在当前选择位置新增横线 */ LogicEditorResult addHorizontalWire(); + /** 在当前选择位置新增竖线 */ LogicEditorResult addVerticalWire(); + /** 删除当前选择位置的横线 */ LogicEditorResult deleteHorizontalWire(); + /** 删除当前选择位置的竖线 */ LogicEditorResult deleteVerticalWire(); + /** 设置当前网络的输出线圈 */ LogicEditorResult setOutput( const LogicNodeConfig &config, bool configured = false); + /** 删除当前选中的节点、线段或网络 */ LogicEditorResult deleteSelected(); signals: + /** 当前选中的节点发生变化时发出 */ void nodeSelected(const QString &node_id); + /** 梯形图模型成功发生变化时发出 */ void graphChanged(); + /** 编辑服务操作失败时发出可显示的错误信息 */ void editorError(const QString &message); protected: + /** 在窗口大小变化后重新布局梯形图场景 */ void resizeEvent(QResizeEvent *event) override; private: + /** 处理图形场景选择变化 */ void handleSelectionChanged(); + /** 把编辑服务结果转换为错误信号 */ void reportFailure(const LogicEditorResult &result); + /** 返回当前选中的网络标识 */ std::string currentRungId() const; + /** 选中指定表达式图元 */ void selectExpression(const std::string &expression_id); + /** 选中指定横线图元 */ void selectWire(const std::string &wire_id); + /** 收集当前选中的表达式标识 */ std::vector selectedExpressionIds() const; + /** 收集当前选中的横线标识 */ std::vector selectedWireIds() const; + /** 收集当前选中的并联支路标识 */ std::vector selectedBranchIds() const; - std::vector selectedEmptySlotColumns() const; + /** 收集当前选中的空网格位置 */ + std::vector> selectedEmptySlots() const; + /** 收集当前选中的横线网格位置 */ std::vector> selectedWireCells() const; + /** 判断当前是否选中了网络内的图元 */ + bool hasSelectedRungItem() const; + /** 梯形图编辑服务,不由控件拥有 */ LogicEditorService &editor_service_; + /** 承载梯形图图元的场景 */ QGraphicsScene *scene_ = nullptr; + /** 当前显示的控制逻辑标识 */ std::string logic_id_; + /** 当前选中的网络标识 */ std::string current_rung_id_; + /** 最近一次收到的运行轨迹 */ LogicTraceSnapshot trace_; + /** 最近一次运行故障节点标识 */ std::string fault_node_id_; + /** 是否显示运行轨迹 */ bool runtime_trace_enabled_ = false; + /** 是否允许编辑梯形图 */ bool editing_enabled_ = true; }; diff --git a/app/src/ui/logic_instruction_dialog.h b/app/src/ui/logic_instruction_dialog.h index aeeba69..e0b2837 100644 --- a/app/src/ui/logic_instruction_dialog.h +++ b/app/src/ui/logic_instruction_dialog.h @@ -1,3 +1,11 @@ +/** + * @file logic_instruction_dialog.h + * @brief 定义梯形图指令参数编辑对话框 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include "domain/control_logic_model.h" @@ -13,7 +21,7 @@ namespace Ui { class LogicInstructionDialog; } -// 梯形图节点配置对话框;只负责把表单转换为强类型 LogicNodeConfig +/** 梯形图节点配置对话框;只负责把表单转换为强类型 LogicNodeConfig */ class LogicInstructionDialog final : public QDialog { Q_OBJECT @@ -24,22 +32,30 @@ public: QWidget *parent = nullptr); ~LogicInstructionDialog() override; - // 返回当前表单配置,调用方仍需交给 LogicEditorService 校验 + /** 返回当前表单配置,调用方仍需交给 LogicEditorService 校验 */ LogicNodeConfig config() const; private: + /** 根据计数器配置初始化计数器输入项 */ void configureCounter(const CounterNodeConfig &config); + /** 根据传送配置初始化传送输入项 */ void configureMove(const MoveNodeConfig &config); + /** 根据算术配置初始化运算输入项 */ void configureArithmetic(const ArithmeticNodeConfig &config); + /** 根据操作数类型更新数值输入框的合法范围 */ void updateOperandRanges(); + /** 将领域操作数写入对应的下拉框和数值框 */ static void setOperand( QComboBox *kind_combo, QSpinBox *value_spin, const WordOperand &operand); + /** 从下拉框和数值框读取领域操作数 */ static WordOperand operandFrom( const QComboBox *kind_combo, const QSpinBox *value_spin); + /** Qt Designer 生成的界面对象 */ std::unique_ptr ui_; + /** 打开对话框时保存的原始节点配置 */ LogicNodeConfig original_config_; }; diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index 9364d2b..02cb29f 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -91,78 +91,105 @@ public: ~MainWindow() override; + /** 返回当前选中的 HMI 页面标识 */ const std::string ¤tHmiPageId() const; + /** 返回当前选中的控制逻辑标识 */ const std::string ¤tLogicId() const; protected: + /** 处理主窗口及子控件的事件过滤请求 */ bool eventFilter(QObject *watched, QEvent *event) override; + /** 关闭窗口前处理保存确认和运行态退出 */ void closeEvent(QCloseEvent *event) override; private: - // 配置菜单和工具栏动作 + /** 配置菜单和工具栏动作 */ void configureActions(); - // 配置主窗口外观和状态栏 + /** 配置主窗口外观和状态栏 */ void configureAppearance(); - // 创建并连接 HMI 编辑画布 + /** 创建并连接 HMI 编辑画布 */ void configureHmiEditor(); - // 创建并连接控制逻辑编辑画布 + /** 创建并连接控制逻辑编辑画布 */ void configureLogicEditor(); + /** 创建并连接运行监控面板 */ void configureRuntimeMonitor(); + /** 创建并连接工程树 */ void configureProjectTree(); - // 创建并连接控件属性编辑表单 + /** 创建并连接控件属性编辑表单 */ void configurePropertyEditor(); + /** 创建并连接 PLC 串口配置入口 */ void configurePlcConnection(); + /** 创建并连接报警配置入口 */ void configureAlarms(); + /** 创建并连接寄存器注释入口 */ void configureRegisterComments(); + /** 撤销当前处于焦点的编辑器操作 */ void undoActiveEditor(); + /** 重做当前处于焦点的编辑器操作 */ void redoActiveEditor(); + /** 删除当前处于焦点的编辑器选中项 */ void deleteActiveSelection(); + /** 清除当前处于焦点的编辑器选择 */ void clearActiveSelection(); + /** 根据编辑器选择状态更新撤销、重做和删除动作 */ void updateEditActions(); + /** 清空 HMI 和梯形图编辑器的撤销历史 */ void clearEditorHistories(); + /** 根据工程路径和修改状态刷新窗口标题 */ void updateWindowTitle(); + /** 对可能破坏当前工程的操作请求保存确认 */ bool confirmSaveBeforeDestructiveAction(); - // 刷新工程树和当前 HMI 页面信息 + /** 刷新工程树和当前 HMI 页面信息 */ void refreshProjectUi(); + /** 更新工程树相关动作的可用状态 */ void updateProjectTreeActions(); - // 显示指定 HMI 控件的可编辑属性 + /** 显示指定 HMI 控件的可编辑属性 */ void showControlProperties(const std::string &control_id); - // 显示指定控制逻辑节点的可编辑属性 + /** 显示指定控制逻辑节点的可编辑属性 */ void showLogicNodeProperties(const std::string &node_id); - // 向当前页面添加指定类型的 HMI 控件 + /** 向当前页面添加指定类型的 HMI 控件 */ void addHmiControl(HmiControlType type); - // 删除当前选中的 HMI 控件 + /** 删除当前选中的 HMI 控件 */ void deleteSelectedControl(); - // 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 + /** 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 */ void addLogicCondition(const LogicNodeConfig &config); - // 为当前选中的连续逻辑范围建立并联支路 + /** 为当前选中的连续逻辑范围建立并联支路 */ void addLogicParallelBranch(const LogicNodeConfig &config); + /** 在当前梯形图位置添加横线 */ void addLogicHorizontalWire(); + /** 在当前梯形图位置添加竖线 */ void addLogicVerticalWire(); + /** 删除当前梯形图位置的横线 */ void deleteLogicHorizontalWire(); + /** 删除当前梯形图位置的竖线 */ void deleteLogicVerticalWire(); - // 设置当前网络右侧的输出线圈 + /** 设置当前网络右侧的输出线圈 */ void setLogicOutput(const LogicNodeConfig &config); + /** 打开输出指令配置并提交输出线圈 */ void configureAndSetLogicOutput(const LogicNodeConfig &config); - // 新增一个梯形图网络 + /** 新增一个梯形图网络 */ void addLogicRung(); + /** 编辑当前网络的注释 */ void editSelectedRungComment(); - // 删除当前选中的逻辑节点或网络 + /** 删除当前选中的逻辑节点或网络 */ void deleteSelectedLogicObject(); + /** 打开 PLC 连接 */ void connectPlc(); + /** 关闭 PLC 连接 */ void disconnectPlc(); - // 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 + /** 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 */ void schedulePlcStatusUpdate(); - // 创建新的工程并刷新编辑界面 + /** 创建新的工程并刷新编辑界面 */ void createNewProject(); - // 保存当前工程到已关联的路径 + /** 保存当前工程到已关联的路径 */ void saveProject(); - // 将当前工程保存到用户指定的路径 + /** 将当前工程保存到用户指定的路径 */ void saveProjectAs(); - // 从用户指定的路径加载工程 + /** 从用户指定的路径加载工程 */ void loadProject(); - // 在状态栏和输出面板显示工程操作结果 + /** 在状态栏和输出面板显示工程操作结果 */ void showProjectResult(const QString &action, const QString &message, bool succeeded); + /** 向输出面板追加一条消息 */ void appendOutputMessage(const QString &message); /** @@ -178,15 +205,17 @@ private: * @param message 要显示在状态栏和输出区的模式结果消息 */ void updateModeUi(const QString &message); - // 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 + /** 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 */ void updateSimulationUi(bool report_fault); /** * @brief 将互斥模式动作同步到服务层当前状态 */ void restoreCurrentModeAction(); + /** 完成主窗口初始化和首次界面刷新 */ void initializeUi(); + /** Qt Designer 生成的主窗口界面对象 */ std::unique_ptr ui_; /** * @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期 @@ -196,29 +225,54 @@ private: * @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期 */ ProjectService &project_service_; + /** 非拥有的 HMI 编辑服务依赖 */ HmiEditorService &hmi_editor_service_; + /** 非拥有的梯形图编辑服务依赖 */ LogicEditorService &logic_editor_service_; + /** 非拥有的 HMI 运行服务依赖 */ HmiRuntimeService &hmi_runtime_service_; + /** 非拥有的报警编辑服务依赖 */ AlarmEditorService &alarm_editor_service_; + /** 非拥有的报警查询服务依赖 */ AlarmService &alarm_service_; + /** 非拥有的寄存器注释服务依赖 */ RegisterCommentService ®ister_comment_service_; + /** 非拥有的寄存器监控服务依赖 */ RegisterMonitorService ®ister_monitor_service_; + /** 当调用方未提供导航服务时由主窗口负责拥有的实例 */ std::unique_ptr owned_hmi_navigation_service_; + /** 当前使用的 HMI 页面导航服务 */ HmiNavigationService *hmi_navigation_service_ = nullptr; + /** 工程树控制器 */ std::unique_ptr project_workspace_controller_; + /** 属性面板控制器 */ std::unique_ptr property_panel_controller_; + /** 运行监控面板控制器 */ std::unique_ptr runtime_panel_controller_; + /** 运行模式互斥动作组 */ QActionGroup *mode_action_group_ = nullptr; + /** 主界面的 HMI 编辑器 */ HmiEditorWidget *hmi_editor_widget_ = nullptr; + /** 主界面的梯形图编辑器 */ LogicEditorWidget *logic_editor_widget_ = nullptr; + /** 唯一的运行监控控件 */ RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; + /** 显示当前运行模式的状态标签 */ QLabel *mode_status_label_ = nullptr; + /** 显示 PLC 连接状态的标签 */ QLabel *register_status_label_ = nullptr; + /** 显示离线执行器状态的标签 */ QLabel *executor_status_label_ = nullptr; + /** 当前保存的 PLC 串口配置 */ PlcSerialConfiguration plc_configuration_; + /** 当前选中的 HMI 控件标识 */ std::string selected_control_id_; + /** 当前选中的梯形图节点标识 */ std::string selected_logic_node_id_; + /** 当前选中的 HMI 页面标识 */ std::string current_hmi_page_id_; + /** 当前选中的控制逻辑标识 */ std::string current_logic_id_; + /** 是否已经安排了待处理的 PLC 状态刷新 */ bool plc_status_update_pending_ = false; }; diff --git a/app/src/ui/plc_connection_dialog.h b/app/src/ui/plc_connection_dialog.h index fc55062..2927f27 100644 --- a/app/src/ui/plc_connection_dialog.h +++ b/app/src/ui/plc_connection_dialog.h @@ -1,3 +1,11 @@ +/** + * @file plc_connection_dialog.h + * @brief 定义 PLC 串口连接参数对话框 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include "services/plc_communication_gateway.h" @@ -10,7 +18,7 @@ namespace Ui { class PlcConnectionDialog; } -// PLC 串口参数对话框;只编辑配置,不负责打开串口 +/** PLC 串口参数对话框;只编辑配置,不负责打开串口 */ class PlcConnectionDialog final : public QDialog { Q_OBJECT @@ -21,9 +29,10 @@ public: QWidget *parent = nullptr); ~PlcConnectionDialog() override; - // 返回表单中的串口配置,连接前由网关再次校验 + /** 返回表单中的串口配置,连接前由网关再次校验 */ PlcSerialConfiguration configuration() const; private: + /** Qt Designer 生成的界面对象 */ std::unique_ptr ui_; }; diff --git a/app/src/ui/project_workspace_controller.h b/app/src/ui/project_workspace_controller.h index 0e6e6ab..9255c62 100644 --- a/app/src/ui/project_workspace_controller.h +++ b/app/src/ui/project_workspace_controller.h @@ -1,3 +1,11 @@ +/** + * @file project_workspace_controller.h + * @brief 定义工程树和当前编辑对象控制器 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include "domain/hmi_model.h" @@ -23,15 +31,21 @@ namespace Ui { class MainWindow; } -// 组织工程树、当前页面/逻辑选择和工程对象的界面刷新 -// 它不直接改 Project,所有增删改都转发给对应服务 +/** + * 组织工程树、当前页面/逻辑选择和工程对象的界面刷新 + * + * 它不直接改 Project,所有增删改都转发给对应服务 + */ class ProjectWorkspaceController final { public: + /** 报告工程操作结果,参数依次为操作名、消息和是否成功 */ using ResultReporter = std::function; + /** 向状态栏报告一条带超时的消息 */ using StatusReporter = std::function; + /** 通知主窗口编辑状态已经发生变化 */ using EditStateChanged = std::function; ProjectWorkspaceController( @@ -53,21 +67,34 @@ public: StatusReporter status_reporter, EditStateChanged edit_state_changed); + /** 连接工程树动作和选择变化信号 */ void configure(); + /** 根据当前工程重新生成工程树和编辑器内容 */ void refresh(); + /** 根据当前选中对象更新工程树动作的可用状态 */ void updateActions(); + /** 新增一个 HMI 页面 */ void addPage(); + /** 新增一个控制逻辑 */ void addLogic(); + /** 重命名当前选中的工程树项目 */ void renameSelectedItem(); + /** 删除当前选中的工程树项目 */ void deleteSelectedItem(); + /** 按偏移量移动当前选中的工程树项目 */ void moveSelectedItem(int offset); + /** 把当前选中的 HMI 页面设为初始页面 */ void setSelectedPageAsInitial(); + /** 启用或停用当前选中的控制逻辑 */ void toggleSelectedLogicEnabled(); + /** 返回当前 HMI 页面标识 */ const std::string ¤tHmiPageId() const; + /** 返回当前控制逻辑标识 */ const std::string ¤tLogicId() const; private: + /** 工程树项目的三种业务类型 */ enum class ItemKind { Root = 0, @@ -75,34 +102,61 @@ private: ControlLogic = 2 }; + /** 处理工程树选择变化并刷新当前编辑器 */ void handleSelectionChanged(); + /** 统一报告工程操作失败 */ void reportFailure( const QString &action, const std::string &message) const; + /** 主窗口父对象,不由控制器拥有 */ QWidget &parent_; + /** 主窗口的 Designer 界面对象 */ Ui::MainWindow &ui_; + /** 工程服务,不由控制器拥有 */ ProjectService &project_service_; + /** HMI 编辑服务,不由控制器拥有 */ HmiEditorService &hmi_editor_service_; + /** 梯形图编辑服务,不由控制器拥有 */ LogicEditorService &logic_editor_service_; + /** 运行模式服务,不由控制器拥有 */ RuntimeModeService &runtime_mode_service_; + /** HMI 页面导航服务,不由控制器拥有 */ HmiNavigationService &hmi_navigation_service_; + /** 主窗口中的 HMI 编辑器 */ HmiEditorWidget &hmi_editor_widget_; + /** 主窗口中的梯形图编辑器 */ LogicEditorWidget &logic_editor_widget_; + /** 运行监控控件 */ RuntimeMonitorWidget &runtime_monitor_widget_; + /** 主窗口保存的当前 HMI 页面标识 */ std::string ¤t_hmi_page_id_; + /** 主窗口保存的当前控制逻辑标识 */ std::string ¤t_logic_id_; + /** 显示 HMI 控件属性的回调 */ std::function show_control_properties_; + /** 显示梯形图节点属性的回调 */ std::function show_logic_properties_; + /** 工程操作结果回调 */ ResultReporter result_reporter_; + /** 状态栏消息回调 */ StatusReporter status_reporter_; + /** 编辑状态变化回调 */ EditStateChanged edit_state_changed_; + /** 新增页面动作 */ QAction *add_page_action_ = nullptr; + /** 新增逻辑动作 */ QAction *add_logic_action_ = nullptr; + /** 重命名动作 */ QAction *rename_item_action_ = nullptr; + /** 删除工程树项目动作 */ QAction *delete_item_action_ = nullptr; + /** 上移工程树项目动作 */ QAction *move_item_up_action_ = nullptr; + /** 下移工程树项目动作 */ QAction *move_item_down_action_ = nullptr; + /** 设置初始页面动作 */ QAction *set_initial_page_action_ = nullptr; + /** 启停控制逻辑动作 */ QAction *toggle_logic_enabled_action_ = nullptr; }; diff --git a/app/src/ui/property_panel_controller.h b/app/src/ui/property_panel_controller.h index 269ad23..6ebf256 100644 --- a/app/src/ui/property_panel_controller.h +++ b/app/src/ui/property_panel_controller.h @@ -1,3 +1,11 @@ +/** + * @file property_panel_controller.h + * @brief 定义 HMI 和梯形图属性面板控制器 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include "domain/control_logic_model.h" @@ -19,13 +27,18 @@ namespace Ui { class MainWindow; } -// 组织 HMI/梯形图选择状态、属性面板和编辑器信号 -// 属性提交统一经过编辑服务,以便校验、原子回滚和撤销/重做 +/** + * 组织 HMI/梯形图选择状态、属性面板和编辑器信号 + * + * 属性提交统一经过编辑服务,以便校验、原子回滚和撤销/重做 + */ class PropertyPanelController final { public: + /** 报告属性操作结果,参数依次为操作名、消息和是否成功 */ using ResultReporter = std::function; + /** 向状态栏报告一条带超时的消息 */ using StatusReporter = std::function; @@ -43,39 +56,67 @@ public: ResultReporter result_reporter, StatusReporter status_reporter); + /** 连接属性面板按钮、输入框和服务信号 */ void configure(); + /** 绑定 HMI 与梯形图编辑器,接收它们的选择变化 */ void bindEditorWidgets( HmiEditorWidget &hmi_editor_widget, LogicEditorWidget &logic_editor_widget); + /** 显示指定 HMI 控件的属性 */ void showControlProperties(const std::string &control_id); + /** 显示指定 HMI 页面尺寸属性 */ void showPageProperties(const std::string &page_id); + /** 显示指定梯形图节点的属性 */ void showLogicNodeProperties(const std::string &node_id); + /** 在当前页面添加指定类型的 HMI 控件 */ void addHmiControl(HmiControlType type); + /** 删除当前选中的 HMI 控件 */ void deleteSelectedControl(); + /** 提交当前 HMI 控件属性 */ void applySelectedControlProperties(); + /** 提交当前 HMI 页面属性 */ void applySelectedPageProperties(); + /** 提交当前梯形图节点属性 */ void applySelectedLogicNodeProperties(); private: + /** 显示 HMI 编辑服务返回的错误 */ void handleHmiEditorError(const QString &message) const; + /** 显示梯形图编辑服务返回的错误 */ void handleLogicEditorError(const QString &message) const; + /** 打开颜色选择器并写回属性输入框 */ void chooseTextColor(); + /** 统一报告属性操作失败 */ void reportFailure( const QString &action, const std::string &message) const; + /** 主窗口父对象,不由控制器拥有 */ QWidget &parent_; + /** 主窗口的 Designer 界面对象 */ Ui::MainWindow &ui_; + /** 工程服务,不由控制器拥有 */ ProjectService &project_service_; + /** HMI 编辑服务,不由控制器拥有 */ HmiEditorService &hmi_editor_service_; + /** 梯形图编辑服务,不由控制器拥有 */ LogicEditorService &logic_editor_service_; + /** 获取当前 HMI 页面标识的回调 */ std::function current_page_id_; + /** 获取当前控制逻辑标识的回调 */ std::function current_logic_id_; + /** 主窗口保存的当前控件标识 */ std::string &selected_control_id_; + /** 主窗口保存的当前逻辑节点标识 */ std::string &selected_logic_node_id_; + /** 请求主窗口刷新工程界面的回调 */ std::function refresh_project_ui_; + /** 属性操作结果回调 */ ResultReporter result_reporter_; + /** 状态栏消息回调 */ StatusReporter status_reporter_; + /** 绑定后的 HMI 编辑器 */ HmiEditorWidget *hmi_editor_widget_ = nullptr; + /** 绑定后的梯形图编辑器 */ LogicEditorWidget *logic_editor_widget_ = nullptr; }; diff --git a/app/src/ui/register_comment_dialog.h b/app/src/ui/register_comment_dialog.h index 2bc7b74..1b7eb30 100644 --- a/app/src/ui/register_comment_dialog.h +++ b/app/src/ui/register_comment_dialog.h @@ -1,3 +1,11 @@ +/** + * @file register_comment_dialog.h + * @brief 定义 M/D 寄存器工程注释编辑对话框 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include "domain/register_address.h" @@ -13,7 +21,7 @@ class RegisterCommentDialog; class RegisterCommentService; -// 工程级 M/D 注释编辑对话框;注释不包含实时寄存器值 +/** 工程级 M/D 注释编辑对话框;注释不包含实时寄存器值 */ class RegisterCommentDialog final : public QDialog { public: @@ -23,13 +31,21 @@ public: ~RegisterCommentDialog() override; private: + /** 重新加载注释列表,并尽量保持原选中地址 */ void reloadComments(const RegisterAddress *selected_address = nullptr); + /** 把当前选中地址的注释加载到编辑框 */ void loadSelectedComment(); + /** 校验并保存当前地址的注释 */ void saveComment(); + /** 删除当前选中地址的注释 */ void removeComment(); + /** 从输入框读取寄存器地址 */ RegisterAddress addressFromInputs() const; + /** 返回列表中当前选中的寄存器地址,没有选择时返回空值 */ std::optional selectedAddress() const; + /** Qt Designer 生成的界面对象 */ std::unique_ptr ui_; + /** 注释读写服务,不由对话框拥有 */ RegisterCommentService &service_; }; diff --git a/app/src/ui/runtime_monitor_widget.h b/app/src/ui/runtime_monitor_widget.h index 1c3155b..429c917 100644 --- a/app/src/ui/runtime_monitor_widget.h +++ b/app/src/ui/runtime_monitor_widget.h @@ -1,3 +1,11 @@ +/** + * @file runtime_monitor_widget.h + * @brief 定义工程师运行监控主控件 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include "domain/runtime_state.h" @@ -24,7 +32,7 @@ class LogicEditorWidget; class RegisterMonitorService; class ProjectService; -// 唯一的工程师运行监控投影,组合 HMI、梯形图轨迹和自由监控 +/** 唯一的工程师运行监控投影,组合 HMI、梯形图轨迹和自由监控 */ class RuntimeMonitorWidget final : public QWidget { Q_OBJECT @@ -41,34 +49,55 @@ public: QWidget *parent = nullptr); ~RuntimeMonitorWidget() override; + /** 将当前页面和控制逻辑绑定到运行监控界面 */ void setProjectObjects(const std::string &page_id, const std::string &logic_id); + /** 切换运行监控中显示的 HMI 页面 */ void setRuntimePage(const std::string &page_id); - // 根据模式决定是否显示本地逻辑轨迹以及是否允许写入 + /** 根据模式决定是否显示本地逻辑轨迹以及是否允许写入 */ void setMode(ApplicationMode mode, PlcConnectionState plc_state); + /** 从寄存器仓库刷新 HMI 和自由监控中的当前值 */ void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); + /** 设置 HMI 控件是否允许写入运行值 */ void setHmiWriteEnabled(bool enabled); + /** 设置自由监控是否允许写入寄存器 */ void setFreeMonitorWriteEnabled(bool enabled); - // 只接收离线执行器轨迹;真机时由上层清空 + /** 只接收离线执行器轨迹;真机时由上层清空 */ void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {}); + /** 返回自由监控子控件,供上层刷新或连接信号 */ FreeMonitorWidget *freeMonitorWidget() const; + /** 返回当前选中的控制逻辑标识 */ std::string selectedLogicId() const; signals: + /** 页面跳转失败时发出可直接显示的错误信息 */ void navigationFailed(const QString &message); + /** 用户请求退出运行态 */ void exitRequested(); private: + /** 显示指定的运行监控页面 */ void showRuntimePage(const std::string &page_id); + /** 选中指定控制逻辑并刷新梯形图投影 */ void selectRuntimeLogic(const std::string &logic_id); + /** Qt Designer 生成的界面对象 */ std::unique_ptr ui_; + /** 工程服务,不由控件拥有 */ ProjectService &project_service_; + /** HMI 页面导航服务,不由控件拥有 */ HmiNavigationService &hmi_navigation_service_; + /** 运行监控中的 HMI 视图 */ HmiEditorWidget *hmi_view_ = nullptr; + /** 运行监控中的梯形图视图 */ LogicEditorWidget *logic_view_ = nullptr; + /** 运行监控中的自由监控控件 */ FreeMonitorWidget *free_monitor_widget_ = nullptr; + /** 当前选中的控制逻辑标识 */ std::string selected_logic_id_; + /** 最近一次收到的离线逻辑执行轨迹 */ LogicTraceSnapshot latest_trace_; + /** 最近一次离线执行故障节点标识 */ std::string latest_fault_node_id_; + /** 是否已经收到过可显示的逻辑轨迹 */ bool has_logic_trace_ = false; }; diff --git a/app/src/ui/runtime_monitor_window.h b/app/src/ui/runtime_monitor_window.h index 98365dc..105e031 100644 --- a/app/src/ui/runtime_monitor_window.h +++ b/app/src/ui/runtime_monitor_window.h @@ -1,3 +1,11 @@ +/** + * @file runtime_monitor_window.h + * @brief 定义独立的工程师运行监控窗口 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include @@ -11,7 +19,7 @@ class RuntimeMonitorWindow; class QCloseEvent; class QWidget; -// 承载唯一工程师运行监控投影的独立顶层窗口 +/** 承载唯一工程师运行监控投影的独立顶层窗口 */ class RuntimeMonitorWindow final : public QMainWindow { Q_OBJECT @@ -20,22 +28,28 @@ public: explicit RuntimeMonitorWindow(QWidget *parent = nullptr); ~RuntimeMonitorWindow() override; + /** 将运行监控控件放入窗口中央区域 */ void setMonitorWidget(QWidget &widget); - // 进入运行态时显示并最大化窗口 + /** 进入运行态时显示并最大化窗口 */ void showForRuntime(); - // 返回编辑态时隐藏窗口但保留唯一监控控件 + /** 返回编辑态时隐藏窗口但保留唯一监控控件 */ void hideForEditing(); - // 应用退出时允许真正关闭窗口 + /** 应用退出时允许真正关闭窗口 */ void closeForApplicationExit(); signals: + /** 用户点击窗口关闭按钮后请求退出运行态 */ void exitRequested(); protected: + /** 根据当前生命周期决定关闭是隐藏还是销毁窗口 */ void closeEvent(QCloseEvent *event) override; private: + /** Qt Designer 生成的窗口界面对象 */ std::unique_ptr ui_; + /** 当前是否处于运行态显示阶段 */ bool runtime_active_ = false; + /** 应用是否正在退出,决定关闭事件是否真正销毁窗口 */ bool application_exit_ = false; }; diff --git a/app/src/ui/runtime_panel_controller.h b/app/src/ui/runtime_panel_controller.h index 5151af6..da99424 100644 --- a/app/src/ui/runtime_panel_controller.h +++ b/app/src/ui/runtime_panel_controller.h @@ -1,3 +1,11 @@ +/** + * @file runtime_panel_controller.h + * @brief 定义运行监控面板控制器 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include "domain/runtime_state.h" @@ -25,13 +33,16 @@ class RuntimeMonitorWindow; class QTimer; class QWidget; -// 组织运行监控工作台、运行刷新定时器和离线执行器反馈 +/** 组织运行监控窗口、刷新定时器和离线执行器反馈 */ class RuntimePanelController final { public: + /** 向状态栏报告一条带超时的消息 */ using StatusReporter = std::function; + /** 向输出面板报告一条消息 */ using OutputReporter = std::function; + /** 请求上层退出当前运行态 */ using RuntimeExitRequester = std::function; RuntimePanelController( @@ -55,44 +66,73 @@ public: RuntimeExitRequester runtime_exit_requester); ~RuntimePanelController(); + /** 连接运行监控相关信号并完成初始配置 */ void configure(); - // 创建/复用唯一监控窗口,并把当前工程对象投影到运行态 + /** 创建或复用唯一监控窗口,并把当前工程投影到运行态 */ void enterRuntime( const std::string &page_id, const std::string &logic_id, ApplicationMode mode, PlcConnectionState plc_state); - // 停止刷新并隐藏监控窗口,离线服务的停止由运行模式服务负责 + /** 停止刷新并隐藏监控窗口,离线服务的停止由运行模式服务负责 */ void leaveRuntime(ApplicationMode mode, PlcConnectionState plc_state); + /** 应用退出时关闭运行监控窗口并停止相关刷新 */ void closeForApplicationExit(); + /** 根据离线执行器状态刷新运行界面 */ void updateSimulationUi(bool report_fault); + /** 返回当前唯一的运行监控控件 */ RuntimeMonitorWidget *runtimeMonitorWidget() const; private: + /** 处理运行刷新定时器到期事件 */ void handleRuntimeTimer(); + /** 处理离线执行器状态变化 */ void handleSimulationStateChanged(); + /** 处理一次离线扫描完成事件 */ void handleScanCompleted(); + /** 主窗口父对象,不由控制器拥有 */ QWidget &parent_; + /** 运行模式服务,不由控制器拥有 */ RuntimeModeService &runtime_mode_service_; + /** 工程服务,不由控制器拥有 */ ProjectService &project_service_; + /** HMI 编辑服务,不由控制器拥有 */ HmiEditorService &hmi_editor_service_; + /** HMI 运行服务,不由控制器拥有 */ HmiRuntimeService &hmi_runtime_service_; + /** 梯形图编辑服务,不由控制器拥有 */ LogicEditorService &logic_editor_service_; + /** HMI 页面导航服务,不由控制器拥有 */ HmiNavigationService &hmi_navigation_service_; + /** 报警服务,不由控制器拥有 */ AlarmService &alarm_service_; + /** 寄存器监控服务,不由控制器拥有 */ RegisterMonitorService ®ister_monitor_service_; + /** 主窗口中的 HMI 编辑器,不由控制器拥有 */ HmiEditorWidget &hmi_editor_widget_; + /** 主窗口中的梯形图编辑器,不由控制器拥有 */ LogicEditorWidget &logic_editor_widget_; + /** 显示离线执行器状态的标签 */ QLabel &executor_status_label_; + /** 获取当前控制逻辑标识的回调 */ std::function current_logic_id_; + /** 切换当前控制逻辑的回调 */ std::function select_logic_; + /** 显示控制逻辑属性的回调 */ std::function show_logic_properties_; + /** 向状态栏报告消息的回调 */ StatusReporter status_reporter_; + /** 向输出面板报告消息的回调 */ OutputReporter output_reporter_; + /** 请求主窗口退出运行态的回调 */ RuntimeExitRequester runtime_exit_requester_; + /** 唯一的运行监控顶层窗口 */ std::unique_ptr runtime_monitor_window_; + /** 运行监控窗口中的主控件,窗口拥有它 */ RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; + /** 定时刷新运行监控数据的计时器 */ QTimer *runtime_refresh_timer_ = nullptr; + /** 当前是否已经进入运行监控会话 */ bool runtime_session_active_ = false; }; diff --git a/app/src/ui/toolbar_icon_factory.h b/app/src/ui/toolbar_icon_factory.h index 397d8c9..871c5e6 100644 --- a/app/src/ui/toolbar_icon_factory.h +++ b/app/src/ui/toolbar_icon_factory.h @@ -1,8 +1,16 @@ +/** + * @file toolbar_icon_factory.h + * @brief 定义工具栏图标类型和图标创建函数 + * @version 1.0.0 + * @author suyu + * @date 2026-08-22 + */ + #pragma once #include -// 工具栏语义图标;具体 QIcon 绘制集中在 toolbar_icon_factory.cpp +/** 工具栏中使用的语义图标类型 */ enum class UiIcon { Edit, @@ -56,5 +64,9 @@ enum class UiIcon ClearList }; -// 根据语义图标类型创建 Qt 图标 +/** + * @brief 根据语义图标类型创建 Qt 图标 + * @param icon 要创建的图标类型 + * @return 创建好的 Qt 图标 + */ QIcon makeUiIcon(UiIcon icon); diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp index 008573a..6aea30b 100644 --- a/app/tests/logic_editor_service_tests.cpp +++ b/app/tests/logic_editor_service_tests.cpp @@ -18,6 +18,13 @@ ContactNodeConfig contact(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, "test fixture must create an empty network"); + return result.id; +} + int conditionColumns(const ConditionExpression &expression) { if (expression.kind == ConditionExpressionKind::Node) @@ -56,13 +63,82 @@ int wireColumns(const ConditionExpression &expression) return columns; } +void testEmptyLogicCreatesNetworksOnFirstEdit() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + require(service.findLogic(logic_id)->rungs.empty(), + "a default control logic must start without an empty network"); + + const LogicEditorResult first_condition = service.appendCondition( + logic_id, {}, contact(0)); + require(first_condition.succeeded + && service.findLogic(logic_id)->rungs.size() == 1U, + "the first condition must create network 1 atomically"); + const LadderRung &condition_rung = service.findLogic(logic_id)->rungs.front(); + require(condition_rung.condition.has_value() + && condition_rung.condition->kind == ConditionExpressionKind::Node, + "the first condition must not create an implicit horizontal wire"); + + require(service.removeRung(logic_id, condition_rung.id).succeeded + && service.findLogic(logic_id)->rungs.empty(), + "the last network must be removable back to an empty logic"); + + const LogicEditorResult first_wire = service.appendWire(logic_id, {}, 1); + require(first_wire.succeeded + && service.findLogic(logic_id)->rungs.size() == 1U + && service.findLogic(logic_id)->rungs.front().condition->kind + == ConditionExpressionKind::Wire, + "the first horizontal wire must create a one-cell network"); + + require(service.removeRung( + logic_id, service.findLogic(logic_id)->rungs.front().id).succeeded, + "the wire-only network must be removable"); + const LogicEditorResult first_output = service.setOutput( + logic_id, + {}, + CoilNodeConfig{RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}); + require(first_output.succeeded + && service.findLogic(logic_id)->rungs.size() == 1U + && !service.findLogic(logic_id)->rungs.front().condition.has_value() + && service.findLogic(logic_id)->rungs.front().output.has_value(), + "the first output must create an unconditional network"); +} + +void testAppendingWireMovesToNextNetworkAfterTenColumns() +{ + 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 < ProjectLimits::kMaximumConditionColumns; ++index) + { + require(service.appendWire(logic_id, rung_id).succeeded, + "ten horizontal wire cells must fit in one network"); + } + const LogicEditorResult next = service.appendWire(logic_id, rung_id); + require(next.succeeded && service.findLogic(logic_id)->rungs.size() == 2U, + "the next appended wire must create the following network"); + const std::string next_rung_id = service.findLogic(logic_id)->rungs.back().id; + require(service.findRung(logic_id, rung_id)->condition.has_value() + && conditionColumns(*service.findRung(logic_id, rung_id)->condition) + == ProjectLimits::kMaximumConditionColumns + && service.findRung(logic_id, next_rung_id)->condition.has_value(), + "automatic network rollover must preserve both network contents"); + require(service.undo().succeeded && service.findLogic(logic_id)->rungs.size() == 1U, + "automatic network rollover must be undone as one edit"); +} + void testStructuredEditingAndNormalization() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult first = service.appendCondition(logic_id, rung_id, contact(0)); const LogicEditorResult second = service.appendCondition(logic_id, rung_id, contact(1)); @@ -130,7 +206,7 @@ void testRangeParallelInsertion() ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); service.appendCondition(logic_id, rung_id, contact(0)); service.appendCondition(logic_id, rung_id, contact(1)); @@ -154,13 +230,66 @@ void testRangeParallelInsertion() "a non-contiguous selection must be rejected"); } +void testParallelBranchGridInsertion() +{ + 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 first = service.appendCondition( + logic_id, rung_id, contact(0)); + const LogicEditorResult second = service.appendCondition( + logic_id, rung_id, contact(1)); + const LogicEditorResult third = service.appendCondition( + logic_id, rung_id, contact(2)); + const LogicEditorResult branch = service.addParallelBranch( + logic_id, rung_id, {first.id, second.id, third.id}, contact(10)); + require(branch.succeeded, + "parallel grid insertion setup must create a short lower branch"); + + service.clearHistory(); + const LogicEditorResult inserted = service.insertConditionInBranchAtColumn( + logic_id, rung_id, branch.id, 2, contact(12)); + require(inserted.succeeded, + "a visible parallel branch padding cell must accept a condition"); + const LadderRung *rung = service.findRung(logic_id, rung_id); + require(rung != nullptr && rung->condition.has_value() + && rung->condition->kind == ConditionExpressionKind::Parallel, + "branch grid insertion must preserve the surrounding parallel expression"); + const ConditionExpression &lower = rung->condition->children.at(1); + require(lower.kind == ConditionExpressionKind::Series + && lower.children.size() == 3U + && lower.children.at(0).node->id == branch.id + && lower.children.at(1).kind == ConditionExpressionKind::Wire + && lower.children.at(1).wire->columnSpan == 1 + && lower.children.at(2).node->id == inserted.id, + "a distant branch cell must persist only the required gap and new condition"); + + require(service.undo().succeeded, + "parallel branch grid insertion must be one undoable edit"); + rung = service.findRung(logic_id, rung_id); + require(rung->condition->children.at(1).kind == ConditionExpressionKind::Node + && rung->condition->children.at(1).node->id == branch.id, + "undo must restore the original short parallel branch"); + + service.clearHistory(); + const bool modified_before = project_service.isModified(); + const LogicEditorResult invalid = service.insertConditionInBranchAtColumn( + logic_id, rung_id, branch.id, 3, contact(13)); + require(!invalid.succeeded && !service.canUndo() + && project_service.isModified() == modified_before + && service.findNode(logic_id, inserted.id) == nullptr, + "a cell outside the visible branch padding must fail atomically"); +} + void testStructuredWireEditing() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); service.appendCondition(logic_id, rung_id, contact(0)); service.appendCondition(logic_id, rung_id, contact(1)); service.appendCondition(logic_id, rung_id, contact(2)); @@ -220,7 +349,7 @@ void testBatchDeleteAllNodesInParallelBranch() ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult first = service.appendCondition( logic_id, rung_id, contact(0)); @@ -274,7 +403,7 @@ void testConditionColumnLimit() ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { @@ -323,7 +452,7 @@ void testUnconditionalOutputEditing() ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); require(service.setOutput( logic_id, @@ -345,7 +474,7 @@ void testColumnTargetedConditionInsertion() ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string first_rung_id = service.firstRungId(logic_id); + const std::string first_rung_id = makeEmptyRung(service, logic_id); require(service.insertConditionAtColumn( logic_id, first_rung_id, 4, contact(4)).succeeded, @@ -429,7 +558,7 @@ void testWireColumnReplacement() ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4); require(wire.succeeded, "wire-column replacement test must create a four-column wire"); @@ -575,7 +704,7 @@ void testSequentialConditionInsertionConsumesFollowingWire() ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10); require(wire.succeeded, "sequential wire replacement must start with a full-width wire"); @@ -675,7 +804,7 @@ void testEdgeTimerNodesAndRungComments() ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult rising = service.appendCondition( logic_id, @@ -745,7 +874,7 @@ void testHistoryAndAtomicBatchDelete() ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string first_rung_id = service.firstRungId(logic_id); + const std::string first_rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult first = service.appendCondition( logic_id, first_rung_id, contact(0)); const LogicEditorResult second = service.appendCondition( @@ -816,8 +945,11 @@ int main() { try { + testEmptyLogicCreatesNetworksOnFirstEdit(); + testAppendingWireMovesToNextNetworkAfterTenColumns(); testStructuredEditingAndNormalization(); testRangeParallelInsertion(); + testParallelBranchGridInsertion(); testStructuredWireEditing(); testBatchDeleteAllNodesInParallelBranch(); testConditionColumnLimit(); diff --git a/app/tests/main_window_tests.cpp b/app/tests/main_window_tests.cpp index 41f12ab..b3a74b3 100644 --- a/app/tests/main_window_tests.cpp +++ b/app/tests/main_window_tests.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -278,13 +279,20 @@ 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 = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); std::vector top_ids; for (int address = 0; address < 4; ++address) @@ -688,7 +696,7 @@ void testLogicEmptyGridSlotInsertion() ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; - const std::string rung_id = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); LogicEditorWidget editor(service); editor.setLogicId(logic_id); editor.resize(1400, 360); @@ -792,13 +800,143 @@ void testLogicEmptyGridSlotInsertion() "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"); + 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 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 = service.firstRungId(logic_id); + const std::string rung_id = makeEmptyRung(service, logic_id); LogicEditorWidget editor(service); editor.setLogicId(logic_id); editor.resize(1400, 360); @@ -1301,11 +1439,14 @@ void testModeActionsControlEditingAvailability() QApplication::processEvents(); undo_action->trigger(); require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) - ->rungs.front().condition.has_value() == false, + ->rungs.empty(), "logic undo must use the ladder history when the ladder tab is active"); redo_action->trigger(); - const LadderRung &restored_rung = logic_editor_service.findLogic( - logic_editor_service.firstLogicId())->rungs.front(); + 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; @@ -2204,8 +2345,8 @@ void testMultiPageAndLogicMainWindowIntegration() 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() == 2U - && logic_editor_service.findLogic(first_logic_id)->rungs.size() == 1U, + 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)); @@ -2276,6 +2417,8 @@ int main(int argc, char *argv[]) testRuntimePageJumpDoesNotRequireRegisterWritePermission(); testRuntimeAlarmListInteraction(); testLogicEmptyGridSlotInsertion(); + testLogicParallelPaddingGridInsertion(); + testLogicParallelBranchConnectsShortBranchToRightJoin(); testLogicContinuousInsertionConsumesFullWire(); testWindowTitleTracksUnsavedProjectChanges(); testModeActionsControlEditingAvailability();