| @@ -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<CoilNodeConfig>(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; | |||
| } | |||
| @@ -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<LogicNode> 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; | |||
| }; | |||
| @@ -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; | |||
| } | |||
| @@ -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; | |||
| }; | |||
| @@ -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; | |||
| } | |||
| @@ -29,4 +29,5 @@ struct Project | |||
| // 校验工程配置并通过 error 返回失败原因 | |||
| bool validate(std::string *error = nullptr) const; | |||
| bool validateForRunning(std::string *error = nullptr) const; | |||
| }; | |||
| @@ -74,7 +74,8 @@ enum class ModeTransitionError | |||
| None, // 切换成功 | |||
| AlreadyInRequestedMode, // 已经处于目标模式 | |||
| MustReturnToEditing, // 必须先返回编辑态 | |||
| InitialPlcReadRequired // 进入真机态前必须完成 PLC 初次读取 | |||
| InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取 | |||
| ProjectNotReady // 工程存在未配置或未完成的运行项 | |||
| }; | |||
| // 模式切换操作结果 | |||
| @@ -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; | |||
| } | |||
| @@ -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, | |||
| @@ -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}; | |||
| } | |||
| } | |||
| @@ -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(); | |||
| } | |||
| @@ -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; | |||
| @@ -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("未绑定")); | |||
| } | |||
| } | |||
| // 向场景和外部控件返回该图元对应的领域控件标识 | |||
| @@ -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<CoilNodeConfig>(&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<CompareNodeConfig>(&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 | |||
| @@ -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<QWidget *>(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() | |||
| @@ -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() | |||
| @@ -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"); | |||
| @@ -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(), | |||
| @@ -1,4 +1,6 @@ | |||
| #include "services/runtime_mode_service.h" | |||
| #include "services/project_service.h" | |||
| #include "domain/project_storage.h" | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| @@ -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"); | |||
| @@ -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 | |||
| @@ -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()` 拒绝缺失必需绑定或绑定区域错误的控件。 | |||