#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", "2.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; 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}}; 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; ProjectService service(storage); require(service.createNewProject("Empty project").succeeded, "empty project creation must succeed"); require(service.project().metadata.formatVersion == "2.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; const QString path = directory.filePath("grid.json"); const Project original = makeExampleProject(); require(original.validate(), "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\": \"2.0\"") && json.contains("\"cells\"") && json.contains("\"verticalConnections\"") && json.contains("\"kind\": \"node\"") && json.contains("\"kind\": \"wire\""), "2.0 JSON must persist cells and independent vertical edges"); require(!json.contains("\"condition\"") && !json.contains("\"children\""), "2.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; 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 testStrictVersionAndRequiredFields() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage; const QString fixture_path = directory.filePath("fixture.json"); QJsonObject root = savedFixture( storage, makeExampleProject(), fixture_path); root.insert(QStringLiteral("formatVersion"), QStringLiteral("1.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 1.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, "2.0 must require the vertical connection array explicitly"); } void testInvalidGridAndConnectionsAreRejected() { QTemporaryDir directory; require(directory.isValid(), "temporary directory must be valid"); JsonProjectStorage storage; 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 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; 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); 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 testRegisterCommentService() { JsonProjectStorage storage; ProjectService projects(storage); 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() { try { testEmptyProjectRoundTrip(); testGridProjectRoundTrip(); testMOffAlarmRoundTrip(); testStrictVersionAndRequiredFields(); testInvalidGridAndConnectionsAreRejected(); testProjectServiceStateAndConfiguredLimits(); testRegisterCommentService(); } 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; }