综合平台编程器项目的远程存储
Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 

413 righe
17 KiB

  1. #include "domain/active_register_repository.h"
  2. #include "domain/project_storage.h"
  3. #include "domain/register_repository.h"
  4. #include "services/hmi_editor_service.h"
  5. #include "services/hmi_runtime_service.h"
  6. #include "services/logic_editor_service.h"
  7. #include "services/offline_simulation_service.h"
  8. #include "services/project_service.h"
  9. #include "services/runtime_mode_service.h"
  10. #include "ui/hmi_editor_widget.h"
  11. #include "ui/logic_editor_widget.h"
  12. #include "ui/main_window.h"
  13. #include "ui/plc_connection_dialog.h"
  14. #include <QAction>
  15. #include <QApplication>
  16. #include <QComboBox>
  17. #include <QDialog>
  18. #include <QDockWidget>
  19. #include <QGraphicsItem>
  20. #include <QGraphicsScene>
  21. #include <QLabel>
  22. #include <QLineEdit>
  23. #include <QListWidget>
  24. #include <QSpinBox>
  25. #include <QTimer>
  26. #include <iostream>
  27. #include <stdexcept>
  28. #include <string>
  29. namespace {
  30. class TestProjectStorage final : public ProjectStorage
  31. {
  32. public:
  33. // 主窗口测试使用无副作用存储实现,隔离文件对 UI 行为的影响
  34. ProjectSaveResult save(const Project &, const std::string &) override
  35. {
  36. return {true, ProjectStorageError::None, {}};
  37. }
  38. ProjectLoadResult load(const std::string &) override
  39. {
  40. return {false, {}, ProjectStorageError::FileReadFailed, {}};
  41. }
  42. };
  43. class RepeatedStatusGateway final : public PlcCommunicationGateway
  44. {
  45. public:
  46. PlcCommunicationResult connectDevice(
  47. const PlcSerialConfiguration &) override
  48. {
  49. connection_state = PlcConnectionState::Connecting;
  50. notifyStateChanged();
  51. connection_state = PlcConnectionState::Connected;
  52. notifyStateChanged();
  53. notifyStateChanged();
  54. return {true, {}};
  55. }
  56. void disconnectDevice() override
  57. {
  58. connection_state = PlcConnectionState::Disconnected;
  59. notifyStateChanged();
  60. }
  61. void setPollAddresses(const std::vector<RegisterAddress> &) override {}
  62. PlcConnectionState state() const override { return connection_state; }
  63. bool initialReadCompleted() const override { return false; }
  64. const std::string &lastError() const override { return last_error; }
  65. void setCallbacks(
  66. std::function<void()> state_callback,
  67. std::function<void(bool)> initial_callback,
  68. std::function<void()> cache_callback,
  69. std::function<void(const std::string &)> error_callback) override
  70. {
  71. state_changed = std::move(state_callback);
  72. initial_read_changed = std::move(initial_callback);
  73. cache_updated = std::move(cache_callback);
  74. error_reported = std::move(error_callback);
  75. }
  76. private:
  77. void notifyStateChanged()
  78. {
  79. if (state_changed)
  80. {
  81. state_changed();
  82. }
  83. }
  84. PlcConnectionState connection_state = PlcConnectionState::Disconnected;
  85. std::string last_error;
  86. std::function<void()> state_changed;
  87. std::function<void(bool)> initial_read_changed;
  88. std::function<void()> cache_updated;
  89. std::function<void(const std::string &)> error_reported;
  90. };
  91. void require(bool condition, const std::string &message)
  92. {
  93. if (!condition)
  94. {
  95. throw std::runtime_error(message);
  96. }
  97. }
  98. template<typename ObjectType>
  99. ObjectType *requiredChild(MainWindow &window, const char *name)
  100. {
  101. // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息
  102. ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name));
  103. require(child != nullptr, std::string("missing UI object ") + name);
  104. return child;
  105. }
  106. void testModeActionsControlEditingAvailability()
  107. {
  108. // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择
  109. TestProjectStorage storage;
  110. ProjectService project_service(storage);
  111. HmiEditorService editor_service(project_service);
  112. LogicEditorService logic_editor_service(project_service);
  113. VirtualRegisterRepository repository;
  114. HmiRuntimeService runtime_service(repository);
  115. OfflineSimulationService simulation_service(repository);
  116. RuntimeModeService mode_service(project_service, simulation_service);
  117. MainWindow window(
  118. mode_service,
  119. project_service,
  120. editor_service,
  121. logic_editor_service,
  122. runtime_service);
  123. window.resize(1000, 640);
  124. window.show();
  125. QApplication::processEvents();
  126. require(window.findChild<QWidget *>(QStringLiteral("dataPointDock")) == nullptr,
  127. "the removed data point dock must not remain in the main window");
  128. require(window.findChild<QWidget *>(QStringLiteral("hmiDataPointComboBox")) == nullptr
  129. && window.findChild<QWidget *>(QStringLiteral("logicDataPointComboBox")) == nullptr,
  130. "HMI and ladder properties must use direct M/D address inputs");
  131. QAction *editing_action = requiredChild<QAction>(window, "editingModeAction");
  132. QAction *offline_action = requiredChild<QAction>(window, "offlineModeAction");
  133. QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
  134. QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction");
  135. QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction");
  136. QAction *add_normally_open_action = requiredChild<QAction>(
  137. window, "addNormallyOpenAction");
  138. QAction *add_normal_coil_action = requiredChild<QAction>(
  139. window, "addNormalCoilAction");
  140. QAction *parallel_insert_action = requiredChild<QAction>(
  141. window, "parallelInsertAction");
  142. QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock");
  143. QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock");
  144. QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel");
  145. QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit");
  146. HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>(
  147. window, "hmiEditorWidget");
  148. QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel");
  149. const qreal compact_scale = hmi_editor->transform().m11();
  150. window.resize(1600, 900);
  151. QApplication::processEvents();
  152. require(hmi_editor->transform().m11() > compact_scale,
  153. "HMI page must refit when the window becomes larger");
  154. require(editing_action->isChecked(), "editing action must be selected initially");
  155. require(project_dock->isEnabled(), "project dock must be enabled while editing");
  156. require(properties_dock->isEnabled(), "properties dock must be enabled while editing");
  157. add_button_action->trigger();
  158. const std::string page_id = editor_service.firstPageId();
  159. require(editor_service.findPage(page_id)->controls.size() == 1,
  160. "adding a control must update the HMI page model");
  161. require(selection->text() == QStringLiteral("button-1(未绑定)"),
  162. "an unbound added control must be marked in the property panel");
  163. require(text_edit->text() == QStringLiteral("按钮"),
  164. "property panel must show the control text");
  165. const QList<QGraphicsItem *> unbound_items = hmi_editor->scene()->selectedItems();
  166. require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0,
  167. "an unbound HMI control must not reserve an address label area");
  168. HmiControl bound_button = *editor_service.findControl(page_id, "button-1");
  169. bound_button.binding = RegisterAddress{RegisterArea::M, 0};
  170. require(editor_service.updateControl(page_id, bound_button.id, bound_button).succeeded,
  171. "binding an HMI button to M0 must succeed");
  172. hmi_editor->reloadPage();
  173. hmi_editor->selectControl(bound_button.id);
  174. const QList<QGraphicsItem *> bound_items = hmi_editor->scene()->selectedItems();
  175. require(bound_items.size() == 1 && bound_items.front()->boundingRect().top() < 0,
  176. "a bound HMI control must include its address label above the control body");
  177. delete_control_action->trigger();
  178. require(editor_service.findPage(page_id)->controls.empty(),
  179. "deleting a selected control must update the HMI page model");
  180. add_normally_open_action->trigger();
  181. parallel_insert_action->trigger();
  182. add_normal_coil_action->trigger();
  183. const ControlLogic *logic = logic_editor_service.findLogic(
  184. logic_editor_service.firstLogicId());
  185. require(logic != nullptr && logic->rungs.size() == 1,
  186. "logic actions must edit the default ladder rung");
  187. require(logic->rungs.front().condition.has_value()
  188. && logic->rungs.front().condition->kind
  189. == ConditionExpressionKind::Parallel
  190. && logic->rungs.front().condition->children.size() == 2U,
  191. "parallel branch action must create a parallel expression");
  192. require(logic->rungs.front().output.has_value(),
  193. "coil action must set the fixed ladder output");
  194. offline_action->trigger();
  195. require(mode_service.mode() == ApplicationMode::Editing,
  196. "unconfigured ladder nodes must block offline running");
  197. const LadderRung &rung = logic->rungs.front();
  198. std::vector<const LogicNode *> condition_nodes;
  199. collectConditionNodes(*rung.condition, &condition_nodes);
  200. for (const LogicNode *node : condition_nodes)
  201. {
  202. require(logic_editor_service.updateNodeConfig(
  203. logic_editor_service.firstLogicId(),
  204. node->id,
  205. ContactNodeConfig{
  206. RegisterAddress{RegisterArea::M, 0},
  207. ContactMode::NormallyOpen})
  208. .succeeded,
  209. "applying a contact configuration must complete the ladder node");
  210. }
  211. require(logic_editor_service.updateNodeConfig(
  212. logic_editor_service.firstLogicId(),
  213. rung.output->id,
  214. CoilNodeConfig{
  215. RegisterAddress{RegisterArea::M, 1},
  216. CoilMode::Normal})
  217. .succeeded,
  218. "applying a coil configuration must complete the ladder node");
  219. offline_action->trigger();
  220. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  221. "offline action must enter offline running");
  222. require(simulation_service.state() == SimulationState::Running,
  223. "offline action must start the actual simulation service");
  224. require(executor_status->text().contains(QStringLiteral("运行")),
  225. "executor status label must report the actual running state");
  226. require(!project_dock->isEnabled(),
  227. "project dock must be disabled while running");
  228. require(!properties_dock->isEnabled(),
  229. "properties dock must be disabled while running");
  230. require(!add_button_action->isEnabled(),
  231. "HMI add controls must be disabled while running");
  232. require(!add_normally_open_action->isEnabled(),
  233. "logic add nodes must be disabled while running");
  234. online_action->trigger();
  235. require(mode_service.mode() == ApplicationMode::OfflineRunning,
  236. "running modes must not switch directly through the UI");
  237. require(offline_action->isChecked(),
  238. "failed mode changes must restore the active action");
  239. editing_action->trigger();
  240. require(mode_service.mode() == ApplicationMode::Editing,
  241. "editing action must return to editing");
  242. require(simulation_service.state() == SimulationState::Stopped,
  243. "editing action must stop the simulation service");
  244. require(executor_status->text() == QStringLiteral("逻辑执行器:停止"),
  245. "executor status label must report the actual stopped state");
  246. require(project_dock->isEnabled(),
  247. "project dock must be restored after returning to editing");
  248. require(properties_dock->isEnabled(),
  249. "properties dock must be restored after returning to editing");
  250. require(add_button_action->isEnabled(),
  251. "HMI add controls must be restored after returning to editing");
  252. require(add_normally_open_action->isEnabled(),
  253. "logic add nodes must be restored after returning to editing");
  254. online_action->trigger();
  255. require(mode_service.mode() == ApplicationMode::Editing,
  256. "online action must require an initial PLC read");
  257. require(editing_action->isChecked(),
  258. "rejected online running must restore the editing action");
  259. }
  260. void testPlcConfigurationUsesDialog()
  261. {
  262. PlcSerialConfiguration initial;
  263. initial.portName = "COM17";
  264. initial.serverAddress = 12;
  265. initial.baudRate = 38400;
  266. initial.dataBits = 7;
  267. initial.parity = 3;
  268. initial.stopBits = 2;
  269. initial.responseTimeoutMs = 2500;
  270. initial.retries = 4;
  271. initial.pollIntervalMs = 350;
  272. PlcConnectionDialog configuration_dialog(initial);
  273. const PlcSerialConfiguration actual = configuration_dialog.configuration();
  274. require(actual.portName == initial.portName,
  275. "PLC dialog must preserve a manually entered serial port");
  276. require(actual.serverAddress == initial.serverAddress
  277. && actual.baudRate == initial.baudRate
  278. && actual.dataBits == initial.dataBits
  279. && actual.parity == initial.parity
  280. && actual.stopBits == initial.stopBits,
  281. "PLC dialog must preserve Modbus RTU serial parameters");
  282. require(actual.responseTimeoutMs == initial.responseTimeoutMs
  283. && actual.retries == initial.retries
  284. && actual.pollIntervalMs == initial.pollIntervalMs,
  285. "PLC dialog must preserve communication timing parameters");
  286. TestProjectStorage storage;
  287. ProjectService project_service(storage);
  288. HmiEditorService editor_service(project_service);
  289. LogicEditorService logic_editor_service(project_service);
  290. VirtualRegisterRepository repository;
  291. HmiRuntimeService runtime_service(repository);
  292. OfflineSimulationService simulation_service(repository);
  293. RuntimeModeService mode_service(project_service, simulation_service);
  294. MainWindow window(
  295. mode_service,
  296. project_service,
  297. editor_service,
  298. logic_editor_service,
  299. runtime_service);
  300. require(window.findChild<QComboBox *>(QStringLiteral("serialPortComboBox")) == nullptr,
  301. "serial configuration controls must not be embedded in the main window");
  302. QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
  303. bool dialog_opened = false;
  304. QTimer::singleShot(
  305. 0,
  306. [&dialog_opened]
  307. {
  308. auto *dialog = qobject_cast<PlcConnectionDialog *>(
  309. QApplication::activeModalWidget());
  310. dialog_opened = dialog != nullptr;
  311. if (dialog != nullptr)
  312. {
  313. dialog->reject();
  314. }
  315. });
  316. configure_action->trigger();
  317. require(dialog_opened,
  318. "PLC configuration action must open the dedicated configuration dialog");
  319. }
  320. void testRepeatedPlcStatusNotificationsAreCoalesced()
  321. {
  322. TestProjectStorage storage;
  323. ProjectService project_service(storage);
  324. HmiEditorService editor_service(project_service);
  325. LogicEditorService logic_editor_service(project_service);
  326. VirtualRegisterRepository virtual_repository;
  327. VirtualRegisterRepository plc_repository;
  328. ActiveRegisterRepository active_repository(virtual_repository);
  329. HmiRuntimeService runtime_service(active_repository);
  330. OfflineSimulationService simulation_service(virtual_repository);
  331. RuntimeModeService mode_service(project_service, simulation_service);
  332. RepeatedStatusGateway gateway;
  333. mode_service.configurePlc(
  334. gateway, active_repository, virtual_repository, plc_repository);
  335. MainWindow window(
  336. mode_service,
  337. project_service,
  338. editor_service,
  339. logic_editor_service,
  340. runtime_service);
  341. QListWidget *output = requiredChild<QListWidget>(window, "outputList");
  342. const int initial_count = output->count();
  343. const PlcCommunicationResult result = mode_service.connectPlc(
  344. {"COM3", 1, 9600, 8, 2, 1, 1000, 2, 200});
  345. require(result.succeeded, "PLC connection setup must succeed");
  346. QApplication::processEvents();
  347. const QString expected = QStringLiteral("PLC 已连接,正在读取工程使用的 M/D 地址");
  348. int matching_count = 0;
  349. for (int index = initial_count; index < output->count(); ++index)
  350. {
  351. if (output->item(index)->text() == expected)
  352. {
  353. ++matching_count;
  354. }
  355. }
  356. require(matching_count == 1,
  357. "repeated PLC status callbacks must append one coalesced output message");
  358. }
  359. } // namespace
  360. int main(int argc, char *argv[])
  361. {
  362. // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行
  363. qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
  364. QApplication application(argc, argv);
  365. try
  366. {
  367. testModeActionsControlEditingAvailability();
  368. testPlcConfigurationUsesDialog();
  369. testRepeatedPlcStatusNotificationsAreCoalesced();
  370. }
  371. catch (const std::exception &error)
  372. {
  373. std::cerr << "main window tests failed: " << error.what() << '\n';
  374. return 1;
  375. }
  376. std::cout << "main window tests passed\n";
  377. return 0;
  378. }