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

973 lines
44 KiB

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