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

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