#include "domain/project_limits.h" #include "infrastructure/json_project_storage.h" #include "services/project_service.h" #include "services/register_comment_service.h" #include "support/test_support.h" #include #include #include #include #include #include #include #include #include namespace { using TestSupport::require; LogicNode contact(const std::string &id, int address) { return { id, ContactNodeConfig{ RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen}, true}; } LogicNode edge(const std::string &id, int address) { return { id, EdgeContactNodeConfig{ RegisterAddress{RegisterArea::M, address}, EdgeMode::Rising}, true}; } LogicNode coil(const std::string &id, int address) { return { id, CoilNodeConfig{ RegisterAddress{RegisterArea::M, address}, CoilMode::Normal}, true}; } LadderRung makeRung( const std::string &id, LogicNode condition, LogicNode output) { LadderRung rung; rung.id = id; rung.name = id; rung.comment = id + " comment"; for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { rung.cells.push_back({ id + "-cell-" + std::to_string(column), column == 0 ? LadderCellKind::Node : LadderCellKind::Wire, column == 0 ? std::optional{condition} : std::nullopt}); } rung.output = std::move(output); return rung; } Project makeExampleProject() { Project project; project.metadata = {"example-project", "Example project", "4.0"}; HmiPage page; page.id = "main-page"; page.name = "Main"; HmiControl button; button.id = "start-button"; button.type = HmiControlType::Button; button.bounds = {10, 10, 100, 40}; button.text = "Start"; button.binding = RegisterAddress{RegisterArea::M, 0}; button.buttonOperation = HmiButtonOperation::SetOn; button.buttonEnableCondition = HmiButtonWordEnableCondition{ RegisterAddress{RegisterArea::D, 100}, RegisterDataType::Int16, HmiButtonConditionOperator::GreaterThanOrEqual, 10.0}; page.controls.push_back(button); project.hmiPages.push_back(page); project.initialHmiPageId = page.id; ControlLogic logic; logic.id = "logic-1"; logic.name = "Main logic"; logic.rungs.push_back(makeRung( "rung-1", contact("start-contact", 0), coil("run-coil", 10))); logic.rungs.push_back(makeRung( "rung-2", edge("edge-contact", 1), coil("edge-coil", 11))); logic.rungs.push_back(makeRung( "rung-3", contact("move-contact", 2), LogicNode{ "move-output", MoveNodeConfig{ WordOperand{ WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, 25}, RegisterAddress{RegisterArea::D, 20}}, true})); logic.rungs.push_back(makeRung( "rung-4", contact("add-contact", 3), 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})); logic.verticalConnections = { {"vertical-left", "rung-1", "rung-2", 0}, {"vertical-right", "rung-1", "rung-2", 1}}; logic.rungs[1].comment.clear(); project.controlLogics.push_back(std::move(logic)); project.registerComments.push_back({ RegisterAddress{RegisterArea::M, 0}, "Start signal"}); return project; } void writeBytes(const QString &path, const QByteArray &bytes) { QFile file(path); require(file.open(QIODevice::WriteOnly), "test file must be writable"); require(file.write(bytes) == bytes.size(), "test file must be written completely"); } QByteArray readBytes(const QString &path) { QFile file(path); require(file.open(QIODevice::ReadOnly), "test file must be readable"); return file.readAll(); } QJsonObject savedFixture( JsonProjectStorage &storage, const Project &project, const QString &path) { require(storage.save(project, path.toStdString()).succeeded, "fixture project must save"); return QJsonDocument::fromJson(readBytes(path)).object(); } void testEmptyProjectRoundTrip() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage(defaultProjectLimitSettings()); ProjectService service(storage, defaultProjectLimitSettings()); require(service.createNewProject("Empty project").succeeded, "empty project creation must succeed"); require(service.project().metadata.formatVersion == "4.0", "new projects must use the strict grid format"); const QString path = directory.filePath("empty.json"); require(service.saveAs(path.toStdString()).succeeded, "empty project save must succeed"); require(service.load(path.toStdString()).succeeded, "empty project load must succeed"); require(service.project().controlLogics.empty() && service.project().hmiPages.empty(), "round trip must not invent project content"); } void testGridProjectRoundTrip() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage(defaultProjectLimitSettings()); const QString path = directory.filePath("grid.json"); const Project original = makeExampleProject(); require(original.validate(defaultProjectLimitSettings()), "the grid fixture must be structurally valid"); require(storage.save(original, path.toStdString()).succeeded, "the grid project must save"); const QByteArray json = readBytes(path); require(json.contains("\"formatVersion\": \"4.0\"") && json.contains("\"cells\"") && json.contains("\"verticalConnections\"") && json.contains("\"kind\": \"node\"") && json.contains("\"kind\": \"wire\""), "4.0 JSON must persist cells and independent vertical edges"); require(!json.contains("\"condition\"") && !json.contains("\"children\""), "4.0 JSON must not serialize the removed expression tree"); const ProjectLoadResult loaded = storage.load(path.toStdString()); require(loaded.succeeded, "the grid project must load"); const ControlLogic &logic = loaded.project.controlLogics.front(); require(logic.rungs.size() == 4U && logic.rungs.front().cells.size() == 10U, "all rows and ten fixed cells must survive round trip"); require(logic.verticalConnections.size() == 2U && logic.verticalConnections.front().columnBoundary == 0 && logic.verticalConnections.back().columnBoundary == 1, "independent vertical edge positions must survive round trip"); require(std::holds_alternative( logic.rungs[1].cells[0].node->config) && std::holds_alternative( logic.rungs[2].output->config) && std::holds_alternative( logic.rungs[3].output->config), "typed ladder instructions must survive grid round trip"); } void testMOffAlarmRoundTrip() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage(defaultProjectLimitSettings()); const QString path = directory.filePath("m-off-alarm.json"); Project project = makeExampleProject(); project.alarmDefinitions.push_back({ "alarm-m-off", RegisterAddress{RegisterArea::M, 5}, AlarmCondition::MOff, 0, "Safety circuit open"}); require(storage.save(project, path.toStdString()).succeeded, "an M OFF alarm project must save"); require(readBytes(path).contains("\"condition\": \"mOff\""), "M OFF alarms must serialize with the explicit mOff condition"); const ProjectLoadResult loaded = storage.load(path.toStdString()); require(loaded.succeeded && loaded.project.alarmDefinitions.size() == 1U && loaded.project.alarmDefinitions.front().condition == AlarmCondition::MOff && loaded.project.alarmDefinitions.front().address == RegisterAddress{RegisterArea::M, 5}, "the M OFF condition and address must survive JSON round trip"); } void testMultiWordHmiDataTypeRoundTrip() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage(defaultProjectLimitSettings()); const QString path = directory.filePath("multi-word-hmi.json"); Project project = makeExampleProject(); HmiControl int32_display; int32_display.id = "int32-display"; int32_display.type = HmiControlType::NumericDisplay; int32_display.bounds = {20, 80, 120, 40}; int32_display.text = "Int32"; int32_display.binding = RegisterAddress{RegisterArea::D, 100}; int32_display.dataType = RegisterDataType::Int32; project.hmiPages.front().controls.push_back(int32_display); HmiControl double_input = int32_display; double_input.id = "double-input"; double_input.type = HmiControlType::NumericInput; double_input.bounds = {160, 80, 120, 40}; double_input.text = "Double"; double_input.binding = RegisterAddress{RegisterArea::D, 200}; double_input.dataType = RegisterDataType::Float64; project.hmiPages.front().controls.push_back(double_input); require(storage.save(project, path.toStdString()).succeeded, "Int32 and Double HMI controls must save in a 4.0 project"); const QByteArray json = readBytes(path); require(json.contains("\"dataType\": \"int32\"") && json.contains("\"dataType\": \"float64\"") && json.contains("\"formatVersion\": \"4.0\""), "multi-word HMI types must use stable JSON names in 4.0"); const ProjectLoadResult loaded = storage.load(path.toStdString()); require(loaded.succeeded && loaded.project.hmiPages.front().controls[1].dataType == RegisterDataType::Int32 && loaded.project.hmiPages.front().controls[1].binding == RegisterAddress{RegisterArea::D, 100} && loaded.project.hmiPages.front().controls[2].dataType == RegisterDataType::Float64 && loaded.project.hmiPages.front().controls[2].binding == RegisterAddress{RegisterArea::D, 200}, "Int32 and Double HMI types and bindings must survive JSON round trip"); QJsonObject root = QJsonDocument::fromJson(json).object(); QJsonArray pages = root.value(QStringLiteral("hmiPages")).toArray(); QJsonObject page = pages.at(0).toObject(); QJsonArray controls = page.value(QStringLiteral("controls")).toArray(); QJsonObject invalid_control = controls.at(1).toObject(); invalid_control.insert(QStringLiteral("dataType"), QStringLiteral("int64")); controls.replace(1, invalid_control); page.insert(QStringLiteral("controls"), controls); pages.replace(0, page); root.insert(QStringLiteral("hmiPages"), pages); const QString invalid_path = directory.filePath("invalid-data-type.json"); writeBytes(invalid_path, QJsonDocument(root).toJson()); require(storage.load(invalid_path.toStdString()).error == ProjectStorageError::InvalidField, "unknown HMI data types must be rejected by strict JSON loading"); } void testButtonEnableConditionRoundTrip() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage(defaultProjectLimitSettings()); const QString path = directory.filePath("button-condition.json"); Project project = makeExampleProject(); HmiControl &button = project.hmiPages.front().controls.front(); button.buttonEnableCondition = HmiButtonWordEnableCondition{ RegisterAddress{RegisterArea::D, 120}, RegisterDataType::Float32, HmiButtonConditionOperator::LessThanOrEqual, 12.5}; require(storage.save(project, path.toStdString()).succeeded, "button enable condition project must save"); const QByteArray json = readBytes(path); require(json.contains("\"buttonEnableCondition\"") && json.contains("\"type\": \"dValue\"") && json.contains("\"operation\": \"lessThanOrEqual\"") && json.contains("\"dataType\": \"float32\""), "button enable conditions must use explicit 4.0 JSON fields"); const ProjectLoadResult loaded = storage.load(path.toStdString()); require(loaded.succeeded, "button enable condition project must load after a JSON round trip"); const HmiControl &loaded_button = loaded.project.hmiPages.front().controls.front(); require(loaded_button.buttonEnableCondition.has_value() && std::get( *loaded_button.buttonEnableCondition).address == RegisterAddress{RegisterArea::D, 120} && std::get( *loaded_button.buttonEnableCondition).value == 12.5, "button enable condition type, address and value must survive round trip"); } void testStatusTextRoundTrip() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage(defaultProjectLimitSettings()); const QString path = directory.filePath("status-text.json"); Project project = makeExampleProject(); HmiControl bit_status; bit_status.id = "bit-status"; bit_status.type = HmiControlType::StatusText; bit_status.bounds = {20, 80, 140, 40}; bit_status.text = "Status"; bit_status.binding = RegisterAddress{RegisterArea::M, 10}; bit_status.statusText = HmiStatusBitTextConfig{"Stopped", "Running"}; project.hmiPages.front().controls.push_back(bit_status); HmiControl word_status = bit_status; word_status.id = "word-status"; word_status.bounds.x = 180; word_status.binding = RegisterAddress{RegisterArea::D, 100}; word_status.dataType = RegisterDataType::Float64; word_status.statusText = HmiStatusWordTextConfig{{ {std::nullopt, 30.0, "Low"}, {30.0, 80.0, "Normal"}, {80.0, std::nullopt, "High"}}}; project.hmiPages.front().controls.push_back(word_status); require(storage.save(project, path.toStdString()).succeeded, "M and D status text controls must save in 4.0"); const QByteArray json = readBytes(path); require(json.contains("\"type\": \"statusText\"") && json.contains("\"source\": \"m\"") && json.contains("\"source\": \"d\"") && json.contains("\"lower\": null") && json.contains("\"upper\": null") && json.contains("\"dataType\": \"float64\""), "4.0 JSON must store explicit status text modes and range bounds"); const ProjectLoadResult loaded = storage.load(path.toStdString()); require(loaded.succeeded && loaded.project.hmiPages.front().controls.size() == 3U, "status text controls must load after a JSON round trip"); const HmiControl &loaded_bit = loaded.project.hmiPages.front().controls[1]; const HmiControl &loaded_word = loaded.project.hmiPages.front().controls[2]; require(std::get(*loaded_bit.statusText).onText == "Running" && std::get(*loaded_word.statusText) .ranges[1].lowerBound == 30.0 && loaded_word.dataType == RegisterDataType::Float64, "status text labels, ranges and D type must survive round trip"); } void testStrictVersionAndRequiredFields() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage(defaultProjectLimitSettings()); const QString fixture_path = directory.filePath("fixture.json"); QJsonObject root = savedFixture( storage, makeExampleProject(), fixture_path); root.insert(QStringLiteral("formatVersion"), QStringLiteral("2.0")); const QString old_path = directory.filePath("old.json"); writeBytes(old_path, QJsonDocument(root).toJson()); require(storage.load(old_path.toStdString()).error == ProjectStorageError::UnsupportedVersion, "old 2.0 files must be rejected without migration code"); root.insert(QStringLiteral("formatVersion"), QStringLiteral("3.0")); const QString old_grid_path = directory.filePath("old-grid.json"); writeBytes(old_grid_path, QJsonDocument(root).toJson()); require(storage.load(old_grid_path.toStdString()).error == ProjectStorageError::UnsupportedVersion, "old 3.0 files must be rejected without migration code"); root = QJsonDocument::fromJson(readBytes(fixture_path)).object(); QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); QJsonObject logic = logics.at(0).toObject(); logic.remove(QStringLiteral("verticalConnections")); logics.replace(0, logic); root.insert(QStringLiteral("controlLogics"), logics); const QString missing_path = directory.filePath("missing-connections.json"); writeBytes(missing_path, QJsonDocument(root).toJson()); require(storage.load(missing_path.toStdString()).error == ProjectStorageError::MissingField, "4.0 must require the vertical connection array explicitly"); } void testInvalidGridAndConnectionsAreRejected() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage(defaultProjectLimitSettings()); const QString fixture_path = directory.filePath("fixture.json"); const QJsonObject original = savedFixture( storage, makeExampleProject(), fixture_path); const auto firstLogic = [](QJsonObject *root) -> QJsonObject { return root->value(QStringLiteral("controlLogics")) .toArray().at(0).toObject(); }; const auto replaceFirstLogic = []( QJsonObject *root, const QJsonObject &logic) { QJsonArray logics = root->value( QStringLiteral("controlLogics")).toArray(); logics.replace(0, logic); root->insert(QStringLiteral("controlLogics"), logics); }; QJsonObject short_grid = original; QJsonObject logic = firstLogic(&short_grid); QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray(); QJsonObject rung = rungs.at(0).toObject(); QJsonArray cells = rung.value(QStringLiteral("cells")).toArray(); cells.removeLast(); rung.insert(QStringLiteral("cells"), cells); rungs.replace(0, rung); logic.insert(QStringLiteral("rungs"), rungs); replaceFirstLogic(&short_grid, logic); const QString short_path = directory.filePath("short-grid.json"); writeBytes(short_path, QJsonDocument(short_grid).toJson()); require(storage.load(short_path.toStdString()).error == ProjectStorageError::InvalidField, "a row with fewer than ten cells must be rejected while parsing"); QJsonObject duplicate_edge = original; logic = firstLogic(&duplicate_edge); QJsonArray edges = logic.value( QStringLiteral("verticalConnections")).toArray(); QJsonObject duplicate = edges.at(0).toObject(); duplicate.insert(QStringLiteral("id"), QStringLiteral("vertical-copy")); edges.append(duplicate); logic.insert(QStringLiteral("verticalConnections"), edges); replaceFirstLogic(&duplicate_edge, logic); const QString duplicate_path = directory.filePath("duplicate-edge.json"); writeBytes(duplicate_path, QJsonDocument(duplicate_edge).toJson()); require(storage.load(duplicate_path.toStdString()).error == ProjectStorageError::InvalidProject, "duplicate edges at one row boundary must be rejected"); QJsonObject hidden_comment = original; logic = firstLogic(&hidden_comment); rungs = logic.value(QStringLiteral("rungs")).toArray(); rung = rungs.at(1).toObject(); rung.insert(QStringLiteral("comment"), QStringLiteral("hidden branch comment")); rungs.replace(1, rung); logic.insert(QStringLiteral("rungs"), rungs); replaceFirstLogic(&hidden_comment, logic); const QString hidden_comment_path = directory.filePath( "hidden-branch-comment.json"); writeBytes(hidden_comment_path, QJsonDocument(hidden_comment).toJson()); require(storage.load(hidden_comment_path.toStdString()).error == ProjectStorageError::InvalidProject, "a 4.0 project must reject comments stored on connected branch rows"); QJsonObject non_adjacent = original; logic = firstLogic(&non_adjacent); edges = logic.value(QStringLiteral("verticalConnections")).toArray(); QJsonObject invalid_edge = edges.at(0).toObject(); invalid_edge.insert(QStringLiteral("lowerRungId"), QStringLiteral("rung-3")); edges.replace(0, invalid_edge); logic.insert(QStringLiteral("verticalConnections"), edges); replaceFirstLogic(&non_adjacent, logic); const QString non_adjacent_path = directory.filePath("non-adjacent.json"); writeBytes(non_adjacent_path, QJsonDocument(non_adjacent).toJson()); require(storage.load(non_adjacent_path.toStdString()).error == ProjectStorageError::InvalidProject, "vertical edges between non-adjacent rows must be rejected"); } void testProjectServiceStateAndConfiguredLimits() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); const QString path = directory.filePath("project.json"); JsonProjectStorage storage(defaultProjectLimitSettings()); require(storage.save(makeExampleProject(), path.toStdString()).succeeded, "service fixture must save"); ProjectLimitSettings limits = defaultProjectLimitSettings(); limits.maximumRungsPerLogic = 2U; JsonProjectStorage limited_storage(limits); ProjectService limited_service(limited_storage, limits); const std::string original_id = limited_service.project().metadata.id; const ProjectOperationResult load = limited_service.load( path.toStdString()); require(!load.succeeded && load.storageError == ProjectStorageError::InvalidField, "configured row limits must be enforced during JSON parsing"); require(limited_service.project().metadata.id == original_id && !limited_service.hasCurrentFile(), "a failed load must leave the current project untouched"); ProjectService service(storage, defaultProjectLimitSettings()); require(service.save().error == ProjectServiceError::FilePathRequired, "save without a current path must fail explicitly"); require(service.createNewProject("State project").succeeded, "a valid project name must create a project"); require(service.saveAs(path.toStdString()).succeeded, "save-as must establish the current path"); service.editProject().metadata.name = "Changed"; require(service.isModified() && service.save().succeeded && !service.isModified(), "successful save must clear the modified state"); } void testHmiRuntimeExportStripsNonHmiProjectData() { QTemporaryDir directory; require(directory.isValid(), "HMI runtime export directory must be valid"); JsonProjectStorage storage(defaultProjectLimitSettings()); ProjectService service(storage, defaultProjectLimitSettings()); const Project fixture = makeExampleProject(); require(storage.save( fixture, directory.filePath("source.json").toStdString()) .succeeded, "HMI runtime export fixture must save"); require(service.load( directory.filePath("source.json").toStdString()).succeeded, "HMI runtime export fixture must load"); const QString destination = directory.filePath("runtime.json"); require(service.exportHmiRuntimeAs(destination.toStdString()).succeeded, "HMI runtime export must accept a configured HMI project"); const ProjectLoadResult loaded = storage.load(destination.toStdString()); require(loaded.succeeded && loaded.project.hmiPages.size() == fixture.hmiPages.size() && loaded.project.hmiPages.front().id == fixture.hmiPages.front().id && loaded.project.alarmDefinitions.size() == fixture.alarmDefinitions.size() && loaded.project.controlLogics.empty() && loaded.project.registerComments.empty(), "HMI runtime export must keep HMI data and strip ladder metadata"); } void testRegisterCommentService() { JsonProjectStorage storage(defaultProjectLimitSettings()); ProjectService projects(storage, defaultProjectLimitSettings()); RegisterCommentService comments(projects); require(comments.setComment( RegisterAddress{RegisterArea::D, 3}, " Target value ") .succeeded, "the comment service must trim and create comments"); require(comments.setComment( RegisterAddress{RegisterArea::M, 2}, "Start signal") .succeeded, "the comment service must support M comments"); require(comments.comments().size() == 2U && comments.comments().front().address.area() == RegisterArea::M && comments.comments().back().text == "Target value", "comments must remain sorted and trimmed"); require(!comments.setComment( RegisterAddress{RegisterArea::M, 4}, " \t").succeeded, "blank comments must be rejected"); require(comments.removeComment( RegisterAddress{RegisterArea::M, 2}).succeeded, "comments must be removable"); } } // namespace int main() { return TestSupport::runTestSuite("project management tests", { {"testEmptyProjectRoundTrip", testEmptyProjectRoundTrip}, {"testGridProjectRoundTrip", testGridProjectRoundTrip}, {"testMOffAlarmRoundTrip", testMOffAlarmRoundTrip}, {"testMultiWordHmiDataTypeRoundTrip", testMultiWordHmiDataTypeRoundTrip}, {"testStatusTextRoundTrip", testStatusTextRoundTrip}, {"testButtonEnableConditionRoundTrip", testButtonEnableConditionRoundTrip}, {"testStrictVersionAndRequiredFields", testStrictVersionAndRequiredFields}, {"testInvalidGridAndConnectionsAreRejected", testInvalidGridAndConnectionsAreRejected}, {"testProjectServiceStateAndConfiguredLimits", testProjectServiceStateAndConfiguredLimits}, {"testHmiRuntimeExportStripsNonHmiProjectData", testHmiRuntimeExportStripsNonHmiProjectData}, {"testRegisterCommentService", testRegisterCommentService}, }); }