From bf417e8dc675f70aafed6c65a57b277d447f0b78 Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Wed, 12 Aug 2026 12:11:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=A6=BB=E7=BA=BF?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=89=8D=E5=B7=A5=E7=A8=8B=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/domain/control_logic_model.cpp | 57 ++++++++++++++++++- app/src/domain/control_logic_model.h | 5 ++ app/src/domain/hmi_model.cpp | 38 ++++++++++++- app/src/domain/hmi_model.h | 2 + app/src/domain/project_model.cpp | 23 ++++++++ app/src/domain/project_model.h | 1 + app/src/domain/runtime_state.h | 3 +- .../infrastructure/json_project_storage.cpp | 8 +++ app/src/main.cpp | 2 +- app/src/services/logic_editor_service.cpp | 4 ++ app/src/services/runtime_mode_service.cpp | 12 ++++ app/src/services/runtime_mode_service.h | 9 +-- app/src/ui/hmi_editor_widget.cpp | 8 +++ app/src/ui/logic_editor_widget.cpp | 24 ++++++-- app/src/ui/main_window.cpp | 46 ++++++++------- app/tests/domain_tests.cpp | 20 ++++++- app/tests/logic_editor_service_tests.cpp | 13 +++++ app/tests/main_window_tests.cpp | 29 +++++++++- app/tests/runtime_mode_service_tests.cpp | 20 ++++++- app/tests/runtime_mode_service_tests.pro | 11 ++++ docs/architecture.md | 5 ++ 21 files changed, 300 insertions(+), 40 deletions(-) diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp index 0b9da98..482be5e 100644 --- a/app/src/domain/control_logic_model.cpp +++ b/app/src/domain/control_logic_model.cpp @@ -101,6 +101,11 @@ bool LogicNode::validate(std::string *error) const config); } +bool LogicNode::isConfigured() const +{ + return configured; +} + bool LogicNode::isCondition() const { return !std::holds_alternative(config); @@ -138,7 +143,29 @@ bool LadderStage::validate(std::string *error) const return true; } +bool LadderStage::validateForRunning(std::string *error) const +{ + if (!validate(error)) + { + return false; + } + for (const LogicNode &node : branches) + { + if (!node.isConfigured()) + { + setError(error, "ladder condition " + node.id + " is not configured"); + return false; + } + } + return true; +} + bool LadderRung::validate(std::string *error) const +{ + return validateStructure(error); +} + +bool LadderRung::validateForRunning(std::string *error) const { if (!validateStructure(error)) { @@ -150,7 +177,19 @@ bool LadderRung::validate(std::string *error) const } if (stages.empty() || !output.has_value()) { - setError(error, "non-empty ladder rung requires conditions and an output coil"); + setError(error, "incomplete ladder network requires conditions and an output coil"); + return false; + } + for (const LadderStage &stage : stages) + { + if (!stage.validateForRunning(error)) + { + return false; + } + } + if (!output->isConfigured()) + { + setError(error, "output coil " + output->id + " is not configured"); return false; } return true; @@ -257,3 +296,19 @@ bool ControlLogic::validateStructure(std::string *error) const } return true; } + +bool ControlLogic::validateForRunning(std::string *error) const +{ + if (!validateStructure(error)) + { + return false; + } + for (const LadderRung &rung : rungs) + { + if (!rung.validateForRunning(error)) + { + return false; + } + } + return true; +} diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h index 4bf5e0b..14dbb6d 100644 --- a/app/src/domain/control_logic_model.h +++ b/app/src/domain/control_logic_model.h @@ -56,8 +56,10 @@ struct LogicNode { std::string id; LogicNodeConfig config; + bool configured = true; bool validate(std::string *error = nullptr) const; + bool isConfigured() const; bool isCondition() const; bool isOutput() const; }; @@ -69,6 +71,7 @@ struct LadderStage std::vector branches; bool validate(std::string *error = nullptr) const; + bool validateForRunning(std::string *error = nullptr) const; }; // 梯级中的各级按顺序串联,输出线圈固定在最右侧 @@ -81,6 +84,7 @@ struct LadderRung bool validate(std::string *error = nullptr) const; bool validateStructure(std::string *error = nullptr) const; + bool validateForRunning(std::string *error = nullptr) const; }; struct ControlLogic @@ -92,4 +96,5 @@ struct ControlLogic bool validate(std::string *error = nullptr) const; bool validateStructure(std::string *error = nullptr) const; + bool validateForRunning(std::string *error = nullptr) const; }; diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp index e1bb29d..3c349da 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -49,7 +49,7 @@ bool HmiControl::validate(std::string *error) const } if (requiresBitBinding(type)) { - if (!binding.has_value() || binding->area() != RegisterArea::M) + if (binding.has_value() && binding->area() != RegisterArea::M) { setError(error, "button and indicator controls require an M address"); return false; @@ -57,7 +57,7 @@ bool HmiControl::validate(std::string *error) const } if (requiresWordBinding(type)) { - if (!binding.has_value() || binding->area() != RegisterArea::D) + if (binding.has_value() && binding->area() != RegisterArea::D) { setError(error, "numeric controls require a D address"); return false; @@ -71,6 +71,23 @@ bool HmiControl::validate(std::string *error) const return true; } +bool HmiControl::isConfigured() const +{ + if (type == HmiControlType::Label) + { + return true; + } + if (!binding.has_value() || !binding->isValid()) + { + return false; + } + if (requiresBitBinding(type)) + { + return binding->area() == RegisterArea::M; + } + return !requiresWordBinding(type) || binding->area() == RegisterArea::D; +} + bool HmiPage::validate(std::string *error) const { if (id.empty() || name.empty()) @@ -109,3 +126,20 @@ bool HmiPage::validate(std::string *error) const } return true; } + +bool HmiPage::validateForRunning(std::string *error) const +{ + if (!validate(error)) + { + return false; + } + for (const HmiControl &control : controls) + { + if (!control.isConfigured()) + { + setError(error, "HMI control " + control.id + " is not bound to a register"); + return false; + } + } + return true; +} diff --git a/app/src/domain/hmi_model.h b/app/src/domain/hmi_model.h index 5ca5473..54feff3 100644 --- a/app/src/domain/hmi_model.h +++ b/app/src/domain/hmi_model.h @@ -66,6 +66,7 @@ struct HmiControl * 按钮和指示灯必须绑定有效 M 地址,数值显示和数值输入必须绑定有效 D 地址 */ bool validate(std::string *error = nullptr) const; + bool isConfigured() const; }; /** @@ -87,4 +88,5 @@ struct HmiPage * @return 页面及其全部控件有效时返回 true */ bool validate(std::string *error = nullptr) const; + bool validateForRunning(std::string *error = nullptr) const; }; diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index fbac748..4d6d118 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -69,3 +69,26 @@ bool Project::validate(std::string *error) const } return true; } + +bool Project::validateForRunning(std::string *error) const +{ + if (!validate(error)) + { + return false; + } + for (const HmiPage &page : hmiPages) + { + if (!page.validateForRunning(error)) + { + return false; + } + } + for (const ControlLogic &logic : controlLogics) + { + if (!logic.validateForRunning(error)) + { + return false; + } + } + return true; +} diff --git a/app/src/domain/project_model.h b/app/src/domain/project_model.h index 1bbce5b..c6aeb39 100644 --- a/app/src/domain/project_model.h +++ b/app/src/domain/project_model.h @@ -29,4 +29,5 @@ struct Project // 校验工程配置并通过 error 返回失败原因 bool validate(std::string *error = nullptr) const; + bool validateForRunning(std::string *error = nullptr) const; }; diff --git a/app/src/domain/runtime_state.h b/app/src/domain/runtime_state.h index 81333f0..abc31f1 100644 --- a/app/src/domain/runtime_state.h +++ b/app/src/domain/runtime_state.h @@ -74,7 +74,8 @@ enum class ModeTransitionError None, // 切换成功 AlreadyInRequestedMode, // 已经处于目标模式 MustReturnToEditing, // 必须先返回编辑态 - InitialPlcReadRequired // 进入真机态前必须完成 PLC 初次读取 + InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取 + ProjectNotReady // 工程存在未配置或未完成的运行项 }; // 模式切换操作结果 diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 476adc1..cff9ee6 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -719,6 +719,7 @@ QJsonObject serializeLogicNode(const LogicNode &node) { QJsonObject object; object.insert(QStringLiteral("id"), fromUtf8(node.id)); + object.insert(QStringLiteral("configured"), node.configured); // std::visit 将不同节点配置统一转换为 config JSON 对象 object.insert( QStringLiteral("config"), @@ -820,6 +821,13 @@ bool parseLogicNode( { return false; } + // 1.0 旧工程没有 configured 字段,按历史默认地址视为已配置 + node->configured = true; + if (object.contains(QStringLiteral("configured")) + && !readBool(object, "configured", context, &node->configured, state)) + { + return false; + } return true; } diff --git a/app/src/main.cpp b/app/src/main.cpp index 54a6717..9817e00 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -30,7 +30,7 @@ int main(int argc, char *argv[]) // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 VirtualRegisterRepository virtual_register_repository; HmiRuntimeService hmi_runtime_service(virtual_register_repository); - RuntimeModeService runtime_mode_service; + RuntimeModeService runtime_mode_service(project_service); MainWindow main_window( runtime_mode_service, project_service, diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index 44b2384..9cb97ab 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -14,6 +14,7 @@ LogicNode makeNode(const std::string &id, const LogicNodeConfig &config) LogicNode node; node.id = id; node.config = config; + node.configured = false; return node; } @@ -367,6 +368,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( "node category cannot be changed after creation"); } LogicNode candidate = makeNode(node_id, config); + candidate.configured = true; std::string error; if (!candidate.validate(&error)) { @@ -388,6 +390,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( if (editable.id == node_id) { editable.config = config; + editable.configured = true; return {true, LogicEditorError::None, {}, node_id}; } } @@ -395,6 +398,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( if (rung.output.has_value() && rung.output->id == node_id) { rung.output->config = config; + rung.output->configured = true; return {true, LogicEditorError::None, {}, node_id}; } } diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index c3f8853..32f7b54 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -8,6 +8,13 @@ #include "runtime_mode_service.h" +#include "project_service.h" + +RuntimeModeService::RuntimeModeService(const ProjectService &project_service) + : project_service_(project_service) +{ +} + ApplicationMode RuntimeModeService::mode() const { return state_.mode(); @@ -25,6 +32,11 @@ ModeTransitionResult RuntimeModeService::enterEditing() ModeTransitionResult RuntimeModeService::enterOfflineRunning() { + std::string error; + if (!project_service_.project().validateForRunning(&error)) + { + return {false, ModeTransitionError::ProjectNotReady}; + } return state_.enterOfflineRunning(); } diff --git a/app/src/services/runtime_mode_service.h b/app/src/services/runtime_mode_service.h index f3d744b..f1a02b0 100644 --- a/app/src/services/runtime_mode_service.h +++ b/app/src/services/runtime_mode_service.h @@ -10,14 +10,14 @@ #include "domain/runtime_state.h" +class ProjectService; + // 隔离 UI 与领域状态机并编排进入真机运行态的前置条件 class RuntimeModeService { public: - /** - * @brief 获取当前应用运行模式 - * @return 当前的编辑、离线运行或真机运行模式 - */ + explicit RuntimeModeService(const ProjectService &project_service); + ApplicationMode mode() const; /** * @brief 获取当前模式下允许的工程编辑和寄存器使用策略 @@ -55,6 +55,7 @@ public: bool initialPlcReadCompleted() const; private: + const ProjectService &project_service_; RuntimeState state_; // 表示 PLC 缓存是否已通过至少一次有效读取建立 bool initial_plc_read_completed_ = false; diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index 6439758..8e0e244 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -138,6 +138,14 @@ public: painter->setPen(QPen(QColor(QStringLiteral("#1677a8")), 2)); painter->drawRect(boundingRect().adjusted(0, 0, -1, -1)); } + if (!control_.isConfigured()) + { + painter->setPen(QColor(QStringLiteral("#b54708"))); + painter->drawText( + rect.adjusted(2, rect.height() - 15, -2, 0), + Qt::AlignCenter, + QStringLiteral("未绑定")); + } } // 向场景和外部控件返回该图元对应的领域控件标识 diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp index 2bc38b4..0043d9f 100644 --- a/app/src/ui/logic_editor_widget.cpp +++ b/app/src/ui/logic_editor_widget.cpp @@ -93,6 +93,12 @@ QString nodeToolTip(const LogicNodeConfig &config) config); } +QString nodeAddressText(const RegisterAddress &address, bool configured) +{ + return configured ? registerAddressText(address) + : LogicEditorWidget::tr("< M 地址 >"); +} + qreal rungHeight(const LadderRung &rung) { std::size_t maximum_branches = 1U; @@ -139,12 +145,15 @@ public: : node_id_(node.id), rung_id_(rung_id), stage_id_(stage_id), - config_(node.config) + config_(node.config), + configured_(node.configured) { setPos(center); setFlag(ItemIsSelectable, true); setZValue(1.0); - setToolTip(nodeToolTip(config_)); + setToolTip(configured_ ? nodeToolTip(config_) + : LogicEditorWidget::tr( + "待配置:请在属性区设置地址和参数")); } QRectF boundingRect() const override @@ -185,7 +194,7 @@ public: painter->drawText( QRectF(-kNodeWidth / 2.0, -44, kNodeWidth, 18), Qt::AlignCenter, - registerAddressText(contact->address)); + nodeAddressText(contact->address, configured_)); } else if (const auto *coil = std::get_if(&config_)) { @@ -212,7 +221,7 @@ public: painter->drawText( QRectF(-kNodeWidth / 2.0, -44, kNodeWidth, 18), Qt::AlignCenter, - registerAddressText(coil->address)); + nodeAddressText(coil->address, configured_)); } else if (const auto *comparison = std::get_if(&config_)) { @@ -232,11 +241,13 @@ public: painter->drawText( QRectF(-kNodeWidth / 2.0, -44, kNodeWidth, 18), Qt::AlignCenter, - registerAddressText(comparison->address)); + configured_ ? registerAddressText(comparison->address) + : tr("< D 地址 >")); painter->drawText( QRectF(-kNodeWidth / 2.0, 22, kNodeWidth, 18), Qt::AlignCenter, - QString::number(comparison->value)); + configured_ ? QString::number(comparison->value) + : LogicEditorWidget::tr("< 常量 >")); } } @@ -249,6 +260,7 @@ private: std::string rung_id_; std::string stage_id_; LogicNodeConfig config_; + bool configured_ = true; }; class LogicEditorWidget::RungItem final : public QGraphicsItem diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index d7a3896..7b51768 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -74,6 +74,10 @@ QString transitionErrorText(ModeTransitionError error) { return MainWindow::tr("当前已处于所选模式"); } + case ModeTransitionError::ProjectNotReady: + { + return MainWindow::tr("工程存在未配置或未完成的 HMI/梯形图节点,暂不能进入离线运行态"); + } case ModeTransitionError::None: default: { @@ -121,11 +125,6 @@ QString controlTypeText(HmiControlType type) } } -bool usesMAddress(HmiControlType type) -{ - return type == HmiControlType::Button || type == HmiControlType::Indicator; -} - } // namespace MainWindow::MainWindow( @@ -481,6 +480,7 @@ void MainWindow::configurePropertyEditor() } binding_area_combo_box_ = new QComboBox(ui_->propertiesPage); binding_area_combo_box_->setObjectName(QStringLiteral("bindingAreaComboBox")); + binding_area_combo_box_->addItem(tr("未绑定"), QVariant::fromValue(-1)); binding_area_combo_box_->addItem(QStringLiteral("M"), QVariant::fromValue(0)); binding_area_combo_box_->addItem(QStringLiteral("D"), QVariant::fromValue(1)); binding_index_spin_box_ = new QSpinBox(ui_->propertiesPage); @@ -613,7 +613,9 @@ void MainWindow::showControlProperties(const std::string &control_id) // 更新属性面板顶部状态标签,显示选中控件ID或提示未选择 ui_->selectionValueLabel->setText( - has_control ? fromUtf8(control->id) : tr("未选择")); + has_control ? fromUtf8(control->id) + + (control->isConfigured() ? QString{} : tr("(未绑定)")) + : tr("未选择")); // 批量控制所有属性编辑组件可用性:无选中控件时整体置灰,禁止编辑 for (QWidget *widget : {static_cast(control_id_edit_), @@ -646,15 +648,9 @@ void MainWindow::showControlProperties(const std::string &control_id) control_height_spin_box_->setValue(control->bounds.height); // 根据控件类型获取默认寄存器区域:按钮/指示灯默认M区;数值类控件默认D区 - const RegisterArea default_area = usesMAddress(control->type) - ? RegisterArea::M : RegisterArea::D; - // 优先使用控件已绑定的寄存器区域;无绑定则使用类型对应的默认区域 - const RegisterArea area = control->binding.has_value() - ? control->binding->area() : default_area; - - // 同步寄存器区域下拉框选中项(0=M,1=D,与combo初始化顺序严格对应) - binding_area_combo_box_->setCurrentIndex(area == RegisterArea::M ? 0 : 1); - // 填充寄存器地址索引;无绑定时默认地址为0 + // 未绑定必须保留明确状态,避免属性编辑把显示默认值误提交为 M0/D0 + binding_area_combo_box_->setCurrentIndex(!control->binding.has_value() ? 0 + : control->binding->area() == RegisterArea::M ? 1 : 2); binding_index_spin_box_->setValue( control->binding.has_value() ? control->binding->index() : 0); } @@ -737,6 +733,11 @@ void MainWindow::showLogicNodeProperties(const std::string &node_id) logic_value_spin_box_->setEnabled(true); logic_value_spin_box_->setValue(compare.value); } + if (!node->configured) + { + logic_node_type_label_->setText(tr("待配置:%1").arg( + logic_node_type_label_->text())); + } } void MainWindow::addHmiControl(HmiControlType type) @@ -790,9 +791,16 @@ void MainWindow::applySelectedControlProperties() control.bounds.y = control_y_spin_box_->value(); control.bounds.width = control_width_spin_box_->value(); control.bounds.height = control_height_spin_box_->value(); - const RegisterArea area = binding_area_combo_box_->currentIndex() == 0 - ? RegisterArea::M : RegisterArea::D; - control.binding = RegisterAddress{area, binding_index_spin_box_->value()}; + const int binding_area = binding_area_combo_box_->currentData().toInt(); + if (binding_area < 0) + { + control.binding.reset(); + } + else + { + const RegisterArea area = binding_area == 0 ? RegisterArea::M : RegisterArea::D; + control.binding = RegisterAddress{area, binding_index_spin_box_->value()}; + } const HmiEditorResult result = hmi_editor_service_.updateControl( hmi_editor_service_.firstPageId(), selected_control_id_, control); if (!result.succeeded) @@ -907,7 +915,7 @@ void MainWindow::applySelectedLogicNodeProperties() logic_editor_widget_->selectNode(selected_logic_node_id_); showLogicNodeProperties(selected_logic_node_id_); refreshProjectUi(); - statusBar()->showMessage(tr("逻辑节点属性已更新"), 3000); + statusBar()->showMessage(tr("逻辑节点属性已更新,节点已配置"), 3000); } void MainWindow::createNewProject() diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index c81dfde..8da83f8 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -170,13 +170,17 @@ void testLadderLogicBoundaries() require(!logic.validate(), "a ladder output must be a coil"); logic.rungs.front().output.reset(); - require(!logic.validate(), "conditions without an output must fail full validation"); + require(logic.validate(), "incomplete ladder may remain in an editable draft"); + require(!logic.validateForRunning(), + "conditions without an output must block runtime validation"); LadderRung empty_rung{"rung-empty", "Empty network", {}, std::nullopt}; require(empty_rung.validate(), "an empty editing network must be valid"); empty_rung.output = coil; - require(!empty_rung.validate(), "an output without conditions must be rejected"); + require(empty_rung.validate(), "output-only network may remain in an editable draft"); + require(!empty_rung.validateForRunning(), + "an output without conditions must block runtime validation"); logic.rungs.front().output = coil; logic.rungs.front().stages.at(1).branches.at(1).id = start.id; @@ -208,6 +212,18 @@ void testModelsValidateBindingsAndIdentifiers() project = makeValidProject(); project.hmiPages.front().controls.front().properties.emplace("", "value"); require(!project.validate(), "empty HMI property names must be rejected"); + + project = makeValidProject(); + project.hmiPages.front().controls.front().binding.reset(); + require(project.validate(), "unbound HMI control must be accepted in a draft"); + require(!project.validateForRunning(), + "unbound HMI control must block runtime validation"); + + project = makeValidProject(); + project.controlLogics.front().rungs.front().output->configured = false; + require(project.validate(), "unconfigured ladder node must be accepted in a draft"); + require(!project.validateForRunning(), + "unconfigured ladder node must block runtime validation"); } void testRuntimeStateBoundaries() diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp index c4606c5..096b865 100644 --- a/app/tests/logic_editor_service_tests.cpp +++ b/app/tests/logic_editor_service_tests.cpp @@ -75,6 +75,19 @@ void testEditorOperations() require(hold_result.succeeded && coil_result.succeeded, "parallel hold contact and output coil must be added"); + require(!service.findNode(logic_id, stop_result.id)->configured, + "new logic nodes must remain unconfigured until properties are applied"); + require(service.updateNodeConfig( + logic_id, + stop_result.id, + ContactNodeConfig{ + RegisterAddress{RegisterArea::M, 1}, + ContactMode::NormallyClosed}) + .succeeded, + "applying logic node properties must succeed"); + require(service.findNode(logic_id, stop_result.id)->configured, + "applying logic node properties must mark the node configured"); + const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->stages.size() == 2, "series conditions must occupy ordered stages"); diff --git a/app/tests/main_window_tests.cpp b/app/tests/main_window_tests.cpp index adb7552..7aca532 100644 --- a/app/tests/main_window_tests.cpp +++ b/app/tests/main_window_tests.cpp @@ -62,7 +62,7 @@ void testModeActionsControlEditingAvailability() LogicEditorService logic_editor_service(project_service); VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); - RuntimeModeService mode_service; + RuntimeModeService mode_service(project_service); MainWindow window( mode_service, project_service, @@ -105,8 +105,8 @@ void testModeActionsControlEditingAvailability() 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"), - "selecting an added control must update the property panel"); + 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"); delete_control_action->trigger(); @@ -128,6 +128,29 @@ void testModeActionsControlEditingAvailability() "coil action must set the fixed ladder output"); offline_action->trigger(); + require(mode_service.mode() == ApplicationMode::Editing, + "unconfigured ladder nodes must block offline running"); + const LadderRung &rung = logic->rungs.front(); + for (const LogicNode &node : rung.stages.front().branches) + { + 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(!project_dock->isEnabled(), diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp index f8bd456..2000d18 100644 --- a/app/tests/runtime_mode_service_tests.cpp +++ b/app/tests/runtime_mode_service_tests.cpp @@ -1,4 +1,6 @@ #include "services/runtime_mode_service.h" +#include "services/project_service.h" +#include "domain/project_storage.h" #include #include @@ -6,6 +8,20 @@ namespace { +class TestProjectStorage final : public ProjectStorage +{ +public: + ProjectSaveResult save(const Project &, const std::string &) override + { + return {true, ProjectStorageError::None, {}}; + } + + ProjectLoadResult load(const std::string &) override + { + return {false, {}, ProjectStorageError::FileReadFailed, {}}; + } +}; + void require(bool condition, const std::string &message) { if (!condition) @@ -17,7 +33,9 @@ void require(bool condition, const std::string &message) void testModeTransitions() { // 验证服务将 PLC 首读状态与领域模式切换规则正确组合 - RuntimeModeService service; + TestProjectStorage storage; + ProjectService project_service(storage); + RuntimeModeService service(project_service); require(service.mode() == ApplicationMode::Editing, "service must start in editing mode"); diff --git a/app/tests/runtime_mode_service_tests.pro b/app/tests/runtime_mode_service_tests.pro index 4be456e..e2d7b05 100644 --- a/app/tests/runtime_mode_service_tests.pro +++ b/app/tests/runtime_mode_service_tests.pro @@ -10,9 +10,20 @@ INCLUDEPATH += ../src SOURCES += \ runtime_mode_service_tests.cpp \ + ../src/domain/register_address.cpp \ + ../src/domain/hmi_model.cpp \ + ../src/domain/control_logic_model.cpp \ + ../src/domain/project_model.cpp \ ../src/domain/runtime_state.cpp \ + ../src/services/project_service.cpp \ ../src/services/runtime_mode_service.cpp HEADERS += \ + ../src/domain/register_address.h \ + ../src/domain/hmi_model.h \ + ../src/domain/control_logic_model.h \ + ../src/domain/project_model.h \ + ../src/domain/project_storage.h \ ../src/domain/runtime_state.h \ + ../src/services/project_service.h \ ../src/services/runtime_mode_service.h diff --git a/docs/architecture.md b/docs/architecture.md index b2b700b..9448ba5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,6 +68,11 @@ main.cpp -> UI + Services + Infrastructure 由 `infrastructure/JsonProjectStorage` 实现。加载必须先完成文件解析、版本检查和领域校验,成功 后才能替换当前工程;保存失败或加载失败不得修改当前工程路径和内存内容。 +工程编辑支持保留草稿:HMI 非标签控件可以暂时没有寄存器绑定,梯形图节点可以处于待配置状态, +这些状态仍通过 `Project::validate()` 保存和加载。进入离线运行前由 +`Project::validateForRunning()` 额外拒绝未绑定控件、待配置节点和缺少条件/输出的网络;旧版 1.0 +工程缺少节点 `configured` 字段时按历史完整节点兼容读取。 + `services/HmiEditorService` 负责 HMI 页面的控件添加、删除、移动和属性更新,不依赖 Qt 图元。 `ui/HmiEditorWidget` 使用 `QGraphicsScene` 将模型投影为画布项,画布项只保存控件标识,移动结束后 才通过编辑服务提交新位置。保存时由 `Project::validate()` 拒绝缺失必需绑定或绑定区域错误的控件。