#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_navigation_service.h" #include "services/hmi_runtime_service.h" #include "services/logic_editor_service.h" #include "services/offline_simulation_service.h" #include "services/project_service.h" #include "services/register_monitor_service.h" #include "services/runtime_mode_service.h" #include "support/test_support.h" #include "ui/alarm_configuration_dialog.h" #include "ui/free_monitor_widget.h" #include "ui/hmi_editor_widget.h" #include "ui/logic_editor_widget.h" #include "ui/property_panel_controller.h" #include "ui/runtime_monitor_widget.h" #include "ui/runtime_panel_controller.h" #include "ui_main_window.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { LogicEditorResult setConditionAtColumn( LogicEditorService &editor, const std::string &logic_id, const std::string &rung_id, int column, const LogicNodeConfig &config, bool configured) { return editor.applyConditionAndAdvance( logic_id, {rung_id, column, false}, config, configured).edit; } using TestProjectStorage = TestSupport::InMemoryProjectStorage; using TestSupport::require; ControlLogic makeAlwaysOnLogic() { ControlLogic logic; logic.id = "queued-trace-logic"; logic.name = "Queued trace logic"; LadderRung rung; rung.id = "always-on-rung"; rung.name = "Always on"; for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { rung.cells.push_back({ "always-on-cell-" + std::to_string(column), LadderCellKind::Wire, std::nullopt}); } LogicNode output; output.id = "always-on-output"; output.config = CoilNodeConfig{ RegisterAddress{RegisterArea::M, 1}, CoilMode::Normal}; rung.output = output; logic.rungs.push_back(rung); return logic; } void testMonitorOffersAllNumericTypes() { VirtualRegisterRepository repository; RegisterMonitorService service(repository); FreeMonitorWidget widget(service); QComboBox *type_combo = widget.findChild( QStringLiteral("dataTypeComboBox")); require(type_combo != nullptr && type_combo->count() == 4 && type_combo->itemData(0).toInt() == static_cast(RegisterDataType::Int16) && type_combo->itemData(1).toInt() == static_cast(RegisterDataType::Int32) && type_combo->itemData(2).toInt() == static_cast(RegisterDataType::Float32) && type_combo->itemData(3).toInt() == static_cast(RegisterDataType::Float64) && type_combo->itemText(3) == QStringLiteral("Double (Float64)"), "shared data/free monitor UI must expose all four numeric types"); } void testAlarmConfigurationOffersMOnAndMOff() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); AlarmEditorService alarm_editor_service(project_service); AlarmConfigurationDialog dialog(alarm_editor_service); QComboBox *area_combo = dialog.findChild( QStringLiteral("areaComboBox")); QComboBox *condition_combo = dialog.findChild( QStringLiteral("conditionComboBox")); require(area_combo != nullptr && condition_combo != nullptr, "alarm configuration must expose its area and condition inputs"); require(area_combo->currentData().toInt() == static_cast(RegisterArea::M) && condition_combo->count() == 2 && condition_combo->findData( static_cast(AlarmCondition::MOn)) >= 0 && condition_combo->findData( static_cast(AlarmCondition::MOff)) >= 0 && condition_combo->findText(QStringLiteral("M 为 ON")) >= 0 && condition_combo->findText(QStringLiteral("M 为 OFF")) >= 0, "M alarm configuration must offer both ON and OFF conditions"); } void testAlarmListKeepsFixedGeometryWhileRecordsChange() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); VirtualRegisterRepository virtual_repository; HmiEditorService hmi_editor_service(project_service, HmiDefaultSettings{}); HmiRuntimeService hmi_runtime_service(virtual_repository); AlarmService alarm_service(project_service, virtual_repository); const HmiEditorResult page = hmi_editor_service.ensureDefaultPage(); const HmiEditorResult alarm_list = hmi_editor_service.addControl( page.id, HmiControlType::AlarmList); require(page.succeeded && alarm_list.succeeded, "alarm-list fixture must create a page and control"); project_service.editProject().alarmDefinitions.push_back({ "fixed-alarm", RegisterAddress{RegisterArea::M, 0}, AlarmCondition::MOn, 0, "Fixed alarm"}); HmiEditorWidget widget( hmi_editor_service, hmi_runtime_service, alarm_service); widget.setPageId(page.id); widget.setEditingEnabled(false); widget.setRuntimeActive(true); widget.refreshRuntimeValues(); QGraphicsItem *alarm_item = nullptr; for (QGraphicsItem *item : widget.scene()->items()) { if (dynamic_cast(item) == nullptr) { alarm_item = item; break; } } const HmiControl *control = hmi_editor_service.findControl( page.id, alarm_list.id); require(alarm_item != nullptr && control != nullptr, "runtime HMI scene must contain the alarm-list item"); const QRectF fixed_bounds = alarm_item->boundingRect(); require(alarm_item->isVisible() && qFuzzyCompare( fixed_bounds.width() + 1.0, static_cast(control->bounds.width) + 1.0) && qFuzzyCompare( fixed_bounds.height() + 1.0, static_cast(control->bounds.height) + 1.0), "an empty runtime alarm list must remain visible at its configured size"); require(virtual_repository.writeBit( RegisterAddress{RegisterArea::M, 0}, true).succeeded, "alarm-list fixture must activate M0"); alarm_service.refresh(); widget.refreshRuntimeValues(); require(alarm_service.records().size() == 1U && alarm_item->isVisible() && alarm_item->boundingRect() == fixed_bounds, "adding an alarm row must not resize or hide the alarm control"); require(virtual_repository.writeBit( RegisterAddress{RegisterArea::M, 0}, false).succeeded, "alarm-list fixture must restore M0"); alarm_service.refresh(); widget.refreshRuntimeValues(); require(alarm_service.records().empty() && alarm_item->isVisible() && alarm_item->boundingRect() == fixed_bounds, "removing the last alarm row must keep the fixed alarm control visible"); } void testHmiPropertyPanelInfersFixedBindingAreas() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); VirtualRegisterRepository repository; HmiDefaultSettings hmi_defaults; HmiEditorService hmi_editor_service(project_service, hmi_defaults); HmiRuntimeService hmi_runtime_service(repository); AlarmService alarm_service(project_service, repository); LogicEditorService logic_editor_service(project_service); const HmiEditorResult page = hmi_editor_service.ensureDefaultPage(); const HmiEditorResult button = hmi_editor_service.addControl( page.id, HmiControlType::Button); const HmiEditorResult second_button = hmi_editor_service.addControl( page.id, HmiControlType::Button); const HmiEditorResult display = hmi_editor_service.addControl( page.id, HmiControlType::NumericDisplay); const HmiEditorResult second_display = hmi_editor_service.addControl( page.id, HmiControlType::NumericDisplay); require(page.succeeded && button.succeeded && second_button.succeeded && display.succeeded && second_display.succeeded, "property-panel fixture must create its page and controls"); const HmiControl *default_button = hmi_editor_service.findControl( page.id, button.id); const HmiControl *default_second_button = hmi_editor_service.findControl( page.id, second_button.id); const HmiControl *default_display = hmi_editor_service.findControl( page.id, display.id); const HmiControl *default_second_display = hmi_editor_service.findControl( page.id, second_display.id); require(default_button != nullptr && default_second_button != nullptr && default_display != nullptr && default_second_display != nullptr && default_button->binding == RegisterAddress{RegisterArea::M, 0} && default_second_button->binding == RegisterAddress{RegisterArea::M, 1} && default_display->binding == RegisterAddress{RegisterArea::D, 0} && default_second_display->binding == RegisterAddress{RegisterArea::D, 1}, "new HMI controls must receive sequential default M/D addresses"); QMainWindow parent; Ui::MainWindow ui; ui.setupUi(&parent); HmiEditorWidget hmi_editor_widget( hmi_editor_service, hmi_runtime_service, alarm_service, &parent); LogicEditorWidget logic_editor_widget(logic_editor_service, &parent); hmi_editor_widget.setPageId(page.id); std::string selected_control_id; std::string selected_logic_node_id; PropertyPanelController controller( parent, ui, project_service, hmi_editor_service, logic_editor_service, hmi_defaults, [&page] { return page.id; }, [] { return std::string{}; }, selected_control_id, selected_logic_node_id, [] {}, [](const QString &, const QString &message, bool succeeded) { require(succeeded, message.toStdString()); }, [](const QString &, int) {}); controller.configure(); controller.bindEditorWidgets(hmi_editor_widget, logic_editor_widget); controller.showControlProperties(button.id); require(ui.bindingAreaLabel->text() == QStringLiteral("绑定地址(M)") && ui.bindingIndexSpinBox->isEnabled() && ui.bindingIndexSpinBox->value() == 0, "button properties must show the fixed M address"); ui.bindingIndexSpinBox->setValue(17); controller.applySelectedControlProperties(); const HmiControl *updated_button = hmi_editor_service.findControl( page.id, button.id); require(updated_button != nullptr && updated_button->binding == RegisterAddress{RegisterArea::M, 17}, "button property submission must infer the M area"); controller.showControlProperties(display.id); require(ui.bindingAreaLabel->text() == QStringLiteral("绑定地址(D)") && ui.bindingIndexSpinBox->isEnabled() && ui.bindingIndexSpinBox->value() == 0, "numeric properties must show the fixed D address"); ui.bindingIndexSpinBox->setValue(23); controller.applySelectedControlProperties(); const HmiControl *updated_display = hmi_editor_service.findControl( page.id, display.id); require(updated_display != nullptr && updated_display->binding == RegisterAddress{RegisterArea::D, 23}, "numeric property submission must infer the D area"); } void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); VirtualRegisterRepository virtual_repository; OfflineSimulationService simulation_service(virtual_repository); OnlineLogicMonitorService online_monitor_service(virtual_repository); LogicEditorService logic_editor_service(project_service); RuntimeModeService runtime_mode_service( project_service, logic_editor_service, simulation_service, online_monitor_service); HmiEditorService hmi_editor_service(project_service, HmiDefaultSettings{}); HmiRuntimeService hmi_runtime_service(virtual_repository); HmiNavigationService hmi_navigation_service(project_service); AlarmService alarm_service(project_service, virtual_repository); RegisterMonitorService register_monitor_service(virtual_repository); const ControlLogic logic = makeAlwaysOnLogic(); project_service.editProject().controlLogics.push_back(logic); QWidget parent; HmiEditorWidget hmi_editor_widget( hmi_editor_service, hmi_runtime_service, alarm_service, &parent); LogicEditorWidget logic_editor_widget(logic_editor_service, &parent); QLabel executor_status_label(&parent); std::string current_logic_id = logic.id; logic_editor_widget.setLogicId(current_logic_id); RuntimePanelController controller( parent, runtime_mode_service, project_service, hmi_editor_service, hmi_runtime_service, logic_editor_service, hmi_navigation_service, alarm_service, register_monitor_service, hmi_editor_widget, logic_editor_widget, executor_status_label, [¤t_logic_id] { return current_logic_id; }, [¤t_logic_id](const std::string &logic_id) { current_logic_id = logic_id; }, [](const std::string &) {}, [](const QString &, int) {}, [](const QString &) {}, [] {}); controller.configure(); require(runtime_mode_service.enterOfflineRunning().succeeded, "offline simulation must start for queued trace regression"); controller.enterRuntime( {}, logic.id, runtime_mode_service.mode(), runtime_mode_service.plcConnectionState()); QLabel *logic_label = controller.runtimeMonitorWidget() ->findChild(QStringLiteral("logicLabel")); QLabel *notice_label = controller.runtimeMonitorWidget() ->findChild(QStringLiteral("logicNoticeLabel")); require(logic_label != nullptr && notice_label != nullptr, "runtime monitor must expose its local trace labels"); require(logic_label->text() == QStringLiteral("梯形图运行状态") && !notice_label->isVisible(), "offline runtime must keep the normal ladder trace heading"); controller.runtimeMonitorWidget()->setMode( ApplicationMode::OnlineRunning, PlcConnectionState::Connected); require(logic_label->text() == QStringLiteral("本地推算轨迹") && notice_label->isVisible() && notice_label->text().contains(QStringLiteral("不写入 PLC 程序或 M/D")) && notice_label->text().contains(QStringLiteral("HMI 和自由监控仍可写 PLC")), "online runtime must explain the read-only local trace boundary"); controller.runtimeMonitorWidget()->setMode( ApplicationMode::OfflineRunning, PlcConnectionState::Disconnected); require(simulation_service.executeOnce().succeeded, "offline simulation must produce a trace before editing"); require(simulation_service.traceSnapshot() .forLogic(logic.id).rungValues.at("always-on-rung"), "the queued trace must contain an energized rung"); QCoreApplication::processEvents(QEventLoop::AllEvents); LogicEditorWidget *runtime_logic_view = controller.runtimeMonitorWidget() ->findChild(QStringLiteral("runtimeLogicView")); bool found_active_runtime_output = false; require(runtime_logic_view != nullptr, "runtime monitor must own the ladder trace view"); for (QGraphicsItem *item : runtime_logic_view->scene()->items()) { if (item->data(0).toString() == QStringLiteral("output") && item->data(1).toString() == QStringLiteral("always-on-rung")) { found_active_runtime_output = item->data(3).toBool() && item->data(4).toBool(); } } require(found_active_runtime_output, "the full executor snapshot must reach the runtime ladder exactly once"); require(simulation_service.executeOnce().succeeded, "a second scan must queue the stale-trace regression event"); require(runtime_mode_service.enterEditing().succeeded, "offline simulation must return to editing before queued delivery"); controller.leaveRuntime( runtime_mode_service.mode(), runtime_mode_service.plcConnectionState()); logic_editor_widget.clearRuntimeTrace(); const auto runtimeOutputIsActive = [runtime_logic_view] { for (QGraphicsItem *item : runtime_logic_view->scene()->items()) { if (item->data(0).toString() == QStringLiteral("output") && item->data(1).toString() == QStringLiteral("always-on-rung")) { return item->data(3).toBool() && item->data(4).toBool(); } } return false; }; require(!runtimeOutputIsActive(), "editing transition must initially clear the runtime trace"); QCoreApplication::processEvents(QEventLoop::AllEvents); require(!runtimeOutputIsActive(), "a queued offline scan must not restore the trace after returning to editing"); } void testLogicEditorGridSelectionAndDeletion() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); LogicEditorService logic_editor_service(project_service); const std::string logic_id = logic_editor_service.ensureDefaultLogic().id; LogicEditorWidget widget(logic_editor_service); widget.setLogicId(logic_id); require(widget.scene()->items().isEmpty(), "an empty logic must not draw standalone power rails"); require((widget.alignment() & Qt::AlignLeft) != 0 && (widget.alignment() & Qt::AlignTop) != 0, "the ladder canvas must start at its top-left origin"); const std::string rung_id = logic_editor_service.addRung(logic_id).id; require(!rung_id.empty(), "the grid regression fixture must create a row"); require(logic_editor_service.setHorizontalWireRange( logic_id, rung_id, 4, 4, true).succeeded, "the grid regression fixture must draw one horizontal cell"); widget.reloadLogic(); widget.resize(1200, 400); widget.show(); QCoreApplication::processEvents(QEventLoop::AllEvents); constexpr qreal left_bus = 60.0; constexpr qreal cell_width = 96.0; constexpr qreal output_width = 224.0; constexpr qreal first_grid_top = 46.0; constexpr qreal row_height = 78.0; const auto clickScene = [&widget](const QPointF &position, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { const QPoint point = widget.mapFromScene(position); QMouseEvent press( QEvent::MouseButtonPress, QPointF(point), Qt::LeftButton, Qt::LeftButton, modifiers); QApplication::sendEvent(widget.viewport(), &press); QMouseEvent release( QEvent::MouseButtonRelease, QPointF(point), Qt::LeftButton, Qt::NoButton, modifiers); QApplication::sendEvent(widget.viewport(), &release); }; const auto dragScene = [&widget]( const QPointF &from, const QPointF &to, Qt::KeyboardModifiers modifiers = Qt::NoModifier) { const QPoint from_point = widget.mapFromScene(from); const QPoint to_point = widget.mapFromScene(to); QMouseEvent press( QEvent::MouseButtonPress, QPointF(from_point), Qt::LeftButton, Qt::LeftButton, modifiers); QApplication::sendEvent(widget.viewport(), &press); QMouseEvent move( QEvent::MouseMove, QPointF(to_point), Qt::NoButton, Qt::LeftButton, modifiers); QApplication::sendEvent(widget.viewport(), &move); QMouseEvent release( QEvent::MouseButtonRelease, QPointF(to_point), Qt::LeftButton, Qt::NoButton, modifiers); QApplication::sendEvent(widget.viewport(), &release); }; clickScene(QPointF( left_bus + 4.0 * cell_width + cell_width / 2.0, first_grid_top + row_height / 2.0)); require(widget.selectedRungId() == rung_id, "clicking a grid cell must select its row"); QString delete_error; QObject::connect( &widget, &LogicEditorWidget::editorError, [&delete_error](const QString &message) { delete_error = message; }); const LogicEditorResult deleted = widget.deleteSelected(); require(deleted.succeeded, "Delete on a selected horizontal cell must succeed: " + delete_error.toStdString()); const LadderRung *rung = logic_editor_service.findRung(logic_id, rung_id); require(rung != nullptr && rung->cells.size() == 10U && rung->cells[4].kind == LadderCellKind::Gap, "Delete on one horizontal cell must preserve the row and clear only that cell"); widget.clearSelection(); require(widget.selectedRungId().empty(), "clearing the selection must not expose the first row as selected"); require(!widget.addHorizontalWire().succeeded, "the horizontal-wire command must require an explicitly selected cell"); require(logic_editor_service.setHorizontalWireRange( logic_id, rung_id, 3, 3, true).succeeded, "fixture must restore a wire for precise hit testing"); widget.reloadLogic(); clickScene(QPointF( left_bus + 3.0 * cell_width, first_grid_top + row_height / 2.0)); require(!widget.deleteSelected().succeeded, "Delete on a column boundary must not delete an adjacent cell"); require(logic_editor_service.findRung(logic_id, rung_id)->cells[3].kind == LadderCellKind::Wire, "a boundary selection must preserve the adjacent horizontal wire"); clickScene(QPointF( left_bus + 10.0 * cell_width + output_width / 2.0, first_grid_top + row_height / 2.0)); require(!widget.deleteSelected().succeeded && logic_editor_service.findLogic(logic_id)->rungs.size() == 1U, "Delete on an empty output slot must never delete the whole row"); clickScene(QPointF( left_bus + 2.0 * cell_width + cell_width / 2.0, first_grid_top + row_height / 2.0)); require(widget.addHorizontalWire().succeeded && logic_editor_service.findRung(logic_id, rung_id)->cells[2].kind == LadderCellKind::Wire, "the horizontal-wire command must act on an explicitly selected cell"); const LogicEditorResult first_node = setConditionAtColumn(logic_editor_service, logic_id, rung_id, 0, ContactNodeConfig{ RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen}, true); const LogicEditorResult second_node = setConditionAtColumn(logic_editor_service, logic_id, rung_id, 1, ContactNodeConfig{ RegisterAddress{RegisterArea::M, 1}, ContactMode::NormallyOpen}, true); require(first_node.succeeded && second_node.succeeded, "fixture must create adjacent conditions for multi-selection"); widget.reloadLogic(); dragScene( QPointF(left_bus + 5.0, first_grid_top + 5.0), QPointF( left_bus + 2.0 * cell_width - 5.0, first_grid_top + row_height - 5.0)); require(widget.selectedNodeIds().size() == 2U, "mouse drag must select multiple conditions in the same row"); widget.clearSelection(); clickScene(QPointF( left_bus + cell_width / 2.0, first_grid_top + row_height / 2.0)); dragScene( QPointF(left_bus + cell_width + 5.0, first_grid_top + 5.0), QPointF( left_bus + 2.0 * cell_width - 5.0, first_grid_top + row_height - 5.0), Qt::ControlModifier); require(widget.selectedNodeIds().size() == 2U, "Ctrl-drag must append objects to the existing selection"); require(widget.addParallelBranch(ContactNodeConfig{ RegisterAddress{RegisterArea::M, 2}, ContactMode::NormallyOpen}).succeeded && logic_editor_service.findLogic(logic_id)->rungs.size() == 2U, "parallel insertion must use the explicitly selected condition range"); const LogicEditorResult output = logic_editor_service.setOutput( logic_id, rung_id, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 3}, CoilMode::Normal}, true); require(output.succeeded, "syntax-location fixture must create an output instruction"); widget.reloadLogic(); widget.focusSyntaxLocation( rung_id, ProjectLimits::kMaximumLadderColumns); require( widget.selectedRungId() == rung_id && widget.selectedNodeId() == output.id, "a syntax error at column 11 must focus the output slot"); widget.focusSyntaxLocation(rung_id, 1); require( widget.selectedRungId() == rung_id && widget.selectedNodeId() == first_node.id, "a syntax error in the condition area must focus its one-based column"); } void testLadderLayoutAndDragDeletion() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); LogicEditorService editor(project_service); const std::string logic_id = editor.ensureDefaultLogic().id; const std::string upper = editor.addRung(logic_id).id; const std::string lower = editor.addRung(logic_id).id; require( setConditionAtColumn(editor, logic_id, upper, 0, ContactNodeConfig{ RegisterAddress{RegisterArea::M, 10}, ContactMode::NormallyClosed}, true).succeeded && editor.setHorizontalWireRange( logic_id, upper, 1, 1, true).succeeded && editor.setOutput( logic_id, upper, MoveNodeConfig{ WordOperand{ WordOperandKind::Register, RegisterAddress{RegisterArea::D, 10}, 0}, RegisterAddress{RegisterArea::D, 20}}, true).succeeded && editor.setHorizontalWireRange( logic_id, lower, 0, 1, true).succeeded && editor.setVerticalConnection( logic_id, upper, lower, 2, true).succeeded && editor.updateNetworkComment( logic_id, lower, "主网络注释").succeeded, "layout fixture must create a connected two-row network"); require( editor.findNetworkHeadRung(logic_id, lower)->id == upper && editor.findRung(logic_id, upper)->comment == "主网络注释" && editor.findRung(logic_id, lower)->comment.empty(), "editing a branch row must store the comment only on the network head"); editor.clearHistory(); LogicEditorWidget widget(editor); widget.setLogicId(logic_id); widget.resize(1320, 360); widget.show(); QCoreApplication::processEvents(QEventLoop::AllEvents); bool found_head_comment = false; for (QGraphicsItem *item : widget.scene()->items()) { auto *text = dynamic_cast(item); if (text == nullptr) { continue; } found_head_comment = found_head_comment || text->text() == QStringLiteral("主网络注释"); } require(found_head_comment, "the comment edited from a branch must render above the network head"); constexpr qreal left_bus = 60.0; constexpr qreal cell_width = 96.0; constexpr qreal right_bus = left_bus + 10.0 * cell_width + 224.0; constexpr qreal first_grid_top = 46.0; constexpr qreal second_grid_bottom = first_grid_top + 2.0 * 78.0; QImage grid_image(1320, 360, QImage::Format_ARGB32_Premultiplied); grid_image.fill(Qt::transparent); { QPainter painter(&grid_image); widget.scene()->render( &painter, QRectF(0.0, 0.0, 1320.0, 360.0), QRectF(0.0, 0.0, 1320.0, 360.0), Qt::IgnoreAspectRatio); } const auto background_at = [&grid_image, first_grid_top](qreal x) { return grid_image.pixelColor( qRound(x), qRound(first_grid_top + 68.0)); }; const QColor node_background = background_at( left_bus + cell_width - 10.0); const QColor wire_background = background_at( left_bus + 2.0 * cell_width - 10.0); const QColor gap_background = background_at( left_bus + 3.0 * cell_width - 10.0); const QColor output_background = background_at(right_bus - 10.0); require( node_background == QColor(QStringLiteral("#ffffff")) && node_background == wire_background && wire_background == gap_background && gap_background == output_background, "node, wire, gap, and output cells must share one grid background"); bool found_left_rail = false; bool found_right_rail = false; for (QGraphicsItem *item : widget.scene()->items()) { auto *line_item = dynamic_cast(item); if (line_item == nullptr || line_item->data(0).isValid()) { continue; } const QLineF line = line_item->line(); const bool exact_span = qFuzzyCompare( line.y1() + 1.0, first_grid_top + 1.0) && qFuzzyCompare( line.y2() + 1.0, second_grid_bottom + 1.0); found_left_rail = found_left_rail || (exact_span && qFuzzyCompare( line.x1() + 1.0, left_bus + 1.0)); found_right_rail = found_right_rail || (exact_span && qFuzzyCompare( line.x1() + 1.0, right_bus + 1.0)); } require(found_left_rail && found_right_rail, "both rails must share the exact first-to-last row span"); const QPoint from = widget.mapFromScene( QPointF(left_bus + 4.0, first_grid_top + 4.0)); const QPoint to = widget.mapFromScene( QPointF(right_bus - 4.0, second_grid_bottom - 4.0)); QMouseEvent press( QEvent::MouseButtonPress, QPointF(from), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(widget.viewport(), &press); QMouseEvent move( QEvent::MouseMove, QPointF(to), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(widget.viewport(), &move); QMouseEvent release( QEvent::MouseButtonRelease, QPointF(to), Qt::LeftButton, Qt::NoButton, Qt::NoModifier); QApplication::sendEvent(widget.viewport(), &release); require(widget.selectedNodeIds().size() == 2U, "drag selection must include the condition and output instruction"); const QList selected_scene_items = widget.scene()->items(); require( std::any_of( selected_scene_items.cbegin(), selected_scene_items.cend(), [](QGraphicsItem *item) { return item->zValue() == 100.0; }), "selected objects must be painted by the highest selection layer"); require(widget.deleteSelected().succeeded, "Delete must submit the complete drag selection once"); const LadderRung *upper_rung = editor.findRung(logic_id, upper); const LadderRung *lower_rung = editor.findRung(logic_id, lower); require( upper_rung->cells[0].kind == LadderCellKind::Gap && upper_rung->cells[1].kind == LadderCellKind::Gap && !upper_rung->output.has_value() && lower_rung->cells[0].kind == LadderCellKind::Gap && lower_rung->cells[1].kind == LadderCellKind::Gap && editor.findLogic(logic_id)->verticalConnections.empty(), "drag deletion must clear cells, output, and vertical connection together"); require(editor.undo().succeeded, "one undo must restore the complete drag deletion"); upper_rung = editor.findRung(logic_id, upper); require( upper_rung->cells[0].kind == LadderCellKind::Node && upper_rung->cells[1].kind == LadderCellKind::Wire && upper_rung->output.has_value() && editor.findLogic(logic_id)->verticalConnections.size() == 1U, "one undo must restore every object removed by the drag selection"); } void testWireGesturePreviewKeepsAtomicCommit() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); LogicEditorService editor(project_service); const std::string logic_id = editor.ensureDefaultLogic().id; const std::string first = editor.addRung(logic_id).id; const std::string second = editor.addRung(logic_id).id; editor.clearHistory(); project_service.restoreModifiedState(false); LogicEditorWidget widget(editor); widget.setLogicId(logic_id); widget.setMouseWireMode(LogicEditorWidget::MouseWireMode::Draw); widget.resize(1200, 360); widget.show(); QCoreApplication::processEvents(QEventLoop::AllEvents); constexpr qreal left_bus = 60.0; constexpr qreal cell_width = 96.0; constexpr qreal first_row_center = 91.0; constexpr qreal second_row_center = 197.0; const QPoint first_cell = widget.mapFromScene(QPointF( left_bus + cell_width / 2.0, first_row_center)); const QPoint fourth_cell = widget.mapFromScene(QPointF( left_bus + 3.0 * cell_width + cell_width / 2.0, first_row_center)); const QPoint invalid_target = widget.mapFromScene(QPointF( left_bus + 3.0 * cell_width + cell_width / 2.0, second_row_center)); const auto press = [&widget](const QPoint &point) { QMouseEvent event( QEvent::MouseButtonPress, QPointF(point), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(widget.viewport(), &event); }; const auto move = [&widget](const QPoint &point) { QMouseEvent event( QEvent::MouseMove, QPointF(point), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(widget.viewport(), &event); QCoreApplication::processEvents(QEventLoop::AllEvents); }; const auto release = [&widget](const QPoint &point) { QMouseEvent event( QEvent::MouseButtonRelease, QPointF(point), Qt::LeftButton, Qt::NoButton, Qt::NoModifier); QApplication::sendEvent(widget.viewport(), &event); QCoreApplication::processEvents(QEventLoop::AllEvents); }; const auto countPreviewPixels = [&widget](bool invalid) { const QImage image = widget.viewport()->grab().toImage(); int count = 0; for (int y = 0; y < image.height(); ++y) { for (int x = 0; x < image.width(); ++x) { const QColor color = image.pixelColor(x, y); const bool matches = invalid ? color.red() > color.green() + 45 && color.red() > color.blue() + 25 : color.blue() > color.red() + 50 && color.blue() > color.green() + 20; count += matches ? 1 : 0; } } return count; }; QString error_message; QObject::connect( &widget, &LogicEditorWidget::editorError, [&error_message](const QString &message) { error_message = message; }); press(first_cell); move(fourth_cell); require(countPreviewPixels(false) > 20, "a legal drag must paint a visible temporary wire preview"); const LadderRung *first_rung = editor.findRung(logic_id, first); require( std::all_of( first_rung->cells.cbegin(), first_rung->cells.cend(), [](const LadderCell &cell) { return cell.kind == LadderCellKind::Gap; }) && !project_service.isModified() && !editor.canUndo(), "the temporary preview must not modify the project or history"); move(invalid_target); require(countPreviewPixels(true) > 20, "an invalid drag direction must switch the preview to an error color"); release(invalid_target); first_rung = editor.findRung(logic_id, first); require( !error_message.isEmpty() && std::all_of( first_rung->cells.cbegin(), first_rung->cells.cend(), [](const LadderCell &cell) { return cell.kind == LadderCellKind::Gap; }) && editor.findRung(logic_id, second) != nullptr && !project_service.isModified() && !editor.canUndo(), "releasing an invalid gesture must discard its complete preview"); error_message.clear(); press(first_cell); move(fourth_cell); require(!project_service.isModified() && !editor.canUndo(), "a legal gesture must remain temporary until mouse release"); release(fourth_cell); first_rung = editor.findRung(logic_id, first); require( error_message.isEmpty() && first_rung->cells[0].kind == LadderCellKind::Wire && first_rung->cells[1].kind == LadderCellKind::Wire && first_rung->cells[2].kind == LadderCellKind::Wire && first_rung->cells[3].kind == LadderCellKind::Wire && project_service.isModified() && editor.canUndo(), "releasing a legal gesture must commit its complete wire range once"); require(editor.undo().succeeded && !editor.canUndo(), "one undo must remove the complete committed gesture"); first_rung = editor.findRung(logic_id, first); require( std::all_of( first_rung->cells.cbegin(), first_rung->cells.cend(), [](const LadderCell &cell) { return cell.kind == LadderCellKind::Gap; }), "undo must restore every cell changed by the gesture"); } void testEscapeExitsMouseWireMode() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); LogicEditorService editor(project_service); LogicEditorWidget widget(editor); const auto press_escape = [&widget] { QKeyEvent event( QEvent::KeyPress, Qt::Key_Escape, Qt::NoModifier); QApplication::sendEvent(&widget, &event); require(event.isAccepted(), "escape must be consumed by the logic editor"); }; widget.setMouseWireMode(LogicEditorWidget::MouseWireMode::Draw); press_escape(); require( widget.mouseWireMode() == LogicEditorWidget::MouseWireMode::Select, "escape must exit mouse draw mode"); widget.setMouseWireMode(LogicEditorWidget::MouseWireMode::Erase); press_escape(); require( widget.mouseWireMode() == LogicEditorWidget::MouseWireMode::Select, "escape must exit mouse erase mode"); } void testCursorAdvanceAndInlineCommandInput() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); LogicEditorService editor(project_service); const std::string logic_id = editor.ensureDefaultLogic().id; LogicEditorWidget widget(editor); widget.setLogicId(logic_id); widget.resize(1320, 440); widget.show(); QCoreApplication::processEvents(QEventLoop::AllEvents); for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) { require(widget.addHorizontalWire().succeeded, "repeated toolbar wire input must advance through all ten cells"); } const ControlLogic *logic = editor.findLogic(logic_id); require(logic != nullptr && logic->rungs.size() == 1U, "the first wire on empty logic must atomically create one row"); for (const LadderCell &cell : logic->rungs.front().cells) { require(cell.kind == LadderCellKind::Wire, "ten repeated wire actions must fill ten distinct cells"); } require(widget.setOutput( CoilNodeConfig{ RegisterAddress{RegisterArea::M, 60}, CoilMode::Normal}, true).succeeded, "the output action must succeed after the tenth cell"); logic = editor.findLogic(logic_id); require(logic->rungs.size() == 2U && logic->rungs.front().output.has_value(), "the output action must append the next empty row atomically"); require(widget.addHorizontalWire().succeeded && editor.findLogic(logic_id)->rungs[1].cells[0].kind == LadderCellKind::Wire, "the toolbar cursor must continue at the appended row first cell"); constexpr qreal left_bus = 60.0; constexpr qreal cell_width = 96.0; constexpr qreal output_width = 224.0; constexpr qreal second_row_center = 197.0; const auto double_click = [&widget](const QPointF &scene_point) { const QPoint point = widget.mapFromScene(scene_point); QMouseEvent event( QEvent::MouseButtonDblClick, QPointF(point), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(widget.viewport(), &event); QCoreApplication::processEvents(QEventLoop::AllEvents); }; const auto press_enter = [](QLineEdit *input) { QKeyEvent event( QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); QApplication::sendEvent(input, &event); QCoreApplication::processEvents(QEventLoop::AllEvents); }; double_click(QPointF( left_bus + 1.5 * cell_width, second_row_center)); QLineEdit *input = widget.findChild( QStringLiteral("logicCommandInput")); require(input != nullptr && input->isVisible() && input->completer() != nullptr, "double-clicking a cell must open the inline command editor with completion"); const int first_input_left = input->geometry().left(); input->setText(QStringLiteral("LD M4")); press_enter(input); const LadderRung &second = editor.findLogic(logic_id)->rungs[1]; require(second.cells[1].node.has_value() && input->isVisible() && input->geometry().left() > first_input_left, "a committed inline condition must move the editor one cell right"); double_click(QPointF( left_bus + ProjectLimits::kMaximumConditionColumns * cell_width + output_width / 2.0, second_row_center)); input->setText(QStringLiteral("OUT M61")); press_enter(input); logic = editor.findLogic(logic_id); require(logic->rungs.size() == 3U && logic->rungs[1].output.has_value() && input->isVisible(), "an inline output must append a row and keep continuous input active"); } void testVerticalWireShortcutAdvancesDownward() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); LogicEditorService editor(project_service); const std::string logic_id = editor.ensureDefaultLogic().id; std::vector rung_ids; for (int row = 0; row < 4; ++row) { rung_ids.push_back(editor.addRung(logic_id).id); } editor.clearHistory(); LogicEditorWidget widget(editor); widget.setLogicId(logic_id); widget.resize(1320, 240); widget.show(); QCoreApplication::processEvents(QEventLoop::AllEvents); constexpr int boundary = 4; constexpr qreal left_bus = 60.0; constexpr qreal cell_width = 96.0; constexpr qreal first_grid_top = 46.0; constexpr qreal row_height = 78.0; const QPoint point = widget.mapFromScene(QPointF( left_bus + (static_cast(boundary) + 0.5) * cell_width, first_grid_top + row_height / 2.0)); QMouseEvent press( QEvent::MouseButtonPress, QPointF(point), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(widget.viewport(), &press); QMouseEvent release( QEvent::MouseButtonRelease, QPointF(point), Qt::LeftButton, Qt::NoButton, Qt::NoModifier); QApplication::sendEvent(widget.viewport(), &release); require(widget.selectedRungId() == rung_ids.front(), "the vertical shortcut fixture must select the first row"); for (std::size_t row = 0U; row + 1U < rung_ids.size(); ++row) { const LogicEditorResult result = widget.addVerticalWire(); require( result.succeeded && widget.selectedRungId() == rung_ids[row + 1U], "each vertical shortcut must advance to the next row"); const ControlLogic *logic = editor.findLogic(logic_id); const bool connected = std::any_of( logic->verticalConnections.cbegin(), logic->verticalConnections.cend(), [&rung_ids, row, boundary](const VerticalConnection &connection) { return connection.upperRungId == rung_ids[row] && connection.lowerRungId == rung_ids[row + 1U] && connection.columnBoundary == boundary; }); require(connected, "repeated vertical shortcuts must stay on one column boundary"); } require( editor.findLogic(logic_id)->verticalConnections.size() == 3U && widget.verticalScrollBar()->value() > 0, "the vertical shortcut must create three segments and keep the target visible"); const LogicEditorResult at_last_row = widget.addVerticalWire(); require( !at_last_row.succeeded && at_last_row.message.find("末行") != std::string::npos && editor.findLogic(logic_id)->verticalConnections.size() == 3U && widget.selectedRungId() == rung_ids.back(), "the vertical shortcut must stop cleanly at the last existing row"); for (int remaining = 2; remaining >= 0; --remaining) { require( editor.undo().succeeded && editor.findLogic(logic_id)->verticalConnections.size() == static_cast(remaining), "each undo must remove exactly one shortcut-created vertical segment"); } require(!editor.canUndo(), "three vertical shortcuts must create exactly three history entries"); } void testSegmentLevelTraceProjection() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); LogicEditorService editor(project_service); const std::string logic_id = editor.ensureDefaultLogic().id; const std::string upper = editor.addRung(logic_id).id; const std::string lower = editor.addRung(logic_id).id; const LogicEditorResult condition = setConditionAtColumn(editor, logic_id, upper, 0, ContactNodeConfig{ RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen}, true); const LogicEditorResult output = editor.setOutput( logic_id, upper, CoilNodeConfig{ RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}, true); require(condition.succeeded && output.succeeded && editor.setVerticalConnection( logic_id, upper, lower, 0, true).succeeded, "the segment trace fixture must create a contact, output and vertical edge"); const ControlLogic *logic = editor.findLogic(logic_id); const std::string cell_id = logic->rungs.front().cells.front().id; const std::string vertical_id = logic->verticalConnections.front().id; LogicTraceSnapshot trace; LogicTraceValues &values = trace.logicValues[logic_id]; values.cellInputPowerValues[cell_id] = true; values.cellPowerValues[cell_id] = false; values.rungValues[upper] = false; values.nodeValues[output.id] = false; LogicEditorWidget widget(editor); widget.setLogicId(logic_id); widget.resize(1320, 360); widget.show(); widget.setRuntimeTrace(trace); QCoreApplication::processEvents(QEventLoop::AllEvents); bool found_split_contact = false; bool found_inactive_output = false; bool found_inactive_vertical = false; for (QGraphicsItem *item : widget.scene()->items()) { const QString type = item->data(0).toString(); if (type == QStringLiteral("cell") && item->data(1).toString().toStdString() == upper && item->data(2).toInt() == 0) { found_split_contact = item->data(6).toBool() && !item->data(7).toBool(); } else if (type == QStringLiteral("output") && item->data(1).toString().toStdString() == upper) { found_inactive_output = !item->data(3).toBool() && !item->data(4).toBool(); } else if (type == QStringLiteral("vertical") && item->data(1).toString().toStdString() == vertical_id) { found_inactive_vertical = !item->data(5).toBool(); } } require(found_split_contact && found_inactive_output && found_inactive_vertical, "trace projection must keep left/right contact power and inactive verticals separate"); values.cellPowerValues[cell_id] = true; values.rungValues[upper] = true; values.nodeValues[output.id] = true; values.verticalConnectionValues[vertical_id] = true; widget.setRuntimeTrace(trace); bool found_active_output = false; bool found_active_vertical = false; for (QGraphicsItem *item : widget.scene()->items()) { const QString type = item->data(0).toString(); if (type == QStringLiteral("output") && item->data(1).toString().toStdString() == upper) { found_active_output = item->data(3).toBool() && item->data(4).toBool(); } else if (type == QStringLiteral("vertical") && item->data(1).toString().toStdString() == vertical_id) { found_active_vertical = item->data(5).toBool(); } } require(found_active_output && found_active_vertical, "explicitly energized output and vertical segments must project as active"); } void testLogicClipboardUsesExplicitObjectAndRowSelection() { TestProjectStorage storage; ProjectService project_service(storage, defaultProjectLimitSettings()); LogicEditorService editor(project_service); const std::string logic_id = editor.ensureDefaultLogic().id; const std::string source = editor.addRung(logic_id).id; const std::string target = editor.addRung(logic_id).id; require( editor.setHorizontalWireRange( logic_id, source, 0, 0, true).succeeded, "clipboard fixture must create one source wire"); editor.clearHistory(); LogicEditorWidget widget(editor); widget.setLogicId(logic_id); widget.resize(1320, 360); widget.show(); QCoreApplication::processEvents(QEventLoop::AllEvents); const auto find_item = [&widget]( const QString &type, const std::string &rung_id, int column) -> QGraphicsItem * { const QList items = widget.scene()->items(); const auto found = std::find_if( items.cbegin(), items.cend(), [&type, &rung_id, column](QGraphicsItem *item) { return item->data(0).toString() == type && item->data(1).toString().toStdString() == rung_id && (column < 0 || item->data(2).toInt() == column); }); return found == items.cend() ? nullptr : *found; }; const auto click_item = [&widget](QGraphicsItem *item) { require(item != nullptr, "clipboard test target item must exist"); const QPoint point = widget.mapFromScene( item->sceneBoundingRect().center()); QMouseEvent press( QEvent::MouseButtonPress, QPointF(point), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(widget.viewport(), &press); QMouseEvent release( QEvent::MouseButtonRelease, QPointF(point), Qt::LeftButton, Qt::NoButton, Qt::NoModifier); QApplication::sendEvent(widget.viewport(), &release); }; click_item(find_item(QStringLiteral("cell"), source, 0)); const LogicClipboardCopyResult wire_copy = widget.copySelection(); require( widget.hasCopyableSelection() && wire_copy.copy.succeeded && wire_copy.fragment.mode == LogicClipboardMode::GridObjects && wire_copy.fragment.cells.size() == 1U && wire_copy.fragment.cells.front().kind == LadderCellKind::Wire && wire_copy.fragment.rows.empty(), "clicking one wire must copy one grid object instead of its whole row"); click_item(find_item(QStringLiteral("cell"), target, 0)); const LogicClipboardPasteResult pasted = widget.pasteClipboard( wire_copy.fragment); require( pasted.edit.succeeded && editor.findLogic(logic_id)->rungs.size() == 2U && editor.findCell(logic_id, target, 0)->kind == LadderCellKind::Wire, "widget paste must place one wire without creating or copying a row"); const LogicClipboardCopyResult pasted_selection = widget.copySelection(); require( pasted_selection.copy.succeeded && pasted_selection.fragment.cells.size() == 1U && pasted_selection.fragment.cells.front().kind == LadderCellKind::Wire, "a successful paste must select the newly pasted wire"); click_item(find_item(QStringLiteral("rowHeader"), source, -1)); const LogicClipboardCopyResult row_copy = widget.copySelection(); require( row_copy.copy.succeeded && row_copy.fragment.mode == LogicClipboardMode::WholeRows && row_copy.fragment.rows.size() == 1U, "only clicking the left row header may create a whole-row clipboard"); } } // namespace int main(int argc, char *argv[]) { qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); QApplication application(argc, argv); return TestSupport::runTestSuite("runtime panel controller tests", { {"testAlarmConfigurationOffersMOnAndMOff", testAlarmConfigurationOffersMOnAndMOff}, {"testMonitorOffersAllNumericTypes", testMonitorOffersAllNumericTypes}, {"testAlarmListKeepsFixedGeometryWhileRecordsChange", testAlarmListKeepsFixedGeometryWhileRecordsChange}, {"testHmiPropertyPanelInfersFixedBindingAreas", testHmiPropertyPanelInfersFixedBindingAreas}, {"testQueuedOfflineTraceIsIgnoredAfterReturningToEditing", testQueuedOfflineTraceIsIgnoredAfterReturningToEditing}, {"testLogicEditorGridSelectionAndDeletion", testLogicEditorGridSelectionAndDeletion}, {"testLadderLayoutAndDragDeletion", testLadderLayoutAndDragDeletion}, {"testWireGesturePreviewKeepsAtomicCommit", testWireGesturePreviewKeepsAtomicCommit}, {"testEscapeExitsMouseWireMode", testEscapeExitsMouseWireMode}, {"testCursorAdvanceAndInlineCommandInput", testCursorAdvanceAndInlineCommandInput}, {"testVerticalWireShortcutAdvancesDownward", testVerticalWireShortcutAdvancesDownward}, {"testSegmentLevelTraceProjection", testSegmentLevelTraceProjection}, {"testLogicClipboardUsesExplicitObjectAndRowSelection", testLogicClipboardUsesExplicitObjectAndRowSelection}, }); }