#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 #include namespace { using TestSupport::require; 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 testMultiWordCodecsAndBlockAccess() { const std::array int32_values = { std::numeric_limits::min(), -1, 0, std::numeric_limits::max()}; for (const std::int32_t value : int32_values) { const std::array words = Int32Codec::encode(value); require(Int32Codec::decode(words[0], words[1]) == value, "Int32 codec must preserve signed boundary values"); } const std::array int32_words = Int32Codec::encode(0x12345678); require(static_cast(int32_words[0]) == 0x5678U && static_cast(int32_words[1]) == 0x1234U, "Int32 codec must store the low 16-bit word at the lower D address"); const std::array values = {1.0f, -2.5f, 0.1f, 0.0f}; for (const float value : values) { const std::array words = Float32Codec::encode(value); const std::optional decoded = Float32Codec::decode(words[0], words[1]); require(decoded.has_value() && *decoded == value, "Float32 codec must preserve representative finite values"); } require(!Float32Codec::decode( static_cast(0), static_cast(0x7f80)).has_value(), "Float32 codec must reject positive infinity"); require(!Float32Codec::decode( static_cast(0), static_cast(0x7fc0)).has_value(), "Float32 codec must reject NaN"); const std::array float20 = Float32Codec::encode(20.0f); require(static_cast(float20[0]) == 0x0000U && static_cast(float20[1]) == 0x41a0U, "Float32 20.0 must match the Xinje low-word-first example"); const std::array double_values = {1.0, -2.5, 0.1, 0.0}; for (const double value : double_values) { const std::array words = Float64Codec::encode(value); const std::optional decoded = Float64Codec::decode(words); require(decoded.has_value() && *decoded == value, "Double codec must preserve representative finite values"); } const std::array double_one = Float64Codec::encode(1.0); require(static_cast(double_one[0]) == 0x0000U && static_cast(double_one[1]) == 0x0000U && static_cast(double_one[2]) == 0x0000U && static_cast(double_one[3]) == 0x3ff0U, "Double 1.0 must occupy four low-address-first D words"); require(!Float64Codec::decode({ static_cast(0), static_cast(0), static_cast(0), static_cast(0x7ff0)}) .has_value() && !Float64Codec::decode({ static_cast(0), static_cast(0), static_cast(0), static_cast(0x7ff8)}) .has_value(), "Double codec must reject infinity and NaN bit patterns"); require(encodeRegisterNumericValue( RegisterDataType::Int32, static_cast(std::numeric_limits::min())) .has_value() && encodeRegisterNumericValue( RegisterDataType::Int32, static_cast(std::numeric_limits::max())) .has_value() && !encodeRegisterNumericValue( RegisterDataType::Int32, 1.5).has_value() && !encodeRegisterNumericValue( RegisterDataType::Float64, std::numeric_limits::infinity()).has_value() && !encodeRegisterNumericValue( RegisterDataType::Float64, std::numeric_limits::quiet_NaN()).has_value(), "typed encoding must enforce Int32 integrality and finite Double values"); VirtualRegisterRepository repository; const RegisterAddress d10{RegisterArea::D, 10}; require(repository.writeWordPair(d10, Float32Codec::encode(-2.5f)).succeeded, "virtual repository must write Float32 low and high words"); const WordPairReadResult pair = repository.readWordPair(d10); require(pair.succeeded && Float32Codec::decode(pair.values[0], pair.values[1]).value() == -2.5f, "virtual repository must read Float32 from two consecutive words"); require(!repository.writeWordPair({RegisterArea::D, 4000}, {}).succeeded, "Float32 write at D4000 must be rejected"); require(repository.writeWords( {RegisterArea::D, 30}, {double_one[0], double_one[1], double_one[2], double_one[3]}) .succeeded && repository.readWords({RegisterArea::D, 30}, 4).values == std::vector( double_one.cbegin(), double_one.cend()), "virtual repository must read and write one complete four-word block"); repository.writeWord({RegisterArea::D, 4000}, 77); require(!repository.writeWords( {RegisterArea::D, 4000}, {1, 2}).succeeded && repository.readWord({RegisterArea::D, 4000}).value == 77, "an overflowing block write must fail before changing any D word"); RegisterDataType parsed = RegisterDataType::Int16; require(parseRegisterDataType("int32", &parsed) && parsed == RegisterDataType::Int32 && parseRegisterDataType("float64", &parsed) && parsed == RegisterDataType::Float64 && registerDataTypeWordCount(RegisterDataType::Float64) == 4, "register data type descriptors must expose Int32 and Float64 names"); const RegisterDataType invalid_type = static_cast(99); require(!registerDataTypeIsSupported(invalid_type) && registerDataTypeWordCount(invalid_type) == 0 && !registerDataTypeAddressIsValid( invalid_type, {RegisterArea::D, 0}), "unknown register data types must not borrow Int16 rules"); } 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"); } Project makeValidProject(); void testMultiWordHmiBoundaries() { Project project = makeValidProject(); HmiControl display; display.id = "float-display"; display.type = HmiControlType::NumericDisplay; display.text = "Value"; display.binding = RegisterAddress{RegisterArea::D, 3999}; display.dataType = RegisterDataType::Float32; project.hmiPages.front().controls.push_back(display); require(project.validate(), "Float32 D3999 must be valid and occupy D3999~D4000"); project.hmiPages.front().controls.back().binding = RegisterAddress{RegisterArea::D, 4000}; require(!project.validate(), "Float32 D4000 must be rejected"); project = makeValidProject(); HmiControl int32_display = display; int32_display.id = "int32-display"; int32_display.dataType = RegisterDataType::Int32; int32_display.binding = RegisterAddress{RegisterArea::D, 3999}; project.hmiPages.front().controls.push_back(int32_display); require(project.validate(), "Int32 D3999 must be valid"); project.hmiPages.front().controls.back().binding = RegisterAddress{RegisterArea::D, 4000}; require(!project.validate(), "Int32 D4000 must be rejected"); project = makeValidProject(); HmiControl double_display = display; double_display.id = "double-display"; double_display.dataType = RegisterDataType::Float64; double_display.binding = RegisterAddress{RegisterArea::D, 3996}; project.hmiPages.front().controls.push_back(double_display); require(project.validate(), "Double D3996 must be a valid even start address"); project.hmiPages.front().controls.back().binding = RegisterAddress{RegisterArea::D, 3997}; require(!project.validate(), "Double D3997 must be rejected"); project.hmiPages.front().controls.back().binding = RegisterAddress{RegisterArea::D, 3995}; require(!project.validate(), "Double odd start addresses must be rejected"); project = makeValidProject(); HmiControl invalid_display = display; invalid_display.id = "invalid-type-display"; invalid_display.binding.reset(); invalid_display.dataType = static_cast(99); project.hmiPages.front().controls.push_back(invalid_display); require(!project.validate(), "an unbound HMI draft must still reject an unknown numeric type"); project = makeValidProject(); HmiControl first = display; first.binding = RegisterAddress{RegisterArea::D, 10}; HmiControl second = first; second.id = "float-display-duplicate"; project.hmiPages.front().controls.push_back(first); project.hmiPages.front().controls.push_back(second); require(project.validate(), "same Float32 start address and type may be bound more than once"); second.dataType = RegisterDataType::Int16; project.hmiPages.front().controls.back() = second; require(!project.validate(), "different HMI data types may not partially overlap"); for (const RegisterDataType type : { RegisterDataType::Int32, RegisterDataType::Float32, RegisterDataType::Float64}) { project = makeValidProject(); HmiControl configured_multi_word = display; configured_multi_word.id = "protected-multi-word"; configured_multi_word.dataType = type; configured_multi_word.binding = RegisterAddress{RegisterArea::D, 10}; project.hmiPages.front().controls.push_back(configured_multi_word); project.controlLogics.front().rungs.front().output = LogicNode{ "move-output", MoveNodeConfig{ WordOperand{ WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, 1}, RegisterAddress{ RegisterArea::D, 10 + registerDataTypeWordCount(type) - 1}}, true}; require(!project.validate(), "16-bit instructions must not write inside any multi-word HMI range"); } } 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"; for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { rung.cells.push_back({ "start-cell-" + std::to_string(column), column == 0 ? LadderCellKind::Node : LadderCellKind::Wire, column == 0 ? std::optional{contact} : std::nullopt}); } rung.output = coil; logic.rungs.push_back(rung); Project project; project.metadata = {"sample-project", "Sample project", "2.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; LadderRung draft_rung; draft_rung.id = "rung-1"; draft_rung.name = "Draft network"; disabled_draft.rungs.push_back(std::move(draft_rung)); 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"); project = makeValidProject(); project.controlLogics.front().rungs.front().cells.pop_back(); require(!project.validate(), "a ladder row with fewer than ten cells must be rejected"); } 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() { ControlLogic logic; logic.id = "grid-logic"; logic.name = "Grid logic"; LadderRung upper; upper.id = "rung-1"; upper.name = "Row 1"; LadderRung lower; lower.id = "rung-2"; lower.name = "Row 2"; for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { upper.cells.push_back({ "upper-cell-" + std::to_string(column), LadderCellKind::Wire, std::nullopt}); lower.cells.push_back({ "lower-cell-" + std::to_string(column), LadderCellKind::Gap, std::nullopt}); } upper.cells[0].kind = LadderCellKind::Node; upper.cells[0].node = LogicNode{ "start", ContactNodeConfig{ RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen}, true}; upper.output = LogicNode{ "run-coil", CoilNodeConfig{ RegisterAddress{RegisterArea::M, 1}, CoilMode::Normal}, true}; logic.rungs = {upper, lower}; logic.verticalConnections = { {"vertical-left", "rung-1", "rung-2", 0}, {"vertical-right", "rung-1", "rung-2", 1}}; require(logic.validate() && logic.validateForRunning(), "a ten-cell grid with adjacent vertical edges must be valid"); logic.rungs.front().cells[5].kind = LadderCellKind::Gap; std::string connectivity_error; require( logic.validate() && !logic.validateForRunning(&connectivity_error) && connectivity_error.find("第 1 行") != std::string::npos && connectivity_error.find("第 6 列") != std::string::npos, "a disconnected output must report its visual row and break column"); logic.rungs.front() = upper; logic.rungs.front().cells[5].kind = LadderCellKind::Gap; for (LadderCell &cell : logic.rungs.back().cells) { cell.kind = LadderCellKind::Wire; cell.node.reset(); } logic.verticalConnections = { {"vertical-left", "rung-1", "rung-2", 0}, {"vertical-bypass", "rung-1", "rung-2", 6}}; require( logic.validateForRunning(), "a vertical branch that bypasses a gap must keep the output reachable"); logic.rungs = {upper, lower}; logic.verticalConnections = { {"vertical-left", "rung-1", "rung-2", 0}, {"vertical-right", "rung-1", "rung-2", 1}}; logic.rungs.front().cells.front().node = LogicNode{ "invalid-coil", CoilNodeConfig{ RegisterAddress{RegisterArea::M, 2}, CoilMode::Normal}, true}; require(!logic.validate(), "a condition cell must reject output nodes"); logic.rungs.front() = upper; logic.rungs.front().output = LogicNode{ "invalid-contact", ContactNodeConfig{ RegisterAddress{RegisterArea::M, 2}, ContactMode::NormallyOpen}, true}; require(!logic.validate(), "the output slot must reject condition nodes"); logic.rungs.front() = upper; logic.rungs.front().output.reset(); require(logic.validate() && logic.validateForRunning(), "a row without an output may act as a connected branch"); logic.rungs.front() = upper; logic.rungs.front().cells[1].id = logic.rungs.front().cells[0].id; require(!logic.validate(), "cell ids must be unique within a logic"); logic.rungs.front() = upper; logic.verticalConnections.front().lowerRungId = "missing-rung"; require(!logic.validate(), "vertical edges must reference adjacent rows"); logic.verticalConnections = { {"vertical-left", "rung-1", "rung-2", 0}, {"vertical-copy", "rung-1", "rung-2", 0}}; require(!logic.validate(), "one row boundary must not contain duplicate vertical edges"); } 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 run the local read-only trace executor"); require(state.policy().usesPlcRegisters, "online mode must use PLC registers"); } void testRuntimeConfiguredProjectLimits() { ProjectLimitSettings limits; std::string error; Project project = makeValidProject(); HmiPage second_page = project.hmiPages.front(); second_page.id = "second-page"; second_page.name = "Second page"; project.hmiPages.push_back(second_page); limits.maximumHmiPages = 1U; require(!project.validate(limits, &error) && error.find("当前配置上限为 1") != std::string::npos, "runtime page limits must be enforced by aggregate validation"); project = makeValidProject(); HmiControl second_control = project.hmiPages.front().controls.front(); second_control.id = "second-control"; project.hmiPages.front().controls.push_back(second_control); limits = {}; limits.maximumHmiControlsPerPage = 1U; require(!project.validate(limits, &error), "runtime per-page control limits must be enforced"); project = makeValidProject(); project.alarmDefinitions.push_back({}); limits = {}; limits.maximumAlarmDefinitions = 0U; require(!project.validate(limits, &error), "runtime alarm limits must be enforced before child validation"); project = makeValidProject(); ControlLogic second_logic = project.controlLogics.front(); second_logic.id = "second-logic"; second_logic.name = "Second logic"; second_logic.rungs.clear(); project.controlLogics.push_back(second_logic); limits = {}; limits.maximumControlLogics = 1U; require(!project.validate(limits, &error), "runtime control-logic limits must be enforced"); project = makeValidProject(); limits = {}; limits.maximumRungsPerLogic = 0U; require(!project.validate(limits, &error), "runtime per-logic rung limits must be enforced"); } } // namespace int main() { try { // 每个测试函数独立覆盖一个领域边界,首个异常即终止测试进程 testRegisterAddressBoundaries(); testRegisterAddressParsing(); testRegisterRepositorySeparatesAreas(); testMultiWordCodecsAndBlockAccess(); testHmiControlRegistryCompleteness(); testMultiWordHmiBoundaries(); testHmiAppearancePropertyBoundaries(); testLogicNodeConfigurationBoundaries(); testEdgeAndCommentBoundaries(); testDataInstructionBoundaries(); testLadderLogicBoundaries(); testModelsValidateBindingsAndIdentifiers(); testMultiPageAndLogicDomainRules(); testQuantityBoundaries(); testRuntimeConfiguredProjectLimits(); testRuntimeStateBoundaries(); } catch (const std::exception &error) { std::cerr << "domain tests failed: " << error.what() << '\n'; return 1; } std::cout << "domain tests passed\n"; return 0; }