综合平台编程器项目的远程存储
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 

446 wiersze
19 KiB

  1. #include "domain/project_storage.h"
  2. #include "infrastructure/json_project_storage.h"
  3. #include "services/project_service.h"
  4. #include <QFile>
  5. #include <QJsonArray>
  6. #include <QJsonDocument>
  7. #include <QJsonObject>
  8. #include <QTemporaryDir>
  9. #include <exception>
  10. #include <iostream>
  11. #include <stdexcept>
  12. #include <string>
  13. namespace {
  14. void require(bool condition, const std::string &message)
  15. {
  16. if (!condition)
  17. {
  18. throw std::runtime_error(message);
  19. }
  20. }
  21. Project makeExampleProject()
  22. {
  23. // 构造覆盖 HMI 控件和梯形图串并联结构的完整 JSON 往返样本
  24. HmiControl start_button;
  25. start_button.id = "start-button";
  26. start_button.type = HmiControlType::Button;
  27. start_button.bounds = {10, 20, 120, 48};
  28. start_button.text = "Start";
  29. start_button.binding = RegisterAddress{RegisterArea::M, 0};
  30. start_button.buttonOperation = HmiButtonOperation::SetOn;
  31. start_button.properties.emplace("color", "green");
  32. HmiControl running_indicator;
  33. running_indicator.id = "running-indicator";
  34. running_indicator.type = HmiControlType::Indicator;
  35. running_indicator.bounds = {150, 20, 64, 64};
  36. running_indicator.text = "Running";
  37. running_indicator.binding = RegisterAddress{RegisterArea::M, 1};
  38. running_indicator.properties.emplace("activeColor", "#24a148");
  39. HmiControl temperature_display;
  40. temperature_display.id = "temperature-display";
  41. temperature_display.type = HmiControlType::NumericDisplay;
  42. temperature_display.bounds = {10, 90, 120, 40};
  43. temperature_display.text = "Temperature";
  44. temperature_display.binding = RegisterAddress{RegisterArea::D, 2};
  45. temperature_display.properties.emplace("format", "decimal");
  46. HmiControl target_input;
  47. target_input.id = "target-input";
  48. target_input.type = HmiControlType::NumericInput;
  49. target_input.bounds = {150, 90, 120, 40};
  50. target_input.text = "Target";
  51. target_input.binding = RegisterAddress{RegisterArea::D, 3};
  52. target_input.properties.emplace("minimum", "-100");
  53. HmiControl title_label;
  54. title_label.id = "title-label";
  55. title_label.type = HmiControlType::Label;
  56. title_label.bounds = {10, 145, 180, 32};
  57. title_label.text = "Production line";
  58. HmiControl settings_jump;
  59. settings_jump.id = "settings-jump";
  60. settings_jump.type = HmiControlType::PageJump;
  61. settings_jump.bounds = {210, 145, 120, 40};
  62. settings_jump.text = "Settings";
  63. settings_jump.pageJump = HmiPageJumpConfig{"settings-page"};
  64. HmiControl alarm_list;
  65. alarm_list.id = "alarm-list";
  66. alarm_list.type = HmiControlType::AlarmList;
  67. alarm_list.bounds = {10, 200, 360, 180};
  68. alarm_list.text = "Alarms";
  69. HmiPage page;
  70. page.id = "main-page";
  71. page.name = "Main";
  72. page.controls.push_back(start_button);
  73. page.controls.push_back(running_indicator);
  74. page.controls.push_back(temperature_display);
  75. page.controls.push_back(target_input);
  76. page.controls.push_back(title_label);
  77. page.controls.push_back(settings_jump);
  78. page.controls.push_back(alarm_list);
  79. HmiPage settings_page;
  80. settings_page.id = "settings-page";
  81. settings_page.name = "Settings";
  82. LogicNode contact;
  83. contact.id = "start-contact";
  84. contact.config = ContactNodeConfig{
  85. RegisterAddress{RegisterArea::M, 0},
  86. ContactMode::NormallyOpen};
  87. LogicNode compare;
  88. compare.id = "temperature-check";
  89. compare.config = CompareNodeConfig{
  90. RegisterAddress{RegisterArea::D, 2},
  91. ComparisonOperator::GreaterThanOrEqual,
  92. static_cast<std::int16_t>(100)};
  93. LogicNode hold_contact;
  94. hold_contact.id = "hold-contact";
  95. hold_contact.config = ContactNodeConfig{
  96. RegisterAddress{RegisterArea::M, 1},
  97. ContactMode::NormallyOpen};
  98. LogicNode coil;
  99. coil.id = "run-coil";
  100. coil.config = CoilNodeConfig{
  101. RegisterAddress{RegisterArea::M, 1},
  102. CoilMode::Set};
  103. ControlLogic logic;
  104. logic.id = "start-logic";
  105. logic.name = "Start logic";
  106. logic.enabled = false;
  107. LadderRung rung;
  108. rung.id = "rung-1";
  109. rung.name = "Network 1";
  110. ConditionExpression parallel;
  111. parallel.id = "parallel-start";
  112. parallel.kind = ConditionExpressionKind::Parallel;
  113. parallel.children = {
  114. ConditionExpression::fromNode(contact),
  115. ConditionExpression::fromNode(hold_contact)};
  116. ConditionExpression series;
  117. series.id = "series-root";
  118. series.kind = ConditionExpressionKind::Series;
  119. series.children = {
  120. std::move(parallel),
  121. ConditionExpression::fromNode(compare)};
  122. rung.condition = std::move(series);
  123. rung.output = coil;
  124. logic.rungs.push_back(rung);
  125. Project project;
  126. project.metadata = {"example-project", "Example project", "1.0"};
  127. project.hmiPages.push_back(page);
  128. project.hmiPages.push_back(settings_page);
  129. project.initialHmiPageId = page.id;
  130. project.alarmDefinitions.push_back(
  131. {"alarm-emergency",
  132. RegisterAddress{RegisterArea::M, 10},
  133. AlarmCondition::MOn,
  134. 0,
  135. "Emergency stop"});
  136. project.alarmDefinitions.push_back(
  137. {"alarm-temperature",
  138. RegisterAddress{RegisterArea::D, 2},
  139. AlarmCondition::DHigh,
  140. 80,
  141. "Temperature high"});
  142. project.controlLogics.push_back(logic);
  143. ControlLogic draft_logic;
  144. draft_logic.id = "draft-logic";
  145. draft_logic.name = "Draft logic";
  146. draft_logic.enabled = false;
  147. draft_logic.rungs.push_back(
  148. {"rung-1", "Draft network", std::nullopt, std::nullopt});
  149. project.controlLogics.push_back(draft_logic);
  150. return project;
  151. }
  152. void writeText(const QString &path, const QByteArray &content)
  153. {
  154. // 直接写入故障样本文件,以验证加载失败时的保护行为
  155. QFile file(path);
  156. require(file.open(QIODevice::WriteOnly), "test file must be writable");
  157. require(file.write(content) == content.size(), "test file must be written completely");
  158. }
  159. QByteArray readBytes(const QString &path)
  160. {
  161. QFile file(path);
  162. require(file.open(QIODevice::ReadOnly), "saved project must be readable");
  163. return file.readAll();
  164. }
  165. void testEmptyProjectRoundTrip()
  166. {
  167. // 空工程是合法工程,保存再加载后不应凭空产生页面或逻辑
  168. QTemporaryDir directory;
  169. require(directory.isValid(), "temporary directory must be valid");
  170. JsonProjectStorage storage;
  171. ProjectService service(storage);
  172. require(service.createNewProject("Empty project").succeeded,
  173. "empty project creation must succeed");
  174. const QString path = directory.filePath("empty.json");
  175. require(service.saveAs(path.toStdString()).succeeded,
  176. "empty project save must succeed");
  177. require(!service.isModified(), "saved project must not be marked modified");
  178. require(service.load(path.toStdString()).succeeded,
  179. "empty project load must succeed");
  180. require(service.project().metadata.name == "Empty project",
  181. "empty project name must survive round trip");
  182. require(service.project().hmiPages.empty(), "empty project must have no HMI pages");
  183. require(service.project().controlLogics.empty(),
  184. "empty project must have no control logics");
  185. require(service.project().alarmDefinitions.empty(),
  186. "empty project must have no alarm definitions");
  187. }
  188. void testExampleProjectRoundTrip()
  189. {
  190. // 验证各层嵌套字段往返后保持不变且序列化结果稳定
  191. QTemporaryDir directory;
  192. require(directory.isValid(), "temporary directory must be valid");
  193. JsonProjectStorage storage;
  194. ProjectService service(storage);
  195. service.editProject() = makeExampleProject();
  196. const QString first_path = directory.filePath("example.json");
  197. const QString second_path = directory.filePath("example-copy.json");
  198. const QString invalid_operation_path = directory.filePath("invalid-operation.json");
  199. const QString missing_initial_path = directory.filePath("missing-initial.json");
  200. const QString missing_target_path = directory.filePath("missing-target.json");
  201. const QString missing_alarms_path = directory.filePath("missing-alarms.json");
  202. require(service.saveAs(first_path.toStdString()).succeeded,
  203. "example project save must succeed");
  204. const QByteArray saved_json = readBytes(first_path);
  205. require(saved_json.contains("\"rungs\"")
  206. && saved_json.contains("\"condition\"")
  207. && saved_json.contains("\"children\"")
  208. && saved_json.contains("\"output\""),
  209. "saved project must use structured ladder expressions");
  210. require(!saved_json.contains("\"dataPoints\""),
  211. "saved project must not contain the removed data point model");
  212. require(saved_json.contains("\"formatVersion\": \"1.0\"")
  213. && saved_json.contains("\"buttonOperation\": \"setOn\"")
  214. && saved_json.contains("\"initialHmiPageId\": \"main-page\"")
  215. && saved_json.contains("\"targetPageId\": \"settings-page\"")
  216. && saved_json.contains("\"alarmDefinitions\"")
  217. && saved_json.contains("\"type\": \"alarmList\""),
  218. "version 1.0 projects must persist pages and alarm definitions");
  219. require(!saved_json.contains("\"stages\"")
  220. && !saved_json.contains("\"branches\""),
  221. "current project format must not contain the removed stage model");
  222. require(!saved_json.contains("\"position\"")
  223. && !saved_json.contains("\"connections\""),
  224. "saved ladder logic must not contain free-graph fields");
  225. require(service.load(first_path.toStdString()).succeeded,
  226. "example project load must succeed");
  227. const Project &project = service.project();
  228. require(project.metadata.id == "example-project", "project id must survive round trip");
  229. require(project.hmiPages.size() == 2, "HMI page count must survive round trip");
  230. require(project.initialHmiPageId == "main-page",
  231. "the initial HMI page id must survive round trip");
  232. require(project.hmiPages.front().controls.size() == 7,
  233. "register, navigation and AlarmList controls must survive round trip");
  234. require(project.hmiPages.front().controls.front().binding->area()
  235. == RegisterArea::M,
  236. "HMI M binding must survive round trip");
  237. require(project.hmiPages.front().controls.front().buttonOperation
  238. == HmiButtonOperation::SetOn,
  239. "HMI button operation must survive round trip");
  240. require(project.hmiPages.front().controls.front().properties.at("color") == "green",
  241. "HMI properties must survive round trip");
  242. require(project.hmiPages.front().controls.at(1).type == HmiControlType::Indicator,
  243. "indicator control type must survive round trip");
  244. require(project.hmiPages.front().controls.at(2).binding->area() == RegisterArea::D,
  245. "numeric display D binding must survive round trip");
  246. require(project.hmiPages.front().controls.at(3).bounds.x == 150,
  247. "numeric input bounds must survive round trip");
  248. require(project.hmiPages.front().controls.at(4).type == HmiControlType::Label
  249. && project.hmiPages.front().controls.at(5).pageJump->targetPageId
  250. == "settings-page",
  251. "Label and PageJump typed data must survive round trip");
  252. require(project.hmiPages.front().controls.at(6).type
  253. == HmiControlType::AlarmList,
  254. "AlarmList control type must survive round trip");
  255. require(project.alarmDefinitions.size() == 2
  256. && project.alarmDefinitions.front().condition
  257. == AlarmCondition::MOn
  258. && project.alarmDefinitions.at(1).threshold == 80,
  259. "M and D alarm definitions must survive round trip");
  260. require(project.controlLogics.size() == 2,
  261. "control logic count must survive round trip");
  262. require(!project.controlLogics.front().enabled,
  263. "control logic enabled state must survive round trip");
  264. const LadderRung &rung = project.controlLogics.front().rungs.front();
  265. require(rung.condition.has_value()
  266. && rung.condition->kind == ConditionExpressionKind::Series,
  267. "series root expression must survive round trip");
  268. require(rung.condition->children.front().kind
  269. == ConditionExpressionKind::Parallel
  270. && rung.condition->children.front().children.size() == 2U,
  271. "parallel expression branches must survive round trip");
  272. require(rung.output.has_value(),
  273. "ladder output must survive round trip");
  274. const auto &compare = std::get<CompareNodeConfig>(
  275. rung.condition->children.at(1).node->config);
  276. require(compare.address.index() == 2 && compare.value == 100,
  277. "comparison configuration must survive round trip");
  278. require(service.saveAs(second_path.toStdString()).succeeded,
  279. "save as must succeed after load");
  280. require(readBytes(first_path) == readBytes(second_path),
  281. "save and save as must produce stable JSON");
  282. QByteArray invalid_operation = saved_json;
  283. invalid_operation.replace(
  284. "\"buttonOperation\": \"setOn\"",
  285. "\"buttonOperation\": \"unsupported\"");
  286. writeText(invalid_operation_path, invalid_operation);
  287. require(!service.load(invalid_operation_path.toStdString()).succeeded,
  288. "unsupported HMI button operations must be rejected");
  289. QJsonObject missing_initial = QJsonDocument::fromJson(saved_json).object();
  290. missing_initial.remove(QStringLiteral("initialHmiPageId"));
  291. writeText(
  292. missing_initial_path,
  293. QJsonDocument(missing_initial).toJson(QJsonDocument::Compact));
  294. ProjectOperationResult missing_result = service.load(
  295. missing_initial_path.toStdString());
  296. require(!missing_result.succeeded
  297. && missing_result.storageError == ProjectStorageError::MissingField,
  298. "the 1.0 schema must require initialHmiPageId without migration defaults");
  299. QJsonObject missing_alarms = QJsonDocument::fromJson(saved_json).object();
  300. missing_alarms.remove(QStringLiteral("alarmDefinitions"));
  301. writeText(
  302. missing_alarms_path,
  303. QJsonDocument(missing_alarms).toJson(QJsonDocument::Compact));
  304. missing_result = service.load(missing_alarms_path.toStdString());
  305. require(!missing_result.succeeded
  306. && missing_result.storageError == ProjectStorageError::MissingField,
  307. "the 1.0 schema must require alarmDefinitions without migration defaults");
  308. QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object();
  309. QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray();
  310. QJsonObject main_page = pages.at(0).toObject();
  311. QJsonArray controls = main_page.value(QStringLiteral("controls")).toArray();
  312. QJsonObject jump = controls.at(5).toObject();
  313. jump.remove(QStringLiteral("targetPageId"));
  314. controls.replace(5, jump);
  315. main_page.insert(QStringLiteral("controls"), controls);
  316. pages.replace(0, main_page);
  317. missing_target.insert(QStringLiteral("hmiPages"), pages);
  318. writeText(
  319. missing_target_path,
  320. QJsonDocument(missing_target).toJson(QJsonDocument::Compact));
  321. missing_result = service.load(missing_target_path.toStdString());
  322. require(!missing_result.succeeded
  323. && missing_result.storageError == ProjectStorageError::MissingField,
  324. "the 1.0 schema must require PageJump targetPageId");
  325. }
  326. void testInvalidFiles()
  327. {
  328. // 非法文件必须被拒绝,并且不得覆盖服务中当前工程
  329. QTemporaryDir directory;
  330. require(directory.isValid(), "temporary directory must be valid");
  331. JsonProjectStorage storage;
  332. ProjectService service(storage);
  333. service.editProject().metadata.name = "Current project";
  334. const QString invalid_json = directory.filePath("invalid-json.json");
  335. const QString missing_field = directory.filePath("missing-field.json");
  336. const QString unsupported_version = directory.filePath("unsupported-version.json");
  337. writeText(invalid_json, "{");
  338. auto result = service.load(invalid_json.toStdString());
  339. require(!result.succeeded
  340. && result.storageError == ProjectStorageError::InvalidJson,
  341. "invalid JSON must be rejected");
  342. require(service.project().metadata.name == "Current project",
  343. "invalid load must keep current project");
  344. writeText(missing_field, R"({"formatVersion":"1.0"})");
  345. result = service.load(missing_field.toStdString());
  346. require(!result.succeeded
  347. && result.storageError == ProjectStorageError::MissingField,
  348. "missing fields must be rejected");
  349. writeText(unsupported_version, R"({"formatVersion":"2.0"})");
  350. result = service.load(unsupported_version.toStdString());
  351. require(!result.succeeded
  352. && result.storageError == ProjectStorageError::UnsupportedVersion,
  353. "unsupported versions must be rejected");
  354. }
  355. void testServiceStateAndSaveErrors()
  356. {
  357. // 保存路径和修改标记只在成功持久化后更新
  358. QTemporaryDir directory;
  359. require(directory.isValid(), "temporary directory must be valid");
  360. JsonProjectStorage storage;
  361. ProjectService service(storage);
  362. require(service.save().error == ProjectServiceError::FilePathRequired,
  363. "save without a current path must be rejected");
  364. require(service.createNewProject(" ").error
  365. == ProjectServiceError::InvalidProjectName,
  366. "blank project names must be rejected");
  367. const QString path = directory.filePath("state.json");
  368. service.editProject().metadata.name = "State project";
  369. require(service.saveAs(path.toStdString()).succeeded,
  370. "state project save must succeed");
  371. service.editProject().metadata.name = "Changed project";
  372. require(service.isModified(), "editing the project must mark it modified");
  373. require(service.save().succeeded, "save must use the current file path");
  374. require(!service.isModified(), "successful save must clear modified state");
  375. const QString failed_path = directory.filePath("missing/subdir/state.json");
  376. require(!service.saveAs(failed_path.toStdString()).succeeded,
  377. "save to an unavailable path must fail");
  378. require(service.currentFilePath() == path.toStdString(),
  379. "failed save as must keep the previous current path");
  380. }
  381. } // namespace
  382. int main()
  383. {
  384. try
  385. {
  386. // 工程服务和 JSON 存储在同一测试进程中验证完整闭环
  387. testEmptyProjectRoundTrip();
  388. testExampleProjectRoundTrip();
  389. testInvalidFiles();
  390. testServiceStateAndSaveErrors();
  391. }
  392. catch (const std::exception &error)
  393. {
  394. std::cerr << "project management tests failed: " << error.what() << '\n';
  395. return 1;
  396. }
  397. std::cout << "project management tests passed\n";
  398. return 0;
  399. }