|
- #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 <QFile>
- #include <QJsonArray>
- #include <QJsonDocument>
- #include <QJsonObject>
- #include <QTemporaryDir>
-
- #include <exception>
- #include <iostream>
- #include <string>
- #include <utility>
-
- 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<LogicNode>{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<EdgeContactNodeConfig>(
- logic.rungs[1].cells[0].node->config)
- && std::holds_alternative<MoveNodeConfig>(
- logic.rungs[2].output->config)
- && std::holds_alternative<ArithmeticNodeConfig>(
- 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;
- }
|