#include "domain/active_register_repository.h" #include "domain/project_storage.h" #include "domain/project_limits.h" #include "domain/register_repository.h" #include "domain/virtual_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/alarm_configuration_dialog.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 "ui/runtime_monitor_window.h" #include "support/test_support.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { using TestProjectStorage = TestSupport::InMemoryProjectStorage; 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(); } PlcCommunicationResult setPollAddresses( const std::vector &) override { return {true, {}}; } 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(); } } PlcCommunicationResult setPollAddresses( const std::vector &addresses) override { poll_addresses = addresses; return {true, {}}; } 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; }; using TestSupport::require; 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); } ContactNodeConfig logicContact(int address) { return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen}; } std::string makeEmptyRung(LogicEditorService &service, const std::string &logic_id) { const LogicEditorResult result = service.addRung(logic_id); require(result.succeeded, "UI test fixture must create an empty network"); return result.id; } void testLogicEditorBatchDeletesWholeParallelRow() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); std::vector top_ids; for (int address = 0; address < 4; ++address) { const LogicEditorResult result = service.appendCondition( logic_id, rung_id, logicContact(address)); require(result.succeeded, "logic UI batch deletion setup contacts must be created"); top_ids.push_back(result.id); } const LogicEditorResult lower_first = service.addParallelBranch( logic_id, rung_id, top_ids, logicContact(10)); require(lower_first.succeeded, "logic UI batch deletion setup branch must be created"); std::string lower_tail = lower_first.id; for (int address = 11; address < 14; ++address) { const LogicEditorResult result = service.insertConditionAfter( logic_id, rung_id, lower_tail, logicContact(address)); require(result.succeeded, "logic UI batch deletion lower branch contacts must be created"); lower_tail = result.id; } LogicEditorWidget view(service); view.setLogicId(logic_id); qreal lower_row_y = 0.0; for (QGraphicsItem *item : view.scene()->items()) { if (qFuzzyCompare(item->zValue(), 3.0)) { lower_row_y = std::max(lower_row_y, item->scenePos().y()); } } int selected_count = 0; for (QGraphicsItem *item : view.scene()->items()) { if (qFuzzyCompare(item->zValue(), 3.0) && qFuzzyCompare(item->scenePos().y(), lower_row_y)) { item->setSelected(true); ++selected_count; } } require(selected_count == 4, "logic UI batch deletion must select every contact in the lower branch"); require(view.deleteSelected().succeeded, "logic UI must batch delete a complete parallel row"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->condition.has_value() && rung->validate(), "logic UI batch deletion must leave a valid ladder expression"); require(rung->condition->kind == ConditionExpressionKind::Series && rung->condition->children.size() == 4U, "logic UI batch deletion must preserve only the original series row"); const QList rendered_items = view.scene()->items(); const bool has_vertical_connector = std::any_of( rendered_items.cbegin(), rendered_items.cend(), [](const QGraphicsItem *item) { return qFuzzyCompare(item->zValue(), 2.5); }); require(!has_vertical_connector, "logic UI must not render a vertical connector after removing the branch"); } 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 testRuntimeProgressBarRendering() { TestProjectStorage storage; ProjectService project_service(storage); HmiEditorService 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 progress_result = editor_service.addControl( page_id, HmiControlType::ProgressBar); require(progress_result.succeeded, "runtime progress test must create a ProgressBar control"); HmiControl progress = *editor_service.findControl( page_id, progress_result.id); progress.binding = RegisterAddress{RegisterArea::D, 0}; progress.progressBar = HmiProgressBarConfig{0, 100, true}; require(editor_service.updateControl( page_id, progress.id, progress).succeeded, "runtime progress test must configure a D value range"); require(repository.writeWord( {RegisterArea::D, 0}, static_cast(25)).succeeded, "runtime progress test must seed D0"); HmiEditorWidget view(editor_service, runtime_service, alarm_service); view.resize(900, 560); view.setPageId(page_id); view.setEditingEnabled(false); view.setRuntimeActive(true); view.refreshRuntimeValues(); view.show(); QApplication::processEvents(); const QPoint sample = view.mapFromScene(QPointF( progress.bounds.x + progress.bounds.width * 0.6, progress.bounds.y + 6.0)); const QColor background_color = renderedColorAt(view, sample); QGraphicsItem *progress_item = view.itemAt(sample); require(progress_item != nullptr && progress_item->acceptedMouseButtons() == Qt::NoButton, "runtime ProgressBar must be visible and read-only"); require(repository.writeWord( {RegisterArea::D, 0}, static_cast(75)).succeeded, "runtime progress test must update D0"); view.refreshRuntimeValues(); QApplication::processEvents(); const QColor fill_color = renderedColorAt(view, sample); require(fill_color != background_color, "ProgressBar fill must expand when the D value increases"); require(repository.writeWord( {RegisterArea::D, 0}, static_cast(200)).succeeded, "runtime progress test must accept an out-of-range source value"); view.refreshRuntimeValues(); QApplication::processEvents(); const QPoint near_end = view.mapFromScene(QPointF( progress.bounds.x + progress.bounds.width * 0.95, progress.bounds.y + 6.0)); require(renderedColorAt(view, near_end) == fill_color, "ProgressBar rendering must clamp values above its maximum"); } 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"); std::vector alarm_ids; for (int index = 0; index <= ProjectLimits::kMaximumVisibleAlarmRows; ++index) { AlarmDefinition definition; definition.address = RegisterAddress{RegisterArea::M, index}; definition.condition = AlarmCondition::MOn; definition.message = "Alarm " + std::to_string(index); const AlarmEditorResult alarm_result = alarm_editor_service.addDefinition(definition); require(alarm_result.succeeded, "runtime alarm test must create paged M alarm definitions"); alarm_ids.push_back(alarm_result.id); } AlarmConfigurationDialog configuration_dialog(alarm_editor_service); QLineEdit *message_edit = configuration_dialog.findChild( QStringLiteral("messageEdit")); require(message_edit != nullptr && message_edit->maxLength() == static_cast( ProjectLimits::kMaximumAlarmMessageCharacters), "alarm configuration must limit messages to 20 characters"); 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"); require(alarm_item->boundingRect().height() < alarm_list->bounds.height, "a runtime AlarmList with one record must remove unused row space"); 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"); for (int index = 1; index <= ProjectLimits::kMaximumVisibleAlarmRows; ++index) { repository.writeBit(RegisterAddress{RegisterArea::M, index}, true); } alarm_service.reset(); alarm_service.refresh(); view.refreshRuntimeValues(); QApplication::processEvents(); require(alarm_service.records().size() == static_cast( ProjectLimits::kMaximumVisibleAlarmRows + 1), "AlarmList pagination must retain records beyond the visible row limit"); require(alarm_item->boundingRect().height() == alarm_list->bounds.height, "a full AlarmList page must use its configured maximum height"); const QPoint next_page_position = view.mapFromScene(QPointF( alarm_list->bounds.x + alarm_list->bounds.width - 14.0, alarm_list->bounds.y + 10.0)); QTest::mouseClick( view.viewport(), Qt::LeftButton, Qt::NoModifier, next_page_position); QApplication::processEvents(); require(alarm_item->boundingRect().height() < alarm_list->bounds.height, "the final partial alarm page must shrink to its visible rows"); QTest::mouseClick( view.viewport(), Qt::LeftButton, Qt::NoModifier, first_row_position); require(alarm_service.records().back().definitionId == alarm_ids.front() && alarm_service.records().back().acknowledged, "the next AlarmList page must acknowledge its own first record"); for (int index = 0; index <= ProjectLimits::kMaximumVisibleAlarmRows; ++index) { repository.writeBit(RegisterAddress{RegisterArea::M, index}, 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 testLogicEmptyGridSlotInsertion() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); LogicEditorWidget editor(service); editor.setLogicId(logic_id); editor.resize(1400, 360); editor.show(); QApplication::processEvents(); auto insertionGridItems = [&editor] { QList items; for (QGraphicsItem *item : editor.scene()->items()) { if (item->toolTip().startsWith(QStringLiteral("空条件网格")) || item->toolTip().startsWith(QStringLiteral("横线网格"))) { items.push_back(item); } } std::sort( items.begin(), items.end(), [](const QGraphicsItem *left, const QGraphicsItem *right) { return left->scenePos().x() < right->scenePos().x(); }); return items; }; QList empty_items = insertionGridItems(); require(empty_items.size() == ProjectLimits::kMaximumConditionColumns && !service.findRung(logic_id, rung_id)->condition.has_value(), "a new network must expose ten selectable grid slots without persisting placeholders"); const QPoint fifth_slot = editor.mapFromScene(empty_items.at(4)->scenePos()); QTest::mouseClick( editor.viewport(), Qt::LeftButton, Qt::NoModifier, fifth_slot); require(editor.scene()->selectedItems().size() == 1, "clicking an empty grid cell must select one insertion position"); require(editor.addCondition(ContactNodeConfig{ RegisterAddress{RegisterArea::M, 4}, ContactMode::NormallyOpen}) .succeeded, "the toolbar condition path must insert at the selected fifth column"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->condition.has_value() && rung->condition->kind == ConditionExpressionKind::Series && rung->condition->children.front().kind == ConditionExpressionKind::Wire && rung->condition->children.front().wire->columnSpan == 4, "a fifth-column click must become four wire columns followed by the contact"); empty_items = insertionGridItems(); require(empty_items.size() == 9, "every unoccupied column must stay clickable after the first insertion"); const QPoint second_slot = editor.mapFromScene(empty_items.at(1)->scenePos()); QTest::mouseClick( editor.viewport(), Qt::LeftButton, Qt::NoModifier, second_slot); require(editor.addCondition(ContactNodeConfig{ RegisterAddress{RegisterArea::M, 1}, ContactMode::NormallyClosed}) .succeeded, "a grid cell inside a multi-column wire must accept a contact"); rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->condition.has_value() && rung->condition->kind == ConditionExpressionKind::Series && rung->condition->children.size() == 4U && rung->condition->children.front().kind == ConditionExpressionKind::Wire && rung->condition->children.front().wire->columnSpan == 1 && rung->condition->children.at(2).kind == ConditionExpressionKind::Wire && rung->condition->children.at(2).wire->columnSpan == 2 && std::get( rung->condition->children.at(1).node->config).mode == ContactMode::NormallyClosed, "wire splitting must preserve both surrounding gaps and contact order"); empty_items = insertionGridItems(); require(empty_items.size() == 8, "two occupied columns must leave eight selectable grid positions"); const QPoint eighth_slot = editor.mapFromScene(empty_items.at(5)->scenePos()); QTest::mouseClick( editor.viewport(), Qt::LeftButton, Qt::NoModifier, eighth_slot); require(editor.addCondition(ContactNodeConfig{ RegisterAddress{RegisterArea::M, 7}, ContactMode::NormallyOpen}) .succeeded, "a trailing empty grid cell must remain a valid insertion target"); require(service.undo().succeeded, "a grid-targeted insertion must remain one undoable edit"); editor.reloadLogic(); require(insertionGridItems().size() == 8, "undo must restore the prior condition layout and empty grid slots"); const LogicEditorResult added_rung = service.addRung(logic_id); require(added_rung.succeeded, "the grid-slot UI test must create another network"); editor.reloadLogic(); require(insertionGridItems().size() == 18 && !service.findRung(logic_id, added_rung.id)->condition.has_value(), "every newly added network must expose its own ten empty insertion slots"); } void testLogicParallelPaddingGridInsertion() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); std::vector top_ids; for (int address = 0; address < 3; ++address) { const LogicEditorResult result = service.appendCondition( logic_id, rung_id, logicContact(address)); require(result.succeeded, "parallel grid UI setup must create the upper branch"); top_ids.push_back(result.id); } const LogicEditorResult lower = service.addParallelBranch( logic_id, rung_id, top_ids, logicContact(10)); require(lower.succeeded, "parallel grid UI setup must create a short lower branch"); LogicEditorWidget editor(service); editor.setLogicId(logic_id); editor.resize(1700, 480); editor.show(); QApplication::processEvents(); QList branch_slots; for (QGraphicsItem *item : editor.scene()->items()) { if (item->toolTip().startsWith(QStringLiteral("并联空网格"))) { branch_slots.push_back(item); } } std::sort( branch_slots.begin(), branch_slots.end(), [](const QGraphicsItem *left, const QGraphicsItem *right) { return left->scenePos().x() < right->scenePos().x(); }); require(branch_slots.size() == 2, "every visible padding cell in a short parallel branch must be selectable"); QTest::mouseClick( editor.viewport(), Qt::LeftButton, Qt::NoModifier, editor.mapFromScene(branch_slots.back()->scenePos())); require(editor.scene()->selectedItems().size() == 1 && editor.scene()->selectedItems().front()->toolTip().startsWith( QStringLiteral("并联空网格")), "clicking a parallel padding line must select its grid cell, not the rung"); QImage selected_branch_cell(128, 120, QImage::Format_ARGB32_Premultiplied); selected_branch_cell.fill(Qt::white); { QPainter painter(&selected_branch_cell); const QPointF center = branch_slots.back()->scenePos(); editor.scene()->render( &painter, QRectF(0, 0, 128, 120), QRectF(center.x() - 64, center.y() - 60, 128, 120)); } bool selected_line_visible = false; for (int x = 12; x < selected_branch_cell.width() - 12 && !selected_line_visible; ++x) { for (int y = 58; y <= 62; ++y) { const QColor pixel = selected_branch_cell.pixelColor(x, y); if (pixel.red() < 100 && pixel.green() < 120 && pixel.blue() < 130) { selected_line_visible = true; break; } } } require(selected_line_visible, "selecting a parallel padding cell must keep its horizontal wire visible"); const LogicEditorResult inserted = editor.addCondition(logicContact(12)); require(inserted.succeeded, "the normal condition action must insert into a selected parallel grid cell"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->condition.has_value() && rung->condition->kind == ConditionExpressionKind::Parallel && rung->condition->children.at(1).kind == ConditionExpressionKind::Series && rung->condition->children.at(1).children.size() == 3U, "parallel grid insertion must update only the selected branch"); int remaining_branch_slots = 0; int persisted_wire_cells = 0; for (QGraphicsItem *item : editor.scene()->items()) { remaining_branch_slots += item->toolTip().startsWith( QStringLiteral("并联空网格")) ? 1 : 0; persisted_wire_cells += item->toolTip().startsWith( QStringLiteral("横线网格")) ? 1 : 0; } require(remaining_branch_slots == 0 && persisted_wire_cells == 1, "using a distant branch cell must retain the skipped cell as a replaceable wire"); } void testLogicParallelActionsRespectGridSelectionType() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); std::vector top_ids; for (int address = 0; address < 3; ++address) { const LogicEditorResult result = service.appendCondition( logic_id, rung_id, logicContact(address)); require(result.succeeded, "grid selection type setup must create the upper branch"); top_ids.push_back(result.id); } require(service.addParallelBranch( logic_id, rung_id, top_ids, logicContact(10)) .succeeded, "grid selection type setup must create a short parallel branch"); LogicEditorWidget editor(service); editor.setLogicId(logic_id); editor.resize(1400, 480); editor.show(); QApplication::processEvents(); QList branch_slots; for (QGraphicsItem *item : editor.scene()->items()) { if (item->toolTip().startsWith(QStringLiteral("并联空网格"))) { branch_slots.push_back(item); } } std::sort( branch_slots.begin(), branch_slots.end(), [](const QGraphicsItem *left, const QGraphicsItem *right) { return left->scenePos().x() < right->scenePos().x(); }); require(branch_slots.size() == 2, "empty parallel branch must expose its two grid cells"); branch_slots.at(0)->setSelected(true); branch_slots.at(1)->setSelected(true); service.clearHistory(); const LogicEditorResult parallel = editor.addParallelBranch(logicContact(20)); require(!parallel.succeeded && parallel.message.find("空白网格没有可并联的逻辑") != std::string::npos && !service.canUndo(), "parallel insertion must reject a selection made only of empty branch cells"); const LogicEditorResult vertical = editor.addVerticalWire(); require(!vertical.succeeded && vertical.message.find("空白网格没有可连接的逻辑") != std::string::npos && !service.canUndo(), "vertical insertion must reject a selection made only of empty branch cells"); } void testLogicVerticalWireUsesSelectedWireCells() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); const LogicEditorResult source = service.appendWire( logic_id, rung_id, 3); require(source.succeeded, "wire-cell UI setup must create a three-column wire"); LogicEditorWidget editor(service); editor.setLogicId(logic_id); editor.resize(1400, 360); editor.show(); QApplication::processEvents(); QList wire_cells; for (QGraphicsItem *item : editor.scene()->items()) { if (item->toolTip().startsWith(QStringLiteral("横线网格"))) { wire_cells.push_back(item); } } std::sort( wire_cells.begin(), wire_cells.end(), [](const QGraphicsItem *left, const QGraphicsItem *right) { return left->scenePos().x() < right->scenePos().x(); }); require(wire_cells.size() == 3, "a three-column wire must expose three selectable cells"); wire_cells.at(0)->setSelected(true); wire_cells.at(1)->setSelected(true); const LogicEditorResult branch = editor.addVerticalWire(); require(branch.succeeded, "vertical insertion must accept two selected wire cells"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->condition.has_value() && rung->condition->kind == ConditionExpressionKind::Series && rung->condition->children.front().kind == ConditionExpressionKind::Parallel && rung->condition->children.front().children.back().kind == ConditionExpressionKind::Wire && rung->condition->children.front().children.back() .wire->columnSpan == 2 && rung->condition->children.back().kind == ConditionExpressionKind::Wire && rung->condition->children.back().wire->columnSpan == 1, "vertical insertion must persist exactly the selected two wire columns"); } void testLogicVerticalWireUsesAdjacentWireCells() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); for (int index = 0; index < 3; ++index) { require(service.appendWire(logic_id, rung_id).succeeded, "adjacent wire UI setup must create one-column segments"); } LogicEditorWidget editor(service); editor.setLogicId(logic_id); editor.resize(1400, 360); editor.show(); QApplication::processEvents(); QList wire_cells; for (QGraphicsItem *item : editor.scene()->items()) { if (item->toolTip().startsWith(QStringLiteral("横线网格"))) { wire_cells.push_back(item); } } std::sort( wire_cells.begin(), wire_cells.end(), [](const QGraphicsItem *left, const QGraphicsItem *right) { return left->scenePos().x() < right->scenePos().x(); }); require(wire_cells.size() == 3, "adjacent one-column wires must expose three selectable cells"); for (QGraphicsItem *cell : wire_cells) { cell->setSelected(true); } const LogicEditorResult branch = editor.addVerticalWire(); require(branch.succeeded, "vertical insertion must accept adjacent cells from separate wire expressions"); const LadderRung *rung = service.findRung(logic_id, rung_id); require(rung != nullptr && rung->condition.has_value() && rung->condition->kind == ConditionExpressionKind::Parallel && rung->condition->children.size() == 2U && rung->condition->children.front().wire->columnSpan == 3 && rung->condition->children.back().wire->columnSpan == 3, "adjacent wire cells must persist a three-column bypass in the UI path"); } void testLogicParallelBranchConnectsShortBranchToRightJoin() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); std::vector selected_ids; for (int address = 0; address < 5; ++address) { const LogicEditorResult result = service.appendCondition( logic_id, rung_id, logicContact(address)); require(result.succeeded, "short parallel branch rendering setup must create the upper series"); selected_ids.push_back(result.id); } const LogicEditorResult branch = service.addParallelBranch( logic_id, rung_id, selected_ids, logicContact(10)); require(branch.succeeded, "parallel branch rendering setup must create a one-column lower branch"); LogicEditorWidget editor(service); editor.setLogicId(logic_id); editor.resize(1700, 480); editor.show(); QApplication::processEvents(); // The lower one-column node must be visibly wired to the parallel right join const qreal expected_lower_y = 28.0 + 52.0 + 1.5 * 120.0; const qreal expected_branch_output_x = 68.0 + 128.0; const qreal expected_right_join_x = 68.0 + 5.0 * 128.0; bool has_right_join_wire = false; for (QGraphicsItem *item : editor.scene()->items()) { const QGraphicsLineItem *line = dynamic_cast(item); if (line == nullptr || line->zValue() <= -1.0) { continue; } const QLineF segment = line->line(); if (qAbs(segment.y1() - expected_lower_y) < 0.1 && qAbs(segment.y2() - expected_lower_y) < 0.1 && qAbs(segment.x1() - expected_branch_output_x) < 0.1 && qAbs(segment.x2() - expected_right_join_x) < 0.1) { has_right_join_wire = true; break; } } require(has_right_join_wire, "a short parallel branch must connect its output to the right join"); } void testLogicContinuousInsertionConsumesFullWire() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; const std::string rung_id = makeEmptyRung(service, logic_id); LogicEditorWidget editor(service); editor.setLogicId(logic_id); editor.resize(1400, 360); editor.show(); QApplication::processEvents(); for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { require(editor.addHorizontalWire().succeeded, "the UI fixture must fill all ten condition columns with wires"); } QList wire_cells; for (QGraphicsItem *item : editor.scene()->items()) { if (item->toolTip().startsWith(QStringLiteral("横线网格"))) { wire_cells.push_back(item); } } std::sort( wire_cells.begin(), wire_cells.end(), [](const QGraphicsItem *left, const QGraphicsItem *right) { return left->scenePos().x() < right->scenePos().x(); }); require(wire_cells.size() == ProjectLimits::kMaximumConditionColumns, "a full wire network must expose ten replaceable wire cells"); QTest::mouseClick( editor.viewport(), Qt::LeftButton, Qt::NoModifier, editor.mapFromScene(wire_cells.front()->scenePos())); for (int address = 0; address < ProjectLimits::kMaximumConditionColumns; ++address) { require(editor.addCondition(logicContact(address)).succeeded, "repeated toolbar conditions must consume the next wire cell"); int remaining_wire_cells = 0; for (QGraphicsItem *item : editor.scene()->items()) { remaining_wire_cells += item->toolTip().startsWith( QStringLiteral("横线网格")) ? 1 : 0; } require(remaining_wire_cells == ProjectLimits::kMaximumConditionColumns - address - 1, "each toolbar condition must consume exactly one visible wire cell"); } const LadderRung *rung = service.findRung(logic_id, rung_id); std::vector nodes; collectConditionNodes(*rung->condition, &nodes); require(rung != nullptr && rung->condition.has_value() && nodes.size() == static_cast( ProjectLimits::kMaximumConditionColumns), "ten repeated toolbar conditions must replace the full wire with ten contacts"); require(!editor.addCondition(logicContact(10)).succeeded, "the toolbar must reject only the eleventh condition after all wires are consumed"); } void testLogicHorizontalWireCanBeInsertedAgainAfterUndoingFirstAutoRung() { TestProjectStorage storage; ProjectService project_service(storage); LogicEditorService service(project_service); const std::string logic_id = service.ensureDefaultLogic().id; LogicEditorWidget editor(service); editor.setLogicId(logic_id); editor.resize(1400, 360); editor.show(); QApplication::processEvents(); require(editor.addHorizontalWire().succeeded, "the first horizontal wire must create the initial ladder network"); const ControlLogic *logic = service.findLogic(logic_id); require(logic != nullptr && logic->rungs.size() == 1U, "the first horizontal wire must leave one network in the model"); require(service.undo().succeeded, "undo must remove the automatically created network"); editor.reloadLogic(); logic = service.findLogic(logic_id); require(logic != nullptr && logic->rungs.empty(), "undo must restore the initially empty ladder logic"); require(editor.addHorizontalWire().succeeded, "a horizontal wire must be insertable again after undoing the first one"); logic = service.findLogic(logic_id); require(logic != nullptr && logic->rungs.size() == 1U && logic->rungs.front().condition.has_value(), "reinserting the horizontal wire must create a fresh network"); } void testWindowTitleTracksUnsavedProjectChanges() { TestProjectStorage storage; ProjectService project_service(storage); HmiEditorService editor_service(project_service); LogicEditorService logic_editor_service(project_service); require(editor_service.ensureDefaultPage().succeeded, "window-title test must create a default HMI page"); require(logic_editor_service.ensureDefaultLogic().succeeded, "window-title test must create a default control logic"); require(project_service.saveAs("window-title-test.json").succeeded, "window-title fixture must start from a saved project"); 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.windowTitle().endsWith(QLatin1Char('*')), "a saved project title must not show an unsaved marker"); requiredChild(window, "addLabelAction")->trigger(); require(project_service.isModified() && window.windowTitle().endsWith(QLatin1Char('*')), "editing the project must immediately add an unsaved marker"); requiredChild(window, "saveProjectAction")->trigger(); require(!project_service.isModified() && !window.windowTitle().endsWith(QLatin1Char('*')), "saving the project must immediately remove the unsaved marker"); } 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 *add_progress_bar_action = requiredChild( window, "addProgressBarAction"); QAction *delete_control_action = requiredChild(window, "deleteControlAction"); QAction *undo_action = requiredChild(window, "undoAction"); QAction *redo_action = requiredChild(window, "redoAction"); QAction *delete_selection_action = requiredChild( window, "deleteSelectionAction"); QAction *toggle_logic_enabled_action = requiredChild( window, "toggleLogicEnabledAction"); QToolButton *hmi_more_controls = requiredChild( window, "hmiMoreControlsButton"); QToolButton *logic_contact_menu = requiredChild( window, "logicContactMenuButton"); QToolButton *logic_output_menu = requiredChild( window, "logicOutputMenuButton"); QToolButton *logic_timer_counter_menu = requiredChild( window, "logicTimerCounterMenuButton"); QToolButton *logic_data_menu = requiredChild( window, "logicDataMenuButton"); QAction *add_normally_open_action = requiredChild( window, "addNormallyOpenAction"); QAction *add_normal_coil_action = requiredChild( window, "addNormalCoilAction"); QAction *add_set_coil_action = requiredChild( window, "addSetCoilAction"); QAction *add_reset_coil_action = requiredChild( window, "addResetCoilAction"); QAction *parallel_insert_action = requiredChild( window, "parallelInsertAction"); QAction *insert_horizontal_wire_action = requiredChild( window, "insertHorizontalWireAction"); QAction *insert_vertical_wire_action = requiredChild( window, "insertVerticalWireAction"); QAction *delete_horizontal_wire_action = requiredChild( window, "deleteHorizontalWireAction"); QAction *delete_vertical_wire_action = requiredChild( window, "deleteVerticalWireAction"); QDockWidget *project_dock = requiredChild(window, "projectDock"); QDockWidget *properties_dock = requiredChild(window, "propertiesDock"); QDockWidget *output_dock = requiredChild(window, "outputDock"); QLabel *selection = requiredChild(window, "selectionValueLabel"); QLineEdit *text_edit = requiredChild(window, "controlTextEdit"); QLineEdit *text_color_edit = requiredChild(window, "textColorEdit"); QComboBox *button_operation = requiredChild( window, "buttonOperationComboBox"); QComboBox *binding_area = requiredChild( window, "bindingAreaComboBox"); QSpinBox *binding_index = requiredChild( window, "bindingIndexSpinBox"); QSpinBox *progress_minimum = requiredChild( window, "progressMinimumSpinBox"); QSpinBox *progress_maximum = requiredChild( window, "progressMaximumSpinBox"); QSpinBox *font_size = requiredChild(window, "fontSizeSpinBox"); QCheckBox *font_bold = requiredChild(window, "fontBoldCheckBox"); QCheckBox *font_italic = requiredChild(window, "fontItalicCheckBox"); QCheckBox *progress_show_value = requiredChild( window, "progressShowValueCheckBox"); QPushButton *apply_properties = requiredChild( window, "applyPropertiesButton"); HmiEditorWidget *hmi_editor = requiredChild( window, "hmiEditorWidget"); LogicEditorWidget *logic_editor = requiredChild( window, "logicEditorWidget"); QLabel *executor_status = requiredChild(window, "executorStatusLabel"); RuntimeMonitorWindow *runtime_window = requiredChild( window, "runtimeMonitorWindow"); QWidget *runtime_hmi = requiredChild(window, "runtimeHmiView"); QWidget *runtime_logic = requiredChild(window, "runtimeLogicView"); QWidget *free_monitor = requiredChild(window, "freeMonitorWidget"); QTabWidget *editor_tabs = requiredChild(window, "editorTabWidget"); QTreeWidget *project_tree = requiredChild(window, "projectTree"); const bool initially_maximized = window.isMaximized(); const bool project_dock_initially_visible = project_dock->isVisible(); const bool properties_dock_initially_visible = properties_dock->isVisible(); const bool output_dock_initially_visible = output_dock->isVisible(); require(!runtime_window->isVisible() && window.findChild(QStringLiteral("runtimeMonitorTab")) == nullptr, "runtime monitor window must be hidden while editing and absent from editor tabs"); for (const char *action_name : { "exitAction", "newProjectAction", "saveProjectAction", "saveAsProjectAction", "loadProjectAction", "undoAction", "redoAction", "deleteSelectionAction", "clearSelectionAction", "configurePlcAction", "configureRegisterCommentsAction", "disconnectPlcAction", "addButtonAction", "addIndicatorAction", "addNumericDisplayAction", "addNumericInputAction", "addProgressBarAction", "addLabelAction", "addPageJumpAction", "addAlarmListAction", "configureAlarmsAction", "deleteControlAction", "addRungAction", "insertHorizontalWireAction", "insertVerticalWireAction", "deleteHorizontalWireAction", "deleteVerticalWireAction", "parallelInsertAction", "addNormallyOpenAction", "addNormallyClosedAction", "addRisingEdgeAction", "addFallingEdgeAction", "addTimerContactAction", "addCounterContactAction", "addNormalCoilAction", "addSetCoilAction", "addResetCoilAction", "addTonAction", "addCtuAction", "addCtdAction", "addMoveAction", "addAddAction", "addSubAction", "addCompareAction", "editRungCommentAction", "deleteLogicAction", "editingModeAction", "offlineModeAction", "onlineModeAction"}) { require(!requiredChild(window, action_name)->icon().isNull(), std::string("UI action must have a semantic icon: ") + action_name); } require(!hmi_more_controls->icon().isNull() && !logic_contact_menu->icon().isNull() && !logic_output_menu->icon().isNull() && !logic_timer_counter_menu->icon().isNull() && !logic_data_menu->icon().isNull(), "every grouped toolbar menu must have an icon"); require(editor_tabs->count() == 2 && !editor_tabs->tabIcon(0).isNull() && !editor_tabs->tabIcon(1).isNull(), "the two editor tabs must remain present and have icons"); QToolButton *monitor_remove = free_monitor->findChild( QStringLiteral("removeButton")); QToolButton *monitor_clear = free_monitor->findChild( QStringLiteral("clearButton")); require(monitor_remove != nullptr && monitor_clear != nullptr && !monitor_remove->icon().isNull() && !monitor_clear->icon().isNull(), "free-monitor delete and clear commands must have icons"); require(hmi_more_controls->menu() != nullptr && hmi_more_controls->menu()->actions().contains(add_progress_bar_action), "advanced HMI controls must remain reachable from the more-controls menu"); require(logic_contact_menu->menu() != nullptr && logic_contact_menu->menu()->actions().contains( requiredChild(window, "addRisingEdgeAction")), "edge and resource contacts must remain reachable from the contact menu"); require(logic_output_menu->menu() != nullptr && logic_output_menu->menu()->actions().contains( requiredChild(window, "addResetCoilAction")), "set/reset coils must remain reachable from the output menu"); require(logic_timer_counter_menu->menu() != nullptr && logic_timer_counter_menu->menu()->actions().contains( requiredChild(window, "addCtdAction")), "timer and counter instructions must remain reachable from their menu"); require(logic_data_menu->menu() != nullptr && logic_data_menu->menu()->actions().contains( requiredChild(window, "addSubAction")), "data instructions must remain reachable from the data menu"); 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(undo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Z) && redo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Y) && delete_selection_action->shortcut() == QKeySequence(Qt::Key_Delete) && insert_horizontal_wire_action->shortcut() == QKeySequence(Qt::Key_F11) && insert_vertical_wire_action->shortcut() == QKeySequence(Qt::Key_F12) && delete_horizontal_wire_action->shortcut() == QKeySequence(Qt::SHIFT | Qt::Key_F11) && delete_vertical_wire_action->shortcut() == QKeySequence(Qt::SHIFT | Qt::Key_F12), "core editor actions must expose the agreed keyboard shortcuts"); require(project_dock->isEnabled(), "project dock must be enabled while editing"); require(properties_dock->isEnabled(), "properties dock must be enabled while editing"); const std::string default_logic_id = logic_editor_service.firstLogicId(); project_tree->setCurrentItem(project_tree->topLevelItem(1)->child(0)); QApplication::processEvents(); const bool default_logic_enabled = logic_editor_service.findLogic(default_logic_id)->enabled; toggle_logic_enabled_action->trigger(); require(undo_action->isEnabled() && logic_editor_service.findLogic(default_logic_id)->enabled != default_logic_enabled, "project-tree edits must immediately enable ladder undo"); undo_action->trigger(); require(logic_editor_service.findLogic(default_logic_id)->enabled == default_logic_enabled, "ladder undo must restore a project-tree edit"); editor_tabs->setCurrentIndex(0); QApplication::processEvents(); 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(text_edit->maxLength() == static_cast( ProjectLimits::kMaximumHmiControlTextCharacters), "all HMI control text inputs must be limited to twelve characters"); require(button_operation->currentText() == QStringLiteral("瞬时 ON"), "new HMI buttons must default to momentary ON"); text_edit->setFocus(); text_edit->selectAll(); QTest::keyClicks(text_edit, "modified"); QTest::keyClick(text_edit, Qt::Key_Z, Qt::ControlModifier); require(text_edit->text() == QStringLiteral("按钮") && editor_service.findControl(page_id, "button-1") != nullptr, "Ctrl+Z in a property input must undo text without undoing the HMI model"); 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))); text_color_edit->setText(QStringLiteral("#E53935")); font_size->setValue(18); font_bold->setChecked(true); font_italic->setChecked(true); apply_properties->click(); const HmiControl *styled_button = editor_service.findControl(page_id, "button-1"); require(styled_button != nullptr && styled_button->buttonOperation == HmiButtonOperation::Toggle && styled_button->properties.at(HmiAppearanceProperty::kTextColor) == "#E53935" && styled_button->properties.at(HmiAppearanceProperty::kFontSize) == "18" && styled_button->properties.at(HmiAppearanceProperty::kFontBold) == "true" && styled_button->properties.at(HmiAppearanceProperty::kFontItalic) == "true", "the property panel must update HMI appearance properties"); 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"); QAction *add_label_action = requiredChild(window, "addLabelAction"); add_label_action->trigger(); add_label_action->trigger(); add_label_action->trigger(); hmi_editor->scene()->clearSelection(); for (QGraphicsItem *item : hmi_editor->scene()->items()) { if (item->zValue() >= 0.0) { item->setSelected(true); } } delete_selection_action->trigger(); require(editor_service.findPage(page_id)->controls.empty(), "Delete must remove every selected HMI control in one operation"); undo_action->trigger(); require(editor_service.findPage(page_id)->controls.size() == 3U, "HMI undo must restore a batch deletion"); redo_action->trigger(); require(editor_service.findPage(page_id)->controls.empty(), "HMI redo must reapply a batch deletion"); add_progress_bar_action->trigger(); const HmiControl *default_progress = editor_service.findControl( page_id, "progress-bar-1"); require(default_progress != nullptr && default_progress->progressBar.has_value() && progress_minimum->isVisible() && progress_maximum->isVisible() && progress_show_value->isVisible() && progress_minimum->value() == 0 && progress_maximum->value() == 100 && progress_show_value->isChecked(), "ProgressBar properties must expose the registered defaults"); progress_minimum->setValue(-20); progress_maximum->setValue(80); progress_show_value->setChecked(false); binding_area->setCurrentIndex(binding_area->findData(1)); binding_index->setValue(4); apply_properties->click(); const HmiControl *configured_progress = editor_service.findControl( page_id, "progress-bar-1"); require(configured_progress != nullptr && configured_progress->binding == RegisterAddress{RegisterArea::D, 4} && configured_progress->progressBar->minimumValue == -20 && configured_progress->progressBar->maximumValue == 80 && !configured_progress->progressBar->showValue, "the property panel must update ProgressBar binding and range settings"); delete_control_action->trigger(); require(editor_service.findPage(page_id)->controls.empty(), "deleting a ProgressBar must remove it from the HMI page"); requiredChild(window, "addAlarmListAction")->trigger(); const HmiControl *default_alarm_list = editor_service.findControl( page_id, "alarm-list-1"); const int expected_alarm_font_size = std::clamp( QApplication::font().pointSize() - ProjectLimits::kAlarmDefaultFontPointReduction, ProjectLimits::kMinimumHmiFontPointSize, ProjectLimits::kMaximumHmiFontPointSize); require(default_alarm_list != nullptr && default_alarm_list->bounds.width == 360 && default_alarm_list->bounds.height == 136 && text_edit->maxLength() == static_cast( ProjectLimits::kMaximumAlarmTitleCharacters) && font_size->value() == expected_alarm_font_size, "AlarmList defaults must expose five rows and the reduced font size"); delete_control_action->trigger(); require(editor_service.findPage(page_id)->controls.empty(), "deleting an AlarmList must remove it from the HMI page"); 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(); editor_tabs->setCurrentIndex(1); QApplication::processEvents(); undo_action->trigger(); require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) ->rungs.empty(), "logic undo must use the ladder history when the ladder tab is active"); redo_action->trigger(); const ControlLogic *restored_logic = logic_editor_service.findLogic( logic_editor_service.firstLogicId()); require(restored_logic != nullptr && restored_logic->rungs.size() == 1U, "logic redo must restore the automatically created network"); const LadderRung &restored_rung = restored_logic->rungs.front(); require(restored_rung.condition.has_value(), "logic redo must restore the ladder edit"); std::vector restored_nodes; collectConditionNodes(*restored_rung.condition, &restored_nodes); require(restored_nodes.size() == 1U, "logic redo must restore the original condition node"); const std::string restored_node_id = restored_nodes.front()->id; QGraphicsItem *fifth_column_slot = nullptr; for (QGraphicsItem *item : logic_editor->scene()->items()) { if (item->toolTip().startsWith(QStringLiteral("空条件网格")) && item->toolTip().contains(QStringLiteral("第 5 列"))) { fifth_column_slot = item; break; } } require(fifth_column_slot != nullptr, "the main-window ladder must expose the fifth condition-grid slot"); QTest::mouseClick( logic_editor->viewport(), Qt::LeftButton, Qt::NoModifier, logic_editor->mapFromScene(fifth_column_slot->scenePos())); add_normally_open_action->trigger(); const LadderRung &action_inserted_rung = logic_editor_service.findLogic( logic_editor_service.firstLogicId())->rungs.front(); require(action_inserted_rung.condition.has_value() && action_inserted_rung.condition->kind == ConditionExpressionKind::Series && action_inserted_rung.condition->children.size() == 3U && action_inserted_rung.condition->children.at(1).kind == ConditionExpressionKind::Wire && action_inserted_rung.condition->children.at(1).wire->columnSpan == 3 && action_inserted_rung.condition->children.at(2).kind == ConditionExpressionKind::Node, "the real contact QAction must insert at the clicked fifth grid column"); undo_action->trigger(); require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) ->rungs.front().condition->kind == ConditionExpressionKind::Node, "undo after a grid-targeted QAction must restore the prior network"); logic_editor->selectNode(restored_node_id); insert_horizontal_wire_action->trigger(); const LadderRung &wired_rung = logic_editor_service.findLogic( logic_editor_service.firstLogicId())->rungs.front(); require(wired_rung.condition->kind == ConditionExpressionKind::Series && wired_rung.condition->children.at(1).kind == ConditionExpressionKind::Wire, "F11 must insert a selectable horizontal wire after the current condition"); delete_horizontal_wire_action->trigger(); require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) ->rungs.front().condition->kind == ConditionExpressionKind::Node, "Shift+F11 must delete the selected horizontal wire"); logic_editor->selectNode(restored_node_id); insert_horizontal_wire_action->trigger(); add_normally_open_action->trigger(); const LadderRung &replaced_wire_rung = logic_editor_service.findLogic( logic_editor_service.firstLogicId())->rungs.front(); require(replaced_wire_rung.condition->kind == ConditionExpressionKind::Series && replaced_wire_rung.condition->children.at(1).kind == ConditionExpressionKind::Node, "adding a contact on a selected wire must replace the wire in place"); undo_action->trigger(); undo_action->trigger(); logic_editor->selectNode(restored_node_id); insert_vertical_wire_action->trigger(); const LadderRung &vertical_rung = logic_editor_service.findLogic( logic_editor_service.firstLogicId())->rungs.front(); require(vertical_rung.condition->kind == ConditionExpressionKind::Parallel && vertical_rung.condition->children.at(1).kind == ConditionExpressionKind::Wire, "F12 must create a structured wire bypass with vertical connectors"); logic_editor->scene()->clearSelection(); for (QGraphicsItem *item : logic_editor->scene()->items()) { if (qFuzzyCompare(item->zValue(), 2.5)) { item->setSelected(true); } } delete_vertical_wire_action->trigger(); require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) ->rungs.front().condition->kind == ConditionExpressionKind::Node, "Shift+F12 must remove the selected vertical connection branch"); logic_editor->selectNode(restored_node_id); 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"); const std::string output_node_id = logic->rungs.front().output->id; add_set_coil_action->trigger(); logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId()); require(logic->rungs.front().output.has_value() && logic->rungs.front().output->id == output_node_id && std::get( logic->rungs.front().output->config).mode == CoilMode::Set, "the set-coil action must switch the existing fixed output in place"); add_reset_coil_action->trigger(); logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId()); require(logic->rungs.front().output.has_value() && logic->rungs.front().output->id == output_node_id && std::get( logic->rungs.front().output->config).mode == CoilMode::Reset, "the reset-coil action must reuse the same fixed output slot"); add_normal_coil_action->trigger(); logic = logic_editor_service.findLogic(logic_editor_service.firstLogicId()); require(logic->rungs.front().output.has_value() && logic->rungs.front().output->id == output_node_id && std::get( logic->rungs.front().output->config).mode == CoilMode::Normal, "switching back to a normal coil must preserve the output node identity"); 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(runtime_window->isVisible() && runtime_window->isMaximized() && project_dock->isVisible() == project_dock_initially_visible && properties_dock->isVisible() == properties_dock_initially_visible && output_dock->isVisible() == output_dock_initially_visible && editor_tabs->tabBar()->isVisible() && window.isMaximized() == initially_maximized, "runtime monitoring must open maximized without changing the editor layout"); require(!add_button_action->isEnabled(), "HMI add controls must be disabled while running"); require(!add_progress_bar_action->isEnabled(), "ProgressBar creation must be disabled while running"); require(!add_normally_open_action->isEnabled(), "logic add nodes must be disabled while running"); require(!undo_action->isEnabled() && !redo_action->isEnabled() && !delete_selection_action->isEnabled(), "undo, redo and delete must be disabled while running"); require(runtime_window->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(project_dock->isVisible() == project_dock_initially_visible && properties_dock->isVisible() == properties_dock_initially_visible && output_dock->isVisible() == output_dock_initially_visible && editor_tabs->tabBar()->isVisible() && window.isMaximized() == initially_maximized, "returning to editing must restore the previous workspace layout"); require(add_button_action->isEnabled(), "HMI add controls must be restored after returning to editing"); require(add_progress_bar_action->isEnabled(), "ProgressBar creation 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_window->isVisible(), "returning to editing must hide the runtime monitor window"); 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 testRuntimeMonitorWindowLifecycle() { 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 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, "runtime-workspace fixture must configure a PageJump target"); const HmiEditorResult button_result = editor_service.addControl( main_page_id, HmiControlType::Button); HmiControl button = *editor_service.findControl( main_page_id, button_result.id); button.binding = RegisterAddress{RegisterArea::M, 0}; button.buttonOperation = HmiButtonOperation::Toggle; button.bounds = HmiRect{200, 20, 120, 40}; require(editor_service.updateControl( main_page_id, button_result.id, button).succeeded, "runtime-workspace fixture must configure an interactive HMI button"); const HmiEditorResult return_jump_result = editor_service.addControl( settings_page_id, HmiControlType::PageJump); HmiControl return_jump = *editor_service.findControl( settings_page_id, return_jump_result.id); return_jump.pageJump = HmiPageJumpConfig{main_page_id}; require(editor_service.updateControl( settings_page_id, return_jump_result.id, return_jump).succeeded, "runtime-workspace fixture must configure a PageJump back to the initial page"); 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.show(); QApplication::processEvents(); requiredChild(window, "offlineModeAction")->trigger(); QApplication::processEvents(); require(mode_service.mode() == ApplicationMode::OfflineRunning, "offline mode must remain the authoritative runtime state"); RuntimeMonitorWindow *runtime_window = requiredChild( window, "runtimeMonitorWindow"); HmiEditorWidget *runtime_hmi = requiredChild( window, "runtimeHmiView"); require(runtime_window->isWindow() && runtime_window->isVisible() && runtime_window->isMaximized(), "offline running must show a maximized runtime monitor window"); require(window.findChildren().size() == 1, "the application must keep exactly one runtime monitor projection"); require(window.findChild(QStringLiteral("runtimeMonitorTab")) == nullptr, "the editor must not retain an embedded runtime monitor tab"); QTest::mouseClick( runtime_hmi->viewport(), Qt::LeftButton, Qt::NoModifier, runtime_hmi->mapFromScene(QPointF( button.bounds.x + button.bounds.width / 2.0, button.bounds.y + button.bounds.height / 2.0))); QApplication::processEvents(); require(repository.readBit({RegisterArea::M, 0}).succeeded && repository.readBit({RegisterArea::M, 0}).value, "the unified runtime HMI must write to the active virtual repository"); QTest::mouseClick(runtime_hmi->viewport(), Qt::LeftButton, Qt::NoModifier, runtime_hmi->mapFromScene(QPointF( jump.bounds.x + jump.bounds.width / 2.0, jump.bounds.y + jump.bounds.height / 2.0))); QApplication::processEvents(); require(navigation_service.currentPageId() == settings_page_id, "PageJump in the unified runtime HMI must update the navigation session"); require(requiredChild(window, "runtimePageLabel")->text() == QStringLiteral("Settings"), "the runtime workspace header must follow the navigated HMI page"); QTest::mouseClick(runtime_hmi->viewport(), Qt::LeftButton, Qt::NoModifier, runtime_hmi->mapFromScene(QPointF( return_jump.bounds.x + return_jump.bounds.width / 2.0, return_jump.bounds.y + return_jump.bounds.height / 2.0))); QApplication::processEvents(); require(navigation_service.currentPageId() == main_page_id, "the unified runtime HMI must return to the initial page before editing"); requiredChild(window, "exitRuntimeButton")->click(); QApplication::processEvents(); require(mode_service.mode() == ApplicationMode::Editing, "the runtime monitor exit button must return to editing"); require(!runtime_window->isVisible(), "returning to editing must hide the runtime monitor window"); requiredChild(window, "addLabelAction")->trigger(); QApplication::processEvents(); require(editor_service.findPage(main_page_id)->controls.size() == 3U, "editing after a runtime session must add the new control to the page"); requiredChild(window, "offlineModeAction")->trigger(); QApplication::processEvents(); runtime_hmi = requiredChild(window, "runtimeHmiView"); require(runtime_window->isVisible() && runtime_window->isMaximized() && runtime_hmi->scene()->items().size() == 4, "a later runtime session must reopen and refresh the monitor window"); runtime_window->close(); QApplication::processEvents(); require(mode_service.mode() == ApplicationMode::Editing, "closing the runtime monitor window must request editing mode"); require(!runtime_window->isVisible(), "closing the runtime monitor window must leave no visible runtime surface"); } void testApplicationExitClosesRuntimeMonitorWindow() { 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(); require(project_service.saveAs("runtime-window-exit-test.json").succeeded, "application-exit fixture must clear the unsaved-project prompt"); requiredChild(window, "offlineModeAction")->trigger(); QApplication::processEvents(); RuntimeMonitorWindow *runtime_window = requiredChild( window, "runtimeMonitorWindow"); require(runtime_window->isVisible(), "application-exit fixture must start with a visible runtime window"); requiredChild(window, "exitAction")->trigger(); QApplication::processEvents(); require(!window.isVisible() && !runtime_window->isVisible(), "application exit must close both programmer and runtime windows"); } 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 testFreeMonitorWritesSingleValues() { VirtualRegisterRepository repository; RegisterMonitorService monitor_service(repository); FreeMonitorWidget widget(monitor_service); widget.show(); QApplication::processEvents(); QLineEdit *address_edit = widget.findChild("addressEdit"); QPushButton *add_button = widget.findChild("addButton"); QTableWidget *table = widget.findChild("monitorTable"); require(address_edit != nullptr && add_button != nullptr && table != nullptr, "free monitor UI test requires the address, add and table controls"); address_edit->setText(QStringLiteral("M0")); add_button->click(); require(table->rowCount() == 1, "free monitor UI must add an M row before writing"); auto *bit_target = qobject_cast(table->cellWidget(0, 3)); auto *bit_write = qobject_cast(table->cellWidget(0, 4)); require(bit_target != nullptr && bit_write != nullptr && !bit_write->isEnabled(), "free monitor writes must be disabled outside a running mode"); widget.setWriteEnabled(true); bit_target->setCurrentIndex(bit_target->findData(true)); bit_write->click(); require(repository.readBit({RegisterArea::M, 0}).succeeded && repository.readBit({RegisterArea::M, 0}).value, "free monitor UI must write the selected M value"); address_edit->setText(QStringLiteral("D0")); add_button->click(); require(table->rowCount() == 2, "free monitor UI must add a D row before writing"); auto *word_target = qobject_cast(table->cellWidget(1, 3)); auto *word_write = qobject_cast(table->cellWidget(1, 4)); require(word_target != nullptr && word_write != nullptr && word_write->isEnabled(), "D monitor rows must expose an enabled write action when permitted"); word_target->setText(QStringLiteral("-321")); word_write->click(); require(repository.readWord({RegisterArea::D, 0}).succeeded && repository.readWord({RegisterArea::D, 0}).value == -321, "free monitor UI must write signed D values"); } void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly() { TestProjectStorage storage; ProjectService project_service(storage); HmiEditorService editor_service(project_service); LogicEditorService logic_editor_service(project_service); const std::string page_id = editor_service.ensureDefaultPage().id; const HmiEditorResult button_result = editor_service.addControl( page_id, HmiControlType::Button); HmiControl button = *editor_service.findControl(page_id, button_result.id); button.binding = RegisterAddress{RegisterArea::M, 5}; button.buttonOperation = HmiButtonOperation::Toggle; require(editor_service.updateControl( page_id, button_result.id, button).succeeded, "online workspace fixture must configure an interactive HMI button"); 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(); RuntimeMonitorWindow *runtime_window = requiredChild( window, "runtimeMonitorWindow"); 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_window->isVisible() && runtime_window->isMaximized() && runtime_hmi->isVisible() && free_monitor->isVisible(), "online running must show HMI and free monitor in the maximized window"); require(!runtime_logic->isVisible(), "online running must not show a misleading local ladder runtime trace"); auto *runtime_hmi_view = qobject_cast(runtime_hmi); require(runtime_hmi_view != nullptr, "online running must expose the unified interactive HMI view"); QTest::mouseClick( runtime_hmi_view->viewport(), Qt::LeftButton, Qt::NoModifier, runtime_hmi_view->mapFromScene(QPointF( button.bounds.x + button.bounds.width / 2.0, button.bounds.y + button.bounds.height / 2.0))); QApplication::processEvents(); require(plc_repository.readBit({RegisterArea::M, 5}).succeeded && plc_repository.readBit({RegisterArea::M, 5}).value, "the unified online HMI must write through the active PLC repository"); QLineEdit *monitor_address = requiredChild(window, "addressEdit"); QPushButton *monitor_add = requiredChild(window, "addButton"); QTableWidget *monitor_table = requiredChild(window, "monitorTable"); 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"); auto *monitor_write = qobject_cast(monitor_table->cellWidget(0, 4)); require(monitor_write != nullptr && monitor_write->isEnabled(), "online Connected mode must enable free monitor writes"); 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(!runtime_window->isVisible(), "a PLC timeout must hide the runtime monitor window"); require(!monitor_write->isEnabled(), "a PLC communication fault must disable free monitor writes"); 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"); QSpinBox *page_width = requiredChild(window, "pageWidthSpinBox"); QSpinBox *page_height = requiredChild(window, "pageHeightSpinBox"); QPushButton *apply_page_properties = requiredChild( window, "applyPagePropertiesButton"); require(page_width->isEnabled() && page_height->isEnabled() && page_width->value() == 800 && page_height->value() == 400, "selecting an HMI page must expose its page dimensions"); page_width->setValue(1024); page_height->setValue(600); apply_page_properties->click(); require(editor_service.findPage(settings_page_id)->width == 1024 && editor_service.findPage(settings_page_id)->height == 600 && requiredChild(window, "hmiPageSizeLabel")->text() == QStringLiteral("1024 x 600"), "applying page properties must update the model and page summary"); 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() == 1U && logic_editor_service.findLogic(first_logic_id)->rungs.empty(), "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 { testLogicEditorBatchDeletesWholeParallelRow(); testRuntimeButtonMouseInteraction(); testRuntimeProgressBarRendering(); testRuntimePageJumpDoesNotRequireRegisterWritePermission(); testRuntimeAlarmListInteraction(); testLogicEmptyGridSlotInsertion(); testLogicParallelPaddingGridInsertion(); testLogicParallelActionsRespectGridSelectionType(); testLogicVerticalWireUsesSelectedWireCells(); testLogicVerticalWireUsesAdjacentWireCells(); testLogicParallelBranchConnectsShortBranchToRightJoin(); testLogicContinuousInsertionConsumesFullWire(); testLogicHorizontalWireCanBeInsertedAgainAfterUndoingFirstAutoRung(); testWindowTitleTracksUnsavedProjectChanges(); testModeActionsControlEditingAvailability(); testRuntimeMonitorWindowLifecycle(); testEdgeTimerPropertyEditingAndParallelMenu(); testPlcConfigurationUsesDialog(); testRepeatedPlcStatusNotificationsAreCoalesced(); testFreeMonitorWritesSingleValues(); testOnlineWorkspaceShowsHmiAndFreeMonitorOnly(); testMultiPageAndLogicMainWindowIntegration(); testApplicationExitClosesRuntimeMonitorWindow(); } 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; }