综合平台编程器项目的远程存储
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

1033 строки
47 KiB

  1. #include "domain/project_storage.h"
  2. #include "domain/hmi_model.h"
  3. #include "infrastructure/json_project_storage.h"
  4. #include "support/test_support.h"
  5. #include "services/register_comment_service.h"
  6. #include "services/project_service.h"
  7. #include "domain/project_limits.h"
  8. #include <QFile>
  9. #include <QJsonArray>
  10. #include <QJsonDocument>
  11. #include <QJsonObject>
  12. #include <QTemporaryDir>
  13. #include <exception>
  14. #include <iostream>
  15. #include <stdexcept>
  16. #include <string>
  17. #include <utility>
  18. namespace {
  19. using TestSupport::require;
  20. Project makeExampleProject()
  21. {
  22. // 构造覆盖 HMI 控件和梯形图串并联结构的完整 JSON 往返样本
  23. HmiControl start_button;
  24. start_button.id = "start-button";
  25. start_button.type = HmiControlType::Button;
  26. start_button.bounds = {10, 20, 120, 48};
  27. start_button.text = "Start";
  28. start_button.binding = RegisterAddress{RegisterArea::M, 0};
  29. start_button.buttonOperation = HmiButtonOperation::SetOn;
  30. start_button.properties.emplace("color", "green");
  31. start_button.properties.emplace(HmiAppearanceProperty::kTextColor, "#E53935");
  32. start_button.properties.emplace(HmiAppearanceProperty::kFontSize, "18");
  33. start_button.properties.emplace(HmiAppearanceProperty::kFontBold, "true");
  34. start_button.properties.emplace(HmiAppearanceProperty::kFontItalic, "false");
  35. HmiControl running_indicator;
  36. running_indicator.id = "running-indicator";
  37. running_indicator.type = HmiControlType::Indicator;
  38. running_indicator.bounds = {150, 20, 64, 64};
  39. running_indicator.text = "Running";
  40. running_indicator.binding = RegisterAddress{RegisterArea::M, 1};
  41. running_indicator.properties.emplace("activeColor", "#24a148");
  42. HmiControl temperature_display;
  43. temperature_display.id = "temperature-display";
  44. temperature_display.type = HmiControlType::NumericDisplay;
  45. temperature_display.bounds = {10, 90, 120, 40};
  46. temperature_display.text = "Temperature";
  47. temperature_display.binding = RegisterAddress{RegisterArea::D, 2};
  48. temperature_display.properties.emplace("format", "decimal");
  49. HmiControl target_input;
  50. target_input.id = "target-input";
  51. target_input.type = HmiControlType::NumericInput;
  52. target_input.bounds = {150, 90, 120, 40};
  53. target_input.text = "Target";
  54. target_input.binding = RegisterAddress{RegisterArea::D, 3};
  55. target_input.properties.emplace("minimum", "-100");
  56. HmiControl title_label;
  57. title_label.id = "title-label";
  58. title_label.type = HmiControlType::Label;
  59. title_label.bounds = {10, 145, 180, 32};
  60. title_label.text = "Production line";
  61. HmiControl settings_jump;
  62. settings_jump.id = "settings-jump";
  63. settings_jump.type = HmiControlType::PageJump;
  64. settings_jump.bounds = {210, 145, 120, 40};
  65. settings_jump.text = "Settings";
  66. settings_jump.pageJump = HmiPageJumpConfig{"settings-page"};
  67. HmiControl alarm_list;
  68. alarm_list.id = "alarm-list";
  69. alarm_list.type = HmiControlType::AlarmList;
  70. alarm_list.bounds = {10, 200, 360, 180};
  71. alarm_list.text = "Alarms";
  72. HmiControl progress_bar;
  73. progress_bar.id = "progress-bar";
  74. progress_bar.type = HmiControlType::ProgressBar;
  75. progress_bar.bounds = {400, 20, 240, 36};
  76. progress_bar.text = "Completion";
  77. progress_bar.binding = RegisterAddress{RegisterArea::D, 4};
  78. progress_bar.progressBar = HmiProgressBarConfig{-20, 80, true};
  79. HmiPage page;
  80. page.id = "main-page";
  81. page.name = "Main";
  82. page.controls.push_back(start_button);
  83. page.controls.push_back(running_indicator);
  84. page.controls.push_back(temperature_display);
  85. page.controls.push_back(target_input);
  86. page.controls.push_back(title_label);
  87. page.controls.push_back(settings_jump);
  88. page.controls.push_back(alarm_list);
  89. page.controls.push_back(progress_bar);
  90. HmiPage settings_page;
  91. settings_page.id = "settings-page";
  92. settings_page.name = "Settings";
  93. LogicNode contact;
  94. contact.id = "start-contact";
  95. contact.config = ContactNodeConfig{
  96. RegisterAddress{RegisterArea::M, 0},
  97. ContactMode::NormallyOpen};
  98. LogicNode compare;
  99. compare.id = "temperature-check";
  100. compare.config = CompareNodeConfig{
  101. RegisterAddress{RegisterArea::D, 2},
  102. ComparisonOperator::GreaterThanOrEqual,
  103. static_cast<std::int16_t>(100)};
  104. LogicNode hold_contact;
  105. hold_contact.id = "hold-contact";
  106. hold_contact.config = ContactNodeConfig{
  107. RegisterAddress{RegisterArea::M, 1},
  108. ContactMode::NormallyOpen};
  109. LogicNode coil;
  110. coil.id = "run-coil";
  111. coil.config = CoilNodeConfig{
  112. RegisterAddress{RegisterArea::M, 1},
  113. CoilMode::Set};
  114. ControlLogic logic;
  115. logic.id = "start-logic";
  116. logic.name = "Start logic";
  117. logic.enabled = false;
  118. LadderRung rung;
  119. rung.id = "rung-1";
  120. rung.name = "Network 1";
  121. rung.comment = "启动条件与温度检查";
  122. ConditionExpression parallel;
  123. parallel.id = "parallel-start";
  124. parallel.kind = ConditionExpressionKind::Parallel;
  125. parallel.children = {
  126. ConditionExpression::fromNode(contact),
  127. ConditionExpression::fromNode(hold_contact)};
  128. ConditionExpression series;
  129. series.id = "series-root";
  130. series.kind = ConditionExpressionKind::Series;
  131. series.children = {
  132. std::move(parallel),
  133. ConditionExpression::fromWire("start-wire", 2),
  134. ConditionExpression::fromNode(compare),
  135. ConditionExpression::fromWire("start-output-wire", 6)};
  136. rung.condition = std::move(series);
  137. rung.output = coil;
  138. logic.rungs.push_back(rung);
  139. LogicNode rising_edge;
  140. rising_edge.id = "rising-edge";
  141. rising_edge.config = EdgeContactNodeConfig{
  142. RegisterAddress{RegisterArea::M, 4}, EdgeMode::Rising};
  143. LogicNode edge_output;
  144. edge_output.id = "edge-output";
  145. edge_output.config = CoilNodeConfig{
  146. RegisterAddress{RegisterArea::M, 6}, CoilMode::Normal};
  147. LadderRung edge_rung;
  148. edge_rung.id = "edge-rung";
  149. edge_rung.name = "Edge network";
  150. edge_rung.comment = "上升沿输出";
  151. ConditionExpression edge_series;
  152. edge_series.id = "edge-series";
  153. edge_series.kind = ConditionExpressionKind::Series;
  154. edge_series.children = {
  155. ConditionExpression::fromNode(rising_edge),
  156. ConditionExpression::fromWire("edge-output-wire", 9)};
  157. edge_rung.condition = std::move(edge_series);
  158. edge_rung.output = edge_output;
  159. logic.rungs.push_back(edge_rung);
  160. const auto addDataRung = [&logic](
  161. const std::string &rung_id,
  162. const std::string &input_id,
  163. int input_address,
  164. LogicNode output)
  165. {
  166. LogicNode input;
  167. input.id = input_id;
  168. input.config = ContactNodeConfig{
  169. RegisterAddress{RegisterArea::M, input_address},
  170. ContactMode::NormallyOpen};
  171. LadderRung data_rung;
  172. data_rung.id = rung_id;
  173. data_rung.name = rung_id;
  174. ConditionExpression data_series;
  175. data_series.id = rung_id + "-series";
  176. data_series.kind = ConditionExpressionKind::Series;
  177. data_series.children = {
  178. ConditionExpression::fromNode(input),
  179. ConditionExpression::fromWire(rung_id + "-output-wire", 9)};
  180. data_rung.condition = std::move(data_series);
  181. data_rung.output = std::move(output);
  182. logic.rungs.push_back(std::move(data_rung));
  183. };
  184. addDataRung(
  185. "move-rung",
  186. "move-input",
  187. 20,
  188. LogicNode{
  189. "move-output",
  190. MoveNodeConfig{
  191. WordOperand{
  192. WordOperandKind::Constant,
  193. RegisterAddress{RegisterArea::D, 0},
  194. 25},
  195. RegisterAddress{RegisterArea::D, 20}},
  196. true});
  197. addDataRung(
  198. "add-rung",
  199. "add-input",
  200. 21,
  201. LogicNode{
  202. "add-output",
  203. ArithmeticNodeConfig{
  204. ArithmeticOperation::Add,
  205. WordOperand{
  206. WordOperandKind::Register,
  207. RegisterAddress{RegisterArea::D, 20},
  208. 0},
  209. WordOperand{
  210. WordOperandKind::Constant,
  211. RegisterAddress{RegisterArea::D, 0},
  212. 1},
  213. RegisterAddress{RegisterArea::D, 21}},
  214. true});
  215. addDataRung(
  216. "sub-rung",
  217. "sub-input",
  218. 22,
  219. LogicNode{
  220. "sub-output",
  221. ArithmeticNodeConfig{
  222. ArithmeticOperation::Subtract,
  223. WordOperand{
  224. WordOperandKind::Register,
  225. RegisterAddress{RegisterArea::D, 21},
  226. 0},
  227. WordOperand{
  228. WordOperandKind::Constant,
  229. RegisterAddress{RegisterArea::D, 0},
  230. 1},
  231. RegisterAddress{RegisterArea::D, 22}},
  232. true});
  233. Project project;
  234. project.metadata = {"example-project", "Example project", "1.0"};
  235. project.hmiPages.push_back(page);
  236. project.hmiPages.push_back(settings_page);
  237. project.initialHmiPageId = page.id;
  238. project.alarmDefinitions.push_back(
  239. {"alarm-emergency",
  240. RegisterAddress{RegisterArea::M, 10},
  241. AlarmCondition::MOn,
  242. 0,
  243. "Emergency stop"});
  244. project.alarmDefinitions.push_back(
  245. {"alarm-temperature",
  246. RegisterAddress{RegisterArea::D, 2},
  247. AlarmCondition::DHigh,
  248. 80,
  249. "Temperature high"});
  250. project.registerComments = {
  251. {RegisterAddress{RegisterArea::M, 0}, "启动按钮"},
  252. {RegisterAddress{RegisterArea::D, 2}, "当前温度"}};
  253. project.controlLogics.push_back(logic);
  254. ControlLogic draft_logic;
  255. draft_logic.id = "draft-logic";
  256. draft_logic.name = "Draft logic";
  257. draft_logic.enabled = false;
  258. draft_logic.rungs.push_back(
  259. {"rung-1", "Draft network", {}, std::nullopt, std::nullopt});
  260. project.controlLogics.push_back(draft_logic);
  261. return project;
  262. }
  263. void writeText(const QString &path, const QByteArray &content)
  264. {
  265. // 直接写入故障样本文件,以验证加载失败时的保护行为
  266. QFile file(path);
  267. require(file.open(QIODevice::WriteOnly), "test file must be writable");
  268. require(file.write(content) == content.size(), "test file must be written completely");
  269. }
  270. QByteArray readBytes(const QString &path)
  271. {
  272. QFile file(path);
  273. require(file.open(QIODevice::ReadOnly), "saved project must be readable");
  274. return file.readAll();
  275. }
  276. void testEmptyProjectRoundTrip()
  277. {
  278. // 空工程是合法工程,保存再加载后不应凭空产生页面或逻辑
  279. QTemporaryDir directory;
  280. require(directory.isValid(), "temporary directory must be valid");
  281. JsonProjectStorage storage;
  282. ProjectService service(storage);
  283. require(service.createNewProject("Empty project").succeeded,
  284. "empty project creation must succeed");
  285. const QString path = directory.filePath("empty.json");
  286. require(service.saveAs(path.toStdString()).succeeded,
  287. "empty project save must succeed");
  288. require(!service.isModified(), "saved project must not be marked modified");
  289. require(service.load(path.toStdString()).succeeded,
  290. "empty project load must succeed");
  291. require(service.project().metadata.name == "Empty project",
  292. "empty project name must survive round trip");
  293. require(service.project().hmiPages.empty(), "empty project must have no HMI pages");
  294. require(service.project().controlLogics.empty(),
  295. "empty project must have no control logics");
  296. require(service.project().alarmDefinitions.empty(),
  297. "empty project must have no alarm definitions");
  298. }
  299. void testUnconditionalOutputRoundTrip()
  300. {
  301. QTemporaryDir directory;
  302. require(directory.isValid(), "temporary directory must be valid");
  303. JsonProjectStorage storage;
  304. ProjectService service(storage);
  305. require(service.createNewProject("Unconditional output").succeeded,
  306. "unconditional output project creation must succeed");
  307. LadderRung rung;
  308. rung.id = "unconditional-rung";
  309. rung.name = "Unconditional rung";
  310. LogicNode coil;
  311. coil.id = "unconditional-coil";
  312. coil.config = CoilNodeConfig{
  313. RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal};
  314. rung.condition = ConditionExpression::fromWire(
  315. "unconditional-wire", ProjectLimits::kMaximumConditionColumns);
  316. rung.output = coil;
  317. service.editProject().controlLogics.push_back(
  318. ControlLogic{"logic-1", "Logic 1", {rung}, true});
  319. require(service.project().validateForRunning(),
  320. "an explicit full-width wire project must be runnable before saving");
  321. const QString path = directory.filePath("unconditional-output.json");
  322. require(service.saveAs(path.toStdString()).succeeded,
  323. "an output-only project must save successfully");
  324. require(service.load(path.toStdString()).succeeded,
  325. "an output-only project must load successfully");
  326. const LadderRung &loaded = service.project().controlLogics.front().rungs.front();
  327. require(loaded.condition.has_value()
  328. && loaded.condition->kind == ConditionExpressionKind::Wire
  329. && loaded.condition->wire->columnSpan
  330. == ProjectLimits::kMaximumConditionColumns
  331. && loaded.output.has_value()
  332. && loaded.validateForRunning(),
  333. "JSON round trip must preserve explicit unconditional wire semantics");
  334. QJsonObject implicit_root = QJsonDocument::fromJson(readBytes(path)).object();
  335. QJsonArray logics = implicit_root.value(QStringLiteral("controlLogics")).toArray();
  336. QJsonObject logic = logics.at(0).toObject();
  337. QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray();
  338. QJsonObject implicit_rung = rungs.at(0).toObject();
  339. implicit_rung.insert(QStringLiteral("condition"), QJsonValue::Null);
  340. rungs.replace(0, implicit_rung);
  341. logic.insert(QStringLiteral("rungs"), rungs);
  342. logics.replace(0, logic);
  343. implicit_root.insert(QStringLiteral("controlLogics"), logics);
  344. const QString implicit_path = directory.filePath("implicit-output.json");
  345. writeText(
  346. implicit_path,
  347. QJsonDocument(implicit_root).toJson(QJsonDocument::Indented));
  348. require(!service.load(implicit_path.toStdString()).succeeded,
  349. "version 1.0 implicit output wiring must not be migrated on load");
  350. }
  351. void testGapRoundTrip()
  352. {
  353. QTemporaryDir directory;
  354. require(directory.isValid(), "temporary directory must be valid");
  355. JsonProjectStorage storage;
  356. ProjectService service(storage);
  357. require(service.createNewProject("Gap draft").succeeded,
  358. "gap draft project creation must succeed");
  359. LadderRung gap_rung;
  360. gap_rung.id = "gap-rung";
  361. gap_rung.name = "Gap rung";
  362. gap_rung.condition = ConditionExpression::fromGap(
  363. "gap-full-width", ProjectLimits::kMaximumConditionColumns);
  364. gap_rung.output = LogicNode{
  365. "gap-output",
  366. CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal},
  367. true};
  368. service.editProject().controlLogics.push_back(
  369. ControlLogic{"gap-logic", "Gap logic", {gap_rung}, true});
  370. require(service.project().validate() && !service.project().validateForRunning(),
  371. "a gap network must be saveable but not runnable");
  372. const QString gap_path = directory.filePath("gap-project.json");
  373. require(service.saveAs(gap_path.toStdString()).succeeded,
  374. "a gap draft must save successfully");
  375. require(readBytes(gap_path).contains("\"kind\": \"gap\""),
  376. "version 1.0 JSON must persist gap expressions explicitly");
  377. require(service.load(gap_path.toStdString()).succeeded,
  378. "a version 1.0 gap draft must load successfully");
  379. const LadderRung &loaded_gap =
  380. service.project().controlLogics.front().rungs.front();
  381. require(service.project().metadata.formatVersion == "1.0"
  382. && loaded_gap.condition.has_value()
  383. && loaded_gap.condition->kind == ConditionExpressionKind::Gap
  384. && loaded_gap.condition->gap->columnSpan
  385. == ProjectLimits::kMaximumConditionColumns
  386. && !loaded_gap.validateForRunning(),
  387. "gap JSON round trip must preserve the disconnected runtime state");
  388. }
  389. void testExampleProjectRoundTrip()
  390. {
  391. // 验证各层嵌套字段往返后保持不变且序列化结果稳定
  392. QTemporaryDir directory;
  393. require(directory.isValid(), "temporary directory must be valid");
  394. JsonProjectStorage storage;
  395. ProjectService service(storage);
  396. service.editProject() = makeExampleProject();
  397. const QString first_path = directory.filePath("example.json");
  398. const QString second_path = directory.filePath("example-copy.json");
  399. const QString invalid_operation_path = directory.filePath("invalid-operation.json");
  400. const QString missing_initial_path = directory.filePath("missing-initial.json");
  401. const QString missing_target_path = directory.filePath("missing-target.json");
  402. const QString missing_progress_range_path = directory.filePath(
  403. "missing-progress-range.json");
  404. const QString missing_alarms_path = directory.filePath("missing-alarms.json");
  405. const QString missing_register_comments_path = directory.filePath(
  406. "missing-register-comments.json");
  407. const QString missing_rung_comment_path = directory.filePath(
  408. "missing-rung-comment.json");
  409. const QString multiline_register_comment_path = directory.filePath(
  410. "multiline-register-comment.json");
  411. const QString multiline_rung_comment_path = directory.filePath(
  412. "multiline-rung-comment.json");
  413. require(service.saveAs(first_path.toStdString()).succeeded,
  414. "example project save must succeed");
  415. const QByteArray saved_json = readBytes(first_path);
  416. require(saved_json.contains("\"rungs\"")
  417. && saved_json.contains("\"condition\"")
  418. && saved_json.contains("\"children\"")
  419. && saved_json.contains("\"output\""),
  420. "saved project must use structured ladder expressions");
  421. require(!saved_json.contains("\"dataPoints\""),
  422. "saved project must not contain the removed data point model");
  423. require(saved_json.contains("\"formatVersion\": \"1.0\"")
  424. && saved_json.contains("\"buttonOperation\": \"setOn\"")
  425. && saved_json.contains("\"initialHmiPageId\": \"main-page\"")
  426. && saved_json.contains("\"targetPageId\": \"settings-page\"")
  427. && saved_json.contains("\"alarmDefinitions\"")
  428. && saved_json.contains("\"registerComments\"")
  429. && saved_json.contains("\"text\": \"启动按钮\"")
  430. && saved_json.contains("\"type\": \"edgeContact\"")
  431. && saved_json.contains("\"type\": \"move\"")
  432. && saved_json.contains("\"type\": \"arithmetic\"")
  433. && saved_json.contains("\"operation\": \"add\"")
  434. && saved_json.contains("\"operation\": \"subtract\"")
  435. && saved_json.contains("\"comment\": \"启动条件与温度检查\"")
  436. && saved_json.contains("\"type\": \"alarmList\"")
  437. && saved_json.contains("\"type\": \"progressBar\"")
  438. && saved_json.contains("\"minimumValue\": -20")
  439. && saved_json.contains("\"maximumValue\": 80")
  440. && saved_json.contains("\"showValue\": true"),
  441. "version 1.0 projects must persist pages and alarm definitions");
  442. require(!saved_json.contains("\"stages\"")
  443. && !saved_json.contains("\"branches\""),
  444. "current project format must not contain the removed stage model");
  445. require(!saved_json.contains("\"position\"")
  446. && !saved_json.contains("\"connections\""),
  447. "saved ladder logic must not contain free-graph fields");
  448. require(service.load(first_path.toStdString()).succeeded,
  449. "example project load must succeed");
  450. const Project &project = service.project();
  451. require(project.metadata.id == "example-project", "project id must survive round trip");
  452. require(project.hmiPages.size() == 2, "HMI page count must survive round trip");
  453. require(project.initialHmiPageId == "main-page",
  454. "the initial HMI page id must survive round trip");
  455. require(project.hmiPages.front().controls.size() == 8,
  456. "register, navigation, AlarmList and ProgressBar controls must survive round trip");
  457. require(project.hmiPages.front().controls.front().binding->area()
  458. == RegisterArea::M,
  459. "HMI M binding must survive round trip");
  460. require(project.hmiPages.front().controls.front().buttonOperation
  461. == HmiButtonOperation::SetOn,
  462. "HMI button operation must survive round trip");
  463. require(project.hmiPages.front().controls.front().properties.at("color") == "green",
  464. "HMI properties must survive round trip");
  465. require(project.hmiPages.front().controls.front().properties.at(
  466. HmiAppearanceProperty::kTextColor) == "#E53935"
  467. && project.hmiPages.front().controls.front().properties.at(
  468. HmiAppearanceProperty::kFontSize) == "18"
  469. && project.hmiPages.front().controls.front().properties.at(
  470. HmiAppearanceProperty::kFontBold) == "true"
  471. && project.hmiPages.front().controls.front().properties.at(
  472. HmiAppearanceProperty::kFontItalic) == "false",
  473. "HMI appearance properties must survive round trip");
  474. require(project.hmiPages.front().controls.at(1).type == HmiControlType::Indicator,
  475. "indicator control type must survive round trip");
  476. require(project.hmiPages.front().controls.at(2).binding->area() == RegisterArea::D,
  477. "numeric display D binding must survive round trip");
  478. require(project.hmiPages.front().controls.at(3).bounds.x == 150,
  479. "numeric input bounds must survive round trip");
  480. require(project.hmiPages.front().controls.at(4).type == HmiControlType::Label
  481. && project.hmiPages.front().controls.at(5).pageJump->targetPageId
  482. == "settings-page",
  483. "Label and PageJump typed data must survive round trip");
  484. require(project.hmiPages.front().controls.at(6).type
  485. == HmiControlType::AlarmList,
  486. "AlarmList control type must survive round trip");
  487. require(project.hmiPages.front().controls.at(7).type
  488. == HmiControlType::ProgressBar
  489. && project.hmiPages.front().controls.at(7).progressBar.has_value()
  490. && project.hmiPages.front().controls.at(7).progressBar->minimumValue == -20
  491. && project.hmiPages.front().controls.at(7).progressBar->maximumValue == 80
  492. && project.hmiPages.front().controls.at(7).progressBar->showValue,
  493. "ProgressBar configuration must survive round trip");
  494. require(project.alarmDefinitions.size() == 2
  495. && project.alarmDefinitions.front().condition
  496. == AlarmCondition::MOn
  497. && project.alarmDefinitions.at(1).threshold == 80,
  498. "M and D alarm definitions must survive round trip");
  499. require(project.registerComments.size() == 2
  500. && project.registerComments.front().address.area() == RegisterArea::M
  501. && project.registerComments.front().text == "启动按钮"
  502. && project.registerComments.at(1).address.area() == RegisterArea::D,
  503. "M/D register comments must survive round trip");
  504. require(project.controlLogics.size() == 2,
  505. "control logic count must survive round trip");
  506. require(!project.controlLogics.front().enabled,
  507. "control logic enabled state must survive round trip");
  508. const LadderRung &rung = project.controlLogics.front().rungs.front();
  509. require(rung.comment == "启动条件与温度检查",
  510. "rung comments must survive round trip");
  511. require(rung.condition.has_value()
  512. && rung.condition->kind == ConditionExpressionKind::Series,
  513. "series root expression must survive round trip");
  514. require(rung.condition->children.front().kind
  515. == ConditionExpressionKind::Parallel
  516. && rung.condition->children.front().children.size() == 2U,
  517. "parallel expression branches must survive round trip");
  518. require(rung.condition->children.at(1).kind == ConditionExpressionKind::Wire
  519. && rung.condition->children.at(1).wire->columnSpan == 2,
  520. "horizontal wire span must survive JSON round trip");
  521. require(rung.output.has_value(),
  522. "ladder output must survive round trip");
  523. const LadderRung &edge_rung = project.controlLogics.front().rungs.at(1);
  524. require(edge_rung.output.has_value()
  525. && std::holds_alternative<CoilNodeConfig>(edge_rung.output->config)
  526. && std::get<CoilNodeConfig>(edge_rung.output->config).address
  527. == RegisterAddress{RegisterArea::M, 6},
  528. "edge network output must survive round trip");
  529. require(std::holds_alternative<EdgeContactNodeConfig>(
  530. edge_rung.condition->children.front().node->config)
  531. && std::get<EdgeContactNodeConfig>(
  532. edge_rung.condition->children.front().node->config).mode
  533. == EdgeMode::Rising,
  534. "rising edge configuration must survive round trip");
  535. require(std::get<MoveNodeConfig>(
  536. project.controlLogics.front().rungs.at(2).output->config)
  537. .source.constant == 25,
  538. "MOVE operands must survive round trip");
  539. require(std::get<ArithmeticNodeConfig>(
  540. project.controlLogics.front().rungs.at(3).output->config)
  541. .operation == ArithmeticOperation::Add
  542. && std::get<ArithmeticNodeConfig>(
  543. project.controlLogics.front().rungs.at(4).output->config)
  544. .operation == ArithmeticOperation::Subtract,
  545. "ADD and SUB operations must survive round trip");
  546. const auto &compare = std::get<CompareNodeConfig>(
  547. rung.condition->children.at(2).node->config);
  548. require(compare.address.index() == 2 && compare.value == 100,
  549. "comparison configuration must survive round trip");
  550. require(service.saveAs(second_path.toStdString()).succeeded,
  551. "save as must succeed after load");
  552. require(readBytes(first_path) == readBytes(second_path),
  553. "save and save as must produce stable JSON");
  554. QByteArray invalid_operation = saved_json;
  555. invalid_operation.replace(
  556. "\"buttonOperation\": \"setOn\"",
  557. "\"buttonOperation\": \"unsupported\"");
  558. writeText(invalid_operation_path, invalid_operation);
  559. require(!service.load(invalid_operation_path.toStdString()).succeeded,
  560. "unsupported HMI button operations must be rejected");
  561. QJsonObject missing_initial = QJsonDocument::fromJson(saved_json).object();
  562. missing_initial.remove(QStringLiteral("initialHmiPageId"));
  563. writeText(
  564. missing_initial_path,
  565. QJsonDocument(missing_initial).toJson(QJsonDocument::Compact));
  566. ProjectOperationResult missing_result = service.load(
  567. missing_initial_path.toStdString());
  568. require(!missing_result.succeeded
  569. && missing_result.storageError == ProjectStorageError::MissingField,
  570. "the 1.0 schema must require initialHmiPageId without migration defaults");
  571. QJsonObject missing_alarms = QJsonDocument::fromJson(saved_json).object();
  572. missing_alarms.remove(QStringLiteral("alarmDefinitions"));
  573. writeText(
  574. missing_alarms_path,
  575. QJsonDocument(missing_alarms).toJson(QJsonDocument::Compact));
  576. missing_result = service.load(missing_alarms_path.toStdString());
  577. require(!missing_result.succeeded
  578. && missing_result.storageError == ProjectStorageError::MissingField,
  579. "the 1.0 schema must require alarmDefinitions without migration defaults");
  580. QJsonObject missing_register_comments = QJsonDocument::fromJson(saved_json).object();
  581. missing_register_comments.remove(QStringLiteral("registerComments"));
  582. writeText(
  583. missing_register_comments_path,
  584. QJsonDocument(missing_register_comments).toJson(QJsonDocument::Compact));
  585. missing_result = service.load(missing_register_comments_path.toStdString());
  586. require(!missing_result.succeeded
  587. && missing_result.storageError == ProjectStorageError::MissingField,
  588. "the 1.0 schema must require registerComments without migration defaults");
  589. QJsonObject missing_rung_comment = QJsonDocument::fromJson(saved_json).object();
  590. QJsonArray missing_comment_logics = missing_rung_comment.value(
  591. QStringLiteral("controlLogics")).toArray();
  592. QJsonObject first_logic = missing_comment_logics.at(0).toObject();
  593. QJsonArray first_rungs = first_logic.value(QStringLiteral("rungs")).toArray();
  594. QJsonObject first_rung = first_rungs.at(0).toObject();
  595. first_rung.remove(QStringLiteral("comment"));
  596. first_rungs.replace(0, first_rung);
  597. first_logic.insert(QStringLiteral("rungs"), first_rungs);
  598. missing_comment_logics.replace(0, first_logic);
  599. missing_rung_comment.insert(QStringLiteral("controlLogics"), missing_comment_logics);
  600. writeText(
  601. missing_rung_comment_path,
  602. QJsonDocument(missing_rung_comment).toJson(QJsonDocument::Compact));
  603. missing_result = service.load(missing_rung_comment_path.toStdString());
  604. require(!missing_result.succeeded
  605. && missing_result.storageError == ProjectStorageError::MissingField,
  606. "the 1.0 schema must require rung comments without migration defaults");
  607. QJsonObject multiline_register_comment = QJsonDocument::fromJson(
  608. saved_json).object();
  609. QJsonArray comments = multiline_register_comment.value(
  610. QStringLiteral("registerComments")).toArray();
  611. QJsonObject first_comment = comments.at(0).toObject();
  612. first_comment.insert(QStringLiteral("text"), QStringLiteral("第一行\n第二行"));
  613. comments.replace(0, first_comment);
  614. multiline_register_comment.insert(QStringLiteral("registerComments"), comments);
  615. writeText(
  616. multiline_register_comment_path,
  617. QJsonDocument(multiline_register_comment).toJson(QJsonDocument::Compact));
  618. missing_result = service.load(multiline_register_comment_path.toStdString());
  619. require(!missing_result.succeeded
  620. && missing_result.storageError == ProjectStorageError::InvalidProject,
  621. "JSON loading must reject multiline register comments");
  622. QJsonObject multiline_rung_comment = QJsonDocument::fromJson(saved_json).object();
  623. QJsonArray multiline_logics = multiline_rung_comment.value(
  624. QStringLiteral("controlLogics")).toArray();
  625. first_logic = multiline_logics.at(0).toObject();
  626. first_rungs = first_logic.value(QStringLiteral("rungs")).toArray();
  627. first_rung = first_rungs.at(0).toObject();
  628. first_rung.insert(QStringLiteral("comment"), QStringLiteral("第一行\n第二行"));
  629. first_rungs.replace(0, first_rung);
  630. first_logic.insert(QStringLiteral("rungs"), first_rungs);
  631. multiline_logics.replace(0, first_logic);
  632. multiline_rung_comment.insert(QStringLiteral("controlLogics"), multiline_logics);
  633. writeText(
  634. multiline_rung_comment_path,
  635. QJsonDocument(multiline_rung_comment).toJson(QJsonDocument::Compact));
  636. missing_result = service.load(multiline_rung_comment_path.toStdString());
  637. require(!missing_result.succeeded
  638. && missing_result.storageError == ProjectStorageError::InvalidProject,
  639. "JSON loading must reject multiline rung comments");
  640. QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object();
  641. QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray();
  642. QJsonObject main_page = pages.at(0).toObject();
  643. QJsonArray controls = main_page.value(QStringLiteral("controls")).toArray();
  644. QJsonObject jump = controls.at(5).toObject();
  645. jump.remove(QStringLiteral("targetPageId"));
  646. controls.replace(5, jump);
  647. main_page.insert(QStringLiteral("controls"), controls);
  648. pages.replace(0, main_page);
  649. missing_target.insert(QStringLiteral("hmiPages"), pages);
  650. writeText(
  651. missing_target_path,
  652. QJsonDocument(missing_target).toJson(QJsonDocument::Compact));
  653. missing_result = service.load(missing_target_path.toStdString());
  654. require(!missing_result.succeeded
  655. && missing_result.storageError == ProjectStorageError::MissingField,
  656. "the 1.0 schema must require PageJump targetPageId");
  657. QJsonObject missing_progress_range = QJsonDocument::fromJson(saved_json).object();
  658. pages = missing_progress_range.value(QStringLiteral("hmiPages")).toArray();
  659. main_page = pages.at(0).toObject();
  660. controls = main_page.value(QStringLiteral("controls")).toArray();
  661. QJsonObject progress = controls.at(7).toObject();
  662. progress.remove(QStringLiteral("maximumValue"));
  663. controls.replace(7, progress);
  664. main_page.insert(QStringLiteral("controls"), controls);
  665. pages.replace(0, main_page);
  666. missing_progress_range.insert(QStringLiteral("hmiPages"), pages);
  667. writeText(
  668. missing_progress_range_path,
  669. QJsonDocument(missing_progress_range).toJson(QJsonDocument::Compact));
  670. missing_result = service.load(missing_progress_range_path.toStdString());
  671. require(!missing_result.succeeded
  672. && missing_result.storageError == ProjectStorageError::MissingField,
  673. "the 1.0 schema must require the ProgressBar value range");
  674. }
  675. void testInvalidFiles()
  676. {
  677. // 非法文件必须被拒绝,并且不得覆盖服务中当前工程
  678. QTemporaryDir directory;
  679. require(directory.isValid(), "temporary directory must be valid");
  680. JsonProjectStorage storage;
  681. ProjectService service(storage);
  682. service.editProject().metadata.name = "Current project";
  683. const QString invalid_json = directory.filePath("invalid-json.json");
  684. const QString missing_field = directory.filePath("missing-field.json");
  685. const QString unsupported_version = directory.filePath("unsupported-version.json");
  686. writeText(invalid_json, "{");
  687. auto result = service.load(invalid_json.toStdString());
  688. require(!result.succeeded
  689. && result.storageError == ProjectStorageError::InvalidJson,
  690. "invalid JSON must be rejected");
  691. require(service.project().metadata.name == "Current project",
  692. "invalid load must keep current project");
  693. writeText(missing_field, R"({"formatVersion":"1.0"})");
  694. result = service.load(missing_field.toStdString());
  695. require(!result.succeeded
  696. && result.storageError == ProjectStorageError::MissingField,
  697. "missing fields must be rejected");
  698. writeText(unsupported_version, R"({"formatVersion":"2.0"})");
  699. result = service.load(unsupported_version.toStdString());
  700. require(!result.succeeded
  701. && result.storageError == ProjectStorageError::UnsupportedVersion,
  702. "unsupported versions must be rejected");
  703. }
  704. void testQuantityFileLimits()
  705. {
  706. QTemporaryDir directory;
  707. require(directory.isValid(), "temporary directory must be valid");
  708. JsonProjectStorage storage;
  709. ProjectService service(storage);
  710. require(service.createNewProject("Quantity limits").succeeded,
  711. "the quantity-limit fixture project must be created");
  712. const QString oversized_path = directory.filePath("oversized.json");
  713. const QByteArray oversized(
  714. static_cast<int>(ProjectLimits::kMaximumProjectFileBytes + 1U), ' ');
  715. writeText(oversized_path, oversized);
  716. const ProjectLoadResult oversized_result =
  717. storage.load(oversized_path.toStdString());
  718. require(!oversized_result.succeeded
  719. && oversized_result.error == ProjectStorageError::InvalidJson,
  720. "a JSON file over 16 MiB must be rejected before parsing");
  721. const QString valid_path = directory.filePath("fixture.json");
  722. require(service.saveAs(valid_path.toStdString()).succeeded,
  723. "the quantity-limit fixture must be saved");
  724. QJsonObject root = QJsonDocument::fromJson(readBytes(valid_path)).object();
  725. QJsonArray too_many_pages;
  726. for (int index = 0;
  727. index <= static_cast<int>(ProjectLimits::kMaximumHmiPages);
  728. ++index)
  729. {
  730. too_many_pages.append(QJsonObject{});
  731. }
  732. root.insert(QStringLiteral("hmiPages"), too_many_pages);
  733. const QString too_many_pages_path = directory.filePath("too-many-pages.json");
  734. writeText(
  735. too_many_pages_path,
  736. QJsonDocument(root).toJson(QJsonDocument::Compact));
  737. const ProjectLoadResult too_many_pages_result =
  738. storage.load(too_many_pages_path.toStdString());
  739. require(!too_many_pages_result.succeeded
  740. && too_many_pages_result.error == ProjectStorageError::InvalidField,
  741. "a JSON page array over the configured limit must be rejected");
  742. service.editProject() = makeExampleProject();
  743. require(service.saveAs(valid_path.toStdString()).succeeded,
  744. "the nested-expression fixture must be saved");
  745. root = QJsonDocument::fromJson(readBytes(valid_path)).object();
  746. QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray();
  747. QJsonObject logic = logics.at(0).toObject();
  748. QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray();
  749. QJsonObject rung = rungs.at(0).toObject();
  750. QJsonObject leaf = rung.value(QStringLiteral("condition")).toObject();
  751. while (leaf.value(QStringLiteral("kind")).toString() != QStringLiteral("node"))
  752. {
  753. leaf = leaf.value(QStringLiteral("children")).toArray().at(0).toObject();
  754. }
  755. QJsonObject nested = leaf;
  756. for (int depth = 1;
  757. depth <= static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1;
  758. ++depth)
  759. {
  760. QJsonObject sibling = leaf;
  761. sibling.insert(
  762. QStringLiteral("id"),
  763. QStringLiteral("json-depth-sibling-") + QString::number(depth));
  764. QJsonArray children;
  765. children.append(nested);
  766. children.append(sibling);
  767. QJsonObject parent;
  768. parent.insert(
  769. QStringLiteral("id"),
  770. QStringLiteral("json-depth-") + QString::number(depth));
  771. parent.insert(
  772. QStringLiteral("kind"),
  773. depth % 2 == 0 ? QStringLiteral("parallel") : QStringLiteral("series"));
  774. parent.insert(QStringLiteral("children"), children);
  775. nested = parent;
  776. }
  777. rung.insert(QStringLiteral("condition"), nested);
  778. rungs[0] = rung;
  779. logic.insert(QStringLiteral("rungs"), rungs);
  780. logics[0] = logic;
  781. root.insert(QStringLiteral("controlLogics"), logics);
  782. const QString deep_path = directory.filePath("too-deep.json");
  783. writeText(deep_path, QJsonDocument(root).toJson(QJsonDocument::Compact));
  784. const ProjectLoadResult deep_result = storage.load(deep_path.toStdString());
  785. require(!deep_result.succeeded
  786. && deep_result.error == ProjectStorageError::InvalidField,
  787. "a condition expression deeper than the configured limit must be rejected while parsing");
  788. }
  789. void testHmiPageJsonBoundaries()
  790. {
  791. // JSON 入口必须和页面领域校验使用同一组业务边界
  792. QTemporaryDir directory;
  793. require(directory.isValid(), "temporary directory must be valid");
  794. JsonProjectStorage storage;
  795. ProjectService service(storage);
  796. Project boundary_project = makeExampleProject();
  797. boundary_project.hmiPages.front().controls.clear();
  798. service.editProject() = std::move(boundary_project);
  799. const QString fixture_path = directory.filePath("hmi-page-boundary-fixture.json");
  800. require(service.saveAs(fixture_path.toStdString()).succeeded,
  801. "the HMI boundary fixture must be saved");
  802. const QJsonObject original_root =
  803. QJsonDocument::fromJson(readBytes(fixture_path)).object();
  804. const auto loadWithSize = [&](int width, int height, const QString &name)
  805. {
  806. QJsonObject root = original_root;
  807. QJsonArray pages = root.value(QStringLiteral("hmiPages")).toArray();
  808. QJsonObject page = pages.at(0).toObject();
  809. page.insert(QStringLiteral("width"), width);
  810. page.insert(QStringLiteral("height"), height);
  811. pages.replace(0, page);
  812. root.insert(QStringLiteral("hmiPages"), pages);
  813. const QString path = directory.filePath(name);
  814. writeText(path, QJsonDocument(root).toJson(QJsonDocument::Compact));
  815. return storage.load(path.toStdString());
  816. };
  817. require(!loadWithSize(
  818. ProjectLimits::kMinimumHmiPageWidth - 1,
  819. ProjectLimits::kDefaultHmiPageHeight,
  820. QStringLiteral("width-below-minimum.json"))
  821. .succeeded,
  822. "JSON must reject an HMI page width below 320");
  823. require(!loadWithSize(
  824. ProjectLimits::kMaximumHmiPageWidth + 1,
  825. ProjectLimits::kDefaultHmiPageHeight,
  826. QStringLiteral("width-above-maximum.json"))
  827. .succeeded,
  828. "JSON must reject an HMI page width above 1600");
  829. require(!loadWithSize(
  830. ProjectLimits::kDefaultHmiPageWidth,
  831. ProjectLimits::kMinimumHmiPageHeight - 1,
  832. QStringLiteral("height-below-minimum.json"))
  833. .succeeded,
  834. "JSON must reject an HMI page height below 200");
  835. require(!loadWithSize(
  836. ProjectLimits::kDefaultHmiPageWidth,
  837. ProjectLimits::kMaximumHmiPageHeight + 1,
  838. QStringLiteral("height-above-maximum.json"))
  839. .succeeded,
  840. "JSON must reject an HMI page height above 800");
  841. const ProjectLoadResult minimum_result = loadWithSize(
  842. ProjectLimits::kMinimumHmiPageWidth,
  843. ProjectLimits::kMinimumHmiPageHeight,
  844. QStringLiteral("minimum-size.json"));
  845. require(minimum_result.succeeded
  846. && minimum_result.project.hmiPages.front().width
  847. == ProjectLimits::kMinimumHmiPageWidth
  848. && minimum_result.project.hmiPages.front().height
  849. == ProjectLimits::kMinimumHmiPageHeight,
  850. "JSON must accept the minimum HMI page size 320x200");
  851. const ProjectLoadResult maximum_result = loadWithSize(
  852. ProjectLimits::kMaximumHmiPageWidth,
  853. ProjectLimits::kMaximumHmiPageHeight,
  854. QStringLiteral("maximum-size.json"));
  855. require(maximum_result.succeeded
  856. && maximum_result.project.hmiPages.front().width
  857. == ProjectLimits::kMaximumHmiPageWidth
  858. && maximum_result.project.hmiPages.front().height
  859. == ProjectLimits::kMaximumHmiPageHeight,
  860. "JSON must accept the maximum HMI page size 1600x800");
  861. }
  862. void testServiceStateAndSaveErrors()
  863. {
  864. // 保存路径和修改标记只在成功持久化后更新
  865. QTemporaryDir directory;
  866. require(directory.isValid(), "temporary directory must be valid");
  867. JsonProjectStorage storage;
  868. ProjectService service(storage);
  869. require(service.save().error == ProjectServiceError::FilePathRequired,
  870. "save without a current path must be rejected");
  871. require(service.createNewProject(" ").error
  872. == ProjectServiceError::InvalidProjectName,
  873. "blank project names must be rejected");
  874. const QString path = directory.filePath("state.json");
  875. service.editProject().metadata.name = "State project";
  876. require(service.saveAs(path.toStdString()).succeeded,
  877. "state project save must succeed");
  878. service.editProject().metadata.name = "Changed project";
  879. require(service.isModified(), "editing the project must mark it modified");
  880. require(service.save().succeeded, "save must use the current file path");
  881. require(!service.isModified(), "successful save must clear modified state");
  882. const QString failed_path = directory.filePath("missing/subdir/state.json");
  883. require(!service.saveAs(failed_path.toStdString()).succeeded,
  884. "save to an unavailable path must fail");
  885. require(service.currentFilePath() == path.toStdString(),
  886. "failed save as must keep the previous current path");
  887. }
  888. void testRegisterCommentService()
  889. {
  890. JsonProjectStorage storage;
  891. ProjectService project_service(storage);
  892. RegisterCommentService service(project_service);
  893. require(service.setComment(
  894. RegisterAddress{RegisterArea::D, 3}, " 目标温度 ").succeeded,
  895. "register comment service must create a trimmed D comment");
  896. require(service.setComment(
  897. RegisterAddress{RegisterArea::M, 2}, "启动信号").succeeded,
  898. "register comment service must create an M comment");
  899. require(service.comments().size() == 2U
  900. && service.comments().front().address.area() == RegisterArea::M
  901. && service.comments().front().address.index() == 2
  902. && service.comments().back().text == "目标温度",
  903. "register comments must be sorted by area and address");
  904. require(service.setComment(
  905. RegisterAddress{RegisterArea::M, 2}, "新的启动信号").succeeded
  906. && service.comments().size() == 2U
  907. && service.findComment(RegisterAddress{RegisterArea::M, 2})->text
  908. == "新的启动信号",
  909. "setting an existing address must update instead of duplicating it");
  910. require(!service.setComment(RegisterAddress{RegisterArea::M, 4}, " \t").succeeded,
  911. "blank register comments must be rejected by the service");
  912. require(service.setComment(
  913. RegisterAddress{RegisterArea::M, 4},
  914. std::string(ProjectLimits::kMaximumRegisterCommentBytes, 'a'))
  915. .succeeded,
  916. "a register comment at the byte limit must be accepted");
  917. require(!service.setComment(
  918. RegisterAddress{RegisterArea::M, 5},
  919. std::string(ProjectLimits::kMaximumRegisterCommentBytes + 1U, 'a'))
  920. .succeeded,
  921. "an oversized register comment must be rejected by the service");
  922. require(!service.setComment(
  923. RegisterAddress{RegisterArea::M, 5}, "第一行\n第二行").succeeded,
  924. "a multiline register comment must be rejected by the service");
  925. require(service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded
  926. && service.findComment(RegisterAddress{RegisterArea::M, 2}) == nullptr,
  927. "register comments must be removable");
  928. require(!service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded,
  929. "removing a missing register comment must fail explicitly");
  930. }
  931. } // namespace
  932. int main()
  933. {
  934. try
  935. {
  936. // 工程服务和 JSON 存储在同一测试进程中验证完整闭环
  937. testEmptyProjectRoundTrip();
  938. testUnconditionalOutputRoundTrip();
  939. testGapRoundTrip();
  940. testExampleProjectRoundTrip();
  941. testRegisterCommentService();
  942. testInvalidFiles();
  943. testQuantityFileLimits();
  944. testHmiPageJsonBoundaries();
  945. testServiceStateAndSaveErrors();
  946. }
  947. catch (const std::exception &error)
  948. {
  949. std::cerr << "project management tests failed: " << error.what() << '\n';
  950. return 1;
  951. }
  952. std::cout << "project management tests passed\n";
  953. return 0;
  954. }