综合平台编程器项目的远程存储
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

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