diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp index bd0ec9f..37b2663 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -25,10 +25,35 @@ bool requiresWordBinding(HmiControlType type) return type == HmiControlType::NumericDisplay || type == HmiControlType::NumericInput; } +bool supportsRegisterBinding(HmiControlType type) +{ + return requiresBitBinding(type) || requiresWordBinding(type); +} + +bool isSupportedControlType(HmiControlType type) +{ + switch (type) + { + case HmiControlType::Button: + case HmiControlType::Indicator: + case HmiControlType::NumericDisplay: + case HmiControlType::NumericInput: + case HmiControlType::Label: + case HmiControlType::PageJump: + return true; + } + return false; +} + } // namespace bool HmiControl::validate(std::string *error) const { + if (!isSupportedControlType(type)) + { + setError(error, "不支持的 HMI 控件类型"); + return false; + } if (id.empty()) { setError(error, "HMI 控件 ID 不能为空"); @@ -68,6 +93,24 @@ bool HmiControl::validate(std::string *error) const setError(error, "HMI 控件绑定了无效地址"); return false; } + if (!supportsRegisterBinding(type) && binding.has_value()) + { + setError(error, "文本和页面跳转控件不能绑定寄存器"); + return false; + } + if (type == HmiControlType::PageJump) + { + if (!pageJump.has_value()) + { + setError(error, "页面跳转控件缺少跳转配置"); + return false; + } + } + else if (pageJump.has_value()) + { + setError(error, "非页面跳转控件不能包含跳转配置"); + return false; + } return true; } @@ -77,6 +120,10 @@ bool HmiControl::isConfigured() const { return true; } + if (type == HmiControlType::PageJump) + { + return pageJump.has_value() && !pageJump->targetPageId.empty(); + } if (!binding.has_value() || !binding->isValid()) { return false; diff --git a/app/src/domain/hmi_model.h b/app/src/domain/hmi_model.h index 46fb923..f3f66bb 100644 --- a/app/src/domain/hmi_model.h +++ b/app/src/domain/hmi_model.h @@ -41,7 +41,8 @@ enum class HmiControlType Indicator, // 指示灯 NumericDisplay, // 数值显示 NumericInput, // 数值输入 - Label // 标签 + Label, // 标签 + PageJump // 页面跳转 }; /** @@ -55,6 +56,11 @@ enum class HmiButtonOperation MomentaryOn // 按下时写入 1,释放时写入 0 }; +struct HmiPageJumpConfig +{ + std::string targetPageId; +}; + /** * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 * @@ -69,6 +75,7 @@ struct HmiControl std::optional binding; std::map properties; HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; + std::optional pageJump; /** * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index 2d12601..956969d 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -33,6 +33,26 @@ bool containsDuplicateId(const std::vector &items) return false; } +template +bool containsDuplicateName(const std::vector &items) +{ + for (auto current = items.cbegin(); current != items.cend(); ++current) + { + const auto duplicate = std::find_if( + current + 1, + items.cend(), + [¤t](const TItem &item) + { + return item.name == current->name; + }); + if (duplicate != items.cend()) + { + return true; + } + } + return false; +} + } // namespace bool Project::validate(std::string *error) const @@ -47,11 +67,40 @@ bool Project::validate(std::string *error) const setError(error, "工程内的 HMI 页面 ID 必须唯一"); return false; } + if (containsDuplicateName(hmiPages)) + { + setError(error, "工程内的 HMI 页面名称必须唯一"); + return false; + } + if (hmiPages.empty()) + { + if (!initialHmiPageId.empty()) + { + setError(error, "空工程不能设置初始 HMI 页面"); + return false; + } + } + else + { + const auto initial_page = std::find_if( + hmiPages.cbegin(), hmiPages.cend(), + [this](const HmiPage &page) { return page.id == initialHmiPageId; }); + if (initial_page == hmiPages.cend()) + { + setError(error, "工程初始 HMI 页面不存在"); + return false; + } + } if (containsDuplicateId(controlLogics)) { setError(error, "工程内的控制逻辑 ID 必须唯一"); return false; } + if (containsDuplicateName(controlLogics)) + { + setError(error, "工程内的控制逻辑名称必须唯一"); + return false; + } for (const HmiPage &page : hmiPages) { // 工程聚合校验会向下委托页面和控件的完整规则 @@ -59,6 +108,26 @@ bool Project::validate(std::string *error) const { return false; } + for (const HmiControl &control : page.controls) + { + if (control.type != HmiControlType::PageJump + || !control.pageJump.has_value() + || control.pageJump->targetPageId.empty()) + { + continue; + } + const auto target = std::find_if( + hmiPages.cbegin(), hmiPages.cend(), + [&control](const HmiPage &candidate) + { + return candidate.id == control.pageJump->targetPageId; + }); + if (target == hmiPages.cend()) + { + setError(error, "页面跳转控件 " + control.id + " 的目标页面不存在"); + return false; + } + } } for (const ControlLogic &logic : controlLogics) { @@ -85,7 +154,7 @@ bool Project::validateForRunning(std::string *error) const } for (const ControlLogic &logic : controlLogics) { - if (!logic.validateForRunning(error)) + if (logic.enabled && !logic.validateForRunning(error)) { return false; } diff --git a/app/src/domain/project_model.h b/app/src/domain/project_model.h index c6aeb39..748314c 100644 --- a/app/src/domain/project_model.h +++ b/app/src/domain/project_model.h @@ -24,6 +24,8 @@ struct Project ProjectMetadata metadata; // 工程包含的 HMI 页面集合 std::vector hmiPages; + // 进入运行态时显示的初始 HMI 页面 + std::string initialHmiPageId; // 工程包含的控制逻辑集合 std::vector controlLogics; diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index ce3447c..674d0d6 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -278,6 +278,10 @@ QString hmiControlTypeName(HmiControlType type) { return QStringLiteral("label"); } + case HmiControlType::PageJump: + { + return QStringLiteral("pageJump"); + } default: { return {}; @@ -309,6 +313,10 @@ bool parseHmiControlType( { *type = HmiControlType::Label; } + else if (value == "pageJump") + { + *type = HmiControlType::PageJump; + } else { return state->fail( @@ -476,6 +484,14 @@ QJsonObject serializeHmiControl(const HmiControl &control) QStringLiteral("buttonOperation"), hmiButtonOperationName(control.buttonOperation)); } + if (control.type == HmiControlType::PageJump) + { + object.insert( + QStringLiteral("targetPageId"), + fromUtf8(control.pageJump.has_value() + ? control.pageJump->targetPageId + : std::string{})); + } return object; } @@ -520,6 +536,15 @@ bool parseHmiControl( return false; } } + if (control->type == HmiControlType::PageJump) + { + std::string target_page_id; + if (!readString(object, "targetPageId", context, &target_page_id, state)) + { + return false; + } + control->pageJump = HmiPageJumpConfig{std::move(target_page_id)}; + } // binding 允许为 null,其余非空值必须是合法的寄存器地址对象 if (binding.isNull()) @@ -1144,6 +1169,8 @@ QJsonObject serializeProject(const Project &project) object.insert(QStringLiteral("id"), fromUtf8(project.metadata.id)); object.insert(QStringLiteral("name"), fromUtf8(project.metadata.name)); object.insert(QStringLiteral("hmiPages"), pages); + object.insert(QStringLiteral("initialHmiPageId"), + fromUtf8(project.initialHmiPageId)); object.insert(QStringLiteral("controlLogics"), logics); return object; } @@ -1174,6 +1201,8 @@ bool parseProject( if (!readString(object, "id", "project", &project->metadata.id, state) || !readString(object, "name", "project", &project->metadata.name, state) || !readArray(object, "hmiPages", "project", &pages, state) + || !readString(object, "initialHmiPageId", "project", + &project->initialHmiPageId, state) || !readArray(object, "controlLogics", "project", &logics, state)) { return false; diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index c8394b9..bfebd76 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -115,10 +115,75 @@ Project makeValidProject() Project project; project.metadata = {"sample-project", "Sample project", "1.0"}; project.hmiPages.push_back(page); + project.initialHmiPageId = page.id; project.controlLogics.push_back(logic); return project; } +void testMultiPageAndLogicDomainRules() +{ + Project project = makeValidProject(); + HmiPage settings; + settings.id = "settings-page"; + settings.name = "Settings"; + project.hmiPages.push_back(settings); + + HmiControl label; + label.id = "title"; + label.type = HmiControlType::Label; + label.text = "Machine"; + project.hmiPages.front().controls.push_back(label); + require(project.validate(), "an unbound label must be a valid static control"); + + project.hmiPages.front().controls.back().binding = + RegisterAddress{RegisterArea::M, 10}; + require(!project.validate(), "labels must reject register bindings"); + + project = makeValidProject(); + project.hmiPages.push_back(settings); + HmiControl jump; + jump.id = "settings-jump"; + jump.type = HmiControlType::PageJump; + jump.text = "Settings"; + jump.pageJump = HmiPageJumpConfig{settings.id}; + project.hmiPages.front().controls.push_back(jump); + require(project.validate() && project.validateForRunning(), + "a page jump must resolve its target by stable page id"); + + project.hmiPages.front().controls.back().pageJump->targetPageId = "missing"; + require(!project.validate(), "a page jump must reject a missing target page"); + + project = makeValidProject(); + project.initialHmiPageId = "missing"; + require(!project.validate(), "the initial HMI page id must resolve to a page"); + + project = makeValidProject(); + HmiPage duplicate_name = settings; + duplicate_name.name = project.hmiPages.front().name; + project.hmiPages.push_back(duplicate_name); + require(!project.validate(), "HMI page names must be unique"); + + project = makeValidProject(); + ControlLogic disabled_draft; + disabled_draft.id = "draft-logic"; + disabled_draft.name = "Draft logic"; + disabled_draft.enabled = false; + disabled_draft.rungs.push_back( + {"rung-1", "Draft network", std::nullopt, std::nullopt}); + project.controlLogics.push_back(disabled_draft); + require(project.validateForRunning(), + "a disabled draft logic must not block offline running"); + + project.controlLogics.back().name = project.controlLogics.front().name; + require(!project.validate(), "control logic names must be unique"); + + project = makeValidProject(); + project.hmiPages.front().controls.front().type = + static_cast(99); + project.hmiPages.front().controls.front().binding.reset(); + require(!project.validate(), "unknown HMI control types must be rejected"); +} + void testLogicNodeConfigurationBoundaries() { // 触点只能绑定 M 区,数值比较只能绑定 D 区 @@ -302,6 +367,7 @@ int main() testLogicNodeConfigurationBoundaries(); testLadderLogicBoundaries(); testModelsValidateBindingsAndIdentifiers(); + testMultiPageAndLogicDomainRules(); testRuntimeStateBoundaries(); } catch (const std::exception &error) diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 68568f8..6a37919 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -3,6 +3,9 @@ #include "services/project_service.h" #include +#include +#include +#include #include #include @@ -56,6 +59,19 @@ Project makeExampleProject() target_input.binding = RegisterAddress{RegisterArea::D, 3}; target_input.properties.emplace("minimum", "-100"); + HmiControl title_label; + title_label.id = "title-label"; + title_label.type = HmiControlType::Label; + title_label.bounds = {10, 145, 180, 32}; + title_label.text = "Production line"; + + HmiControl settings_jump; + settings_jump.id = "settings-jump"; + settings_jump.type = HmiControlType::PageJump; + settings_jump.bounds = {210, 145, 120, 40}; + settings_jump.text = "Settings"; + settings_jump.pageJump = HmiPageJumpConfig{"settings-page"}; + HmiPage page; page.id = "main-page"; page.name = "Main"; @@ -63,6 +79,12 @@ Project makeExampleProject() page.controls.push_back(running_indicator); page.controls.push_back(temperature_display); page.controls.push_back(target_input); + page.controls.push_back(title_label); + page.controls.push_back(settings_jump); + + HmiPage settings_page; + settings_page.id = "settings-page"; + settings_page.name = "Settings"; LogicNode contact; contact.id = "start-contact"; @@ -115,7 +137,16 @@ Project makeExampleProject() Project project; project.metadata = {"example-project", "Example project", "1.0"}; project.hmiPages.push_back(page); + project.hmiPages.push_back(settings_page); + project.initialHmiPageId = page.id; project.controlLogics.push_back(logic); + ControlLogic draft_logic; + draft_logic.id = "draft-logic"; + draft_logic.name = "Draft logic"; + draft_logic.enabled = false; + draft_logic.rungs.push_back( + {"rung-1", "Draft network", std::nullopt, std::nullopt}); + project.controlLogics.push_back(draft_logic); return project; } @@ -172,6 +203,8 @@ void testExampleProjectRoundTrip() const QString first_path = directory.filePath("example.json"); const QString second_path = directory.filePath("example-copy.json"); const QString invalid_operation_path = directory.filePath("invalid-operation.json"); + const QString missing_initial_path = directory.filePath("missing-initial.json"); + const QString missing_target_path = directory.filePath("missing-target.json"); require(service.saveAs(first_path.toStdString()).succeeded, "example project save must succeed"); const QByteArray saved_json = readBytes(first_path); @@ -183,8 +216,10 @@ void testExampleProjectRoundTrip() require(!saved_json.contains("\"dataPoints\""), "saved project must not contain the removed data point model"); require(saved_json.contains("\"formatVersion\": \"1.0\"") - && saved_json.contains("\"buttonOperation\": \"setOn\""), - "version 1.0 projects must persist the button operation"); + && saved_json.contains("\"buttonOperation\": \"setOn\"") + && saved_json.contains("\"initialHmiPageId\": \"main-page\"") + && saved_json.contains("\"targetPageId\": \"settings-page\""), + "version 1.0 projects must persist the current multi-page schema"); require(!saved_json.contains("\"stages\"") && !saved_json.contains("\"branches\""), "current project format must not contain the removed stage model"); @@ -196,9 +231,11 @@ void testExampleProjectRoundTrip() const Project &project = service.project(); require(project.metadata.id == "example-project", "project id must survive round trip"); - require(project.hmiPages.size() == 1, "HMI page count must survive round trip"); - require(project.hmiPages.front().controls.size() == 4, - "all basic HMI controls must survive round trip"); + require(project.hmiPages.size() == 2, "HMI page count must survive round trip"); + require(project.initialHmiPageId == "main-page", + "the initial HMI page id must survive round trip"); + require(project.hmiPages.front().controls.size() == 6, + "register, Label and PageJump controls must survive round trip"); require(project.hmiPages.front().controls.front().binding->area() == RegisterArea::M, "HMI M binding must survive round trip"); @@ -213,7 +250,11 @@ void testExampleProjectRoundTrip() "numeric display D binding must survive round trip"); require(project.hmiPages.front().controls.at(3).bounds.x == 150, "numeric input bounds must survive round trip"); - require(project.controlLogics.size() == 1, + require(project.hmiPages.front().controls.at(4).type == HmiControlType::Label + && project.hmiPages.front().controls.at(5).pageJump->targetPageId + == "settings-page", + "Label and PageJump typed data must survive round trip"); + require(project.controlLogics.size() == 2, "control logic count must survive round trip"); require(!project.controlLogics.front().enabled, "control logic enabled state must survive round trip"); @@ -244,6 +285,35 @@ void testExampleProjectRoundTrip() writeText(invalid_operation_path, invalid_operation); require(!service.load(invalid_operation_path.toStdString()).succeeded, "unsupported HMI button operations must be rejected"); + + QJsonObject missing_initial = QJsonDocument::fromJson(saved_json).object(); + missing_initial.remove(QStringLiteral("initialHmiPageId")); + writeText( + missing_initial_path, + QJsonDocument(missing_initial).toJson(QJsonDocument::Compact)); + ProjectOperationResult missing_result = service.load( + missing_initial_path.toStdString()); + require(!missing_result.succeeded + && missing_result.storageError == ProjectStorageError::MissingField, + "the 1.0 schema must require initialHmiPageId without migration defaults"); + + QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object(); + QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray(); + QJsonObject main_page = pages.at(0).toObject(); + QJsonArray controls = main_page.value(QStringLiteral("controls")).toArray(); + QJsonObject jump = controls.at(5).toObject(); + jump.remove(QStringLiteral("targetPageId")); + controls.replace(5, jump); + main_page.insert(QStringLiteral("controls"), controls); + pages.replace(0, main_page); + missing_target.insert(QStringLiteral("hmiPages"), pages); + writeText( + missing_target_path, + QJsonDocument(missing_target).toJson(QJsonDocument::Compact)); + missing_result = service.load(missing_target_path.toStdString()); + require(!missing_result.succeeded + && missing_result.storageError == ProjectStorageError::MissingField, + "the 1.0 schema must require PageJump targetPageId"); } void testInvalidFiles()