#include "domain/project_storage.h" #include "infrastructure/json_project_storage.h" #include "services/project_service.h" #include #include #include #include #include #include #include #include #include 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"); 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"}; 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); 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(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.hmiPages.push_back(settings_page); project.initialHmiPageId = page.id; 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"); } 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"); 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\""), "version 1.0 projects must persist the current multi-page schema"); 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() == 6, "register, Label and PageJump 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.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.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.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( 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"); 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_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"); } 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; }