综合平台编程器项目的远程存储
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 

437 Zeilen
16 KiB

  1. #include "domain/project_limits.h"
  2. #include "infrastructure/json_project_storage.h"
  3. #include "services/project_service.h"
  4. #include "services/register_comment_service.h"
  5. #include "support/test_support.h"
  6. #include <QFile>
  7. #include <QJsonArray>
  8. #include <QJsonDocument>
  9. #include <QJsonObject>
  10. #include <QTemporaryDir>
  11. #include <exception>
  12. #include <iostream>
  13. #include <string>
  14. #include <utility>
  15. namespace {
  16. using TestSupport::require;
  17. LogicNode contact(const std::string &id, int address)
  18. {
  19. return {
  20. id,
  21. ContactNodeConfig{
  22. RegisterAddress{RegisterArea::M, address},
  23. ContactMode::NormallyOpen},
  24. true};
  25. }
  26. LogicNode edge(const std::string &id, int address)
  27. {
  28. return {
  29. id,
  30. EdgeContactNodeConfig{
  31. RegisterAddress{RegisterArea::M, address},
  32. EdgeMode::Rising},
  33. true};
  34. }
  35. LogicNode coil(const std::string &id, int address)
  36. {
  37. return {
  38. id,
  39. CoilNodeConfig{
  40. RegisterAddress{RegisterArea::M, address},
  41. CoilMode::Normal},
  42. true};
  43. }
  44. LadderRung makeRung(
  45. const std::string &id,
  46. LogicNode condition,
  47. LogicNode output)
  48. {
  49. LadderRung rung;
  50. rung.id = id;
  51. rung.name = id;
  52. rung.comment = id + " comment";
  53. for (int column = 0;
  54. column < ProjectLimits::kMaximumConditionColumns;
  55. ++column)
  56. {
  57. rung.cells.push_back({
  58. id + "-cell-" + std::to_string(column),
  59. column == 0 ? LadderCellKind::Node : LadderCellKind::Wire,
  60. column == 0 ? std::optional<LogicNode>{condition}
  61. : std::nullopt});
  62. }
  63. rung.output = std::move(output);
  64. return rung;
  65. }
  66. Project makeExampleProject()
  67. {
  68. Project project;
  69. project.metadata = {"example-project", "Example project", "2.0"};
  70. HmiPage page;
  71. page.id = "main-page";
  72. page.name = "Main";
  73. HmiControl button;
  74. button.id = "start-button";
  75. button.type = HmiControlType::Button;
  76. button.bounds = {10, 10, 100, 40};
  77. button.text = "Start";
  78. button.binding = RegisterAddress{RegisterArea::M, 0};
  79. button.buttonOperation = HmiButtonOperation::SetOn;
  80. page.controls.push_back(button);
  81. project.hmiPages.push_back(page);
  82. project.initialHmiPageId = page.id;
  83. ControlLogic logic;
  84. logic.id = "logic-1";
  85. logic.name = "Main logic";
  86. logic.rungs.push_back(makeRung(
  87. "rung-1", contact("start-contact", 0), coil("run-coil", 10)));
  88. logic.rungs.push_back(makeRung(
  89. "rung-2", edge("edge-contact", 1), coil("edge-coil", 11)));
  90. logic.rungs.push_back(makeRung(
  91. "rung-3", contact("move-contact", 2),
  92. LogicNode{
  93. "move-output",
  94. MoveNodeConfig{
  95. WordOperand{
  96. WordOperandKind::Constant,
  97. RegisterAddress{RegisterArea::D, 0},
  98. 25},
  99. RegisterAddress{RegisterArea::D, 20}},
  100. true}));
  101. logic.rungs.push_back(makeRung(
  102. "rung-4", contact("add-contact", 3),
  103. LogicNode{
  104. "add-output",
  105. ArithmeticNodeConfig{
  106. ArithmeticOperation::Add,
  107. WordOperand{
  108. WordOperandKind::Register,
  109. RegisterAddress{RegisterArea::D, 20},
  110. 0},
  111. WordOperand{
  112. WordOperandKind::Constant,
  113. RegisterAddress{RegisterArea::D, 0},
  114. 1},
  115. RegisterAddress{RegisterArea::D, 21}},
  116. true}));
  117. logic.verticalConnections = {
  118. {"vertical-left", "rung-1", "rung-2", 0},
  119. {"vertical-right", "rung-1", "rung-2", 1}};
  120. project.controlLogics.push_back(std::move(logic));
  121. project.registerComments.push_back({
  122. RegisterAddress{RegisterArea::M, 0}, "Start signal"});
  123. return project;
  124. }
  125. void writeBytes(const QString &path, const QByteArray &bytes)
  126. {
  127. QFile file(path);
  128. require(file.open(QIODevice::WriteOnly), "test file must be writable");
  129. require(file.write(bytes) == bytes.size(),
  130. "test file must be written completely");
  131. }
  132. QByteArray readBytes(const QString &path)
  133. {
  134. QFile file(path);
  135. require(file.open(QIODevice::ReadOnly), "test file must be readable");
  136. return file.readAll();
  137. }
  138. QJsonObject savedFixture(
  139. JsonProjectStorage &storage,
  140. const Project &project,
  141. const QString &path)
  142. {
  143. require(storage.save(project, path.toStdString()).succeeded,
  144. "fixture project must save");
  145. return QJsonDocument::fromJson(readBytes(path)).object();
  146. }
  147. void testEmptyProjectRoundTrip()
  148. {
  149. QTemporaryDir directory;
  150. require(directory.isValid(), "temporary directory must be valid");
  151. JsonProjectStorage storage;
  152. ProjectService service(storage);
  153. require(service.createNewProject("Empty project").succeeded,
  154. "empty project creation must succeed");
  155. require(service.project().metadata.formatVersion == "2.0",
  156. "new projects must use the strict grid format");
  157. const QString path = directory.filePath("empty.json");
  158. require(service.saveAs(path.toStdString()).succeeded,
  159. "empty project save must succeed");
  160. require(service.load(path.toStdString()).succeeded,
  161. "empty project load must succeed");
  162. require(service.project().controlLogics.empty()
  163. && service.project().hmiPages.empty(),
  164. "round trip must not invent project content");
  165. }
  166. void testGridProjectRoundTrip()
  167. {
  168. QTemporaryDir directory;
  169. require(directory.isValid(), "temporary directory must be valid");
  170. JsonProjectStorage storage;
  171. const QString path = directory.filePath("grid.json");
  172. const Project original = makeExampleProject();
  173. require(original.validate(), "the grid fixture must be structurally valid");
  174. require(storage.save(original, path.toStdString()).succeeded,
  175. "the grid project must save");
  176. const QByteArray json = readBytes(path);
  177. require(json.contains("\"formatVersion\": \"2.0\"")
  178. && json.contains("\"cells\"")
  179. && json.contains("\"verticalConnections\"")
  180. && json.contains("\"kind\": \"node\"")
  181. && json.contains("\"kind\": \"wire\""),
  182. "2.0 JSON must persist cells and independent vertical edges");
  183. require(!json.contains("\"condition\"")
  184. && !json.contains("\"children\""),
  185. "2.0 JSON must not serialize the removed expression tree");
  186. const ProjectLoadResult loaded = storage.load(path.toStdString());
  187. require(loaded.succeeded, "the grid project must load");
  188. const ControlLogic &logic = loaded.project.controlLogics.front();
  189. require(logic.rungs.size() == 4U
  190. && logic.rungs.front().cells.size() == 10U,
  191. "all rows and ten fixed cells must survive round trip");
  192. require(logic.verticalConnections.size() == 2U
  193. && logic.verticalConnections.front().columnBoundary == 0
  194. && logic.verticalConnections.back().columnBoundary == 1,
  195. "independent vertical edge positions must survive round trip");
  196. require(std::holds_alternative<EdgeContactNodeConfig>(
  197. logic.rungs[1].cells[0].node->config)
  198. && std::holds_alternative<MoveNodeConfig>(
  199. logic.rungs[2].output->config)
  200. && std::holds_alternative<ArithmeticNodeConfig>(
  201. logic.rungs[3].output->config),
  202. "typed ladder instructions must survive grid round trip");
  203. }
  204. void testMOffAlarmRoundTrip()
  205. {
  206. QTemporaryDir directory;
  207. require(directory.isValid(), "temporary directory must be valid");
  208. JsonProjectStorage storage;
  209. const QString path = directory.filePath("m-off-alarm.json");
  210. Project project = makeExampleProject();
  211. project.alarmDefinitions.push_back({
  212. "alarm-m-off",
  213. RegisterAddress{RegisterArea::M, 5},
  214. AlarmCondition::MOff,
  215. 0,
  216. "Safety circuit open"});
  217. require(storage.save(project, path.toStdString()).succeeded,
  218. "an M OFF alarm project must save");
  219. require(readBytes(path).contains("\"condition\": \"mOff\""),
  220. "M OFF alarms must serialize with the explicit mOff condition");
  221. const ProjectLoadResult loaded = storage.load(path.toStdString());
  222. require(loaded.succeeded
  223. && loaded.project.alarmDefinitions.size() == 1U
  224. && loaded.project.alarmDefinitions.front().condition
  225. == AlarmCondition::MOff
  226. && loaded.project.alarmDefinitions.front().address
  227. == RegisterAddress{RegisterArea::M, 5},
  228. "the M OFF condition and address must survive JSON round trip");
  229. }
  230. void testStrictVersionAndRequiredFields()
  231. {
  232. QTemporaryDir directory;
  233. require(directory.isValid(), "temporary directory must be valid");
  234. JsonProjectStorage storage;
  235. const QString fixture_path = directory.filePath("fixture.json");
  236. QJsonObject root = savedFixture(
  237. storage, makeExampleProject(), fixture_path);
  238. root.insert(QStringLiteral("formatVersion"), QStringLiteral("1.0"));
  239. const QString old_path = directory.filePath("old.json");
  240. writeBytes(old_path, QJsonDocument(root).toJson());
  241. require(storage.load(old_path.toStdString()).error
  242. == ProjectStorageError::UnsupportedVersion,
  243. "old 1.0 files must be rejected without migration code");
  244. root = QJsonDocument::fromJson(readBytes(fixture_path)).object();
  245. QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray();
  246. QJsonObject logic = logics.at(0).toObject();
  247. logic.remove(QStringLiteral("verticalConnections"));
  248. logics.replace(0, logic);
  249. root.insert(QStringLiteral("controlLogics"), logics);
  250. const QString missing_path = directory.filePath("missing-connections.json");
  251. writeBytes(missing_path, QJsonDocument(root).toJson());
  252. require(storage.load(missing_path.toStdString()).error
  253. == ProjectStorageError::MissingField,
  254. "2.0 must require the vertical connection array explicitly");
  255. }
  256. void testInvalidGridAndConnectionsAreRejected()
  257. {
  258. QTemporaryDir directory;
  259. require(directory.isValid(), "temporary directory must be valid");
  260. JsonProjectStorage storage;
  261. const QString fixture_path = directory.filePath("fixture.json");
  262. const QJsonObject original = savedFixture(
  263. storage, makeExampleProject(), fixture_path);
  264. const auto firstLogic = [](QJsonObject *root) -> QJsonObject
  265. {
  266. return root->value(QStringLiteral("controlLogics"))
  267. .toArray().at(0).toObject();
  268. };
  269. const auto replaceFirstLogic = [](
  270. QJsonObject *root, const QJsonObject &logic)
  271. {
  272. QJsonArray logics = root->value(
  273. QStringLiteral("controlLogics")).toArray();
  274. logics.replace(0, logic);
  275. root->insert(QStringLiteral("controlLogics"), logics);
  276. };
  277. QJsonObject short_grid = original;
  278. QJsonObject logic = firstLogic(&short_grid);
  279. QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray();
  280. QJsonObject rung = rungs.at(0).toObject();
  281. QJsonArray cells = rung.value(QStringLiteral("cells")).toArray();
  282. cells.removeLast();
  283. rung.insert(QStringLiteral("cells"), cells);
  284. rungs.replace(0, rung);
  285. logic.insert(QStringLiteral("rungs"), rungs);
  286. replaceFirstLogic(&short_grid, logic);
  287. const QString short_path = directory.filePath("short-grid.json");
  288. writeBytes(short_path, QJsonDocument(short_grid).toJson());
  289. require(storage.load(short_path.toStdString()).error
  290. == ProjectStorageError::InvalidField,
  291. "a row with fewer than ten cells must be rejected while parsing");
  292. QJsonObject duplicate_edge = original;
  293. logic = firstLogic(&duplicate_edge);
  294. QJsonArray edges = logic.value(
  295. QStringLiteral("verticalConnections")).toArray();
  296. QJsonObject duplicate = edges.at(0).toObject();
  297. duplicate.insert(QStringLiteral("id"), QStringLiteral("vertical-copy"));
  298. edges.append(duplicate);
  299. logic.insert(QStringLiteral("verticalConnections"), edges);
  300. replaceFirstLogic(&duplicate_edge, logic);
  301. const QString duplicate_path = directory.filePath("duplicate-edge.json");
  302. writeBytes(duplicate_path, QJsonDocument(duplicate_edge).toJson());
  303. require(storage.load(duplicate_path.toStdString()).error
  304. == ProjectStorageError::InvalidProject,
  305. "duplicate edges at one row boundary must be rejected");
  306. QJsonObject non_adjacent = original;
  307. logic = firstLogic(&non_adjacent);
  308. edges = logic.value(QStringLiteral("verticalConnections")).toArray();
  309. QJsonObject invalid_edge = edges.at(0).toObject();
  310. invalid_edge.insert(QStringLiteral("lowerRungId"),
  311. QStringLiteral("rung-3"));
  312. edges.replace(0, invalid_edge);
  313. logic.insert(QStringLiteral("verticalConnections"), edges);
  314. replaceFirstLogic(&non_adjacent, logic);
  315. const QString non_adjacent_path = directory.filePath("non-adjacent.json");
  316. writeBytes(non_adjacent_path, QJsonDocument(non_adjacent).toJson());
  317. require(storage.load(non_adjacent_path.toStdString()).error
  318. == ProjectStorageError::InvalidProject,
  319. "vertical edges between non-adjacent rows must be rejected");
  320. }
  321. void testProjectServiceStateAndConfiguredLimits()
  322. {
  323. QTemporaryDir directory;
  324. require(directory.isValid(), "temporary directory must be valid");
  325. const QString path = directory.filePath("project.json");
  326. JsonProjectStorage storage;
  327. require(storage.save(makeExampleProject(), path.toStdString()).succeeded,
  328. "service fixture must save");
  329. ProjectLimitSettings limits = defaultProjectLimitSettings();
  330. limits.maximumRungsPerLogic = 2U;
  331. JsonProjectStorage limited_storage(limits);
  332. ProjectService limited_service(limited_storage, limits);
  333. const std::string original_id = limited_service.project().metadata.id;
  334. const ProjectOperationResult load = limited_service.load(
  335. path.toStdString());
  336. require(!load.succeeded
  337. && load.storageError == ProjectStorageError::InvalidField,
  338. "configured row limits must be enforced during JSON parsing");
  339. require(limited_service.project().metadata.id == original_id
  340. && !limited_service.hasCurrentFile(),
  341. "a failed load must leave the current project untouched");
  342. ProjectService service(storage);
  343. require(service.save().error == ProjectServiceError::FilePathRequired,
  344. "save without a current path must fail explicitly");
  345. require(service.createNewProject("State project").succeeded,
  346. "a valid project name must create a project");
  347. require(service.saveAs(path.toStdString()).succeeded,
  348. "save-as must establish the current path");
  349. service.editProject().metadata.name = "Changed";
  350. require(service.isModified() && service.save().succeeded
  351. && !service.isModified(),
  352. "successful save must clear the modified state");
  353. }
  354. void testRegisterCommentService()
  355. {
  356. JsonProjectStorage storage;
  357. ProjectService projects(storage);
  358. RegisterCommentService comments(projects);
  359. require(comments.setComment(
  360. RegisterAddress{RegisterArea::D, 3}, " Target value ")
  361. .succeeded,
  362. "the comment service must trim and create comments");
  363. require(comments.setComment(
  364. RegisterAddress{RegisterArea::M, 2}, "Start signal")
  365. .succeeded,
  366. "the comment service must support M comments");
  367. require(comments.comments().size() == 2U
  368. && comments.comments().front().address.area()
  369. == RegisterArea::M
  370. && comments.comments().back().text == "Target value",
  371. "comments must remain sorted and trimmed");
  372. require(!comments.setComment(
  373. RegisterAddress{RegisterArea::M, 4}, " \t").succeeded,
  374. "blank comments must be rejected");
  375. require(comments.removeComment(
  376. RegisterAddress{RegisterArea::M, 2}).succeeded,
  377. "comments must be removable");
  378. }
  379. } // namespace
  380. int main()
  381. {
  382. try
  383. {
  384. testEmptyProjectRoundTrip();
  385. testGridProjectRoundTrip();
  386. testMOffAlarmRoundTrip();
  387. testStrictVersionAndRequiredFields();
  388. testInvalidGridAndConnectionsAreRejected();
  389. testProjectServiceStateAndConfiguredLimits();
  390. testRegisterCommentService();
  391. }
  392. catch (const std::exception &error)
  393. {
  394. std::cerr << "project management tests failed: "
  395. << error.what() << '\n';
  396. return 1;
  397. }
  398. std::cout << "project management tests passed\n";
  399. return 0;
  400. }