|
- #include "domain/project_storage.h"
- #include "domain/hmi_model.h"
- #include "infrastructure/json_project_storage.h"
- #include "services/register_comment_service.h"
- #include "services/project_service.h"
- #include "domain/project_limits.h"
-
- #include <QFile>
- #include <QJsonArray>
- #include <QJsonDocument>
- #include <QJsonObject>
- #include <QTemporaryDir>
-
- #include <exception>
- #include <iostream>
- #include <stdexcept>
- #include <string>
- #include <utility>
-
- namespace {
-
- void require(bool condition, const std::string &message)
- {
- if (!condition)
- {
- throw std::runtime_error(message);
- }
- }
-
- Project makeExampleProject()
- {
- // 构造覆盖 HMI 控件和梯形图串并联结构的完整 JSON 往返样本
- HmiControl start_button;
- start_button.id = "start-button";
- start_button.type = HmiControlType::Button;
- start_button.bounds = {10, 20, 120, 48};
- start_button.text = "Start";
- start_button.binding = RegisterAddress{RegisterArea::M, 0};
- start_button.buttonOperation = HmiButtonOperation::SetOn;
- start_button.properties.emplace("color", "green");
- start_button.properties.emplace(HmiAppearanceProperty::kTextColor, "#E53935");
- start_button.properties.emplace(HmiAppearanceProperty::kFontSize, "18");
- start_button.properties.emplace(HmiAppearanceProperty::kFontBold, "true");
- start_button.properties.emplace(HmiAppearanceProperty::kFontItalic, "false");
-
- HmiControl running_indicator;
- running_indicator.id = "running-indicator";
- running_indicator.type = HmiControlType::Indicator;
- running_indicator.bounds = {150, 20, 64, 64};
- running_indicator.text = "Running";
- running_indicator.binding = RegisterAddress{RegisterArea::M, 1};
- running_indicator.properties.emplace("activeColor", "#24a148");
-
- HmiControl temperature_display;
- temperature_display.id = "temperature-display";
- temperature_display.type = HmiControlType::NumericDisplay;
- temperature_display.bounds = {10, 90, 120, 40};
- temperature_display.text = "Temperature";
- temperature_display.binding = RegisterAddress{RegisterArea::D, 2};
- temperature_display.properties.emplace("format", "decimal");
-
- HmiControl target_input;
- target_input.id = "target-input";
- target_input.type = HmiControlType::NumericInput;
- target_input.bounds = {150, 90, 120, 40};
- target_input.text = "Target";
- target_input.binding = RegisterAddress{RegisterArea::D, 3};
- target_input.properties.emplace("minimum", "-100");
-
- HmiControl title_label;
- title_label.id = "title-label";
- title_label.type = HmiControlType::Label;
- title_label.bounds = {10, 145, 180, 32};
- title_label.text = "Production line";
-
- HmiControl settings_jump;
- settings_jump.id = "settings-jump";
- settings_jump.type = HmiControlType::PageJump;
- settings_jump.bounds = {210, 145, 120, 40};
- settings_jump.text = "Settings";
- settings_jump.pageJump = HmiPageJumpConfig{"settings-page"};
-
- HmiControl alarm_list;
- alarm_list.id = "alarm-list";
- alarm_list.type = HmiControlType::AlarmList;
- alarm_list.bounds = {10, 200, 360, 180};
- alarm_list.text = "Alarms";
-
- HmiControl progress_bar;
- progress_bar.id = "progress-bar";
- progress_bar.type = HmiControlType::ProgressBar;
- progress_bar.bounds = {400, 20, 240, 36};
- progress_bar.text = "Completion";
- progress_bar.binding = RegisterAddress{RegisterArea::D, 4};
- progress_bar.progressBar = HmiProgressBarConfig{-20, 80, true};
-
- HmiPage page;
- page.id = "main-page";
- page.name = "Main";
- page.controls.push_back(start_button);
- page.controls.push_back(running_indicator);
- page.controls.push_back(temperature_display);
- page.controls.push_back(target_input);
- page.controls.push_back(title_label);
- page.controls.push_back(settings_jump);
- page.controls.push_back(alarm_list);
- page.controls.push_back(progress_bar);
-
- HmiPage settings_page;
- settings_page.id = "settings-page";
- settings_page.name = "Settings";
-
- LogicNode contact;
- contact.id = "start-contact";
- contact.config = ContactNodeConfig{
- RegisterAddress{RegisterArea::M, 0},
- ContactMode::NormallyOpen};
-
- LogicNode compare;
- compare.id = "temperature-check";
- compare.config = CompareNodeConfig{
- RegisterAddress{RegisterArea::D, 2},
- ComparisonOperator::GreaterThanOrEqual,
- static_cast<std::int16_t>(100)};
-
- LogicNode hold_contact;
- hold_contact.id = "hold-contact";
- hold_contact.config = ContactNodeConfig{
- RegisterAddress{RegisterArea::M, 1},
- ContactMode::NormallyOpen};
-
- LogicNode coil;
- coil.id = "run-coil";
- coil.config = CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 1},
- CoilMode::Set};
-
- ControlLogic logic;
- logic.id = "start-logic";
- logic.name = "Start logic";
- logic.enabled = false;
- LadderRung rung;
- rung.id = "rung-1";
- rung.name = "Network 1";
- rung.comment = "启动条件与温度检查";
- ConditionExpression parallel;
- parallel.id = "parallel-start";
- parallel.kind = ConditionExpressionKind::Parallel;
- parallel.children = {
- ConditionExpression::fromNode(contact),
- ConditionExpression::fromNode(hold_contact)};
- ConditionExpression series;
- series.id = "series-root";
- series.kind = ConditionExpressionKind::Series;
- series.children = {
- std::move(parallel),
- ConditionExpression::fromWire("start-wire", 2),
- ConditionExpression::fromNode(compare)};
- rung.condition = std::move(series);
- rung.output = coil;
- logic.rungs.push_back(rung);
-
- LogicNode rising_edge;
- rising_edge.id = "rising-edge";
- rising_edge.config = EdgeContactNodeConfig{
- RegisterAddress{RegisterArea::M, 4}, EdgeMode::Rising};
- LogicNode ton_output;
- ton_output.id = "ton-output";
- ton_output.config = TonNodeConfig{TimerAddress{7}, 2500};
- LadderRung ton_rung;
- ton_rung.id = "ton-rung";
- ton_rung.name = "TON network";
- ton_rung.comment = "延时启动";
- ton_rung.condition = ConditionExpression::fromNode(rising_edge);
- ton_rung.output = ton_output;
- logic.rungs.push_back(ton_rung);
-
- LogicNode falling_edge;
- falling_edge.id = "falling-edge";
- falling_edge.config = EdgeContactNodeConfig{
- RegisterAddress{RegisterArea::M, 5}, EdgeMode::Falling};
- LogicNode timer_contact;
- timer_contact.id = "timer-contact";
- timer_contact.config = TimerContactNodeConfig{
- TimerAddress{7}, ContactMode::NormallyOpen};
- LogicNode timer_coil;
- timer_coil.id = "timer-coil";
- timer_coil.config = CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 6}, CoilMode::Normal};
- LadderRung timer_rung;
- timer_rung.id = "timer-rung";
- timer_rung.name = "Timer feedback";
- timer_rung.comment = "定时器完成反馈";
- ConditionExpression timer_series;
- timer_series.id = "timer-series";
- timer_series.kind = ConditionExpressionKind::Series;
- timer_series.children = {
- ConditionExpression::fromNode(falling_edge),
- ConditionExpression::fromNode(timer_contact)};
- timer_rung.condition = std::move(timer_series);
- timer_rung.output = timer_coil;
- logic.rungs.push_back(timer_rung);
-
- LogicNode counter_input;
- counter_input.id = "counter-input";
- counter_input.config = ContactNodeConfig{
- RegisterAddress{RegisterArea::M, 7}, ContactMode::NormallyOpen};
- LogicNode counter_output;
- counter_output.id = "counter-output";
- counter_output.config = CounterNodeConfig{
- CounterAddress{3},
- CounterMode::Up,
- RegisterAddress{RegisterArea::D, 10},
- WordOperand{
- WordOperandKind::Register,
- RegisterAddress{RegisterArea::D, 11},
- 0},
- RegisterAddress{RegisterArea::M, 8}};
- LadderRung counter_rung;
- counter_rung.id = "counter-rung";
- counter_rung.name = "Counter network";
- counter_rung.comment = "计数器上升沿计数";
- counter_rung.condition = ConditionExpression::fromNode(counter_input);
- counter_rung.output = counter_output;
- logic.rungs.push_back(counter_rung);
-
- LogicNode counter_contact;
- counter_contact.id = "counter-contact";
- counter_contact.config = CounterContactNodeConfig{
- CounterAddress{3}, ContactMode::NormallyOpen};
- LogicNode counter_coil;
- counter_coil.id = "counter-coil";
- counter_coil.config = CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 9}, CoilMode::Normal};
- LadderRung counter_feedback_rung;
- counter_feedback_rung.id = "counter-feedback-rung";
- counter_feedback_rung.name = "Counter feedback";
- counter_feedback_rung.condition = ConditionExpression::fromNode(counter_contact);
- counter_feedback_rung.output = counter_coil;
- logic.rungs.push_back(counter_feedback_rung);
-
- const auto addDataRung = [&logic](
- const std::string &rung_id,
- const std::string &input_id,
- int input_address,
- LogicNode output)
- {
- LogicNode input;
- input.id = input_id;
- input.config = ContactNodeConfig{
- RegisterAddress{RegisterArea::M, input_address},
- ContactMode::NormallyOpen};
- LadderRung data_rung;
- data_rung.id = rung_id;
- data_rung.name = rung_id;
- data_rung.condition = ConditionExpression::fromNode(input);
- data_rung.output = std::move(output);
- logic.rungs.push_back(std::move(data_rung));
- };
- addDataRung(
- "move-rung",
- "move-input",
- 20,
- LogicNode{
- "move-output",
- MoveNodeConfig{
- WordOperand{
- WordOperandKind::Constant,
- RegisterAddress{RegisterArea::D, 0},
- 25},
- RegisterAddress{RegisterArea::D, 20}},
- true});
- addDataRung(
- "add-rung",
- "add-input",
- 21,
- LogicNode{
- "add-output",
- ArithmeticNodeConfig{
- ArithmeticOperation::Add,
- WordOperand{
- WordOperandKind::Register,
- RegisterAddress{RegisterArea::D, 20},
- 0},
- WordOperand{
- WordOperandKind::Constant,
- RegisterAddress{RegisterArea::D, 0},
- 1},
- RegisterAddress{RegisterArea::D, 21}},
- true});
- addDataRung(
- "sub-rung",
- "sub-input",
- 22,
- LogicNode{
- "sub-output",
- ArithmeticNodeConfig{
- ArithmeticOperation::Subtract,
- WordOperand{
- WordOperandKind::Register,
- RegisterAddress{RegisterArea::D, 21},
- 0},
- WordOperand{
- WordOperandKind::Constant,
- RegisterAddress{RegisterArea::D, 0},
- 1},
- RegisterAddress{RegisterArea::D, 22}},
- true});
-
- Project project;
- project.metadata = {"example-project", "Example project", "1.0"};
- project.hmiPages.push_back(page);
- project.hmiPages.push_back(settings_page);
- project.initialHmiPageId = page.id;
- project.alarmDefinitions.push_back(
- {"alarm-emergency",
- RegisterAddress{RegisterArea::M, 10},
- AlarmCondition::MOn,
- 0,
- "Emergency stop"});
- project.alarmDefinitions.push_back(
- {"alarm-temperature",
- RegisterAddress{RegisterArea::D, 2},
- AlarmCondition::DHigh,
- 80,
- "Temperature high"});
- project.registerComments = {
- {RegisterAddress{RegisterArea::M, 0}, "启动按钮"},
- {RegisterAddress{RegisterArea::D, 2}, "当前温度"}};
- project.controlLogics.push_back(logic);
- ControlLogic draft_logic;
- draft_logic.id = "draft-logic";
- draft_logic.name = "Draft logic";
- draft_logic.enabled = false;
- draft_logic.rungs.push_back(
- {"rung-1", "Draft network", {}, std::nullopt, std::nullopt});
- project.controlLogics.push_back(draft_logic);
- return project;
- }
-
- void writeText(const QString &path, const QByteArray &content)
- {
- // 直接写入故障样本文件,以验证加载失败时的保护行为
- QFile file(path);
- require(file.open(QIODevice::WriteOnly), "test file must be writable");
- require(file.write(content) == content.size(), "test file must be written completely");
- }
-
- QByteArray readBytes(const QString &path)
- {
- QFile file(path);
- require(file.open(QIODevice::ReadOnly), "saved project must be readable");
- return file.readAll();
- }
-
- void testEmptyProjectRoundTrip()
- {
- // 空工程是合法工程,保存再加载后不应凭空产生页面或逻辑
- QTemporaryDir directory;
- require(directory.isValid(), "temporary directory must be valid");
-
- JsonProjectStorage storage;
- ProjectService service(storage);
- require(service.createNewProject("Empty project").succeeded,
- "empty project creation must succeed");
-
- const QString path = directory.filePath("empty.json");
- require(service.saveAs(path.toStdString()).succeeded,
- "empty project save must succeed");
- require(!service.isModified(), "saved project must not be marked modified");
-
- require(service.load(path.toStdString()).succeeded,
- "empty project load must succeed");
- require(service.project().metadata.name == "Empty project",
- "empty project name must survive round trip");
- require(service.project().hmiPages.empty(), "empty project must have no HMI pages");
- require(service.project().controlLogics.empty(),
- "empty project must have no control logics");
- require(service.project().alarmDefinitions.empty(),
- "empty project must have no alarm definitions");
- }
-
- void testUnconditionalOutputRoundTrip()
- {
- QTemporaryDir directory;
- require(directory.isValid(), "temporary directory must be valid");
-
- JsonProjectStorage storage;
- ProjectService service(storage);
- require(service.createNewProject("Unconditional output").succeeded,
- "unconditional output project creation must succeed");
- LadderRung rung;
- rung.id = "unconditional-rung";
- rung.name = "Unconditional rung";
- LogicNode coil;
- coil.id = "unconditional-coil";
- coil.config = CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal};
- rung.output = coil;
- service.editProject().controlLogics.push_back(
- ControlLogic{"logic-1", "Logic 1", {rung}, true});
- require(service.project().validateForRunning(),
- "an output-only project must be runnable before saving");
-
- const QString path = directory.filePath("unconditional-output.json");
- require(service.saveAs(path.toStdString()).succeeded,
- "an output-only project must save successfully");
- require(service.load(path.toStdString()).succeeded,
- "an output-only project must load successfully");
- const LadderRung &loaded = service.project().controlLogics.front().rungs.front();
- require(!loaded.condition.has_value() && loaded.output.has_value()
- && loaded.validateForRunning(),
- "JSON round trip must preserve unconditional output semantics");
- }
-
- void testExampleProjectRoundTrip()
- {
- // 验证各层嵌套字段往返后保持不变且序列化结果稳定
- QTemporaryDir directory;
- require(directory.isValid(), "temporary directory must be valid");
-
- JsonProjectStorage storage;
- ProjectService service(storage);
- service.editProject() = makeExampleProject();
-
- const QString first_path = directory.filePath("example.json");
- const QString second_path = directory.filePath("example-copy.json");
- const QString invalid_operation_path = directory.filePath("invalid-operation.json");
- const QString missing_initial_path = directory.filePath("missing-initial.json");
- const QString missing_target_path = directory.filePath("missing-target.json");
- const QString missing_progress_range_path = directory.filePath(
- "missing-progress-range.json");
- const QString missing_alarms_path = directory.filePath("missing-alarms.json");
- const QString missing_register_comments_path = directory.filePath(
- "missing-register-comments.json");
- const QString missing_rung_comment_path = directory.filePath(
- "missing-rung-comment.json");
- const QString multiline_register_comment_path = directory.filePath(
- "multiline-register-comment.json");
- const QString multiline_rung_comment_path = directory.filePath(
- "multiline-rung-comment.json");
- require(service.saveAs(first_path.toStdString()).succeeded,
- "example project save must succeed");
- const QByteArray saved_json = readBytes(first_path);
- require(saved_json.contains("\"rungs\"")
- && saved_json.contains("\"condition\"")
- && saved_json.contains("\"children\"")
- && saved_json.contains("\"output\""),
- "saved project must use structured ladder expressions");
- require(!saved_json.contains("\"dataPoints\""),
- "saved project must not contain the removed data point model");
- require(saved_json.contains("\"formatVersion\": \"1.0\"")
- && saved_json.contains("\"buttonOperation\": \"setOn\"")
- && saved_json.contains("\"initialHmiPageId\": \"main-page\"")
- && saved_json.contains("\"targetPageId\": \"settings-page\"")
- && saved_json.contains("\"alarmDefinitions\"")
- && saved_json.contains("\"registerComments\"")
- && saved_json.contains("\"text\": \"启动按钮\"")
- && saved_json.contains("\"type\": \"edgeContact\"")
- && saved_json.contains("\"type\": \"timerContact\"")
- && saved_json.contains("\"type\": \"ton\"")
- && saved_json.contains("\"type\": \"counterContact\"")
- && saved_json.contains("\"type\": \"counter\"")
- && saved_json.contains("\"type\": \"move\"")
- && saved_json.contains("\"type\": \"arithmetic\"")
- && saved_json.contains("\"operation\": \"add\"")
- && saved_json.contains("\"operation\": \"subtract\"")
- && saved_json.contains("\"comment\": \"启动条件与温度检查\"")
- && saved_json.contains("\"type\": \"alarmList\"")
- && saved_json.contains("\"type\": \"progressBar\"")
- && saved_json.contains("\"minimumValue\": -20")
- && saved_json.contains("\"maximumValue\": 80")
- && saved_json.contains("\"showValue\": true"),
- "version 1.0 projects must persist pages and alarm definitions");
- require(!saved_json.contains("\"stages\"")
- && !saved_json.contains("\"branches\""),
- "current project format must not contain the removed stage model");
- require(!saved_json.contains("\"position\"")
- && !saved_json.contains("\"connections\""),
- "saved ladder logic must not contain free-graph fields");
- require(service.load(first_path.toStdString()).succeeded,
- "example project load must succeed");
-
- const Project &project = service.project();
- require(project.metadata.id == "example-project", "project id must survive round trip");
- require(project.hmiPages.size() == 2, "HMI page count must survive round trip");
- require(project.initialHmiPageId == "main-page",
- "the initial HMI page id must survive round trip");
- require(project.hmiPages.front().controls.size() == 8,
- "register, navigation, AlarmList and ProgressBar controls must survive round trip");
- require(project.hmiPages.front().controls.front().binding->area()
- == RegisterArea::M,
- "HMI M binding must survive round trip");
- require(project.hmiPages.front().controls.front().buttonOperation
- == HmiButtonOperation::SetOn,
- "HMI button operation must survive round trip");
- require(project.hmiPages.front().controls.front().properties.at("color") == "green",
- "HMI properties must survive round trip");
- require(project.hmiPages.front().controls.front().properties.at(
- HmiAppearanceProperty::kTextColor) == "#E53935"
- && project.hmiPages.front().controls.front().properties.at(
- HmiAppearanceProperty::kFontSize) == "18"
- && project.hmiPages.front().controls.front().properties.at(
- HmiAppearanceProperty::kFontBold) == "true"
- && project.hmiPages.front().controls.front().properties.at(
- HmiAppearanceProperty::kFontItalic) == "false",
- "HMI appearance properties must survive round trip");
- require(project.hmiPages.front().controls.at(1).type == HmiControlType::Indicator,
- "indicator control type must survive round trip");
- require(project.hmiPages.front().controls.at(2).binding->area() == RegisterArea::D,
- "numeric display D binding must survive round trip");
- require(project.hmiPages.front().controls.at(3).bounds.x == 150,
- "numeric input bounds must survive round trip");
- require(project.hmiPages.front().controls.at(4).type == HmiControlType::Label
- && project.hmiPages.front().controls.at(5).pageJump->targetPageId
- == "settings-page",
- "Label and PageJump typed data must survive round trip");
- require(project.hmiPages.front().controls.at(6).type
- == HmiControlType::AlarmList,
- "AlarmList control type must survive round trip");
- require(project.hmiPages.front().controls.at(7).type
- == HmiControlType::ProgressBar
- && project.hmiPages.front().controls.at(7).progressBar.has_value()
- && project.hmiPages.front().controls.at(7).progressBar->minimumValue == -20
- && project.hmiPages.front().controls.at(7).progressBar->maximumValue == 80
- && project.hmiPages.front().controls.at(7).progressBar->showValue,
- "ProgressBar configuration must survive round trip");
- require(project.alarmDefinitions.size() == 2
- && project.alarmDefinitions.front().condition
- == AlarmCondition::MOn
- && project.alarmDefinitions.at(1).threshold == 80,
- "M and D alarm definitions must survive round trip");
- require(project.registerComments.size() == 2
- && project.registerComments.front().address.area() == RegisterArea::M
- && project.registerComments.front().text == "启动按钮"
- && project.registerComments.at(1).address.area() == RegisterArea::D,
- "M/D register comments must survive round trip");
- require(project.controlLogics.size() == 2,
- "control logic count must survive round trip");
- require(!project.controlLogics.front().enabled,
- "control logic enabled state must survive round trip");
- const LadderRung &rung = project.controlLogics.front().rungs.front();
- require(rung.comment == "启动条件与温度检查",
- "rung comments must survive round trip");
- require(rung.condition.has_value()
- && rung.condition->kind == ConditionExpressionKind::Series,
- "series root expression must survive round trip");
- require(rung.condition->children.front().kind
- == ConditionExpressionKind::Parallel
- && rung.condition->children.front().children.size() == 2U,
- "parallel expression branches must survive round trip");
- require(rung.condition->children.at(1).kind == ConditionExpressionKind::Wire
- && rung.condition->children.at(1).wire->columnSpan == 2,
- "horizontal wire span must survive JSON round trip");
- require(rung.output.has_value(),
- "ladder output must survive round trip");
-
- const LadderRung &ton_rung = project.controlLogics.front().rungs.at(1);
- require(ton_rung.output.has_value()
- && std::holds_alternative<TonNodeConfig>(ton_rung.output->config)
- && std::get<TonNodeConfig>(ton_rung.output->config).address
- == TimerAddress{7}
- && std::get<TonNodeConfig>(ton_rung.output->config).presetMs == 2500,
- "TON timer address and preset must survive round trip");
- require(std::holds_alternative<EdgeContactNodeConfig>(
- ton_rung.condition->node->config)
- && std::get<EdgeContactNodeConfig>(ton_rung.condition->node->config).mode
- == EdgeMode::Rising,
- "rising edge configuration must survive round trip");
- const LadderRung &timer_rung = project.controlLogics.front().rungs.at(2);
- require(timer_rung.comment == "定时器完成反馈"
- && std::holds_alternative<TimerContactNodeConfig>(
- timer_rung.condition->children.at(1).node->config)
- && std::get<TimerContactNodeConfig>(
- timer_rung.condition->children.at(1).node->config).address
- == TimerAddress{7}
- && std::holds_alternative<EdgeContactNodeConfig>(
- timer_rung.condition->children.front().node->config)
- && std::get<EdgeContactNodeConfig>(
- timer_rung.condition->children.front().node->config).mode
- == EdgeMode::Falling,
- "falling edge and T contact configurations must survive round trip");
-
- const LadderRung &counter_rung = project.controlLogics.front().rungs.at(3);
- const auto &counter = std::get<CounterNodeConfig>(counter_rung.output->config);
- require(counter.address == CounterAddress{3}
- && counter.currentValueAddress == RegisterAddress{RegisterArea::D, 10}
- && counter.preset.kind == WordOperandKind::Register
- && counter.preset.address == RegisterAddress{RegisterArea::D, 11}
- && counter.resetAddress == RegisterAddress{RegisterArea::M, 8},
- "counter resource and external M/D addresses must survive round trip");
- const LadderRung &counter_feedback = project.controlLogics.front().rungs.at(4);
- require(std::holds_alternative<CounterContactNodeConfig>(
- counter_feedback.condition->node->config)
- && std::get<CounterContactNodeConfig>(
- counter_feedback.condition->node->config).address
- == CounterAddress{3},
- "counter contacts must survive round trip");
- require(std::get<MoveNodeConfig>(
- project.controlLogics.front().rungs.at(5).output->config)
- .source.constant == 25,
- "MOVE operands must survive round trip");
- require(std::get<ArithmeticNodeConfig>(
- project.controlLogics.front().rungs.at(6).output->config)
- .operation == ArithmeticOperation::Add
- && std::get<ArithmeticNodeConfig>(
- project.controlLogics.front().rungs.at(7).output->config)
- .operation == ArithmeticOperation::Subtract,
- "ADD and SUB operations must survive round trip");
-
- const auto &compare = std::get<CompareNodeConfig>(
- rung.condition->children.at(2).node->config);
- require(compare.address.index() == 2 && compare.value == 100,
- "comparison configuration must survive round trip");
- require(service.saveAs(second_path.toStdString()).succeeded,
- "save as must succeed after load");
- require(readBytes(first_path) == readBytes(second_path),
- "save and save as must produce stable JSON");
-
- QByteArray invalid_operation = saved_json;
- invalid_operation.replace(
- "\"buttonOperation\": \"setOn\"",
- "\"buttonOperation\": \"unsupported\"");
- writeText(invalid_operation_path, invalid_operation);
- require(!service.load(invalid_operation_path.toStdString()).succeeded,
- "unsupported HMI button operations must be rejected");
-
- QJsonObject missing_initial = QJsonDocument::fromJson(saved_json).object();
- missing_initial.remove(QStringLiteral("initialHmiPageId"));
- writeText(
- missing_initial_path,
- QJsonDocument(missing_initial).toJson(QJsonDocument::Compact));
- ProjectOperationResult missing_result = service.load(
- missing_initial_path.toStdString());
- require(!missing_result.succeeded
- && missing_result.storageError == ProjectStorageError::MissingField,
- "the 1.0 schema must require initialHmiPageId without migration defaults");
-
- QJsonObject missing_alarms = QJsonDocument::fromJson(saved_json).object();
- missing_alarms.remove(QStringLiteral("alarmDefinitions"));
- writeText(
- missing_alarms_path,
- QJsonDocument(missing_alarms).toJson(QJsonDocument::Compact));
- missing_result = service.load(missing_alarms_path.toStdString());
- require(!missing_result.succeeded
- && missing_result.storageError == ProjectStorageError::MissingField,
- "the 1.0 schema must require alarmDefinitions without migration defaults");
-
- QJsonObject missing_register_comments = QJsonDocument::fromJson(saved_json).object();
- missing_register_comments.remove(QStringLiteral("registerComments"));
- writeText(
- missing_register_comments_path,
- QJsonDocument(missing_register_comments).toJson(QJsonDocument::Compact));
- missing_result = service.load(missing_register_comments_path.toStdString());
- require(!missing_result.succeeded
- && missing_result.storageError == ProjectStorageError::MissingField,
- "the 1.0 schema must require registerComments without migration defaults");
-
- QJsonObject missing_rung_comment = QJsonDocument::fromJson(saved_json).object();
- QJsonArray missing_comment_logics = missing_rung_comment.value(
- QStringLiteral("controlLogics")).toArray();
- QJsonObject first_logic = missing_comment_logics.at(0).toObject();
- QJsonArray first_rungs = first_logic.value(QStringLiteral("rungs")).toArray();
- QJsonObject first_rung = first_rungs.at(0).toObject();
- first_rung.remove(QStringLiteral("comment"));
- first_rungs.replace(0, first_rung);
- first_logic.insert(QStringLiteral("rungs"), first_rungs);
- missing_comment_logics.replace(0, first_logic);
- missing_rung_comment.insert(QStringLiteral("controlLogics"), missing_comment_logics);
- writeText(
- missing_rung_comment_path,
- QJsonDocument(missing_rung_comment).toJson(QJsonDocument::Compact));
- missing_result = service.load(missing_rung_comment_path.toStdString());
- require(!missing_result.succeeded
- && missing_result.storageError == ProjectStorageError::MissingField,
- "the 1.0 schema must require rung comments without migration defaults");
-
- QJsonObject multiline_register_comment = QJsonDocument::fromJson(
- saved_json).object();
- QJsonArray comments = multiline_register_comment.value(
- QStringLiteral("registerComments")).toArray();
- QJsonObject first_comment = comments.at(0).toObject();
- first_comment.insert(QStringLiteral("text"), QStringLiteral("第一行\n第二行"));
- comments.replace(0, first_comment);
- multiline_register_comment.insert(QStringLiteral("registerComments"), comments);
- writeText(
- multiline_register_comment_path,
- QJsonDocument(multiline_register_comment).toJson(QJsonDocument::Compact));
- missing_result = service.load(multiline_register_comment_path.toStdString());
- require(!missing_result.succeeded
- && missing_result.storageError == ProjectStorageError::InvalidProject,
- "JSON loading must reject multiline register comments");
-
- QJsonObject multiline_rung_comment = QJsonDocument::fromJson(saved_json).object();
- QJsonArray multiline_logics = multiline_rung_comment.value(
- QStringLiteral("controlLogics")).toArray();
- first_logic = multiline_logics.at(0).toObject();
- first_rungs = first_logic.value(QStringLiteral("rungs")).toArray();
- first_rung = first_rungs.at(0).toObject();
- first_rung.insert(QStringLiteral("comment"), QStringLiteral("第一行\n第二行"));
- first_rungs.replace(0, first_rung);
- first_logic.insert(QStringLiteral("rungs"), first_rungs);
- multiline_logics.replace(0, first_logic);
- multiline_rung_comment.insert(QStringLiteral("controlLogics"), multiline_logics);
- writeText(
- multiline_rung_comment_path,
- QJsonDocument(multiline_rung_comment).toJson(QJsonDocument::Compact));
- missing_result = service.load(multiline_rung_comment_path.toStdString());
- require(!missing_result.succeeded
- && missing_result.storageError == ProjectStorageError::InvalidProject,
- "JSON loading must reject multiline rung comments");
-
- QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object();
- QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray();
- QJsonObject main_page = pages.at(0).toObject();
- QJsonArray controls = main_page.value(QStringLiteral("controls")).toArray();
- QJsonObject jump = controls.at(5).toObject();
- jump.remove(QStringLiteral("targetPageId"));
- controls.replace(5, jump);
- main_page.insert(QStringLiteral("controls"), controls);
- pages.replace(0, main_page);
- missing_target.insert(QStringLiteral("hmiPages"), pages);
- writeText(
- missing_target_path,
- QJsonDocument(missing_target).toJson(QJsonDocument::Compact));
- missing_result = service.load(missing_target_path.toStdString());
- require(!missing_result.succeeded
- && missing_result.storageError == ProjectStorageError::MissingField,
- "the 1.0 schema must require PageJump targetPageId");
-
- QJsonObject missing_progress_range = QJsonDocument::fromJson(saved_json).object();
- pages = missing_progress_range.value(QStringLiteral("hmiPages")).toArray();
- main_page = pages.at(0).toObject();
- controls = main_page.value(QStringLiteral("controls")).toArray();
- QJsonObject progress = controls.at(7).toObject();
- progress.remove(QStringLiteral("maximumValue"));
- controls.replace(7, progress);
- main_page.insert(QStringLiteral("controls"), controls);
- pages.replace(0, main_page);
- missing_progress_range.insert(QStringLiteral("hmiPages"), pages);
- writeText(
- missing_progress_range_path,
- QJsonDocument(missing_progress_range).toJson(QJsonDocument::Compact));
- missing_result = service.load(missing_progress_range_path.toStdString());
- require(!missing_result.succeeded
- && missing_result.storageError == ProjectStorageError::MissingField,
- "the 1.0 schema must require the ProgressBar value range");
- }
-
- void testInvalidFiles()
- {
- // 非法文件必须被拒绝,并且不得覆盖服务中当前工程
- QTemporaryDir directory;
- require(directory.isValid(), "temporary directory must be valid");
-
- JsonProjectStorage storage;
- ProjectService service(storage);
- service.editProject().metadata.name = "Current project";
- const QString invalid_json = directory.filePath("invalid-json.json");
- const QString missing_field = directory.filePath("missing-field.json");
- const QString unsupported_version = directory.filePath("unsupported-version.json");
-
- writeText(invalid_json, "{");
- auto result = service.load(invalid_json.toStdString());
- require(!result.succeeded
- && result.storageError == ProjectStorageError::InvalidJson,
- "invalid JSON must be rejected");
- require(service.project().metadata.name == "Current project",
- "invalid load must keep current project");
-
- writeText(missing_field, R"({"formatVersion":"1.0"})");
- result = service.load(missing_field.toStdString());
- require(!result.succeeded
- && result.storageError == ProjectStorageError::MissingField,
- "missing fields must be rejected");
-
- writeText(unsupported_version, R"({"formatVersion":"2.0"})");
- result = service.load(unsupported_version.toStdString());
- require(!result.succeeded
- && result.storageError == ProjectStorageError::UnsupportedVersion,
- "unsupported versions must be rejected");
- }
-
- void testQuantityFileLimits()
- {
- QTemporaryDir directory;
- require(directory.isValid(), "temporary directory must be valid");
-
- JsonProjectStorage storage;
- ProjectService service(storage);
- require(service.createNewProject("Quantity limits").succeeded,
- "the quantity-limit fixture project must be created");
-
- const QString oversized_path = directory.filePath("oversized.json");
- const QByteArray oversized(
- static_cast<int>(ProjectLimits::kMaximumProjectFileBytes + 1U), ' ');
- writeText(oversized_path, oversized);
- const ProjectLoadResult oversized_result =
- storage.load(oversized_path.toStdString());
- require(!oversized_result.succeeded
- && oversized_result.error == ProjectStorageError::InvalidJson,
- "a JSON file over 16 MiB must be rejected before parsing");
-
- const QString valid_path = directory.filePath("fixture.json");
- require(service.saveAs(valid_path.toStdString()).succeeded,
- "the quantity-limit fixture must be saved");
- QJsonObject root = QJsonDocument::fromJson(readBytes(valid_path)).object();
- QJsonArray too_many_pages;
- for (int index = 0;
- index <= static_cast<int>(ProjectLimits::kMaximumHmiPages);
- ++index)
- {
- too_many_pages.append(QJsonObject{});
- }
- root.insert(QStringLiteral("hmiPages"), too_many_pages);
- const QString too_many_pages_path = directory.filePath("too-many-pages.json");
- writeText(
- too_many_pages_path,
- QJsonDocument(root).toJson(QJsonDocument::Compact));
- const ProjectLoadResult too_many_pages_result =
- storage.load(too_many_pages_path.toStdString());
- require(!too_many_pages_result.succeeded
- && too_many_pages_result.error == ProjectStorageError::InvalidField,
- "a JSON page array over 128 items must be rejected");
-
- service.editProject() = makeExampleProject();
- require(service.saveAs(valid_path.toStdString()).succeeded,
- "the nested-expression fixture must be saved");
- root = QJsonDocument::fromJson(readBytes(valid_path)).object();
- QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray();
- QJsonObject logic = logics.at(0).toObject();
- QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray();
- QJsonObject rung = rungs.at(0).toObject();
- QJsonObject leaf = rung.value(QStringLiteral("condition")).toObject();
- while (leaf.value(QStringLiteral("kind")).toString() != QStringLiteral("node"))
- {
- leaf = leaf.value(QStringLiteral("children")).toArray().at(0).toObject();
- }
- QJsonObject nested = leaf;
- for (int depth = 1;
- depth <= static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1;
- ++depth)
- {
- QJsonObject sibling = leaf;
- sibling.insert(
- QStringLiteral("id"),
- QStringLiteral("json-depth-sibling-") + QString::number(depth));
- QJsonArray children;
- children.append(nested);
- children.append(sibling);
- QJsonObject parent;
- parent.insert(
- QStringLiteral("id"),
- QStringLiteral("json-depth-") + QString::number(depth));
- parent.insert(
- QStringLiteral("kind"),
- depth % 2 == 0 ? QStringLiteral("parallel") : QStringLiteral("series"));
- parent.insert(QStringLiteral("children"), children);
- nested = parent;
- }
- rung.insert(QStringLiteral("condition"), nested);
- rungs[0] = rung;
- logic.insert(QStringLiteral("rungs"), rungs);
- logics[0] = logic;
- root.insert(QStringLiteral("controlLogics"), logics);
- const QString deep_path = directory.filePath("too-deep.json");
- writeText(deep_path, QJsonDocument(root).toJson(QJsonDocument::Compact));
- const ProjectLoadResult deep_result = storage.load(deep_path.toStdString());
- require(!deep_result.succeeded
- && deep_result.error == ProjectStorageError::InvalidField,
- "a condition expression deeper than 20 levels must be rejected while parsing");
- }
-
- void testServiceStateAndSaveErrors()
- {
- // 保存路径和修改标记只在成功持久化后更新
- QTemporaryDir directory;
- require(directory.isValid(), "temporary directory must be valid");
-
- JsonProjectStorage storage;
- ProjectService service(storage);
- require(service.save().error == ProjectServiceError::FilePathRequired,
- "save without a current path must be rejected");
- require(service.createNewProject(" ").error
- == ProjectServiceError::InvalidProjectName,
- "blank project names must be rejected");
-
- const QString path = directory.filePath("state.json");
- service.editProject().metadata.name = "State project";
- require(service.saveAs(path.toStdString()).succeeded,
- "state project save must succeed");
- service.editProject().metadata.name = "Changed project";
- require(service.isModified(), "editing the project must mark it modified");
- require(service.save().succeeded, "save must use the current file path");
- require(!service.isModified(), "successful save must clear modified state");
-
- const QString failed_path = directory.filePath("missing/subdir/state.json");
- require(!service.saveAs(failed_path.toStdString()).succeeded,
- "save to an unavailable path must fail");
- require(service.currentFilePath() == path.toStdString(),
- "failed save as must keep the previous current path");
- }
-
- void testRegisterCommentService()
- {
- JsonProjectStorage storage;
- ProjectService project_service(storage);
- RegisterCommentService service(project_service);
-
- require(service.setComment(
- RegisterAddress{RegisterArea::D, 3}, " 目标温度 ").succeeded,
- "register comment service must create a trimmed D comment");
- require(service.setComment(
- RegisterAddress{RegisterArea::M, 2}, "启动信号").succeeded,
- "register comment service must create an M comment");
- require(service.comments().size() == 2U
- && service.comments().front().address.area() == RegisterArea::M
- && service.comments().front().address.index() == 2
- && service.comments().back().text == "目标温度",
- "register comments must be sorted by area and address");
- require(service.setComment(
- RegisterAddress{RegisterArea::M, 2}, "新的启动信号").succeeded
- && service.comments().size() == 2U
- && service.findComment(RegisterAddress{RegisterArea::M, 2})->text
- == "新的启动信号",
- "setting an existing address must update instead of duplicating it");
- require(!service.setComment(RegisterAddress{RegisterArea::M, 4}, " \t").succeeded,
- "blank register comments must be rejected by the service");
- require(service.setComment(
- RegisterAddress{RegisterArea::M, 4},
- std::string(ProjectLimits::kMaximumRegisterCommentBytes, 'a'))
- .succeeded,
- "a register comment at the byte limit must be accepted");
- require(!service.setComment(
- RegisterAddress{RegisterArea::M, 5},
- std::string(ProjectLimits::kMaximumRegisterCommentBytes + 1U, 'a'))
- .succeeded,
- "an oversized register comment must be rejected by the service");
- require(!service.setComment(
- RegisterAddress{RegisterArea::M, 5}, "第一行\n第二行").succeeded,
- "a multiline register comment must be rejected by the service");
- require(service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded
- && service.findComment(RegisterAddress{RegisterArea::M, 2}) == nullptr,
- "register comments must be removable");
- require(!service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded,
- "removing a missing register comment must fail explicitly");
- }
-
- } // namespace
-
- int main()
- {
- try
- {
- // 工程服务和 JSON 存储在同一测试进程中验证完整闭环
- testEmptyProjectRoundTrip();
- testUnconditionalOutputRoundTrip();
- testExampleProjectRoundTrip();
- testRegisterCommentService();
- testInvalidFiles();
- testQuantityFileLimits();
- testServiceStateAndSaveErrors();
- }
- catch (const std::exception &error)
- {
- std::cerr << "project management tests failed: " << error.what() << '\n';
- return 1;
- }
-
- std::cout << "project management tests passed\n";
- return 0;
- }
|