综合平台编程器项目的远程存储
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.
 
 
 
 

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