|
- #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 <algorithm>
- #include <cmath>
- #include <cstdint>
- #include <exception>
- #include <iostream>
- #include <limits>
- #include <set>
- #include <stdexcept>
- #include <string>
-
- 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<RegisterArea>(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<std::int16_t>(-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<std::int32_t, 4> int32_values = {
- std::numeric_limits<std::int32_t>::min(), -1, 0,
- std::numeric_limits<std::int32_t>::max()};
- for (const std::int32_t value : int32_values)
- {
- const std::array<std::int16_t, 2> words = Int32Codec::encode(value);
- require(Int32Codec::decode(words[0], words[1]) == value,
- "Int32 codec must preserve signed boundary values");
- }
- const std::array<std::int16_t, 2> int32_words =
- Int32Codec::encode(0x12345678);
- require(static_cast<std::uint16_t>(int32_words[0]) == 0x5678U
- && static_cast<std::uint16_t>(int32_words[1]) == 0x1234U,
- "Int32 codec must store the low 16-bit word at the lower D address");
-
- const std::array<float, 4> values = {1.0f, -2.5f, 0.1f, 0.0f};
- for (const float value : values)
- {
- const std::array<std::int16_t, 2> words = Float32Codec::encode(value);
- const std::optional<float> 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<std::int16_t>(0),
- static_cast<std::int16_t>(0x7f80)).has_value(),
- "Float32 codec must reject positive infinity");
- require(!Float32Codec::decode(
- static_cast<std::int16_t>(0),
- static_cast<std::int16_t>(0x7fc0)).has_value(),
- "Float32 codec must reject NaN");
- const std::array<std::int16_t, 2> float20 = Float32Codec::encode(20.0f);
- require(static_cast<std::uint16_t>(float20[0]) == 0x0000U
- && static_cast<std::uint16_t>(float20[1]) == 0x41a0U,
- "Float32 20.0 must match the Xinje low-word-first example");
-
- const std::array<double, 4> double_values = {1.0, -2.5, 0.1, 0.0};
- for (const double value : double_values)
- {
- const std::array<std::int16_t, 4> words = Float64Codec::encode(value);
- const std::optional<double> decoded = Float64Codec::decode(words);
- require(decoded.has_value() && *decoded == value,
- "Double codec must preserve representative finite values");
- }
- const std::array<std::int16_t, 4> double_one = Float64Codec::encode(1.0);
- require(static_cast<std::uint16_t>(double_one[0]) == 0x0000U
- && static_cast<std::uint16_t>(double_one[1]) == 0x0000U
- && static_cast<std::uint16_t>(double_one[2]) == 0x0000U
- && static_cast<std::uint16_t>(double_one[3]) == 0x3ff0U,
- "Double 1.0 must occupy four low-address-first D words");
- require(!Float64Codec::decode({
- static_cast<std::int16_t>(0), static_cast<std::int16_t>(0),
- static_cast<std::int16_t>(0), static_cast<std::int16_t>(0x7ff0)})
- .has_value()
- && !Float64Codec::decode({
- static_cast<std::int16_t>(0), static_cast<std::int16_t>(0),
- static_cast<std::int16_t>(0), static_cast<std::int16_t>(0x7ff8)})
- .has_value(),
- "Double codec must reject infinity and NaN bit patterns");
- require(encodeRegisterNumericValue(
- RegisterDataType::Int32,
- static_cast<double>(std::numeric_limits<std::int32_t>::min()))
- .has_value()
- && encodeRegisterNumericValue(
- RegisterDataType::Int32,
- static_cast<double>(std::numeric_limits<std::int32_t>::max()))
- .has_value()
- && !encodeRegisterNumericValue(
- RegisterDataType::Int32, 1.5).has_value()
- && !encodeRegisterNumericValue(
- RegisterDataType::Float64,
- std::numeric_limits<double>::infinity()).has_value()
- && !encodeRegisterNumericValue(
- RegisterDataType::Float64,
- std::numeric_limits<double>::quiet_NaN()).has_value(),
- "typed encoding must enforce Int32 integrality and finite Double values");
-
- VirtualRegisterRepository repository;
- const RegisterAddress d10{RegisterArea::D, 10};
- const auto float_words = Float32Codec::encode(-2.5f);
- require(repository.writeWords(
- d10, {float_words[0], float_words[1]}).succeeded,
- "virtual repository must write Float32 low and high words");
- const WordsReadResult pair = repository.readWords(d10, 2);
- 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.writeWords({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<std::int16_t>(
- 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<RegisterDataType>(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<const HmiControlDescriptor *> descriptors;
- std::set<std::string> storage_names;
- std::set<std::string> id_prefixes;
- const std::size_t control_type_count =
- static_cast<std::size_t>(HmiControlType::Count);
- for (std::size_t index = 0; index < control_type_count; ++index)
- {
- const HmiControlType type = static_cast<HmiControlType>(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<RegisterArea> 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::BitOrWord:
- {
- require(descriptor->bindingKind == HmiBindingKind::BitOrWord
- && !binding_area.has_value(),
- "flexible runtime controls must choose M or D from their config");
- 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<HmiControlType>(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 testStatusTextDomainRules()
- {
- HmiControl status;
- status.id = "status";
- status.type = HmiControlType::StatusText;
- status.text = "Status";
- status.binding = RegisterAddress{RegisterArea::M, 0};
- status.statusText = HmiStatusBitTextConfig{"Stopped", "Running"};
- require(status.validate(), "M status text with OFF and ON labels must be valid");
-
- status.binding = RegisterAddress{RegisterArea::D, 0};
- require(!status.validate(), "M status text configuration must reject a D binding");
-
- status.binding = RegisterAddress{RegisterArea::D, 20};
- status.dataType = RegisterDataType::Float32;
- status.statusText = HmiStatusWordTextConfig{{
- {std::nullopt, 30.0, "Low"},
- {30.0, 80.0, "Normal"},
- {80.0, std::nullopt, "High"}}};
- require(status.validate(),
- "D status text must accept complete adjacent half-open ranges");
-
- status.binding.reset();
- require(status.validate(),
- "an unbound D status text draft must keep its selected data type");
- status.binding = RegisterAddress{RegisterArea::D, 20};
-
- const auto consecutive_ranges = [](std::size_t count)
- {
- std::vector<HmiStatusValueRange> result;
- result.reserve(count);
- for (std::size_t index = 0; index < count; ++index)
- {
- result.push_back({
- index == 0U
- ? std::optional<double>{}
- : std::optional<double>{static_cast<double>(index)},
- index + 1U == count
- ? std::optional<double>{}
- : std::optional<double>{static_cast<double>(index + 1U)},
- "State"});
- }
- return result;
- };
- status.statusText = HmiStatusWordTextConfig{
- consecutive_ranges(ProjectLimits::kMaximumStatusTextRanges)};
- require(status.validate(), "a status text control must accept 16 ranges");
- status.statusText = HmiStatusWordTextConfig{
- consecutive_ranges(ProjectLimits::kMaximumStatusTextRanges + 1U)};
- require(!status.validate(), "a status text control must reject a 17th range");
- status.statusText = HmiStatusWordTextConfig{{
- {std::nullopt, 30.0, "Low"},
- {30.0, 80.0, "Normal"},
- {80.0, std::nullopt, "High"}}};
-
- auto &ranges = std::get<HmiStatusWordTextConfig>(*status.statusText).ranges;
- ranges[1].lowerBound = 31.0;
- require(!status.validate(), "D status text must reject range gaps");
- ranges[1].lowerBound = 29.0;
- require(!status.validate(), "D status text must reject overlapping ranges");
- ranges[1].lowerBound = 30.0;
- ranges.front().lowerBound = -100.0;
- require(!status.validate(), "D status text must start with an unlimited lower bound");
- ranges.front().lowerBound.reset();
- status.dataType = RegisterDataType::Int32;
- ranges[1].upperBound = 80.5;
- require(!status.validate(), "integer status text boundaries must be integers");
-
- Project project = makeValidProject();
- status.dataType = RegisterDataType::Float64;
- status.binding = RegisterAddress{RegisterArea::D, 100};
- ranges[1].upperBound = 80.0;
- project.hmiPages.front().controls.push_back(status);
- HmiControl overlapping;
- overlapping.id = "overlapping";
- overlapping.type = HmiControlType::NumericDisplay;
- overlapping.text = "Overlap";
- overlapping.binding = RegisterAddress{RegisterArea::D, 102};
- project.hmiPages.front().controls.push_back(overlapping);
- require(!project.validate(defaultProjectLimitSettings()),
- "D status text must participate in multi-word HMI overlap checks");
- }
-
- 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(defaultProjectLimitSettings()),
- "Float32 D3999 must be valid and occupy D3999~D4000");
-
- project.hmiPages.front().controls.back().binding =
- RegisterAddress{RegisterArea::D, 4000};
- require(!project.validate(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()), "Int32 D3999 must be valid");
- project.hmiPages.front().controls.back().binding =
- RegisterAddress{RegisterArea::D, 4000};
- require(!project.validate(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()), "Double D3996 must be a valid even start address");
- project.hmiPages.front().controls.back().binding =
- RegisterAddress{RegisterArea::D, 3997};
- require(!project.validate(defaultProjectLimitSettings()), "Double D3997 must be rejected");
- project.hmiPages.front().controls.back().binding =
- RegisterAddress{RegisterArea::D, 3995};
- require(!project.validate(defaultProjectLimitSettings()), "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<RegisterDataType>(99);
- project.hmiPages.front().controls.push_back(invalid_display);
- require(!project.validate(defaultProjectLimitSettings()),
- "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(defaultProjectLimitSettings()),
- "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(defaultProjectLimitSettings()),
- "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(defaultProjectLimitSettings()),
- "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(defaultProjectLimitSettings()), "valid HMI appearance properties must pass validation");
-
- button.properties[HmiAppearanceProperty::kTextColor] = "red";
- require(!project.validate(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()), "font sizes below the minimum must be rejected");
- font_control.properties[HmiAppearanceProperty::kFontSize] = "73";
- require(!project.validate(defaultProjectLimitSettings()), "font sizes above the maximum must be rejected");
- font_control.properties[HmiAppearanceProperty::kFontSize] = "large";
- require(!project.validate(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()), "font style flags must be true or false");
-
- project = makeValidProject();
- project.hmiPages.front().controls.front().properties["legacyColor"] = "green";
- require(project.validate(defaultProjectLimitSettings()), "unknown HMI extension properties must remain supported");
- }
-
- void testButtonEnableConditionBoundaries()
- {
- Project project = makeValidProject();
- HmiControl &button = project.hmiPages.front().controls.front();
- button.buttonEnableCondition = HmiButtonBitEnableCondition{
- RegisterAddress{RegisterArea::M, 5}, false};
- require(project.validate(defaultProjectLimitSettings()),
- "valid M button enable conditions must pass validation");
-
- button.buttonEnableCondition = HmiButtonWordEnableCondition{
- RegisterAddress{RegisterArea::D, 20},
- RegisterDataType::Float64,
- HmiButtonConditionOperator::LessThan,
- 12.5};
- require(project.validate(defaultProjectLimitSettings()),
- "valid D button enable conditions must pass validation");
-
- button.buttonEnableCondition = HmiButtonBitEnableCondition{
- RegisterAddress{RegisterArea::D, 5}, true};
- require(!project.validate(defaultProjectLimitSettings()),
- "M button conditions must reject D addresses");
-
- project = makeValidProject();
- HmiControl &d_button = project.hmiPages.front().controls.front();
- d_button.buttonEnableCondition = HmiButtonWordEnableCondition{
- RegisterAddress{RegisterArea::D, 20},
- RegisterDataType::Int16,
- HmiButtonConditionOperator::Equal,
- 1.5};
- require(!project.validate(defaultProjectLimitSettings()),
- "Int16 button conditions must reject fractional comparison values");
-
- d_button.buttonEnableCondition = HmiButtonWordEnableCondition{
- RegisterAddress{RegisterArea::D, 3997},
- RegisterDataType::Float64,
- HmiButtonConditionOperator::Equal,
- 1.0};
- require(!project.validate(defaultProjectLimitSettings()),
- "Float64 button conditions must reject overflowing start addresses");
-
- d_button.buttonEnableCondition = HmiButtonWordEnableCondition{
- RegisterAddress{RegisterArea::D, 20},
- RegisterDataType::Int16,
- static_cast<HmiButtonConditionOperator>(99),
- 1.0};
- require(!project.validate(defaultProjectLimitSettings()),
- "button conditions must reject unknown comparison operators");
-
- HmiControl label = d_button;
- label.type = HmiControlType::Label;
- label.binding.reset();
- require(!label.validate(),
- "non-button controls must reject button enable conditions");
- }
-
- 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<LogicNode>{contact} : std::nullopt});
- }
- rung.output = coil;
- logic.rungs.push_back(rung);
-
- Project project;
- project.metadata = {"sample-project", "Sample project", "4.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(defaultProjectLimitSettings()), "an unbound label must be a valid static control");
-
- project.hmiPages.front().controls.back().binding =
- RegisterAddress{RegisterArea::M, 10};
- require(!project.validate(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()) && project.validateForRunning(defaultProjectLimitSettings()),
- "a page jump must resolve its target by stable page id");
-
- project.hmiPages.front().controls.back().pageJump->targetPageId = "missing";
- require(!project.validate(defaultProjectLimitSettings()), "a page jump must reject a missing target page");
-
- project = makeValidProject();
- project.initialHmiPageId = "missing";
- require(!project.validate(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()),
- "a disabled draft logic must not block offline running");
-
- project.controlLogics.back().name = project.controlLogics.front().name;
- require(!project.validate(defaultProjectLimitSettings()), "control logic names must be unique");
-
- project = makeValidProject();
- project.hmiPages.front().controls.front().type =
- static_cast<HmiControlType>(99);
- project.hmiPages.front().controls.front().binding.reset();
- require(!project.validate(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()), "an HMI page count at the configured limit must be accepted");
- project.hmiPages.push_back({"page-over", "Page over", 800, 400, {}});
- require(
- !project.validate(defaultProjectLimitSettings()),
- "an HMI page count of "
- + std::to_string(ProjectLimits::kMaximumHmiPages + 1U)
- + " 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(defaultProjectLimitSettings()),
- "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(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()),
- "a page control count of "
- + std::to_string(ProjectLimits::kMaximumHmiControlsPerPage + 1U)
- + " 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(defaultProjectLimitSettings()),
- "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(defaultProjectLimitSettings()), "an HMI page size of 1600 by 800 must be accepted");
- project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth + 1;
- require(!project.validate(defaultProjectLimitSettings()), "an HMI page width of 1601 must be rejected");
- project.hmiPages.front().width = ProjectLimits::kMinimumHmiPageWidth - 1;
- require(!project.validate(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()), "an HMI page height of 199 must be rejected");
-
- project = makeValidProject();
- project.controlLogics.front().rungs.front().cells.pop_back();
- require(!project.validate(defaultProjectLimitSettings()),
- "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<std::int16_t>(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.validateStructure(), "a rung comment at the byte limit must be valid");
- comment_rung.comment.push_back('a');
- require(!comment_rung.validateStructure(), "a rung comment above the byte limit must fail");
- comment_rung.comment = "第一行\n第二行";
- require(!comment_rung.validateStructure(), "a multiline rung comment must be rejected");
- comment_rung.comment = "第一行\r第二行";
- require(!comment_rung.validateStructure(), "a rung comment containing CR must be rejected");
-
- ControlLogic commented_logic;
- commented_logic.id = "commented-logic";
- commented_logic.name = "Commented logic";
- LadderRung head;
- head.id = "head";
- head.name = "Head";
- head.comment = "Network comment";
- LadderRung branch;
- branch.id = "branch";
- branch.name = "Branch";
- branch.comment = "Hidden branch comment";
- commented_logic.rungs = {head, branch};
- commented_logic.verticalConnections = {
- {"comment-edge", "head", "branch", 0}};
- require(
- commented_logic.networkHeadIndex(1U) == 0U
- && !commented_logic.validateStructure(),
- "a connected branch row must not persist a hidden network comment");
- commented_logic.rungs[1].comment.clear();
- require(commented_logic.validateStructure(),
- "a connected network must be valid when only its head stores the comment");
-
- Project project = makeValidProject();
- project.registerComments = {
- {RegisterAddress{RegisterArea::M, 0}, "启动按钮"},
- {RegisterAddress{RegisterArea::M, 0}, "重复地址"}};
- require(!project.validate(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()) && logic.validateForRunning(defaultProjectLimitSettings()),
- "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(defaultProjectLimitSettings()) && !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(defaultProjectLimitSettings()),
- "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(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()), "the output slot must reject condition nodes");
-
- logic.rungs.front() = upper;
- logic.rungs.front().output.reset();
- require(logic.validate(defaultProjectLimitSettings()) && logic.validateForRunning(defaultProjectLimitSettings()),
- "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(defaultProjectLimitSettings()), "cell ids must be unique within a logic");
-
- logic.rungs.front() = upper;
- logic.verticalConnections.front().lowerRungId = "missing-rung";
- require(!logic.validate(defaultProjectLimitSettings()), "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(defaultProjectLimitSettings()),
- "one row boundary must not contain duplicate vertical edges");
- }
-
- void testModelsValidateBindingsAndIdentifiers()
- {
- // 聚合验证必须拒绝错误绑定、重复标识和越界控件
- Project project = makeValidProject();
- require(project.validate(defaultProjectLimitSettings()), "valid project model must pass validation");
-
- project.hmiPages.front().controls.front().binding =
- RegisterAddress{RegisterArea::D, 0};
- require(!project.validate(defaultProjectLimitSettings()), "button bound to D area must be rejected");
-
- project = makeValidProject();
- project.hmiPages.push_back(project.hmiPages.front());
- require(!project.validate(defaultProjectLimitSettings()), "duplicate HMI page id must be rejected");
-
- project = makeValidProject();
- project.hmiPages.front().controls.front().bounds.x = -1;
- require(!project.validate(defaultProjectLimitSettings()), "controls outside the page must be rejected");
-
- project = makeValidProject();
- project.hmiPages.front().controls.front().bounds.width = 801;
- require(!project.validate(defaultProjectLimitSettings()), "controls wider than the page must be rejected");
-
- project = makeValidProject();
- project.hmiPages.front().controls.front().properties.emplace("", "value");
- require(!project.validate(defaultProjectLimitSettings()), "empty HMI property names must be rejected");
-
- project = makeValidProject();
- project.hmiPages.front().controls.front().binding.reset();
- require(project.validate(defaultProjectLimitSettings()), "unbound HMI control must be accepted in a draft");
- require(!project.validateForRunning(defaultProjectLimitSettings()),
- "unbound HMI control must block runtime validation");
-
- project = makeValidProject();
- project.controlLogics.front().rungs.front().output->configured = false;
- require(project.validate(defaultProjectLimitSettings()), "unconfigured ladder node must be accepted in a draft");
- require(!project.validateForRunning(defaultProjectLimitSettings()),
- "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()
- {
- return TestSupport::runTestSuite("domain tests", {
- {"testRegisterAddressBoundaries", testRegisterAddressBoundaries},
- {"testRegisterAddressParsing", testRegisterAddressParsing},
- {"testRegisterRepositorySeparatesAreas", testRegisterRepositorySeparatesAreas},
- {"testMultiWordCodecsAndBlockAccess", testMultiWordCodecsAndBlockAccess},
- {"testHmiControlRegistryCompleteness", testHmiControlRegistryCompleteness},
- {"testStatusTextDomainRules", testStatusTextDomainRules},
- {"testMultiWordHmiBoundaries", testMultiWordHmiBoundaries},
- {"testHmiAppearancePropertyBoundaries", testHmiAppearancePropertyBoundaries},
- {"testButtonEnableConditionBoundaries", testButtonEnableConditionBoundaries},
- {"testLogicNodeConfigurationBoundaries", testLogicNodeConfigurationBoundaries},
- {"testEdgeAndCommentBoundaries", testEdgeAndCommentBoundaries},
- {"testDataInstructionBoundaries", testDataInstructionBoundaries},
- {"testLadderLogicBoundaries", testLadderLogicBoundaries},
- {"testModelsValidateBindingsAndIdentifiers", testModelsValidateBindingsAndIdentifiers},
- {"testMultiPageAndLogicDomainRules", testMultiPageAndLogicDomainRules},
- {"testQuantityBoundaries", testQuantityBoundaries},
- {"testRuntimeConfiguredProjectLimits", testRuntimeConfiguredProjectLimits},
- {"testRuntimeStateBoundaries", testRuntimeStateBoundaries},
- });
- }
|