#include "domain/active_register_repository.h" #include "domain/project_storage.h" #include "domain/register_repository.h" #include "services/alarm_editor_service.h" #include "services/alarm_service.h" #include "services/hmi_editor_service.h" #include "services/hmi_runtime_service.h" #include "services/hmi_navigation_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 "services/register_monitor_service.h" #include "services/register_comment_service.h" #include "ui/hmi_editor_widget.h" #include "ui/free_monitor_widget.h" #include "ui/logic_editor_widget.h" #include "ui/main_window.h" #include "ui/plc_connection_dialog.h" #include "ui/runtime_monitor_widget.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 &) override {} PlcConnectionState state() const override { return connection_state; } bool initialReadCompleted() const override { return false; } PlcCommunicationError lastErrorType() const override { return PlcCommunicationError::None; } const std::string &lastError() const override { return last_error; } void setCallbacks( std::function state_callback, std::function initial_callback, std::function cache_callback, std::function 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 state_changed; std::function initial_read_changed; std::function cache_updated; std::function error_reported; }; class OnlineReadyGateway final : public PlcCommunicationGateway { public: PlcCommunicationResult connectDevice(const PlcSerialConfiguration &) override { connection_state = PlcConnectionState::Connected; if (state_changed) { state_changed(); } return {true, {}}; } void disconnectDevice() override { connection_state = PlcConnectionState::Disconnected; initial_read = false; if (initial_read_changed) { initial_read_changed(false); } if (state_changed) { state_changed(); } } void setPollAddresses(const std::vector &addresses) override { poll_addresses = addresses; } PlcConnectionState state() const override { return connection_state; } bool initialReadCompleted() const override { return initial_read; } PlcCommunicationError lastErrorType() const override { return last_error_type; } const std::string &lastError() const override { return last_error; } void setCallbacks( std::function state_callback, std::function initial_callback, std::function cache_callback, std::function 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); } void completeInitialRead() { initial_read = true; last_error_type = PlcCommunicationError::None; last_error.clear(); if (connection_state == PlcConnectionState::Recovering) { connection_state = PlcConnectionState::Connected; if (state_changed) { state_changed(); } } if (initial_read_changed) { initial_read_changed(true); } } void timeoutCommunication(const std::string &message) { initial_read = false; last_error_type = PlcCommunicationError::CommunicationTimeout; last_error = message; connection_state = PlcConnectionState::Faulted; if (initial_read_changed) { initial_read_changed(false); } if (state_changed) { state_changed(); } if (error_reported) { error_reported(last_error); } } void recoverCommunication() { initial_read = false; last_error_type = PlcCommunicationError::None; last_error.clear(); connection_state = PlcConnectionState::Recovering; if (state_changed) { state_changed(); } } void loseSerialConnection(const std::string &message) { initial_read = false; last_error_type = PlcCommunicationError::SerialConnectionLost; last_error = message; connection_state = PlcConnectionState::Disconnected; if (initial_read_changed) { initial_read_changed(false); } if (state_changed) { state_changed(); } if (error_reported) { error_reported(last_error); } } PlcConnectionState connection_state = PlcConnectionState::Disconnected; bool initial_read = false; PlcCommunicationError last_error_type = PlcCommunicationError::None; std::string last_error; std::vector poll_addresses; std::function state_changed; std::function initial_read_changed; std::function cache_updated; std::function error_reported; }; void require(bool condition, const std::string &message) { if (!condition) { throw std::runtime_error(message); } } template ObjectType *requiredChild(MainWindow &window, const char *name) { // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息 ObjectType *child = window.findChild(QString::fromLatin1(name)); require(child != nullptr, std::string("missing UI object ") + name); return child; } QColor renderedColorAt(HmiEditorWidget &view, const QPoint &viewport_position) { QImage image(view.viewport()->size(), QImage::Format_ARGB32_Premultiplied); image.fill(Qt::transparent); QPainter painter(&image); view.viewport()->render(&painter); return image.pixelColor(viewport_position); } void testRuntimeButtonMouseInteraction() { TestProjectStorage storage; ProjectService project_service(storage); HmiEditorService editor_service(project_service); VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); AlarmService alarm_service(project_service, repository); const HmiEditorResult page_result = editor_service.ensureDefaultPage(); require(page_result.succeeded, "runtime button test must create an HMI page"); const HmiEditorResult button_result = editor_service.addControl( page_result.id, HmiControlType::Button); require(button_result.succeeded, "runtime button test must create a button"); HmiControl button = *editor_service.findControl(page_result.id, button_result.id); button.binding = RegisterAddress{RegisterArea::M, 0}; button.buttonOperation = HmiButtonOperation::MomentaryOn; require(editor_service.updateControl(page_result.id, button.id, button).succeeded, "runtime button test must bind the button to M0"); HmiEditorWidget view(editor_service, runtime_service, alarm_service); view.resize(900, 600); view.setPageId(page_result.id); view.setEditingEnabled(false); view.setRuntimeActive(true); view.setRuntimeWriteEnabled(true); view.show(); QApplication::processEvents(); const QPoint center = view.mapFromScene(QPointF( button.bounds.x + button.bounds.width / 2.0, button.bounds.y + button.bounds.height / 2.0)); const QPoint color_sample = view.mapFromScene(QPointF( button.bounds.x + 8.0, button.bounds.y + 8.0)); const QColor normal_color = renderedColorAt(view, color_sample); QTest::mouseMove(view.viewport(), center); QApplication::processEvents(); QGraphicsItem *button_item = view.itemAt(center); require(button_item != nullptr, "runtime button must be hit-testable while writes are enabled"); require(button_item->cursor().shape() == Qt::PointingHandCursor, "runtime button must use a pointing-hand cursor"); const QColor hovered_color = renderedColorAt(view, color_sample); require(hovered_color != normal_color, "hovering a runtime button must change its visual state"); QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, center); QApplication::processEvents(); require(repository.readBit({RegisterArea::M, 0}).value, "pressing a momentary runtime button must write M0 ON"); require(view.scene()->selectedItems().isEmpty(), "pressing a runtime button must not show an editing selection"); const QColor pressed_color = renderedColorAt(view, color_sample); require(pressed_color != hovered_color, "pressing a runtime button must change its visual state"); QTest::mouseRelease(view.viewport(), Qt::LeftButton, Qt::NoModifier, center); QApplication::processEvents(); require(!repository.readBit({RegisterArea::M, 0}).value, "releasing a momentary runtime button must write M0 OFF"); require(renderedColorAt(view, color_sample) == hovered_color, "releasing a runtime button must restore its hovered visual state"); view.setRuntimeWriteEnabled(false); QApplication::processEvents(); const QColor disabled_color = renderedColorAt(view, color_sample); require(disabled_color != hovered_color, "a non-writable runtime button must use its disabled visual state"); QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, center); require(!repository.readBit({RegisterArea::M, 0}).value, "a disabled runtime button must not write its register"); } void testRuntimePageJumpDoesNotRequireRegisterWritePermission() { TestProjectStorage storage; ProjectService project_service(storage); HmiEditorService editor_service(project_service); const std::string source_page = editor_service.ensureDefaultPage().id; const std::string target_page = editor_service.addPage("Target").id; const HmiEditorResult jump_result = editor_service.addControl( source_page, HmiControlType::PageJump); HmiControl jump = *editor_service.findControl(source_page, jump_result.id); jump.pageJump = HmiPageJumpConfig{target_page}; require(editor_service.updateControl( source_page, jump_result.id, jump).succeeded, "PageJump fixture setup must succeed"); VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); AlarmService alarm_service(project_service, repository); HmiEditorWidget view(editor_service, runtime_service, alarm_service); view.setPageId(source_page); view.setEditingEnabled(false); view.setRuntimeActive(true); view.setRuntimeWriteEnabled(false); view.refreshRuntimeValues(); view.resize(900, 560); view.show(); QApplication::processEvents(); QString requested_page; QObject::connect( &view, &HmiEditorWidget::pageNavigationRequested, [&requested_page](const QString &page_id) { requested_page = page_id; }); QGraphicsItem *jump_item = nullptr; for (QGraphicsItem *item : view.scene()->items()) { if (item->zValue() >= 0.0) { jump_item = item; break; } } require(jump_item != nullptr, "PageJump graphics item must be rendered"); const QPoint center = view.mapFromScene( jump_item->mapToScene(jump_item->boundingRect().center())); QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, center); require(requested_page == QString::fromStdString(target_page), "PageJump must remain active when register writes are disabled"); } void testRuntimeAlarmListInteraction() { TestProjectStorage storage; ProjectService project_service(storage); HmiEditorService editor_service(project_service); AlarmEditorService alarm_editor_service(project_service); VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); AlarmService alarm_service(project_service, repository); const std::string page_id = editor_service.ensureDefaultPage().id; const HmiEditorResult list_result = editor_service.addControl( page_id, HmiControlType::AlarmList); require(list_result.succeeded, "runtime alarm test must create an AlarmList control"); const HmiControl *alarm_list = editor_service.findControl( page_id, list_result.id); require(alarm_list != nullptr && !alarm_list->binding.has_value(), "AlarmList must not require a register binding"); AlarmDefinition definition; definition.address = RegisterAddress{RegisterArea::M, 0}; definition.condition = AlarmCondition::MOn; definition.message = "Emergency stop"; const AlarmEditorResult alarm_result = alarm_editor_service.addDefinition(definition); require(alarm_result.succeeded, "runtime alarm test must create an M alarm definition"); HmiEditorWidget view(editor_service, runtime_service, alarm_service); view.setPageId(page_id); QGraphicsItem *alarm_item = nullptr; for (QGraphicsItem *item : view.scene()->items()) { if (item->zValue() >= 0.0) { alarm_item = item; break; } } require(alarm_item != nullptr && alarm_item->isVisible(), "AlarmList must remain visible while editing"); view.setEditingEnabled(false); view.setRuntimeActive(true); view.setRuntimeWriteEnabled(false); view.refreshRuntimeValues(); view.resize(900, 560); view.show(); QApplication::processEvents(); require(!alarm_item->isVisible(), "AlarmList must be hidden when runtime has no active alarm"); repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true); alarm_service.refresh(); view.refreshRuntimeValues(); QApplication::processEvents(); require(alarm_service.records().size() == 1, "an active M alarm must be available to AlarmList"); require(alarm_item->isVisible() && alarm_item->zValue() > 0.0, "AlarmList must appear above the page for an active alarm"); const QPoint header_position = view.mapFromScene(QPointF( alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 10.0)); QTest::mouseClick( view.viewport(), Qt::LeftButton, Qt::NoModifier, header_position); require(!alarm_service.records().front().acknowledged, "clicking the AlarmList header must not acknowledge an alarm"); const QPoint first_row_position = view.mapFromScene(QPointF( alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 34.0)); QTest::mouseClick( view.viewport(), Qt::LeftButton, Qt::NoModifier, first_row_position); require(alarm_service.records().front().acknowledged, "clicking an active AlarmList row must acknowledge the alarm"); repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false); alarm_service.refresh(); view.refreshRuntimeValues(); QApplication::processEvents(); require(alarm_service.records().empty(), "clearing the M condition must remove the alarm immediately"); require(!alarm_item->isVisible(), "AlarmList must hide after the final alarm recovers"); view.setRuntimeActive(false); view.setEditingEnabled(true); require(alarm_item->isVisible(), "AlarmList must become visible again after returning to editing"); } 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); AlarmEditorService alarm_editor_service(project_service); AlarmService alarm_service(project_service, repository); RegisterCommentService register_comment_service(project_service); OfflineSimulationService simulation_service(repository); RuntimeModeService mode_service(project_service, simulation_service); RegisterMonitorService monitor_service(repository); MainWindow window( mode_service, project_service, editor_service, logic_editor_service, runtime_service, alarm_editor_service, alarm_service, register_comment_service, monitor_service); window.resize(1000, 640); window.show(); QApplication::processEvents(); require(window.findChild(QStringLiteral("dataPointDock")) == nullptr, "the removed data point dock must not remain in the main window"); require(window.findChild(QStringLiteral("hmiDataPointComboBox")) == nullptr && window.findChild(QStringLiteral("logicDataPointComboBox")) == nullptr, "HMI and ladder properties must use direct M/D address inputs"); QAction *editing_action = requiredChild(window, "editingModeAction"); QAction *offline_action = requiredChild(window, "offlineModeAction"); QAction *online_action = requiredChild(window, "onlineModeAction"); QAction *add_button_action = requiredChild(window, "addButtonAction"); QAction *add_indicator_action = requiredChild(window, "addIndicatorAction"); QAction *delete_control_action = requiredChild(window, "deleteControlAction"); QAction *add_normally_open_action = requiredChild( window, "addNormallyOpenAction"); QAction *add_normal_coil_action = requiredChild( window, "addNormalCoilAction"); QAction *parallel_insert_action = requiredChild( window, "parallelInsertAction"); QDockWidget *project_dock = requiredChild(window, "projectDock"); QDockWidget *properties_dock = requiredChild(window, "propertiesDock"); QLabel *selection = requiredChild(window, "selectionValueLabel"); QLineEdit *text_edit = requiredChild(window, "controlTextEdit"); QComboBox *button_operation = requiredChild( window, "buttonOperationComboBox"); QPushButton *apply_properties = requiredChild( window, "applyPropertiesButton"); HmiEditorWidget *hmi_editor = requiredChild( window, "hmiEditorWidget"); QLabel *executor_status = requiredChild(window, "executorStatusLabel"); QWidget *runtime_tab = requiredChild(window, "runtimeMonitorTab"); QWidget *runtime_hmi = requiredChild(window, "runtimeHmiView"); QWidget *runtime_logic = requiredChild(window, "runtimeLogicView"); QWidget *free_monitor = requiredChild(window, "freeMonitorWidget"); require(!runtime_tab->isVisible(), "runtime monitor workspace must be hidden while editing"); 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"); require(button_operation->currentText() == QStringLiteral("瞬时 ON"), "new HMI buttons must default to momentary ON"); const QList 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"); button_operation->setCurrentIndex( button_operation->findData(static_cast(HmiButtonOperation::Toggle))); apply_properties->click(); require(editor_service.findControl(page_id, "button-1")->buttonOperation == HmiButtonOperation::Toggle, "the property panel must update the HMI button operation"); 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 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_indicator_action->trigger(); HmiControl runtime_indicator = *editor_service.findControl(page_id, "indicator-1"); runtime_indicator.binding = RegisterAddress{RegisterArea::M, 3}; require(editor_service.updateControl( page_id, runtime_indicator.id, runtime_indicator).succeeded, "a configured HMI control must be available for the runtime projection"); hmi_editor->reloadPage(); 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 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"); require(runtime_tab->isVisible() && runtime_hmi->isVisible() && runtime_logic->isVisible() && free_monitor->isVisible(), "offline running must show HMI, ladder trace and free monitor together"); auto *runtime_hmi_view = qobject_cast(runtime_hmi); require(runtime_hmi_view != nullptr && runtime_hmi_view->scene()->items().size() == 2, "offline runtime HMI must project the configured page control"); QLineEdit *monitor_address = requiredChild(window, "addressEdit"); QPushButton *monitor_add = requiredChild(window, "addButton"); QTableWidget *monitor_table = requiredChild(window, "monitorTable"); monitor_address->setText(QStringLiteral("M0")); monitor_add->click(); repository.writeBit({RegisterArea::M, 0}, true); auto *monitor_widget = requiredChild(window, "freeMonitorWidget"); monitor_widget->refreshValues( ApplicationMode::OfflineRunning, PlcConnectionState::Disconnected); require(monitor_table->rowCount() == 1 && monitor_table->item(0, 2)->text() == QStringLiteral("ON"), "offline free monitor must read the same virtual M/D repository as HMI"); 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"); require(!runtime_tab->isVisible(), "returning to editing must hide the runtime monitor workspace"); 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 testEdgeTimerPropertyEditingAndParallelMenu() { TestProjectStorage storage; ProjectService project_service(storage); HmiEditorService editor_service(project_service); LogicEditorService logic_editor_service(project_service); VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); AlarmEditorService alarm_editor_service(project_service); AlarmService alarm_service(project_service, repository); RegisterCommentService register_comment_service(project_service); OfflineSimulationService simulation_service(repository); RuntimeModeService mode_service(project_service, simulation_service); RegisterMonitorService monitor_service(repository); MainWindow window( mode_service, project_service, editor_service, logic_editor_service, runtime_service, alarm_editor_service, alarm_service, register_comment_service, monitor_service); window.show(); QApplication::processEvents(); QAction *parallel_action = requiredChild(window, "parallelInsertAction"); require(parallel_action->menu() != nullptr, "parallel insertion must expose its node type menu"); QStringList parallel_labels; for (QAction *action : parallel_action->menu()->actions()) { parallel_labels.push_back(action->text()); } require(parallel_labels.contains(QStringLiteral("并联上升沿触点")) && parallel_labels.contains(QStringLiteral("并联下降沿触点")) && parallel_labels.contains(QStringLiteral("并联 T 触点")), "parallel insertion must include edge and T contact choices"); QAction *rising_action = requiredChild(window, "addRisingEdgeAction"); QAction *falling_action = requiredChild(window, "addFallingEdgeAction"); QAction *timer_contact_action = requiredChild( window, "addTimerContactAction"); QAction *ton_action = requiredChild(window, "addTonAction"); requiredChild(window, "editRungCommentAction"); requiredChild(window, "configureRegisterCommentsAction"); QSpinBox *address = requiredChild(window, "logicAddressSpinBox"); QSpinBox *preset = requiredChild(window, "logicPresetSpinBox"); QComboBox *mode = requiredChild(window, "logicModeComboBox"); QPushButton *apply = requiredChild( window, "applyLogicPropertiesButton"); const std::string logic_id = logic_editor_service.firstLogicId(); rising_action->trigger(); address->setValue(8); mode->setCurrentIndex(mode->findData(static_cast(EdgeMode::Falling))); apply->click(); const LogicNode *edge = logic_editor_service.findNode(logic_id, "edge-1"); require(edge != nullptr && edge->configured && std::get(edge->config).address.index() == 8 && std::get(edge->config).mode == EdgeMode::Falling, "edge properties must update M address and edge mode"); timer_contact_action->trigger(); address->setValue(9); mode->setCurrentIndex(mode->findData( static_cast(ContactMode::NormallyClosed))); apply->click(); const LogicNode *timer_contact = logic_editor_service.findNode( logic_id, "timer-contact-1"); require(timer_contact != nullptr && timer_contact->configured && std::get(timer_contact->config).address == TimerAddress{9} && std::get(timer_contact->config).mode == ContactMode::NormallyClosed, "T contact properties must update T address and contact mode"); ton_action->trigger(); require(preset->isEnabled() && !mode->isEnabled(), "TON properties must enable PT and disable irrelevant mode editing"); address->setValue(9); preset->setValue(1234); apply->click(); const LogicNode *ton = logic_editor_service.findNode(logic_id, "ton-1"); require(ton != nullptr && ton->configured && std::get(ton->config).address == TimerAddress{9} && std::get(ton->config).presetMs == 1234, "TON properties must update T address and preset milliseconds"); falling_action->trigger(); const LogicNode *falling = logic_editor_service.findNode(logic_id, "edge-2"); require(falling != nullptr && std::get(falling->config).mode == EdgeMode::Falling, "falling edge action must create a falling-edge condition"); } 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); AlarmEditorService alarm_editor_service(project_service); AlarmService alarm_service(project_service, repository); RegisterCommentService register_comment_service(project_service); OfflineSimulationService simulation_service(repository); RuntimeModeService mode_service(project_service, simulation_service); RegisterMonitorService monitor_service(repository); MainWindow window( mode_service, project_service, editor_service, logic_editor_service, runtime_service, alarm_editor_service, alarm_service, register_comment_service, monitor_service); require(window.findChild(QStringLiteral("serialPortComboBox")) == nullptr, "serial configuration controls must not be embedded in the main window"); QAction *configure_action = requiredChild(window, "configurePlcAction"); bool dialog_opened = false; QTimer::singleShot( 0, [&dialog_opened] { auto *dialog = qobject_cast( 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); AlarmEditorService alarm_editor_service(project_service); AlarmService alarm_service(project_service, active_repository); RegisterCommentService register_comment_service(project_service); OfflineSimulationService simulation_service(virtual_repository); RepeatedStatusGateway gateway; RuntimeModeService mode_service(project_service, simulation_service); RegisterMonitorService monitor_service(active_repository); mode_service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); MainWindow window( mode_service, project_service, editor_service, logic_editor_service, runtime_service, alarm_editor_service, alarm_service, register_comment_service, monitor_service); QListWidget *output = requiredChild(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"); } void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly() { 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); AlarmEditorService alarm_editor_service(project_service); AlarmService alarm_service(project_service, active_repository); RegisterCommentService register_comment_service(project_service); OfflineSimulationService simulation_service(virtual_repository); OnlineReadyGateway gateway; RuntimeModeService mode_service(project_service, simulation_service); RegisterMonitorService monitor_service(active_repository); mode_service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); MainWindow window( mode_service, project_service, editor_service, logic_editor_service, runtime_service, alarm_editor_service, alarm_service, register_comment_service, monitor_service); window.resize(1200, 760); window.show(); QApplication::processEvents(); require(mode_service.connectPlc( {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, "online workspace test must connect the fake PLC gateway"); gateway.completeInitialRead(); QAction *online_action = requiredChild(window, "onlineModeAction"); online_action->trigger(); QApplication::processEvents(); QWidget *runtime_tab = requiredChild(window, "runtimeMonitorTab"); QWidget *runtime_hmi = requiredChild(window, "runtimeHmiView"); QWidget *runtime_logic = requiredChild(window, "runtimeLogicView"); QWidget *free_monitor = requiredChild(window, "freeMonitorWidget"); require(mode_service.mode() == ApplicationMode::OnlineRunning, "completed PLC initial read must allow online running"); require(runtime_tab->isVisible() && runtime_hmi->isVisible() && free_monitor->isVisible(), "online running must show HMI and free monitor together"); require(!runtime_logic->isVisible(), "online running must not show a misleading local ladder runtime trace"); QLineEdit *monitor_address = requiredChild(window, "addressEdit"); QPushButton *monitor_add = requiredChild(window, "addButton"); monitor_address->setText(QStringLiteral("D9")); monitor_add->click(); require(std::find( gateway.poll_addresses.begin(), gateway.poll_addresses.end(), RegisterAddress{RegisterArea::D, 9}) != gateway.poll_addresses.end(), "online free monitor addresses must join the active PLC poll set immediately"); QAction *configure_action = requiredChild(window, "configurePlcAction"); QAction *disconnect_action = requiredChild(window, "disconnectPlcAction"); QListWidget *output = requiredChild(window, "outputList"); gateway.timeoutCommunication( "PLC 通信超时,本地串口 COM9 仍处于打开状态;请检查 PLC 供电和 RS-485 接线"); QApplication::processEvents(); require(mode_service.mode() == ApplicationMode::Editing, "a PLC timeout must return online running to editing"); require(disconnect_action->isEnabled(), "a timeout must keep disconnect available while the local port remains open"); require(configure_action->isEnabled() && configure_action->text() == QStringLiteral("PLC 重新配置"), "a timeout must keep PLC reconfiguration available"); gateway.recoverCommunication(); QApplication::processEvents(); require(mode_service.mode() == ApplicationMode::Editing, "automatic communication recovery must remain in editing mode"); require(output->count() > 0 && output->item(output->count() - 1)->text().contains( QStringLiteral("通信已恢复")), "automatic recovery must be retained in the output log"); online_action->trigger(); require(mode_service.mode() == ApplicationMode::Editing, "recovered communication must complete a fresh initial read before online mode"); gateway.completeInitialRead(); QApplication::processEvents(); online_action->trigger(); require(mode_service.mode() == ApplicationMode::OnlineRunning, "users must be able to re-enter online mode after the recovered initial read"); gateway.loseSerialConnection( "PLC 串口 COM9 连接已中断;请检查 USB 转串口是否被拔出或已经失效"); QApplication::processEvents(); require(mode_service.mode() == ApplicationMode::Editing, "a communication fault must return the UI from online running to editing"); require(configure_action->isEnabled() && configure_action->text() == QStringLiteral("PLC 配置"), "a lost serial connection must expose direct configuration"); require(!disconnect_action->isEnabled(), "a lost serial connection must disable redundant disconnect actions"); require(output->count() > 0 && output->item(output->count() - 1)->text().contains( QStringLiteral("连接已中断")), "communication fault details must remain in the output log"); online_action->trigger(); require(mode_service.mode() == ApplicationMode::Editing, "faulted PLC state must not re-enter online running without a fresh read"); } void testMultiPageAndLogicMainWindowIntegration() { TestProjectStorage storage; ProjectService project_service(storage); HmiEditorService editor_service(project_service); LogicEditorService logic_editor_service(project_service); const std::string main_page_id = editor_service.ensureDefaultPage().id; const std::string settings_page_id = editor_service.addPage("Settings").id; const std::string first_logic_id = logic_editor_service.ensureDefaultLogic().id; const std::string second_logic_id = logic_editor_service.addLogic("Safety logic").id; const HmiEditorResult jump_result = editor_service.addControl( main_page_id, HmiControlType::PageJump); HmiControl jump = *editor_service.findControl(main_page_id, jump_result.id); jump.pageJump = HmiPageJumpConfig{settings_page_id}; require(editor_service.updateControl( main_page_id, jump_result.id, jump).succeeded, "the integration fixture must configure PageJump"); VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); AlarmEditorService alarm_editor_service(project_service); AlarmService alarm_service(project_service, repository); RegisterCommentService register_comment_service(project_service); OfflineSimulationService simulation_service(repository); RuntimeModeService mode_service(project_service, simulation_service); RegisterMonitorService monitor_service(repository); HmiNavigationService navigation_service(project_service); MainWindow window( mode_service, project_service, editor_service, logic_editor_service, runtime_service, alarm_editor_service, alarm_service, navigation_service, register_comment_service, monitor_service); window.resize(1400, 820); window.show(); QApplication::processEvents(); QTreeWidget *tree = requiredChild(window, "projectTree"); require(tree->topLevelItemCount() == 2 && tree->topLevelItem(0)->childCount() == 2 && tree->topLevelItem(1)->childCount() == 2, "the project tree must list every page and logic module"); tree->setCurrentItem(tree->topLevelItem(0)->child(1)); QApplication::processEvents(); require(window.currentHmiPageId() == settings_page_id, "selecting a page tree node must change the current HMI page id"); QAction *add_label = requiredChild(window, "addLabelAction"); add_label->trigger(); require(editor_service.findPage(settings_page_id)->controls.size() == 1U && editor_service.findPage(main_page_id)->controls.size() == 1U, "HMI toolbar actions must edit the selected page rather than the first page"); QComboBox *binding_area = requiredChild(window, "bindingAreaComboBox"); QComboBox *target_page = requiredChild(window, "targetPageComboBox"); require(!binding_area->isVisible() && !target_page->isVisible(), "Label properties must hide both register binding and PageJump target fields"); tree->setCurrentItem(tree->topLevelItem(1)->child(1)); QApplication::processEvents(); require(window.currentLogicId() == second_logic_id, "selecting a logic tree node must change the current logic id"); requiredChild(window, "addRungAction")->trigger(); require(logic_editor_service.findLogic(second_logic_id)->rungs.size() == 2U && logic_editor_service.findLogic(first_logic_id)->rungs.size() == 1U, "logic toolbar actions must edit the selected logic module"); tree->setCurrentItem(tree->topLevelItem(0)->child(0)); QApplication::processEvents(); HmiEditorWidget *editor = requiredChild(window, "hmiEditorWidget"); editor->selectControl(jump_result.id); QApplication::processEvents(); require(target_page->isVisible() && !binding_area->isVisible() && target_page->currentData().toString() == QString::fromStdString(settings_page_id), "PageJump properties must show a target-page selector without register fields"); requiredChild(window, "offlineModeAction")->trigger(); QApplication::processEvents(); require(mode_service.mode() == ApplicationMode::OfflineRunning && navigation_service.currentPageId() == main_page_id, "runtime HMI navigation must start on the persisted initial page"); HmiEditorWidget *runtime_hmi = requiredChild( window, "runtimeHmiView"); QGraphicsItem *jump_item = nullptr; for (QGraphicsItem *item : runtime_hmi->scene()->items()) { if (item->zValue() >= 0.0) { jump_item = item; break; } } require(jump_item != nullptr, "runtime HMI must render the PageJump control"); const QPoint jump_center = runtime_hmi->mapFromScene( jump_item->mapToScene(jump_item->boundingRect().center())); QTest::mouseClick(runtime_hmi->viewport(), Qt::LeftButton, Qt::NoModifier, jump_center); QApplication::processEvents(); require(navigation_service.currentPageId() == settings_page_id && requiredChild(window, "runtimePageLabel")->text() == QStringLiteral("Settings"), "clicking PageJump at runtime must navigate locally to its target page"); QComboBox *logic_selector = requiredChild( window, "runtimeLogicComboBox"); require(logic_selector->count() == 2, "runtime logic selector must list all enabled logic modules"); logic_selector->setCurrentIndex( logic_selector->findData(QString::fromStdString(second_logic_id))); QApplication::processEvents(); auto *runtime_monitor = requiredChild( window, "runtimeMonitorWidget"); require(runtime_monitor->selectedLogicId() == second_logic_id, "runtime logic selection must change only the visible trace projection"); requiredChild(window, "editingModeAction")->trigger(); } } // namespace int main(int argc, char *argv[]) { // 无窗口平台使 Qt Widgets 测试可在自动化环境稳定运行 qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); QApplication application(argc, argv); try { testRuntimeButtonMouseInteraction(); testRuntimePageJumpDoesNotRequireRegisterWritePermission(); testRuntimeAlarmListInteraction(); testModeActionsControlEditingAvailability(); testEdgeTimerPropertyEditingAndParallelMenu(); testPlcConfigurationUsesDialog(); testRepeatedPlcStatusNotificationsAreCoalesced(); testOnlineWorkspaceShowsHmiAndFreeMonitorOnly(); testMultiPageAndLogicMainWindowIntegration(); } 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; }