#include "domain/project_storage.h" #include "domain/hmi_model.h" #include "infrastructure/json_project_storage.h" #include "support/test_support.h" #include "services/register_comment_service.h" #include "services/project_service.h" #include "domain/project_limits.h" #include #include #include #include #include #include #include #include #include #include namespace { using TestSupport::require; 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(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), ConditionExpression::fromWire("start-output-wire", 6)}; 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 edge_output; edge_output.id = "edge-output"; edge_output.config = CoilNodeConfig{ RegisterAddress{RegisterArea::M, 6}, CoilMode::Normal}; LadderRung edge_rung; edge_rung.id = "edge-rung"; edge_rung.name = "Edge network"; edge_rung.comment = "上升沿输出"; ConditionExpression edge_series; edge_series.id = "edge-series"; edge_series.kind = ConditionExpressionKind::Series; edge_series.children = { ConditionExpression::fromNode(rising_edge), ConditionExpression::fromWire("edge-output-wire", 9)}; edge_rung.condition = std::move(edge_series); edge_rung.output = edge_output; logic.rungs.push_back(edge_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; ConditionExpression data_series; data_series.id = rung_id + "-series"; data_series.kind = ConditionExpressionKind::Series; data_series.children = { ConditionExpression::fromNode(input), ConditionExpression::fromWire(rung_id + "-output-wire", 9)}; data_rung.condition = std::move(data_series); 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.condition = ConditionExpression::fromWire( "unconditional-wire", ProjectLimits::kMaximumConditionColumns); rung.output = coil; service.editProject().controlLogics.push_back( ControlLogic{"logic-1", "Logic 1", {rung}, true}); require(service.project().validateForRunning(), "an explicit full-width wire 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.condition->kind == ConditionExpressionKind::Wire && loaded.condition->wire->columnSpan == ProjectLimits::kMaximumConditionColumns && loaded.output.has_value() && loaded.validateForRunning(), "JSON round trip must preserve explicit unconditional wire semantics"); QJsonObject implicit_root = QJsonDocument::fromJson(readBytes(path)).object(); QJsonArray logics = implicit_root.value(QStringLiteral("controlLogics")).toArray(); QJsonObject logic = logics.at(0).toObject(); QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray(); QJsonObject implicit_rung = rungs.at(0).toObject(); implicit_rung.insert(QStringLiteral("condition"), QJsonValue::Null); rungs.replace(0, implicit_rung); logic.insert(QStringLiteral("rungs"), rungs); logics.replace(0, logic); implicit_root.insert(QStringLiteral("controlLogics"), logics); const QString implicit_path = directory.filePath("implicit-output.json"); writeText( implicit_path, QJsonDocument(implicit_root).toJson(QJsonDocument::Indented)); require(!service.load(implicit_path.toStdString()).succeeded, "version 1.0 implicit output wiring must not be migrated on load"); } void testGapRoundTrip() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage; ProjectService service(storage); require(service.createNewProject("Gap draft").succeeded, "gap draft project creation must succeed"); LadderRung gap_rung; gap_rung.id = "gap-rung"; gap_rung.name = "Gap rung"; gap_rung.condition = ConditionExpression::fromGap( "gap-full-width", ProjectLimits::kMaximumConditionColumns); gap_rung.output = LogicNode{ "gap-output", CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, true}; service.editProject().controlLogics.push_back( ControlLogic{"gap-logic", "Gap logic", {gap_rung}, true}); require(service.project().validate() && !service.project().validateForRunning(), "a gap network must be saveable but not runnable"); const QString gap_path = directory.filePath("gap-project.json"); require(service.saveAs(gap_path.toStdString()).succeeded, "a gap draft must save successfully"); require(readBytes(gap_path).contains("\"kind\": \"gap\""), "version 1.0 JSON must persist gap expressions explicitly"); require(service.load(gap_path.toStdString()).succeeded, "a version 1.0 gap draft must load successfully"); const LadderRung &loaded_gap = service.project().controlLogics.front().rungs.front(); require(service.project().metadata.formatVersion == "1.0" && loaded_gap.condition.has_value() && loaded_gap.condition->kind == ConditionExpressionKind::Gap && loaded_gap.condition->gap->columnSpan == ProjectLimits::kMaximumConditionColumns && !loaded_gap.validateForRunning(), "gap JSON round trip must preserve the disconnected runtime state"); } 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\": \"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 &edge_rung = project.controlLogics.front().rungs.at(1); require(edge_rung.output.has_value() && std::holds_alternative(edge_rung.output->config) && std::get(edge_rung.output->config).address == RegisterAddress{RegisterArea::M, 6}, "edge network output must survive round trip"); require(std::holds_alternative( edge_rung.condition->children.front().node->config) && std::get( edge_rung.condition->children.front().node->config).mode == EdgeMode::Rising, "rising edge configuration must survive round trip"); require(std::get( project.controlLogics.front().rungs.at(2).output->config) .source.constant == 25, "MOVE operands must survive round trip"); require(std::get( project.controlLogics.front().rungs.at(3).output->config) .operation == ArithmeticOperation::Add && std::get( project.controlLogics.front().rungs.at(4).output->config) .operation == ArithmeticOperation::Subtract, "ADD and SUB operations must survive round trip"); const auto &compare = std::get( 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(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(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 the configured limit 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(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 the configured limit must be rejected while parsing"); } void testHmiPageJsonBoundaries() { // JSON 入口必须和页面领域校验使用同一组业务边界 QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage; ProjectService service(storage); Project boundary_project = makeExampleProject(); boundary_project.hmiPages.front().controls.clear(); service.editProject() = std::move(boundary_project); const QString fixture_path = directory.filePath("hmi-page-boundary-fixture.json"); require(service.saveAs(fixture_path.toStdString()).succeeded, "the HMI boundary fixture must be saved"); const QJsonObject original_root = QJsonDocument::fromJson(readBytes(fixture_path)).object(); const auto loadWithSize = [&](int width, int height, const QString &name) { QJsonObject root = original_root; QJsonArray pages = root.value(QStringLiteral("hmiPages")).toArray(); QJsonObject page = pages.at(0).toObject(); page.insert(QStringLiteral("width"), width); page.insert(QStringLiteral("height"), height); pages.replace(0, page); root.insert(QStringLiteral("hmiPages"), pages); const QString path = directory.filePath(name); writeText(path, QJsonDocument(root).toJson(QJsonDocument::Compact)); return storage.load(path.toStdString()); }; require(!loadWithSize( ProjectLimits::kMinimumHmiPageWidth - 1, ProjectLimits::kDefaultHmiPageHeight, QStringLiteral("width-below-minimum.json")) .succeeded, "JSON must reject an HMI page width below 320"); require(!loadWithSize( ProjectLimits::kMaximumHmiPageWidth + 1, ProjectLimits::kDefaultHmiPageHeight, QStringLiteral("width-above-maximum.json")) .succeeded, "JSON must reject an HMI page width above 1600"); require(!loadWithSize( ProjectLimits::kDefaultHmiPageWidth, ProjectLimits::kMinimumHmiPageHeight - 1, QStringLiteral("height-below-minimum.json")) .succeeded, "JSON must reject an HMI page height below 200"); require(!loadWithSize( ProjectLimits::kDefaultHmiPageWidth, ProjectLimits::kMaximumHmiPageHeight + 1, QStringLiteral("height-above-maximum.json")) .succeeded, "JSON must reject an HMI page height above 800"); const ProjectLoadResult minimum_result = loadWithSize( ProjectLimits::kMinimumHmiPageWidth, ProjectLimits::kMinimumHmiPageHeight, QStringLiteral("minimum-size.json")); require(minimum_result.succeeded && minimum_result.project.hmiPages.front().width == ProjectLimits::kMinimumHmiPageWidth && minimum_result.project.hmiPages.front().height == ProjectLimits::kMinimumHmiPageHeight, "JSON must accept the minimum HMI page size 320x200"); const ProjectLoadResult maximum_result = loadWithSize( ProjectLimits::kMaximumHmiPageWidth, ProjectLimits::kMaximumHmiPageHeight, QStringLiteral("maximum-size.json")); require(maximum_result.succeeded && maximum_result.project.hmiPages.front().width == ProjectLimits::kMaximumHmiPageWidth && maximum_result.project.hmiPages.front().height == ProjectLimits::kMaximumHmiPageHeight, "JSON must accept the maximum HMI page size 1600x800"); } 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(); testGapRoundTrip(); testExampleProjectRoundTrip(); testRegisterCommentService(); testInvalidFiles(); testQuantityFileLimits(); testHmiPageJsonBoundaries(); 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; }