|
- #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 <QAction>
- #include <QApplication>
- #include <QCheckBox>
- #include <QComboBox>
- #include <QDialog>
- #include <QDockWidget>
- #include <QGraphicsItem>
- #include <QGraphicsLineItem>
- #include <QGraphicsScene>
- #include <QImage>
- #include <QLabel>
- #include <QLineEdit>
- #include <QListWidget>
- #include <QMenu>
- #include <QPainter>
- #include <QSpinBox>
- #include <QPushButton>
- #include <QStringList>
- #include <QTableWidget>
- #include <QTabBar>
- #include <QTabWidget>
- #include <QTimer>
- #include <QTest>
- #include <QToolButton>
- #include <QTreeWidget>
-
- #include <algorithm>
- #include <iostream>
- #include <stdexcept>
- #include <string>
- #include <vector>
-
- 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<RegisterAddress> &) 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<void()> state_callback,
- std::function<void(bool)> initial_callback,
- std::function<void()> cache_callback,
- std::function<void(const std::string &)> error_callback) override
- {
- state_changed = std::move(state_callback);
- initial_read_changed = std::move(initial_callback);
- cache_updated = std::move(cache_callback);
- error_reported = std::move(error_callback);
- }
-
- private:
- void notifyStateChanged()
- {
- if (state_changed)
- {
- state_changed();
- }
- }
-
- PlcConnectionState connection_state = PlcConnectionState::Disconnected;
- std::string last_error;
- std::function<void()> state_changed;
- std::function<void(bool)> initial_read_changed;
- std::function<void()> cache_updated;
- std::function<void(const std::string &)> error_reported;
- };
-
- 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<RegisterAddress> &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<void()> state_callback,
- std::function<void(bool)> initial_callback,
- std::function<void()> cache_callback,
- std::function<void(const std::string &)> error_callback) override
- {
- state_changed = std::move(state_callback);
- initial_read_changed = std::move(initial_callback);
- cache_updated = std::move(cache_callback);
- error_reported = std::move(error_callback);
- }
-
- 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<RegisterAddress> poll_addresses;
- std::function<void()> state_changed;
- std::function<void(bool)> initial_read_changed;
- std::function<void()> cache_updated;
- std::function<void(const std::string &)> error_reported;
- };
-
- using TestSupport::require;
-
- template<typename ObjectType>
- ObjectType *requiredChild(MainWindow &window, const char *name)
- {
- // 通过 objectName 取得 Designer 组件,缺失时给出明确测试失败信息
- ObjectType *child = window.findChild<ObjectType *>(QString::fromLatin1(name));
- require(child != nullptr, std::string("missing UI object ") + name);
- return child;
- }
-
- 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<std::string> 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<QGraphicsItem *> 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<std::int16_t>(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<std::int16_t>(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<std::int16_t>(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<std::string> 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<QLineEdit *>(
- QStringLiteral("messageEdit"));
- require(message_edit != nullptr
- && message_edit->maxLength()
- == static_cast<int>(
- 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<std::size_t>(
- 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<QGraphicsItem *> 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<QGraphicsItem *> 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<ContactNodeConfig>(
- 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<std::string> 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<QGraphicsItem *> 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<std::string> 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<QGraphicsItem *> 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<QGraphicsItem *> 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<QGraphicsItem *> 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<std::string> 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<const QGraphicsLineItem *>(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<QGraphicsItem *> 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<const LogicNode *> nodes;
- collectConditionNodes(*rung->condition, &nodes);
- require(rung != nullptr && rung->condition.has_value()
- && nodes.size() == static_cast<std::size_t>(
- 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<QAction>(window, "addLabelAction")->trigger();
- require(project_service.isModified()
- && window.windowTitle().endsWith(QLatin1Char('*')),
- "editing the project must immediately add an unsaved marker");
-
- requiredChild<QAction>(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<QWidget *>(QStringLiteral("dataPointDock")) == nullptr,
- "the removed data point dock must not remain in the main window");
- require(window.findChild<QWidget *>(QStringLiteral("hmiDataPointComboBox")) == nullptr
- && window.findChild<QWidget *>(QStringLiteral("logicDataPointComboBox")) == nullptr,
- "HMI and ladder properties must use direct M/D address inputs");
-
- QAction *editing_action = requiredChild<QAction>(window, "editingModeAction");
- QAction *offline_action = requiredChild<QAction>(window, "offlineModeAction");
- QAction *online_action = requiredChild<QAction>(window, "onlineModeAction");
- QAction *add_button_action = requiredChild<QAction>(window, "addButtonAction");
- QAction *add_indicator_action = requiredChild<QAction>(window, "addIndicatorAction");
- QAction *add_progress_bar_action = requiredChild<QAction>(
- window, "addProgressBarAction");
- QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction");
- QAction *undo_action = requiredChild<QAction>(window, "undoAction");
- QAction *redo_action = requiredChild<QAction>(window, "redoAction");
- QAction *delete_selection_action = requiredChild<QAction>(
- window, "deleteSelectionAction");
- QAction *toggle_logic_enabled_action = requiredChild<QAction>(
- window, "toggleLogicEnabledAction");
- QToolButton *hmi_more_controls = requiredChild<QToolButton>(
- window, "hmiMoreControlsButton");
- QToolButton *logic_contact_menu = requiredChild<QToolButton>(
- window, "logicContactMenuButton");
- QToolButton *logic_output_menu = requiredChild<QToolButton>(
- window, "logicOutputMenuButton");
- QToolButton *logic_timer_counter_menu = requiredChild<QToolButton>(
- window, "logicTimerCounterMenuButton");
- QToolButton *logic_data_menu = requiredChild<QToolButton>(
- window, "logicDataMenuButton");
- QAction *add_normally_open_action = requiredChild<QAction>(
- window, "addNormallyOpenAction");
- QAction *add_normal_coil_action = requiredChild<QAction>(
- window, "addNormalCoilAction");
- QAction *add_set_coil_action = requiredChild<QAction>(
- window, "addSetCoilAction");
- QAction *add_reset_coil_action = requiredChild<QAction>(
- window, "addResetCoilAction");
- QAction *parallel_insert_action = requiredChild<QAction>(
- window, "parallelInsertAction");
- QAction *insert_horizontal_wire_action = requiredChild<QAction>(
- window, "insertHorizontalWireAction");
- QAction *insert_vertical_wire_action = requiredChild<QAction>(
- window, "insertVerticalWireAction");
- QAction *delete_horizontal_wire_action = requiredChild<QAction>(
- window, "deleteHorizontalWireAction");
- QAction *delete_vertical_wire_action = requiredChild<QAction>(
- window, "deleteVerticalWireAction");
- QDockWidget *project_dock = requiredChild<QDockWidget>(window, "projectDock");
- QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock");
- QDockWidget *output_dock = requiredChild<QDockWidget>(window, "outputDock");
- QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel");
- QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit");
- QLineEdit *text_color_edit = requiredChild<QLineEdit>(window, "textColorEdit");
- QComboBox *button_operation = requiredChild<QComboBox>(
- window, "buttonOperationComboBox");
- QComboBox *binding_area = requiredChild<QComboBox>(
- window, "bindingAreaComboBox");
- QSpinBox *binding_index = requiredChild<QSpinBox>(
- window, "bindingIndexSpinBox");
- QSpinBox *progress_minimum = requiredChild<QSpinBox>(
- window, "progressMinimumSpinBox");
- QSpinBox *progress_maximum = requiredChild<QSpinBox>(
- window, "progressMaximumSpinBox");
- QSpinBox *font_size = requiredChild<QSpinBox>(window, "fontSizeSpinBox");
- QCheckBox *font_bold = requiredChild<QCheckBox>(window, "fontBoldCheckBox");
- QCheckBox *font_italic = requiredChild<QCheckBox>(window, "fontItalicCheckBox");
- QCheckBox *progress_show_value = requiredChild<QCheckBox>(
- window, "progressShowValueCheckBox");
- QPushButton *apply_properties = requiredChild<QPushButton>(
- window, "applyPropertiesButton");
- HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>(
- window, "hmiEditorWidget");
- LogicEditorWidget *logic_editor = requiredChild<LogicEditorWidget>(
- window, "logicEditorWidget");
- QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel");
- RuntimeMonitorWindow *runtime_window = requiredChild<RuntimeMonitorWindow>(
- window, "runtimeMonitorWindow");
- QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
- QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
- QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget");
- QTabWidget *editor_tabs = requiredChild<QTabWidget>(window, "editorTabWidget");
- QTreeWidget *project_tree = requiredChild<QTreeWidget>(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<QWidget *>(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<QAction>(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<QToolButton *>(
- QStringLiteral("removeButton"));
- QToolButton *monitor_clear = free_monitor->findChild<QToolButton *>(
- 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<QAction>(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<QAction>(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<QAction>(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<QAction>(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<int>(
- 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<QGraphicsItem *> unbound_items = hmi_editor->scene()->selectedItems();
- require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0,
- "an unbound HMI control must not reserve an address label area");
- button_operation->setCurrentIndex(
- button_operation->findData(static_cast<int>(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<QGraphicsItem *> bound_items = hmi_editor->scene()->selectedItems();
- require(bound_items.size() == 1 && bound_items.front()->boundingRect().top() < 0,
- "a bound HMI control must include its address label above the control body");
-
- delete_control_action->trigger();
- require(editor_service.findPage(page_id)->controls.empty(),
- "deleting a selected control must update the HMI page model");
-
- QAction *add_label_action = requiredChild<QAction>(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<QAction>(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<int>(
- 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<const LogicNode *> 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<CoilNodeConfig>(
- 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<CoilNodeConfig>(
- 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<CoilNodeConfig>(
- 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<const LogicNode *> condition_nodes;
- collectConditionNodes(*rung.condition, &condition_nodes);
- for (const LogicNode *node : condition_nodes)
- {
- require(logic_editor_service.updateNodeConfig(
- logic_editor_service.firstLogicId(),
- node->id,
- ContactNodeConfig{
- RegisterAddress{RegisterArea::M, 0},
- ContactMode::NormallyOpen})
- .succeeded,
- "applying a contact configuration must complete the ladder node");
- }
- require(logic_editor_service.updateNodeConfig(
- logic_editor_service.firstLogicId(),
- rung.output->id,
- CoilNodeConfig{
- RegisterAddress{RegisterArea::M, 1},
- CoilMode::Normal})
- .succeeded,
- "applying a coil configuration must complete the ladder node");
- offline_action->trigger();
- require(mode_service.mode() == ApplicationMode::OfflineRunning,
- "offline action must enter offline running");
- require(simulation_service.state() == SimulationState::Running,
- "offline action must start the actual simulation service");
- require(executor_status->text().contains(QStringLiteral("运行")),
- "executor status label must report the actual running state");
- require(!project_dock->isEnabled(),
- "project dock must be disabled while running");
- require(!properties_dock->isEnabled(),
- "properties dock must be disabled while running");
- require(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<HmiEditorWidget *>(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<QLineEdit>(window, "addressEdit");
- QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
- QTableWidget *monitor_table = requiredChild<QTableWidget>(window, "monitorTable");
- monitor_address->setText(QStringLiteral("M0"));
- monitor_add->click();
- repository.writeBit({RegisterArea::M, 0}, true);
- auto *monitor_widget = requiredChild<FreeMonitorWidget>(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<QAction>(window, "offlineModeAction")->trigger();
- QApplication::processEvents();
- require(mode_service.mode() == ApplicationMode::OfflineRunning,
- "offline mode must remain the authoritative runtime state");
- RuntimeMonitorWindow *runtime_window = requiredChild<RuntimeMonitorWindow>(
- window, "runtimeMonitorWindow");
- HmiEditorWidget *runtime_hmi = requiredChild<HmiEditorWidget>(
- window, "runtimeHmiView");
- require(runtime_window->isWindow() && runtime_window->isVisible()
- && runtime_window->isMaximized(),
- "offline running must show a maximized runtime monitor window");
- require(window.findChildren<RuntimeMonitorWidget *>().size() == 1,
- "the application must keep exactly one runtime monitor projection");
- require(window.findChild<QWidget *>(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<QLabel>(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<QToolButton>(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<QAction>(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<QAction>(window, "offlineModeAction")->trigger();
- QApplication::processEvents();
- runtime_hmi = requiredChild<HmiEditorWidget>(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<QAction>(window, "offlineModeAction")->trigger();
- QApplication::processEvents();
- RuntimeMonitorWindow *runtime_window = requiredChild<RuntimeMonitorWindow>(
- window, "runtimeMonitorWindow");
- require(runtime_window->isVisible(),
- "application-exit fixture must start with a visible runtime window");
-
- requiredChild<QAction>(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<QAction>(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<QAction>(window, "addRisingEdgeAction");
- QAction *falling_action = requiredChild<QAction>(window, "addFallingEdgeAction");
- QAction *timer_contact_action = requiredChild<QAction>(
- window, "addTimerContactAction");
- QAction *ton_action = requiredChild<QAction>(window, "addTonAction");
- requiredChild<QAction>(window, "editRungCommentAction");
- requiredChild<QAction>(window, "configureRegisterCommentsAction");
- QSpinBox *address = requiredChild<QSpinBox>(window, "logicAddressSpinBox");
- QSpinBox *preset = requiredChild<QSpinBox>(window, "logicPresetSpinBox");
- QComboBox *mode = requiredChild<QComboBox>(window, "logicModeComboBox");
- QPushButton *apply = requiredChild<QPushButton>(
- window, "applyLogicPropertiesButton");
- const std::string logic_id = logic_editor_service.firstLogicId();
-
- rising_action->trigger();
- address->setValue(8);
- mode->setCurrentIndex(mode->findData(static_cast<int>(EdgeMode::Falling)));
- apply->click();
- const LogicNode *edge = logic_editor_service.findNode(logic_id, "edge-1");
- require(edge != nullptr && edge->configured
- && std::get<EdgeContactNodeConfig>(edge->config).address.index() == 8
- && std::get<EdgeContactNodeConfig>(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<int>(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<TimerContactNodeConfig>(timer_contact->config).address
- == TimerAddress{9}
- && std::get<TimerContactNodeConfig>(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<TonNodeConfig>(ton->config).address == TimerAddress{9}
- && std::get<TonNodeConfig>(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<EdgeContactNodeConfig>(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<QComboBox *>(QStringLiteral("serialPortComboBox")) == nullptr,
- "serial configuration controls must not be embedded in the main window");
- QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
- bool dialog_opened = false;
- QTimer::singleShot(
- 0,
- [&dialog_opened]
- {
- auto *dialog = qobject_cast<PlcConnectionDialog *>(
- QApplication::activeModalWidget());
- dialog_opened = dialog != nullptr;
- if (dialog != nullptr)
- {
- dialog->reject();
- }
- });
- configure_action->trigger();
- require(dialog_opened,
- "PLC configuration action must open the dedicated configuration dialog");
- }
-
- void testRepeatedPlcStatusNotificationsAreCoalesced()
- {
- TestProjectStorage storage;
- ProjectService project_service(storage);
- HmiEditorService editor_service(project_service);
- LogicEditorService logic_editor_service(project_service);
- VirtualRegisterRepository virtual_repository;
- VirtualRegisterRepository plc_repository;
- ActiveRegisterRepository active_repository(virtual_repository);
- HmiRuntimeService runtime_service(active_repository);
- 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<QListWidget>(window, "outputList");
- const int initial_count = output->count();
-
- const PlcCommunicationResult result = mode_service.connectPlc(
- {"COM3", 1, 9600, 8, 2, 1, 1000, 2, 200});
- require(result.succeeded, "PLC connection setup must succeed");
- QApplication::processEvents();
-
- const QString expected = QStringLiteral("PLC 已连接,正在读取工程使用的 M/D 地址");
- int matching_count = 0;
- for (int index = initial_count; index < output->count(); ++index)
- {
- if (output->item(index)->text() == expected)
- {
- ++matching_count;
- }
- }
- require(matching_count == 1,
- "repeated PLC status callbacks must append one coalesced output message");
- }
-
- void testFreeMonitorWritesSingleValues()
- {
- VirtualRegisterRepository repository;
- RegisterMonitorService monitor_service(repository);
- FreeMonitorWidget widget(monitor_service);
- widget.show();
- QApplication::processEvents();
-
- QLineEdit *address_edit = widget.findChild<QLineEdit *>("addressEdit");
- QPushButton *add_button = widget.findChild<QPushButton *>("addButton");
- QTableWidget *table = widget.findChild<QTableWidget *>("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<QComboBox *>(table->cellWidget(0, 3));
- auto *bit_write = qobject_cast<QPushButton *>(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<QLineEdit *>(table->cellWidget(1, 3));
- auto *word_write = qobject_cast<QPushButton *>(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<QAction>(window, "onlineModeAction");
- online_action->trigger();
- QApplication::processEvents();
-
- RuntimeMonitorWindow *runtime_window = requiredChild<RuntimeMonitorWindow>(
- window, "runtimeMonitorWindow");
- QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView");
- QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView");
- QWidget *free_monitor = requiredChild<QWidget>(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<HmiEditorWidget *>(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<QLineEdit>(window, "addressEdit");
- QPushButton *monitor_add = requiredChild<QPushButton>(window, "addButton");
- QTableWidget *monitor_table = requiredChild<QTableWidget>(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<QPushButton *>(monitor_table->cellWidget(0, 4));
- require(monitor_write != nullptr && monitor_write->isEnabled(),
- "online Connected mode must enable free monitor writes");
-
- QAction *configure_action = requiredChild<QAction>(window, "configurePlcAction");
- QAction *disconnect_action = requiredChild<QAction>(window, "disconnectPlcAction");
- QListWidget *output = requiredChild<QListWidget>(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<QTreeWidget>(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<QSpinBox>(window, "pageWidthSpinBox");
- QSpinBox *page_height = requiredChild<QSpinBox>(window, "pageHeightSpinBox");
- QPushButton *apply_page_properties = requiredChild<QPushButton>(
- 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<QLabel>(window, "hmiPageSizeLabel")->text()
- == QStringLiteral("1024 x 600"),
- "applying page properties must update the model and page summary");
- QAction *add_label = requiredChild<QAction>(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<QComboBox>(window, "bindingAreaComboBox");
- QComboBox *target_page = requiredChild<QComboBox>(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<QAction>(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<HmiEditorWidget>(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<QAction>(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<HmiEditorWidget>(
- 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<QLabel>(window, "runtimePageLabel")->text()
- == QStringLiteral("Settings"),
- "clicking PageJump at runtime must navigate locally to its target page");
-
- QComboBox *logic_selector = requiredChild<QComboBox>(
- 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<RuntimeMonitorWidget>(
- window, "runtimeMonitorWidget");
- require(runtime_monitor->selectedLogicId() == second_logic_id,
- "runtime logic selection must change only the visible trace projection");
-
- requiredChild<QAction>(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;
- }
|