综合平台编程器项目的远程存储
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 
 

781 rinda
35 KiB

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