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

334 wiersze
13 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 <QTemporaryDir>
  6. #include <exception>
  7. #include <iostream>
  8. #include <stdexcept>
  9. #include <string>
  10. namespace {
  11. void require(bool condition, const std::string &message)
  12. {
  13. if (!condition)
  14. {
  15. throw std::runtime_error(message);
  16. }
  17. }
  18. Project makeExampleProject()
  19. {
  20. // 构造覆盖 HMI 控件和梯形图串并联结构的完整 JSON 往返样本
  21. HmiControl start_button;
  22. start_button.id = "start-button";
  23. start_button.type = HmiControlType::Button;
  24. start_button.bounds = {10, 20, 120, 48};
  25. start_button.text = "Start";
  26. start_button.binding = RegisterAddress{RegisterArea::M, 0};
  27. start_button.buttonOperation = HmiButtonOperation::SetOn;
  28. start_button.properties.emplace("color", "green");
  29. HmiControl running_indicator;
  30. running_indicator.id = "running-indicator";
  31. running_indicator.type = HmiControlType::Indicator;
  32. running_indicator.bounds = {150, 20, 64, 64};
  33. running_indicator.text = "Running";
  34. running_indicator.binding = RegisterAddress{RegisterArea::M, 1};
  35. running_indicator.properties.emplace("activeColor", "#24a148");
  36. HmiControl temperature_display;
  37. temperature_display.id = "temperature-display";
  38. temperature_display.type = HmiControlType::NumericDisplay;
  39. temperature_display.bounds = {10, 90, 120, 40};
  40. temperature_display.text = "Temperature";
  41. temperature_display.binding = RegisterAddress{RegisterArea::D, 2};
  42. temperature_display.properties.emplace("format", "decimal");
  43. HmiControl target_input;
  44. target_input.id = "target-input";
  45. target_input.type = HmiControlType::NumericInput;
  46. target_input.bounds = {150, 90, 120, 40};
  47. target_input.text = "Target";
  48. target_input.binding = RegisterAddress{RegisterArea::D, 3};
  49. target_input.properties.emplace("minimum", "-100");
  50. HmiPage page;
  51. page.id = "main-page";
  52. page.name = "Main";
  53. page.controls.push_back(start_button);
  54. page.controls.push_back(running_indicator);
  55. page.controls.push_back(temperature_display);
  56. page.controls.push_back(target_input);
  57. LogicNode contact;
  58. contact.id = "start-contact";
  59. contact.config = ContactNodeConfig{
  60. RegisterAddress{RegisterArea::M, 0},
  61. ContactMode::NormallyOpen};
  62. LogicNode compare;
  63. compare.id = "temperature-check";
  64. compare.config = CompareNodeConfig{
  65. RegisterAddress{RegisterArea::D, 2},
  66. ComparisonOperator::GreaterThanOrEqual,
  67. static_cast<std::int16_t>(100)};
  68. LogicNode hold_contact;
  69. hold_contact.id = "hold-contact";
  70. hold_contact.config = ContactNodeConfig{
  71. RegisterAddress{RegisterArea::M, 1},
  72. ContactMode::NormallyOpen};
  73. LogicNode coil;
  74. coil.id = "run-coil";
  75. coil.config = CoilNodeConfig{
  76. RegisterAddress{RegisterArea::M, 1},
  77. CoilMode::Set};
  78. ControlLogic logic;
  79. logic.id = "start-logic";
  80. logic.name = "Start logic";
  81. logic.enabled = false;
  82. LadderRung rung;
  83. rung.id = "rung-1";
  84. rung.name = "Network 1";
  85. ConditionExpression parallel;
  86. parallel.id = "parallel-start";
  87. parallel.kind = ConditionExpressionKind::Parallel;
  88. parallel.children = {
  89. ConditionExpression::fromNode(contact),
  90. ConditionExpression::fromNode(hold_contact)};
  91. ConditionExpression series;
  92. series.id = "series-root";
  93. series.kind = ConditionExpressionKind::Series;
  94. series.children = {
  95. std::move(parallel),
  96. ConditionExpression::fromNode(compare)};
  97. rung.condition = std::move(series);
  98. rung.output = coil;
  99. logic.rungs.push_back(rung);
  100. Project project;
  101. project.metadata = {"example-project", "Example project", "1.0"};
  102. project.hmiPages.push_back(page);
  103. project.controlLogics.push_back(logic);
  104. return project;
  105. }
  106. void writeText(const QString &path, const QByteArray &content)
  107. {
  108. // 直接写入故障样本文件,以验证加载失败时的保护行为
  109. QFile file(path);
  110. require(file.open(QIODevice::WriteOnly), "test file must be writable");
  111. require(file.write(content) == content.size(), "test file must be written completely");
  112. }
  113. QByteArray readBytes(const QString &path)
  114. {
  115. QFile file(path);
  116. require(file.open(QIODevice::ReadOnly), "saved project must be readable");
  117. return file.readAll();
  118. }
  119. void testEmptyProjectRoundTrip()
  120. {
  121. // 空工程是合法工程,保存再加载后不应凭空产生页面或逻辑
  122. QTemporaryDir directory;
  123. require(directory.isValid(), "temporary directory must be valid");
  124. JsonProjectStorage storage;
  125. ProjectService service(storage);
  126. require(service.createNewProject("Empty project").succeeded,
  127. "empty project creation must succeed");
  128. const QString path = directory.filePath("empty.json");
  129. require(service.saveAs(path.toStdString()).succeeded,
  130. "empty project save must succeed");
  131. require(!service.isModified(), "saved project must not be marked modified");
  132. require(service.load(path.toStdString()).succeeded,
  133. "empty project load must succeed");
  134. require(service.project().metadata.name == "Empty project",
  135. "empty project name must survive round trip");
  136. require(service.project().hmiPages.empty(), "empty project must have no HMI pages");
  137. require(service.project().controlLogics.empty(),
  138. "empty project must have no control logics");
  139. }
  140. void testExampleProjectRoundTrip()
  141. {
  142. // 验证各层嵌套字段往返后保持不变且序列化结果稳定
  143. QTemporaryDir directory;
  144. require(directory.isValid(), "temporary directory must be valid");
  145. JsonProjectStorage storage;
  146. ProjectService service(storage);
  147. service.editProject() = makeExampleProject();
  148. const QString first_path = directory.filePath("example.json");
  149. const QString second_path = directory.filePath("example-copy.json");
  150. const QString invalid_operation_path = directory.filePath("invalid-operation.json");
  151. require(service.saveAs(first_path.toStdString()).succeeded,
  152. "example project save must succeed");
  153. const QByteArray saved_json = readBytes(first_path);
  154. require(saved_json.contains("\"rungs\"")
  155. && saved_json.contains("\"condition\"")
  156. && saved_json.contains("\"children\"")
  157. && saved_json.contains("\"output\""),
  158. "saved project must use structured ladder expressions");
  159. require(!saved_json.contains("\"dataPoints\""),
  160. "saved project must not contain the removed data point model");
  161. require(saved_json.contains("\"formatVersion\": \"1.0\"")
  162. && saved_json.contains("\"buttonOperation\": \"setOn\""),
  163. "version 1.0 projects must persist the button operation");
  164. require(!saved_json.contains("\"stages\"")
  165. && !saved_json.contains("\"branches\""),
  166. "current project format must not contain the removed stage model");
  167. require(!saved_json.contains("\"position\"")
  168. && !saved_json.contains("\"connections\""),
  169. "saved ladder logic must not contain free-graph fields");
  170. require(service.load(first_path.toStdString()).succeeded,
  171. "example project load must succeed");
  172. const Project &project = service.project();
  173. require(project.metadata.id == "example-project", "project id must survive round trip");
  174. require(project.hmiPages.size() == 1, "HMI page count must survive round trip");
  175. require(project.hmiPages.front().controls.size() == 4,
  176. "all basic HMI controls must survive round trip");
  177. require(project.hmiPages.front().controls.front().binding->area()
  178. == RegisterArea::M,
  179. "HMI M binding must survive round trip");
  180. require(project.hmiPages.front().controls.front().buttonOperation
  181. == HmiButtonOperation::SetOn,
  182. "HMI button operation must survive round trip");
  183. require(project.hmiPages.front().controls.front().properties.at("color") == "green",
  184. "HMI properties must survive round trip");
  185. require(project.hmiPages.front().controls.at(1).type == HmiControlType::Indicator,
  186. "indicator control type must survive round trip");
  187. require(project.hmiPages.front().controls.at(2).binding->area() == RegisterArea::D,
  188. "numeric display D binding must survive round trip");
  189. require(project.hmiPages.front().controls.at(3).bounds.x == 150,
  190. "numeric input bounds must survive round trip");
  191. require(project.controlLogics.size() == 1,
  192. "control logic count must survive round trip");
  193. require(!project.controlLogics.front().enabled,
  194. "control logic enabled state must survive round trip");
  195. const LadderRung &rung = project.controlLogics.front().rungs.front();
  196. require(rung.condition.has_value()
  197. && rung.condition->kind == ConditionExpressionKind::Series,
  198. "series root expression must survive round trip");
  199. require(rung.condition->children.front().kind
  200. == ConditionExpressionKind::Parallel
  201. && rung.condition->children.front().children.size() == 2U,
  202. "parallel expression branches must survive round trip");
  203. require(rung.output.has_value(),
  204. "ladder output must survive round trip");
  205. const auto &compare = std::get<CompareNodeConfig>(
  206. rung.condition->children.at(1).node->config);
  207. require(compare.address.index() == 2 && compare.value == 100,
  208. "comparison configuration must survive round trip");
  209. require(service.saveAs(second_path.toStdString()).succeeded,
  210. "save as must succeed after load");
  211. require(readBytes(first_path) == readBytes(second_path),
  212. "save and save as must produce stable JSON");
  213. QByteArray invalid_operation = saved_json;
  214. invalid_operation.replace(
  215. "\"buttonOperation\": \"setOn\"",
  216. "\"buttonOperation\": \"unsupported\"");
  217. writeText(invalid_operation_path, invalid_operation);
  218. require(!service.load(invalid_operation_path.toStdString()).succeeded,
  219. "unsupported HMI button operations must be rejected");
  220. }
  221. void testInvalidFiles()
  222. {
  223. // 非法文件必须被拒绝,并且不得覆盖服务中当前工程
  224. QTemporaryDir directory;
  225. require(directory.isValid(), "temporary directory must be valid");
  226. JsonProjectStorage storage;
  227. ProjectService service(storage);
  228. service.editProject().metadata.name = "Current project";
  229. const QString invalid_json = directory.filePath("invalid-json.json");
  230. const QString missing_field = directory.filePath("missing-field.json");
  231. const QString unsupported_version = directory.filePath("unsupported-version.json");
  232. writeText(invalid_json, "{");
  233. auto result = service.load(invalid_json.toStdString());
  234. require(!result.succeeded
  235. && result.storageError == ProjectStorageError::InvalidJson,
  236. "invalid JSON must be rejected");
  237. require(service.project().metadata.name == "Current project",
  238. "invalid load must keep current project");
  239. writeText(missing_field, R"({"formatVersion":"1.0"})");
  240. result = service.load(missing_field.toStdString());
  241. require(!result.succeeded
  242. && result.storageError == ProjectStorageError::MissingField,
  243. "missing fields must be rejected");
  244. writeText(unsupported_version, R"({"formatVersion":"2.0"})");
  245. result = service.load(unsupported_version.toStdString());
  246. require(!result.succeeded
  247. && result.storageError == ProjectStorageError::UnsupportedVersion,
  248. "unsupported versions must be rejected");
  249. }
  250. void testServiceStateAndSaveErrors()
  251. {
  252. // 保存路径和修改标记只在成功持久化后更新
  253. QTemporaryDir directory;
  254. require(directory.isValid(), "temporary directory must be valid");
  255. JsonProjectStorage storage;
  256. ProjectService service(storage);
  257. require(service.save().error == ProjectServiceError::FilePathRequired,
  258. "save without a current path must be rejected");
  259. require(service.createNewProject(" ").error
  260. == ProjectServiceError::InvalidProjectName,
  261. "blank project names must be rejected");
  262. const QString path = directory.filePath("state.json");
  263. service.editProject().metadata.name = "State project";
  264. require(service.saveAs(path.toStdString()).succeeded,
  265. "state project save must succeed");
  266. service.editProject().metadata.name = "Changed project";
  267. require(service.isModified(), "editing the project must mark it modified");
  268. require(service.save().succeeded, "save must use the current file path");
  269. require(!service.isModified(), "successful save must clear modified state");
  270. const QString failed_path = directory.filePath("missing/subdir/state.json");
  271. require(!service.saveAs(failed_path.toStdString()).succeeded,
  272. "save to an unavailable path must fail");
  273. require(service.currentFilePath() == path.toStdString(),
  274. "failed save as must keep the previous current path");
  275. }
  276. } // namespace
  277. int main()
  278. {
  279. try
  280. {
  281. // 工程服务和 JSON 存储在同一测试进程中验证完整闭环
  282. testEmptyProjectRoundTrip();
  283. testExampleProjectRoundTrip();
  284. testInvalidFiles();
  285. testServiceStateAndSaveErrors();
  286. }
  287. catch (const std::exception &error)
  288. {
  289. std::cerr << "project management tests failed: " << error.what() << '\n';
  290. return 1;
  291. }
  292. std::cout << "project management tests passed\n";
  293. return 0;
  294. }