#include "domain/control_logic_model.h" #include "domain/hmi_control_registry.h" #include "domain/hmi_model.h" #include "domain/project_model.h" #include "domain/project_limits.h" #include "domain/register_address.h" #include "domain/register_repository.h" #include "domain/runtime_state.h" #include "domain/virtual_register_repository.h" #include "support/test_support.h" #include #include #include #include #include #include #include #include namespace { using TestSupport::require; int expressionColumns(const ConditionExpression &expression) { if (expression.kind == ConditionExpressionKind::Node) { return 1; } if (expression.kind == ConditionExpressionKind::Wire) { return expression.wire->columnSpan; } if (expression.kind == ConditionExpressionKind::Gap) { return expression.gap->columnSpan; } int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; for (const ConditionExpression &child : expression.children) { const int child_columns = expressionColumns(child); columns = expression.kind == ConditionExpressionKind::Series ? columns + child_columns : std::max(columns, child_columns); } return columns; } void testRegisterAddressBoundaries() { // 覆盖 M/D 地址允许范围及未知枚举值的拒绝路径 require(RegisterAddress{RegisterArea::M, 0}.isValid(), "M0 must be valid"); require(RegisterAddress{RegisterArea::D, 4000}.isValid(), "D4000 must be valid"); require(!(RegisterAddress{RegisterArea::M, -1}.isValid()), "negative register index must be rejected"); require(!(RegisterAddress{RegisterArea::D, 4001}.isValid()), "register index above 4000 must be rejected"); require(!(RegisterAddress{static_cast(99), 0}.isValid()), "unknown register area must be rejected"); } void testRegisterAddressParsing() { const RegisterAddressParseResult m0 = parseRegisterAddress(" m0 "); require(m0.succeeded && m0.address == RegisterAddress{RegisterArea::M, 0}, "register parser must trim and normalize lowercase M addresses"); const RegisterAddressParseResult d4000 = parseRegisterAddress("D4000"); require(d4000.succeeded && d4000.address == RegisterAddress{RegisterArea::D, 4000}, "register parser must accept the maximum D address"); require(parseRegisterAddress("").error == RegisterAddressParseError::Empty, "register parser must distinguish empty input"); require(parseRegisterAddress("X0").error == RegisterAddressParseError::UnsupportedArea, "register parser must reject unsupported areas"); require(parseRegisterAddress("M1.0").error == RegisterAddressParseError::InvalidFormat, "register parser must reject non-decimal indices"); require(parseRegisterAddress("D4001").error == RegisterAddressParseError::OutOfRange, "register parser must reject addresses above the project range"); } void testRegisterRepositorySeparatesAreas() { // 验证离线仓库不会把 M 位和 D 字交叉解释 VirtualRegisterRepository repository; const RegisterAddress m0{RegisterArea::M, 0}; const RegisterAddress d0{RegisterArea::D, 0}; require(repository.writeBit(m0, true).succeeded, "M bit write must succeed"); require(repository.readBit(m0).value, "M bit read must return written value"); require(repository.writeWord(d0, static_cast(-123)).succeeded, "D word write must succeed"); require(repository.readWord(d0).value == -123, "D word read must return written value"); require(repository.readBit(d0).error == RegisterError::AreaMismatch, "D address must not be read as a bit"); require(repository.readWord(m0).error == RegisterError::AreaMismatch, "M address must not be read as a word"); } void testHmiControlRegistryCompleteness() { std::set descriptors; std::set storage_names; std::set id_prefixes; const std::size_t control_type_count = static_cast(HmiControlType::Count); for (std::size_t index = 0; index < control_type_count; ++index) { const HmiControlType type = static_cast(index); const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); require(descriptor != nullptr, "every HMI control type must have one descriptor"); require(descriptor->type == type, "HMI type lookup must return the requested descriptor"); require(descriptors.emplace(descriptor).second, "each HMI control type must resolve to a different descriptor"); require(descriptor->storageName != nullptr && descriptor->storageName[0] != '\0', "HMI storage names must not be empty"); require(descriptor->displayName != nullptr && descriptor->displayName[0] != '\0', "HMI display names must not be empty"); require(descriptor->idPrefix != nullptr && descriptor->idPrefix[0] != '\0', "HMI id prefixes must not be empty"); require(descriptor->defaultText != nullptr, "HMI default text must not be null"); require(descriptor->defaultBounds.width > 0 && descriptor->defaultBounds.height > 0, "HMI default bounds must have a positive size"); require(findHmiControlDescriptor(descriptor->storageName) == descriptor, "HMI storage name lookup must return its registered descriptor"); require(storage_names.emplace(descriptor->storageName).second, "HMI storage names must be unique"); require(id_prefixes.emplace(descriptor->idPrefix).second, "HMI id prefixes must be unique"); const std::optional binding_area = hmiBindingArea(descriptor->bindingKind); switch (descriptor->runtimeValueKind) { case HmiRuntimeValueKind::Bit: { require(descriptor->bindingKind == HmiBindingKind::Bit && binding_area == RegisterArea::M, "bit runtime controls must bind to the M area"); break; } case HmiRuntimeValueKind::Word: { require(descriptor->bindingKind == HmiBindingKind::Word && binding_area == RegisterArea::D, "word runtime controls must bind to the D area"); break; } case HmiRuntimeValueKind::None: { require(descriptor->bindingKind == HmiBindingKind::None && !binding_area.has_value(), "static controls must not declare a register binding"); break; } } require(descriptor->requiresBindingForRunning == (descriptor->runtimeValueKind != HmiRuntimeValueKind::None), "runtime value controls must require a configured binding"); } require(findHmiControlDescriptor(HmiControlType::Count) == nullptr, "the HMI control count marker must not be registered"); require(findHmiControlDescriptor(static_cast(99)) == nullptr, "unknown HMI control types must not resolve"); require(findHmiControlDescriptor("unknown") == nullptr, "unknown HMI storage names must not resolve"); require(hmiBindingArea(HmiBindingKind::Bit) == RegisterArea::M, "bit bindings must use the M area"); require(hmiBindingArea(HmiBindingKind::Word) == RegisterArea::D, "word bindings must use the D area"); require(!hmiBindingArea(HmiBindingKind::None).has_value(), "controls without bindings must not resolve a register area"); } void testProgressBarConfigurationBoundaries() { const HmiProgressBarConfig config{-20, 80, true}; require(config.isValid(), "a progress bar range must accept increasing bounds"); require(config.percentageForValue(-20) == 0, "progress bar minimum must map to zero percent"); require(config.percentageForValue(30) == 50, "progress bar midpoint must map to fifty percent"); require(config.percentageForValue(80) == 100, "progress bar maximum must map to one hundred percent"); require(config.percentageForValue(-100) == 0 && config.percentageForValue(100) == 100, "progress bar percentages must clamp values outside the range"); HmiProgressBarConfig invalid_range{10, 10, true}; require(!invalid_range.isValid() && invalid_range.percentageForValue(10) == 0, "progress bar must reject an empty range defensively"); HmiControl progress; progress.id = "progress"; progress.type = HmiControlType::ProgressBar; progress.progressBar = config; require(progress.validate(), "an unbound progress bar with valid configuration must remain a valid draft"); require(!progress.isConfigured(), "an unbound progress bar must not be ready for running"); progress.binding = RegisterAddress{RegisterArea::D, 0}; require(progress.validate() && progress.isConfigured(), "a progress bar with a D binding must be ready for running"); progress.progressBar->maximumValue = progress.progressBar->minimumValue; require(!progress.validate(), "a progress bar with an invalid range must be rejected"); progress.progressBar = config; progress.binding = RegisterAddress{RegisterArea::M, 0}; require(!progress.validate(), "a progress bar must reject an M binding"); } Project makeValidProject(); void testHmiAppearancePropertyBoundaries() { // 外观属性必须在领域层拒绝格式错误,但不能影响未知扩展属性 Project project = makeValidProject(); HmiControl &button = project.hmiPages.front().controls.front(); button.properties[HmiAppearanceProperty::kTextColor] = "#E53935"; button.properties[HmiAppearanceProperty::kFontSize] = "18"; button.properties[HmiAppearanceProperty::kFontBold] = "true"; button.properties[HmiAppearanceProperty::kFontItalic] = "false"; require(project.validate(), "valid HMI appearance properties must pass validation"); button.properties[HmiAppearanceProperty::kTextColor] = "red"; require(!project.validate(), "text colors must use the #RRGGBB format"); project = makeValidProject(); HmiControl &font_control = project.hmiPages.front().controls.front(); font_control.properties[HmiAppearanceProperty::kFontSize] = "5"; require(!project.validate(), "font sizes below the minimum must be rejected"); font_control.properties[HmiAppearanceProperty::kFontSize] = "73"; require(!project.validate(), "font sizes above the maximum must be rejected"); font_control.properties[HmiAppearanceProperty::kFontSize] = "large"; require(!project.validate(), "non-numeric font sizes must be rejected"); project = makeValidProject(); HmiControl &style_control = project.hmiPages.front().controls.front(); style_control.properties[HmiAppearanceProperty::kFontBold] = "yes"; require(!project.validate(), "font style flags must be true or false"); project = makeValidProject(); project.hmiPages.front().controls.front().properties["legacyColor"] = "green"; require(project.validate(), "unknown HMI extension properties must remain supported"); } Project makeValidProject() { // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线 HmiControl start_button; start_button.id = "start-button"; start_button.type = HmiControlType::Button; start_button.text = "Start"; start_button.binding = {RegisterArea::M, 0}; HmiPage page; page.id = "main-page"; page.name = "Main"; page.controls.push_back(start_button); LogicNode contact; contact.id = "start-contact"; contact.config = ContactNodeConfig{ RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen}; LogicNode coil; coil.id = "run-coil"; coil.config = CoilNodeConfig{ RegisterAddress{RegisterArea::M, 1}, CoilMode::Normal}; ControlLogic logic; logic.id = "start-logic"; logic.name = "Start logic"; LadderRung rung; rung.id = "rung-1"; rung.name = "Network 1"; ConditionExpression condition; condition.id = "start-series"; condition.kind = ConditionExpressionKind::Series; condition.children = { ConditionExpression::fromNode(contact), ConditionExpression::fromWire("start-wire", 9)}; rung.condition = std::move(condition); rung.output = coil; logic.rungs.push_back(rung); 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 testQuantityBoundaries() { Project project = makeValidProject(); for (std::size_t index = 1U; index < ProjectLimits::kMaximumHmiPages; ++index) { project.hmiPages.push_back({ "page-" + std::to_string(index), "Page " + std::to_string(index), 800, 400, {}}); } require(project.validate(), "an HMI page count at the configured limit must be accepted"); project.hmiPages.push_back({"page-over", "Page over", 800, 400, {}}); require(!project.validate(), "an HMI page count of 129 must be rejected"); project = makeValidProject(); project.hmiPages.clear(); project.initialHmiPageId.clear(); for (std::size_t page_index = 0U; page_index < 17U; ++page_index) { HmiPage page{ "bulk-page-" + std::to_string(page_index), "Bulk page " + std::to_string(page_index), 800, 400, {}}; for (std::size_t control_index = 0U; control_index < ProjectLimits::kMaximumHmiControlsPerPage; ++control_index) { HmiControl label; label.id = "label-" + std::to_string(control_index); label.type = HmiControlType::Label; label.bounds = {0, 0, 1, 1}; label.text = "label"; page.controls.push_back(std::move(label)); } project.hmiPages.push_back(std::move(page)); } project.initialHmiPageId = project.hmiPages.front().id; require(!project.validate(), "an HMI control count over the project limit must be rejected"); project = makeValidProject(); project.hmiPages.front().controls.clear(); for (std::size_t index = 0U; index < ProjectLimits::kMaximumHmiControlsPerPage; ++index) { HmiControl label; label.id = "label-" + std::to_string(index); label.type = HmiControlType::Label; label.bounds = {0, 0, 1, 1}; label.text = "label"; project.hmiPages.front().controls.push_back(std::move(label)); } require(project.validate(), "a page control count at the configured limit must be accepted"); HmiControl extra_label; extra_label.id = "label-over"; extra_label.type = HmiControlType::Label; extra_label.bounds = {0, 0, 1, 1}; extra_label.text = "label"; project.hmiPages.front().controls.push_back(std::move(extra_label)); require(!project.validate(), "a page control count of 513 must be rejected"); project = makeValidProject(); project.controlLogics.clear(); for (std::size_t logic_index = 0U; logic_index < 9U; ++logic_index) { ControlLogic logic{ "bulk-logic-" + std::to_string(logic_index), "Bulk logic " + std::to_string(logic_index), {}, true}; for (std::size_t rung_index = 0U; rung_index < ProjectLimits::kMaximumRungsPerLogic; ++rung_index) { logic.rungs.push_back({ "rung-" + std::to_string(rung_index), "Rung " + std::to_string(rung_index), {}, std::nullopt, {}}); } project.controlLogics.push_back(std::move(logic)); } require(!project.validate(), "a ladder rung count over the project limit must be rejected"); project = makeValidProject(); project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth; project.hmiPages.front().height = ProjectLimits::kMaximumHmiPageHeight; require(project.validate(), "an HMI page size of 1600 by 800 must be accepted"); project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth + 1; require(!project.validate(), "an HMI page width of 1601 must be rejected"); project.hmiPages.front().width = ProjectLimits::kMinimumHmiPageWidth - 1; require(!project.validate(), "an HMI page width of 319 must be rejected"); project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth; project.hmiPages.front().height = ProjectLimits::kMinimumHmiPageHeight - 1; require(!project.validate(), "an HMI page height of 199 must be rejected"); ConditionExpression leaf = ConditionExpression::fromNode({ "depth-node-0", ContactNodeConfig{RegisterAddress{RegisterArea::M, 0}}, true}); std::function makeNested = [&makeNested](int depth, int *next_address) { if (depth == 1) { const int address = (*next_address)++; return ConditionExpression::fromNode({ "depth-node-" + std::to_string(address), ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, true}); } const int address = (*next_address)++; ConditionExpression nested = makeNested(depth - 1, next_address); ConditionExpression sibling = ConditionExpression::fromNode({ "depth-node-" + std::to_string(address), ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, true}); ConditionExpression expression; expression.id = "depth-expression-" + std::to_string(address); expression.kind = depth % 2 == 0 ? ConditionExpressionKind::Parallel : ConditionExpressionKind::Series; if (expression.kind == ConditionExpressionKind::Parallel && expressionColumns(nested) > 1) { ConditionExpression padded_sibling; padded_sibling.id = "depth-padding-" + std::to_string(address); padded_sibling.kind = ConditionExpressionKind::Series; padded_sibling.children = { std::move(sibling), ConditionExpression::fromWire( "depth-wire-" + std::to_string(address), expressionColumns(nested) - 1)}; sibling = std::move(padded_sibling); } expression.children = {std::move(nested), std::move(sibling)}; return expression; }; int next_address = 1; ConditionExpression maximum_depth = makeNested( static_cast(ProjectLimits::kMaximumExpressionDepth), &next_address); require(maximum_depth.validate(), "an expression depth at the configured limit must be accepted"); ConditionExpression excessive_depth = makeNested( static_cast(ProjectLimits::kMaximumExpressionDepth) + 1, &next_address); require(!excessive_depth.validate(), "an expression depth above the configured limit must be rejected"); (void)leaf; } void testLogicNodeConfigurationBoundaries() { // 触点只能绑定 M 区,数值比较只能绑定 D 区 LogicNode contact; contact.id = "contact"; contact.config = ContactNodeConfig{ RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen}; require(contact.validate(), "contact node bound to M address must be valid"); contact.config = ContactNodeConfig{ RegisterAddress{RegisterArea::D, 0}, ContactMode::NormallyOpen}; require(!contact.validate(), "contact node bound to D address must be rejected"); LogicNode comparison; comparison.id = "comparison"; comparison.config = CompareNodeConfig{ RegisterAddress{RegisterArea::D, 0}, ComparisonOperator::GreaterThan, static_cast(100)}; require(comparison.validate(), "comparison node bound to D address must be valid"); } void testEdgeAndCommentBoundaries() { LogicNode edge; edge.id = "edge"; edge.config = EdgeContactNodeConfig{ RegisterAddress{RegisterArea::M, 0}, EdgeMode::Rising}; require(edge.validate(), "a valid rising edge contact must pass validation"); RegisterComment comment{RegisterAddress{RegisterArea::M, 0}, "启动按钮"}; require(comment.validate(), "a nonblank register comment must be valid"); comment.text.assign(ProjectLimits::kMaximumRegisterCommentBytes, 'a'); require(comment.validate(), "a register comment at the byte limit must be valid"); comment.text.push_back('a'); require(!comment.validate(), "a register comment above the byte limit must fail"); comment.text = "启动\n按钮"; require(!comment.validate(), "a multiline register comment must be rejected"); comment.text = "启动\r按钮"; require(!comment.validate(), "a register comment containing CR must be rejected"); comment.text = " \t"; require(!comment.validate(), "a blank register comment must be rejected"); LadderRung comment_rung; comment_rung.id = "comment-rung"; comment_rung.name = "Comment rung"; comment_rung.comment.assign(ProjectLimits::kMaximumRungCommentBytes, 'a'); require(comment_rung.validate(), "a rung comment at the byte limit must be valid"); comment_rung.comment.push_back('a'); require(!comment_rung.validate(), "a rung comment above the byte limit must fail"); comment_rung.comment = "第一行\n第二行"; require(!comment_rung.validate(), "a multiline rung comment must be rejected"); comment_rung.comment = "第一行\r第二行"; require(!comment_rung.validate(), "a rung comment containing CR must be rejected"); Project project = makeValidProject(); project.registerComments = { {RegisterAddress{RegisterArea::M, 0}, "启动按钮"}, {RegisterAddress{RegisterArea::M, 0}, "重复地址"}}; require(!project.validate(), "duplicate register comments must be rejected"); } void testDataInstructionBoundaries() { LogicNode move; move.id = "move"; move.config = MoveNodeConfig{ WordOperand{ WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, -100}, RegisterAddress{RegisterArea::D, 20}}; require(move.validate() && move.isOutput(), "MOVE with a constant source and D destination must be a valid output"); LogicNode add; add.id = "add"; add.config = ArithmeticNodeConfig{ ArithmeticOperation::Add, WordOperand{ WordOperandKind::Register, RegisterAddress{RegisterArea::D, 20}, 0}, WordOperand{ WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, 1}, RegisterAddress{RegisterArea::D, 20}}; require(add.validate() && add.isOutput(), "ADD must allow the same D register as source and destination"); } void testLadderLogicBoundaries() { LogicNode stop; stop.id = "stop"; stop.config = ContactNodeConfig{ RegisterAddress{RegisterArea::M, 1}, ContactMode::NormallyClosed}; LogicNode start; start.id = "start"; start.config = ContactNodeConfig{ RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen}; LogicNode run_contact; run_contact.id = "run-contact"; run_contact.config = ContactNodeConfig{ RegisterAddress{RegisterArea::M, 1}, ContactMode::NormallyOpen}; LogicNode coil; coil.id = "run-coil"; coil.config = CoilNodeConfig{ RegisterAddress{RegisterArea::M, 1}, CoilMode::Normal}; ControlLogic logic; logic.id = "hold-logic"; logic.name = "Hold logic"; ConditionExpression start_parallel; start_parallel.id = "parallel-start"; start_parallel.kind = ConditionExpressionKind::Parallel; start_parallel.children = { ConditionExpression::fromNode(start), ConditionExpression::fromNode(run_contact)}; ConditionExpression root; root.id = "series-root"; root.kind = ConditionExpressionKind::Series; root.children = { ConditionExpression::fromNode(stop), start_parallel, ConditionExpression::fromWire("hold-output-wire", 8)}; LadderRung rung; rung.id = "rung-1"; rung.name = "Self hold"; rung.condition = root; rung.output = coil; logic.rungs.push_back(rung); require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid"); ConditionExpression wire = ConditionExpression::fromWire("wire-1", 2); require(wire.validate() && wire.validateForRunning() && wire.wire->columnSpan == 2, "a configured horizontal wire must be a valid runnable expression leaf"); ConditionExpression invalid_wire = ConditionExpression::fromWire("wire-invalid", 0); require(!invalid_wire.validate(), "a zero-column horizontal wire must be rejected"); invalid_wire = ConditionExpression::fromWire( "wire-too-wide", WireSegment::kMaximumColumnSpan + 1); require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected"); ConditionExpression gap = ConditionExpression::fromGap("gap-1", 1); require(gap.validate() && !gap.validateForRunning(), "a gap must be a valid editing draft but must block runtime validation"); ConditionExpression maximum_columns; maximum_columns.id = "maximum-columns"; maximum_columns.kind = ConditionExpressionKind::Series; for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { LogicNode node; node.id = "column-" + std::to_string(column + 1); node.config = ContactNodeConfig{ RegisterAddress{RegisterArea::M, column}, ContactMode::NormallyOpen}; maximum_columns.children.push_back( ConditionExpression::fromNode(std::move(node))); } require(maximum_columns.validate(), "ten condition columns must be accepted"); LogicNode extra_column; extra_column.id = "column-11"; extra_column.config = ContactNodeConfig{ RegisterAddress{RegisterArea::M, 10}, ContactMode::NormallyOpen}; maximum_columns.children.push_back( ConditionExpression::fromNode(std::move(extra_column))); require(!maximum_columns.validate(), "an eleventh condition column must be rejected"); ConditionExpression wired_series; wired_series.id = "wired-series"; wired_series.kind = ConditionExpressionKind::Series; wired_series.children = { ConditionExpression::fromNode(stop), ConditionExpression::fromWire("wire-series"), start_parallel}; require(wired_series.validateForRunning(), "a wire must preserve a valid structured series expression"); logic.rungs.front().condition->children.front() = ConditionExpression::fromNode(coil); require(!logic.validate(), "a ladder condition expression must reject coils"); logic.rungs.front().condition = root; logic.rungs.front().output = start; require(!logic.validate(), "a ladder output must be a coil"); logic.rungs.front().output.reset(); 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, std::nullopt}; require(empty_rung.validate(), "an empty editing network must be valid"); empty_rung.output = coil; require(!empty_rung.validate(), "an output without an explicit ten-column condition path must be rejected"); empty_rung.condition = ConditionExpression::fromWire("unconditional-wire", 10); require(empty_rung.validate() && empty_rung.validateForRunning(), "a ten-column wire path must represent a runnable unconditional rung"); empty_rung.condition = ConditionExpression::fromGap("unconditional-gap", 10); require(empty_rung.validate() && !empty_rung.validateForRunning(), "a full-width gap draft must remain saved but disconnected"); logic.rungs.front().output = coil; logic.rungs.front().condition = root; logic.rungs.front().condition->children.at(1).children.at(1).node->id = start.id; require(!logic.validate(), "logic node ids must be unique"); ConditionExpression nested_parallel; nested_parallel.id = "parallel-nested"; nested_parallel.kind = ConditionExpressionKind::Parallel; nested_parallel.children = { ConditionExpression::fromNode(start), root}; require(!nested_parallel.validate(), "parallel branches with unequal explicit widths must be rejected"); ConditionExpression padded_start; padded_start.id = "padded-start"; padded_start.kind = ConditionExpressionKind::Series; padded_start.children = { ConditionExpression::fromNode(start), ConditionExpression::fromWire("nested-parallel-wire", 9)}; nested_parallel.children.front() = std::move(padded_start); require(nested_parallel.validate(), "parallel branches padded with explicit wires must be valid"); } void testModelsValidateBindingsAndIdentifiers() { // 聚合验证必须拒绝错误绑定、重复标识和越界控件 Project project = makeValidProject(); require(project.validate(), "valid project model must pass validation"); project.hmiPages.front().controls.front().binding = RegisterAddress{RegisterArea::D, 0}; require(!project.validate(), "button bound to D area must be rejected"); project = makeValidProject(); project.hmiPages.push_back(project.hmiPages.front()); require(!project.validate(), "duplicate HMI page id must be rejected"); project = makeValidProject(); project.hmiPages.front().controls.front().bounds.x = -1; require(!project.validate(), "controls outside the page must be rejected"); project = makeValidProject(); project.hmiPages.front().controls.front().bounds.width = 801; require(!project.validate(), "controls wider than the page must be rejected"); 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() { // 运行模式测试覆盖离线和真机的互斥及 PLC 首读前置条件 RuntimeState state; require(state.policy().allowsProjectEditing, "editing mode must allow project editing"); require(state.enterOfflineRunning().succeeded, "editing may enter offline running"); require(state.policy().usesVirtualRegisters, "offline mode must use virtual registers"); require(state.policy().runsLogicExecutor, "offline mode must run logic executor"); require(state.enterOnlineRunning(true).error == ModeTransitionError::MustReturnToEditing, "offline mode must not directly enter online mode"); require(state.enterEditing().succeeded, "offline mode may return to editing"); require(state.enterOnlineRunning(false).error == ModeTransitionError::InitialPlcReadRequired, "online mode must require an initial PLC read"); require(state.enterOnlineRunning(true).succeeded, "editing may enter online mode after initial PLC read"); require(!state.policy().runsLogicExecutor, "online mode must keep the software logic executor stopped"); require(state.policy().usesPlcRegisters, "online mode must use PLC registers"); } } // namespace int main() { try { // 每个测试函数独立覆盖一个领域边界,首个异常即终止测试进程 testRegisterAddressBoundaries(); testRegisterAddressParsing(); testRegisterRepositorySeparatesAreas(); testHmiControlRegistryCompleteness(); testProgressBarConfigurationBoundaries(); testHmiAppearancePropertyBoundaries(); testLogicNodeConfigurationBoundaries(); testEdgeAndCommentBoundaries(); testDataInstructionBoundaries(); testLadderLogicBoundaries(); testModelsValidateBindingsAndIdentifiers(); testMultiPageAndLogicDomainRules(); testQuantityBoundaries(); testRuntimeStateBoundaries(); } catch (const std::exception &error) { std::cerr << "domain tests failed: " << error.what() << '\n'; return 1; } std::cout << "domain tests passed\n"; return 0; }