|
- #include "domain/active_register_repository.h"
- #include "domain/project_storage.h"
- #include "domain/register_repository.h"
- #include "services/hmi_editor_service.h"
- #include "services/hmi_runtime_service.h"
- #include "services/logic_editor_service.h"
- #include "services/offline_simulation_service.h"
- #include "services/project_service.h"
- #include "services/runtime_mode_service.h"
- #include "ui/hmi_editor_widget.h"
- #include "ui/logic_editor_widget.h"
- #include "ui/main_window.h"
- #include "ui/plc_connection_dialog.h"
-
- #include <QAction>
- #include <QApplication>
- #include <QComboBox>
- #include <QDialog>
- #include <QDockWidget>
- #include <QGraphicsItem>
- #include <QGraphicsScene>
- #include <QLabel>
- #include <QLineEdit>
- #include <QListWidget>
- #include <QSpinBox>
- #include <QTimer>
-
- #include <iostream>
- #include <stdexcept>
- #include <string>
-
- namespace {
-
- class TestProjectStorage final : public ProjectStorage
- {
- public:
- // 主窗口测试使用无副作用存储实现,隔离文件对 UI 行为的影响
- ProjectSaveResult save(const Project &, const std::string &) override
- {
- return {true, ProjectStorageError::None, {}};
- }
-
- ProjectLoadResult load(const std::string &) override
- {
- return {false, {}, ProjectStorageError::FileReadFailed, {}};
- }
- };
-
- class RepeatedStatusGateway final : public PlcCommunicationGateway
- {
- public:
- PlcCommunicationResult connectDevice(
- const PlcSerialConfiguration &) override
- {
- connection_state = PlcConnectionState::Connecting;
- notifyStateChanged();
- connection_state = PlcConnectionState::Connected;
- notifyStateChanged();
- notifyStateChanged();
- return {true, {}};
- }
-
- void disconnectDevice() override
- {
- connection_state = PlcConnectionState::Disconnected;
- notifyStateChanged();
- }
-
- void setPollAddresses(const std::vector<RegisterAddress> &) override {}
- PlcConnectionState state() const override { return connection_state; }
- bool initialReadCompleted() const override { return false; }
- const std::string &lastError() const override { return last_error; }
-
- void setCallbacks(
- std::function<void()> state_callback,
- std::function<void(bool)> initial_callback,
- std::function<void()> cache_callback,
- std::function<void(const std::string &)> error_callback) override
- {
- state_changed = std::move(state_callback);
- initial_read_changed = std::move(initial_callback);
- cache_updated = std::move(cache_callback);
- error_reported = std::move(error_callback);
- }
-
- private:
- void notifyStateChanged()
- {
- if (state_changed)
- {
- state_changed();
- }
- }
-
- PlcConnectionState connection_state = PlcConnectionState::Disconnected;
- std::string last_error;
- std::function<void()> state_changed;
- std::function<void(bool)> initial_read_changed;
- std::function<void()> cache_updated;
- std::function<void(const std::string &)> error_reported;
- };
-
- void require(bool condition, const std::string &message)
- {
- if (!condition)
- {
- throw std::runtime_error(message);
- }
- }
-
- template<typename ObjectType>
- ObjectType *requiredChild(MainWindow &window, const char *name)
- {
- // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息
- ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name));
- require(child != nullptr, std::string("missing UI object ") + name);
- return child;
- }
-
- void testModeActionsControlEditingAvailability()
- {
- // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择
- TestProjectStorage storage;
- ProjectService project_service(storage);
- HmiEditorService editor_service(project_service);
- LogicEditorService logic_editor_service(project_service);
- VirtualRegisterRepository repository;
- HmiRuntimeService runtime_service(repository);
- OfflineSimulationService simulation_service(repository);
- RuntimeModeService mode_service(project_service, simulation_service);
- MainWindow window(
- mode_service,
- project_service,
- editor_service,
- logic_editor_service,
- runtime_service);
- window.resize(1000, 640);
- window.show();
- QApplication::processEvents();
-
- require(window.findChild<QWidget *>(QStringLiteral("dataPointDock")) == nullptr,
- "the removed data point dock must not remain in the main window");
- require(window.findChild<QWidget *>(QStringLiteral("hmiDataPointComboBox")) == nullptr
- && window.findChild<QWidget *>(QStringLiteral("logicDataPointComboBox")) == nullptr,
- "HMI and ladder properties must use direct M/D address inputs");
-
- QAction *editing_action = requiredChild<QAction>(window, "editingModeAction");
- QAction *offline_action = requiredChild<QAction>(window, "offlineModeAction");
- QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
- QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction");
- QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction");
- QAction *add_normally_open_action = requiredChild<QAction>(
- window, "addNormallyOpenAction");
- QAction *add_normal_coil_action = requiredChild<QAction>(
- window, "addNormalCoilAction");
- QAction *parallel_insert_action = requiredChild<QAction>(
- window, "parallelInsertAction");
- QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock");
- QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock");
- QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel");
- QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit");
- HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>(
- window, "hmiEditorWidget");
- QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel");
-
- const qreal compact_scale = hmi_editor->transform().m11();
- window.resize(1600, 900);
- QApplication::processEvents();
- require(hmi_editor->transform().m11() > compact_scale,
- "HMI page must refit when the window becomes larger");
-
- require(editing_action->isChecked(), "editing action must be selected initially");
- require(project_dock->isEnabled(), "project dock must be enabled while editing");
- require(properties_dock->isEnabled(), "properties dock must be enabled while editing");
-
- add_button_action->trigger();
- const std::string page_id = editor_service.firstPageId();
- require(editor_service.findPage(page_id)->controls.size() == 1,
- "adding a control must update the HMI page model");
- require(selection->text() == QStringLiteral("button-1(未绑定)"),
- "an unbound added control must be marked in the property panel");
- require(text_edit->text() == QStringLiteral("按钮"),
- "property panel must show the control text");
-
- const QList<QGraphicsItem *> unbound_items = hmi_editor->scene()->selectedItems();
- require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0,
- "an unbound HMI control must not reserve an address label area");
- HmiControl bound_button = *editor_service.findControl(page_id, "button-1");
- bound_button.binding = RegisterAddress{RegisterArea::M, 0};
- require(editor_service.updateControl(page_id, bound_button.id, bound_button).succeeded,
- "binding an HMI button to M0 must succeed");
- hmi_editor->reloadPage();
- hmi_editor->selectControl(bound_button.id);
- const QList<QGraphicsItem *> bound_items = hmi_editor->scene()->selectedItems();
- require(bound_items.size() == 1 && bound_items.front()->boundingRect().top() < 0,
- "a bound HMI control must include its address label above the control body");
-
- delete_control_action->trigger();
- require(editor_service.findPage(page_id)->controls.empty(),
- "deleting a selected control must update the HMI page model");
-
- add_normally_open_action->trigger();
- parallel_insert_action->trigger();
- add_normal_coil_action->trigger();
- const ControlLogic *logic = logic_editor_service.findLogic(
- logic_editor_service.firstLogicId());
- require(logic != nullptr && logic->rungs.size() == 1,
- "logic actions must edit the default ladder rung");
- require(logic->rungs.front().condition.has_value()
- && logic->rungs.front().condition->kind
- == ConditionExpressionKind::Parallel
- && logic->rungs.front().condition->children.size() == 2U,
- "parallel branch action must create a parallel expression");
- require(logic->rungs.front().output.has_value(),
- "coil action must set the fixed ladder output");
- offline_action->trigger();
- require(mode_service.mode() == ApplicationMode::Editing,
- "unconfigured ladder nodes must block offline running");
- const LadderRung &rung = logic->rungs.front();
- std::vector<const LogicNode *> condition_nodes;
- collectConditionNodes(*rung.condition, &condition_nodes);
- for (const LogicNode *node : condition_nodes)
- {
- require(logic_editor_service.updateNodeConfig(
- logic_editor_service.firstLogicId(),
- node->id,
- ContactNodeConfig{
- RegisterAddress{RegisterArea::M, 0},
- ContactMode::NormallyOpen})
- .succeeded,
- "applying a contact configuration must complete the ladder node");
- }
- require(logic_editor_service.updateNodeConfig(
- logic_editor_service.firstLogicId(),
- rung.output->id,
- CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 1},
- CoilMode::Normal})
- .succeeded,
- "applying a coil configuration must complete the ladder node");
- offline_action->trigger();
- require(mode_service.mode() == ApplicationMode::OfflineRunning,
- "offline action must enter offline running");
- require(simulation_service.state() == SimulationState::Running,
- "offline action must start the actual simulation service");
- require(executor_status->text().contains(QStringLiteral("运行")),
- "executor status label must report the actual running state");
- require(!project_dock->isEnabled(),
- "project dock must be disabled while running");
- require(!properties_dock->isEnabled(),
- "properties dock must be disabled while running");
- require(!add_button_action->isEnabled(),
- "HMI add controls must be disabled while running");
- require(!add_normally_open_action->isEnabled(),
- "logic add nodes must be disabled while running");
-
- online_action->trigger();
- require(mode_service.mode() == ApplicationMode::OfflineRunning,
- "running modes must not switch directly through the UI");
- require(offline_action->isChecked(),
- "failed mode changes must restore the active action");
-
- editing_action->trigger();
- require(mode_service.mode() == ApplicationMode::Editing,
- "editing action must return to editing");
- require(simulation_service.state() == SimulationState::Stopped,
- "editing action must stop the simulation service");
- require(executor_status->text() == QStringLiteral("逻辑执行器:停止"),
- "executor status label must report the actual stopped state");
- require(project_dock->isEnabled(),
- "project dock must be restored after returning to editing");
- require(properties_dock->isEnabled(),
- "properties dock must be restored after returning to editing");
- require(add_button_action->isEnabled(),
- "HMI add controls must be restored after returning to editing");
- require(add_normally_open_action->isEnabled(),
- "logic add nodes must be restored after returning to editing");
-
- online_action->trigger();
- require(mode_service.mode() == ApplicationMode::Editing,
- "online action must require an initial PLC read");
- require(editing_action->isChecked(),
- "rejected online running must restore the editing action");
- }
-
- void testPlcConfigurationUsesDialog()
- {
- PlcSerialConfiguration initial;
- initial.portName = "COM17";
- initial.serverAddress = 12;
- initial.baudRate = 38400;
- initial.dataBits = 7;
- initial.parity = 3;
- initial.stopBits = 2;
- initial.responseTimeoutMs = 2500;
- initial.retries = 4;
- initial.pollIntervalMs = 350;
- PlcConnectionDialog configuration_dialog(initial);
- const PlcSerialConfiguration actual = configuration_dialog.configuration();
- require(actual.portName == initial.portName,
- "PLC dialog must preserve a manually entered serial port");
- require(actual.serverAddress == initial.serverAddress
- && actual.baudRate == initial.baudRate
- && actual.dataBits == initial.dataBits
- && actual.parity == initial.parity
- && actual.stopBits == initial.stopBits,
- "PLC dialog must preserve Modbus RTU serial parameters");
- require(actual.responseTimeoutMs == initial.responseTimeoutMs
- && actual.retries == initial.retries
- && actual.pollIntervalMs == initial.pollIntervalMs,
- "PLC dialog must preserve communication timing parameters");
-
- TestProjectStorage storage;
- ProjectService project_service(storage);
- HmiEditorService editor_service(project_service);
- LogicEditorService logic_editor_service(project_service);
- VirtualRegisterRepository repository;
- HmiRuntimeService runtime_service(repository);
- OfflineSimulationService simulation_service(repository);
- RuntimeModeService mode_service(project_service, simulation_service);
- MainWindow window(
- mode_service,
- project_service,
- editor_service,
- logic_editor_service,
- runtime_service);
- require(window.findChild<QComboBox *>(QStringLiteral("serialPortComboBox")) == nullptr,
- "serial configuration controls must not be embedded in the main window");
- QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
- bool dialog_opened = false;
- QTimer::singleShot(
- 0,
- [&dialog_opened]
- {
- auto *dialog = qobject_cast<PlcConnectionDialog *>(
- QApplication::activeModalWidget());
- dialog_opened = dialog != nullptr;
- if (dialog != nullptr)
- {
- dialog->reject();
- }
- });
- configure_action->trigger();
- require(dialog_opened,
- "PLC configuration action must open the dedicated configuration dialog");
- }
-
- void testRepeatedPlcStatusNotificationsAreCoalesced()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- HmiEditorService editor_service(project_service);
- LogicEditorService logic_editor_service(project_service);
- VirtualRegisterRepository virtual_repository;
- VirtualRegisterRepository plc_repository;
- ActiveRegisterRepository active_repository(virtual_repository);
- HmiRuntimeService runtime_service(active_repository);
- OfflineSimulationService simulation_service(virtual_repository);
- RuntimeModeService mode_service(project_service, simulation_service);
- RepeatedStatusGateway gateway;
- mode_service.configurePlc(
- gateway, active_repository, virtual_repository, plc_repository);
- MainWindow window(
- mode_service,
- project_service,
- editor_service,
- logic_editor_service,
- runtime_service);
- QListWidget *output = requiredChild<QListWidget>(window, "outputList");
- const int initial_count = output->count();
-
- const PlcCommunicationResult result = mode_service.connectPlc(
- {"COM3", 1, 9600, 8, 2, 1, 1000, 2, 200});
- require(result.succeeded, "PLC connection setup must succeed");
- QApplication::processEvents();
-
- const QString expected = QStringLiteral("PLC 已连接,正在读取工程使用的 M/D 地址");
- int matching_count = 0;
- for (int index = initial_count; index < output->count(); ++index)
- {
- if (output->item(index)->text() == expected)
- {
- ++matching_count;
- }
- }
- require(matching_count == 1,
- "repeated PLC status callbacks must append one coalesced output message");
- }
-
- } // namespace
-
- int main(int argc, char *argv[])
- {
- // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行
- qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen"));
- QApplication application(argc, argv);
-
- try
- {
- testModeActionsControlEditingAvailability();
- testPlcConfigurationUsesDialog();
- testRepeatedPlcStatusNotificationsAreCoalesced();
- }
- catch (const std::exception &error)
- {
- std::cerr << "main window tests failed: " << error.what() << '\n';
- return 1;
- }
-
- std::cout << "main window tests passed\n";
- return 0;
- }
|