|
- #include "domain/project_storage.h"
- #include "infrastructure/json_project_storage.h"
- #include "services/project_service.h"
-
- #include <QFile>
- #include <QTemporaryDir>
-
- #include <exception>
- #include <iostream>
- #include <stdexcept>
- #include <string>
-
- 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.properties.emplace("color", "green");
-
- 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");
-
- 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);
-
- 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";
- 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::fromNode(compare)};
- rung.condition = std::move(series);
- rung.output = coil;
- logic.rungs.push_back(rung);
-
- Project project;
- project.metadata = {"example-project", "Example project", "1.0"};
- project.hmiPages.push_back(page);
- project.controlLogics.push_back(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");
- }
-
- 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");
- 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("\"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() == 1, "HMI page count must survive round trip");
- require(project.hmiPages.front().controls.size() == 4,
- "all basic HMI 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().properties.at("color") == "green",
- "HMI 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.controlLogics.size() == 1,
- "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.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.output.has_value(),
- "ladder output must survive round trip");
-
- const auto &compare = std::get<CompareNodeConfig>(
- rung.condition->children.at(1).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");
- }
-
- 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 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");
- }
-
- } // namespace
-
- int main()
- {
- try
- {
- // 工程服务和 JSON 存储在同一测试进程中验证完整闭环
- testEmptyProjectRoundTrip();
- testExampleProjectRoundTrip();
- testInvalidFiles();
- 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;
- }
|