#include "domain/control_logic_model.h" #include "domain/hmi_model.h" #include "domain/project_model.h" #include "domain/register_address.h" #include "domain/register_repository.h" #include "domain/runtime_state.h" #include #include #include #include #include namespace { void require(bool condition, const std::string &message) { if (!condition) { throw std::runtime_error(message); } } 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"); } 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"; rung.condition = ConditionExpression::fromNode(contact); 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 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 testTimerAndCommentBoundaries() { 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"); LogicNode timer_contact; timer_contact.id = "timer-contact"; timer_contact.config = TimerContactNodeConfig{ TimerAddress{4000}, ContactMode::NormallyOpen}; require(timer_contact.validate(), "T4000 must be a valid timer contact"); timer_contact.config = TimerContactNodeConfig{ TimerAddress{-1}, ContactMode::NormallyOpen}; require(!timer_contact.validate(), "a negative T address must be rejected"); LogicNode ton; ton.id = "ton"; ton.config = TonNodeConfig{TimerAddress{0}, TonNodeConfig::kMinimumPresetMs}; require(ton.validate(), "the minimum TON preset must be valid"); ton.config = TonNodeConfig{TimerAddress{0}, 0}; require(!ton.validate(), "a zero TON preset must be rejected"); ton.config = TonNodeConfig{ TimerAddress{0}, TonNodeConfig::kMaximumPresetMs + 1}; require(!ton.validate(), "an oversized TON preset must be rejected"); RegisterComment comment{RegisterAddress{RegisterArea::M, 0}, "启动按钮"}; require(comment.validate(), "a nonblank register comment must be valid"); comment.text = " \t"; require(!comment.validate(), "a blank register comment 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"); } LadderRung makeTimerRung( const std::string &rung_id, const std::string &condition_id, const std::string &output_id, int timer_index, int preset_ms) { LogicNode condition; condition.id = condition_id; condition.config = ContactNodeConfig{ RegisterAddress{RegisterArea::M, timer_index}, ContactMode::NormallyOpen}; LogicNode output; output.id = output_id; output.config = TonNodeConfig{TimerAddress{timer_index}, preset_ms}; LadderRung rung; rung.id = rung_id; rung.name = rung_id; rung.condition = ConditionExpression::fromNode(condition); rung.output = output; return rung; } void testTimerReferencesForRunning() { ControlLogic valid; valid.id = "timer-valid"; valid.name = "Timer valid"; valid.rungs.push_back(makeTimerRung("rung-0", "input-0", "ton-0", 0, 100)); require(validateTimerReferencesForRunning({valid}), "a timer contact-free TON network must pass timer reference validation"); ControlLogic duplicate = valid; duplicate.id = "timer-duplicate"; duplicate.name = "Timer duplicate"; duplicate.rungs.front().output->id = "ton-duplicate"; require(!validateTimerReferencesForRunning({valid, duplicate}), "the same T must not have two enabled TON drivers"); ControlLogic missing; missing.id = "timer-missing"; missing.name = "Timer missing"; LogicNode contact; contact.id = "missing-contact"; contact.config = TimerContactNodeConfig{ TimerAddress{7}, ContactMode::NormallyOpen}; LogicNode output; output.id = "missing-coil"; output.config = CoilNodeConfig{ RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal}; LadderRung missing_rung; missing_rung.id = "missing-rung"; missing_rung.name = "Missing rung"; missing_rung.condition = ConditionExpression::fromNode(contact); missing_rung.output = output; missing.rungs.push_back(missing_rung); require(!validateTimerReferencesForRunning({missing}), "a T contact without an enabled TON driver must be rejected"); missing.enabled = false; require(validateTimerReferencesForRunning({missing}), "disabled timer drafts must not block runtime timer validation"); } LadderRung makeCounterRung( const std::string &rung_id, const std::string &output_id, int counter_index) { LogicNode condition; condition.id = rung_id + "-input"; condition.config = ContactNodeConfig{ RegisterAddress{RegisterArea::M, counter_index}, ContactMode::NormallyOpen}; LogicNode output; output.id = output_id; output.config = CounterNodeConfig{ CounterAddress{counter_index}, CounterMode::Up, RegisterAddress{RegisterArea::D, counter_index}, WordOperand{ WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, 10}, RegisterAddress{RegisterArea::M, counter_index + 1}}; LadderRung rung; rung.id = rung_id; rung.name = rung_id; rung.condition = ConditionExpression::fromNode(condition); rung.output = output; return rung; } void testCounterAndDataInstructionBoundaries() { require(CounterAddress{0}.isValid() && CounterAddress{4000}.isValid(), "C0 and C4000 must be valid counter resources"); require(!CounterAddress{-1}.isValid() && !CounterAddress{4001}.isValid(), "counter resources outside 0 through 4000 must be rejected"); LogicNode counter; counter.id = "counter"; counter.config = CounterNodeConfig{ CounterAddress{0}, CounterMode::Up, RegisterAddress{RegisterArea::D, 10}, WordOperand{ WordOperandKind::Register, RegisterAddress{RegisterArea::D, 11}, 0}, RegisterAddress{RegisterArea::M, 12}}; require(counter.validate(), "a counter with C identity and external M/D addresses must be valid"); CounterNodeConfig invalid_counter = std::get(counter.config); invalid_counter.currentValueAddress = RegisterAddress{RegisterArea::M, 10}; counter.config = invalid_counter; require(!counter.validate(), "counter CV must reject M addresses"); 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"); ControlLogic valid; valid.id = "counter-valid"; valid.name = "Counter valid"; valid.rungs.push_back(makeCounterRung("counter-rung", "ctu-0", 0)); require(validateCounterReferencesForRunning({valid}), "a counter output without contacts must pass reference validation"); ControlLogic duplicate = valid; duplicate.id = "counter-duplicate"; duplicate.name = "Counter duplicate"; duplicate.rungs.front().output->id = "ctu-duplicate"; require(!validateCounterReferencesForRunning({valid, duplicate}), "the same C resource must not have multiple enabled drivers"); ControlLogic missing; missing.id = "counter-missing"; missing.name = "Counter missing"; LogicNode missing_contact; missing_contact.id = "missing-counter-contact"; missing_contact.config = CounterContactNodeConfig{ CounterAddress{7}, ContactMode::NormallyOpen}; LogicNode output; output.id = "missing-counter-coil"; output.config = CoilNodeConfig{ RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal}; LadderRung missing_rung; missing_rung.id = "missing-counter-rung"; missing_rung.name = "Missing counter rung"; missing_rung.condition = ConditionExpression::fromNode(missing_contact); missing_rung.output = output; missing.rungs.push_back(missing_rung); require(!validateCounterReferencesForRunning({missing}), "a C contact without an enabled counter driver must be rejected"); } 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}; 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"); 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(), "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().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(), "nested series and parallel expressions 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(); testLogicNodeConfigurationBoundaries(); testTimerAndCommentBoundaries(); testTimerReferencesForRunning(); testCounterAndDataInstructionBoundaries(); testLadderLogicBoundaries(); testModelsValidateBindingsAndIdentifiers(); testMultiPageAndLogicDomainRules(); testRuntimeStateBoundaries(); } catch (const std::exception &error) { std::cerr << "domain tests failed: " << error.what() << '\n'; return 1; } std::cout << "domain tests passed\n"; return 0; }