| @@ -41,11 +41,12 @@ bool AlarmDefinition::validate(std::string *error) const | |||
| setError(error, "报警定义使用了无效地址"); | |||
| return false; | |||
| } | |||
| if (condition == AlarmCondition::MOn) | |||
| if (condition == AlarmCondition::MOn | |||
| || condition == AlarmCondition::MOff) | |||
| { | |||
| if (address.area() != RegisterArea::M) | |||
| { | |||
| setError(error, "M ON 报警必须使用 M 地址"); | |||
| setError(error, "M ON/OFF 报警必须使用 M 地址"); | |||
| return false; | |||
| } | |||
| return true; | |||
| @@ -5,10 +5,11 @@ | |||
| #include <cstdint> | |||
| #include <string> | |||
| // 报警触发条件:M 位为 1,或 D 字高于/低于阈值 | |||
| // 报警触发条件:M 位为 1/0,或 D 字高于/低于阈值 | |||
| enum class AlarmCondition | |||
| { | |||
| MOn, // M 位为 1 时触发 | |||
| MOff, // M 位为 0 时触发 | |||
| DHigh, // D 寄存器值高于阈值时触发 | |||
| DLow // D 寄存器值低于阈值时触发 | |||
| }; | |||
| @@ -434,6 +434,10 @@ QString alarmConditionName(AlarmCondition condition) | |||
| { | |||
| return QStringLiteral("mOn"); | |||
| } | |||
| case AlarmCondition::MOff: | |||
| { | |||
| return QStringLiteral("mOff"); | |||
| } | |||
| case AlarmCondition::DHigh: | |||
| { | |||
| return QStringLiteral("dHigh"); | |||
| @@ -456,6 +460,10 @@ bool parseAlarmCondition( | |||
| { | |||
| *condition = AlarmCondition::MOn; | |||
| } | |||
| else if (value == "mOff") | |||
| { | |||
| *condition = AlarmCondition::MOff; | |||
| } | |||
| else if (value == "dHigh") | |||
| { | |||
| *condition = AlarmCondition::DHigh; | |||
| @@ -78,11 +78,17 @@ const std::vector<AlarmRecord> &AlarmService::records() const | |||
| std::optional<bool> AlarmService::evaluate( | |||
| const AlarmDefinition &definition) const | |||
| { | |||
| if (definition.condition == AlarmCondition::MOn) | |||
| if (definition.condition == AlarmCondition::MOn | |||
| || definition.condition == AlarmCondition::MOff) | |||
| { | |||
| // MOn 报警直接读取 M 位;读取失败返回空值,交由 refresh 保留旧记录 | |||
| // M ON/OFF 共用一次位读取;读取失败时保留旧记录 | |||
| const BitReadResult value = repository_.readBit(definition.address); | |||
| return value.succeeded ? std::optional<bool>{value.value} : std::nullopt; | |||
| if (!value.succeeded) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| return definition.condition == AlarmCondition::MOn | |||
| ? value.value : !value.value; | |||
| } | |||
| // DHigh 和 DLow 报警读取 D 字,比较方向由定义中的条件决定 | |||
| const WordReadResult value = repository_.readWord(definition.address); | |||
| @@ -64,7 +64,7 @@ private: | |||
| * @param definition 待评估的报警定义 | |||
| * @return true 表示触发,false 表示未触发;读取失败时返回空值 | |||
| * | |||
| * MOn 比较 M 位是否为 1;DHigh 使用大于等于阈值,DLow 使用小于等于阈值 | |||
| * MOn/MOff 比较 M 位是否为 1/0;DHigh 使用大于等于阈值,DLow 使用小于等于阈值 | |||
| */ | |||
| std::optional<bool> evaluate(const AlarmDefinition &definition) const; | |||
| @@ -29,6 +29,10 @@ QString conditionText(AlarmCondition condition) | |||
| { | |||
| return AlarmConfigurationDialog::tr("M 为 ON"); | |||
| } | |||
| case AlarmCondition::MOff: | |||
| { | |||
| return AlarmConfigurationDialog::tr("M 为 OFF"); | |||
| } | |||
| case AlarmCondition::DHigh: | |||
| { | |||
| return AlarmConfigurationDialog::tr("D 大于等于阈值"); | |||
| @@ -109,7 +113,7 @@ void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id) | |||
| row, | |||
| 3, | |||
| new QTableWidgetItem( | |||
| definition.condition == AlarmCondition::MOn | |||
| definition.address.area() == RegisterArea::M | |||
| ? QStringLiteral("-") | |||
| : QString::number(definition.threshold))); | |||
| ui_->alarmTable->setItem( | |||
| @@ -162,6 +166,9 @@ void AlarmConfigurationDialog::updateConditionOptions() | |||
| ui_->conditionComboBox->addItem( | |||
| conditionText(AlarmCondition::MOn), | |||
| static_cast<int>(AlarmCondition::MOn)); | |||
| ui_->conditionComboBox->addItem( | |||
| conditionText(AlarmCondition::MOff), | |||
| static_cast<int>(AlarmCondition::MOff)); | |||
| } | |||
| else | |||
| { | |||
| @@ -83,9 +83,9 @@ public: | |||
| { | |||
| if (!control_.binding.has_value()) | |||
| { | |||
| return visibleControlRect(); | |||
| return controlRect(); | |||
| } | |||
| return visibleControlRect().united(addressRect()); | |||
| return controlRect().united(addressRect()); | |||
| } | |||
| void paint( | |||
| @@ -94,7 +94,7 @@ public: | |||
| QWidget *) override | |||
| { | |||
| // 留出一个像素边距,避免描边被图元边界裁剪 | |||
| const QRectF rect = visibleControlRect().adjusted(1, 1, -1, -1); | |||
| const QRectF rect = controlRect().adjusted(1, 1, -1, -1); | |||
| painter->setRenderHint(QPainter::Antialiasing, true); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | |||
| @@ -363,13 +363,9 @@ public: | |||
| void setInteractionState( | |||
| bool editable, bool runtime_active, bool runtime_write_enabled) | |||
| { | |||
| if (control_.type == HmiControlType::AlarmList) | |||
| if (control_.type == HmiControlType::AlarmList && !runtime_active) | |||
| { | |||
| prepareGeometryChange(); | |||
| if (!runtime_active) | |||
| { | |||
| alarm_page_ = 0U; | |||
| } | |||
| alarm_page_ = 0U; | |||
| } | |||
| editing_enabled_ = editable; | |||
| runtime_active_ = runtime_active; | |||
| @@ -412,7 +408,6 @@ public: | |||
| page_hovered_ = false; | |||
| button_pressed_ = false; | |||
| } | |||
| updateAlarmVisibility(); | |||
| update(); | |||
| } | |||
| @@ -429,15 +424,10 @@ public: | |||
| void setAlarmRecords(const std::vector<AlarmRecord> &records) | |||
| { | |||
| if (control_.type == HmiControlType::AlarmList) | |||
| { | |||
| prepareGeometryChange(); | |||
| } | |||
| alarm_records_ = records; | |||
| const std::size_t page_count = alarmPageCount(); | |||
| alarm_page_ = page_count == 0U | |||
| ? 0U : std::min(alarm_page_, page_count - 1U); | |||
| updateAlarmVisibility(); | |||
| update(); | |||
| } | |||
| @@ -469,7 +459,7 @@ protected: | |||
| if (event->pos().y() < kAlarmHeaderHeight) | |||
| { | |||
| const QRectF content_rect = | |||
| visibleControlRect().adjusted(1, 1, -1, -1); | |||
| controlRect().adjusted(1, 1, -1, -1); | |||
| const QRectF header( | |||
| content_rect.left(), | |||
| content_rect.top(), | |||
| @@ -644,22 +634,6 @@ private: | |||
| alarmPageSize(), alarm_records_.size() - first_record); | |||
| } | |||
| QRectF visibleControlRect() const | |||
| { | |||
| const QRectF full_rect = controlRect(); | |||
| if (control_.type != HmiControlType::AlarmList || !runtime_active_) | |||
| { | |||
| return full_rect; | |||
| } | |||
| const qreal content_height = kAlarmHeaderHeight | |||
| + static_cast<qreal>(visibleAlarmRecordCount()) * kAlarmRowHeight | |||
| + 2.0; | |||
| return {full_rect.left(), | |||
| full_rect.top(), | |||
| full_rect.width(), | |||
| std::min(full_rect.height(), content_height)}; | |||
| } | |||
| QRectF alarmNextPageRect(const QRectF &header) const | |||
| { | |||
| return { | |||
| @@ -689,17 +663,6 @@ private: | |||
| kAlarmPageButtonSize}; | |||
| } | |||
| void updateAlarmVisibility() | |||
| { | |||
| if (control_.type != HmiControlType::AlarmList) | |||
| { | |||
| return; | |||
| } | |||
| const bool has_active_alarm = !alarm_records_.empty(); | |||
| setVisible(editing_enabled_ || !runtime_active_ || has_active_alarm); | |||
| setZValue(runtime_active_ && has_active_alarm ? 1000.0 : 0.0); | |||
| } | |||
| // 控件本体范围保持领域坐标和尺寸语义不变 | |||
| QRectF controlRect() const | |||
| { | |||
| @@ -44,6 +44,13 @@ void testAlarmDefinitionsAndRuntimeLifecycle() | |||
| m_alarm.message = "Emergency stop"; | |||
| const AlarmEditorResult m_result = editor_service.addDefinition(m_alarm); | |||
| AlarmDefinition m_off_alarm; | |||
| m_off_alarm.address = RegisterAddress{RegisterArea::M, 1}; | |||
| m_off_alarm.condition = AlarmCondition::MOff; | |||
| m_off_alarm.message = "Safety circuit open"; | |||
| const AlarmEditorResult m_off_result = editor_service.addDefinition( | |||
| m_off_alarm); | |||
| AlarmDefinition high_alarm; | |||
| high_alarm.address = RegisterAddress{RegisterArea::D, 0}; | |||
| high_alarm.condition = AlarmCondition::DHigh; | |||
| @@ -57,8 +64,9 @@ void testAlarmDefinitionsAndRuntimeLifecycle() | |||
| low_alarm.threshold = 10; | |||
| low_alarm.message = "Pressure low"; | |||
| const AlarmEditorResult low_result = editor_service.addDefinition(low_alarm); | |||
| require(m_result.succeeded && high_result.succeeded && low_result.succeeded, | |||
| "M, D high and D low alarm definitions must be accepted"); | |||
| require(m_result.succeeded && m_off_result.succeeded | |||
| && high_result.succeeded && low_result.succeeded, | |||
| "M ON, M OFF, D high and D low alarms must be accepted"); | |||
| AlarmDefinition invalid = high_alarm; | |||
| invalid.address = RegisterAddress{RegisterArea::M, 2}; | |||
| @@ -66,24 +74,34 @@ void testAlarmDefinitionsAndRuntimeLifecycle() | |||
| == AlarmEditorError::InvalidDefinition, | |||
| "D alarm conditions must reject M addresses"); | |||
| invalid = m_off_alarm; | |||
| invalid.address = RegisterAddress{RegisterArea::D, 2}; | |||
| require(editor_service.addDefinition(invalid).error | |||
| == AlarmEditorError::InvalidDefinition, | |||
| "M OFF alarm conditions must reject D addresses"); | |||
| repository.writeBit(RegisterAddress{RegisterArea::M, 1}, true); | |||
| repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 20); | |||
| alarm_service.refresh(); | |||
| require(alarm_service.records().empty(), | |||
| "inactive alarm conditions must not create records"); | |||
| repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true); | |||
| repository.writeBit(RegisterAddress{RegisterArea::M, 1}, false); | |||
| repository.writeWord(RegisterAddress{RegisterArea::D, 0}, 80); | |||
| repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 10); | |||
| alarm_service.refresh(); | |||
| require(findRecord(alarm_service, m_result.id) != nullptr | |||
| && findRecord(alarm_service, m_off_result.id) != nullptr | |||
| && findRecord(alarm_service, high_result.id) != nullptr | |||
| && findRecord(alarm_service, low_result.id) != nullptr, | |||
| "all three alarm conditions must create active records"); | |||
| "all four alarm conditions must create active records"); | |||
| require(alarm_service.acknowledge(m_result.id) | |||
| && findRecord(alarm_service, m_result.id)->acknowledged, | |||
| "an active alarm must be locally acknowledged"); | |||
| repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false); | |||
| repository.writeBit(RegisterAddress{RegisterArea::M, 1}, true); | |||
| repository.writeWord(RegisterAddress{RegisterArea::D, 0}, 79); | |||
| repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 11); | |||
| alarm_service.refresh(); | |||
| @@ -221,6 +221,35 @@ void testGridProjectRoundTrip() | |||
| "typed ladder instructions must survive grid round trip"); | |||
| } | |||
| void testMOffAlarmRoundTrip() | |||
| { | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| JsonProjectStorage storage; | |||
| const QString path = directory.filePath("m-off-alarm.json"); | |||
| Project project = makeExampleProject(); | |||
| project.alarmDefinitions.push_back({ | |||
| "alarm-m-off", | |||
| RegisterAddress{RegisterArea::M, 5}, | |||
| AlarmCondition::MOff, | |||
| 0, | |||
| "Safety circuit open"}); | |||
| require(storage.save(project, path.toStdString()).succeeded, | |||
| "an M OFF alarm project must save"); | |||
| require(readBytes(path).contains("\"condition\": \"mOff\""), | |||
| "M OFF alarms must serialize with the explicit mOff condition"); | |||
| const ProjectLoadResult loaded = storage.load(path.toStdString()); | |||
| require(loaded.succeeded | |||
| && loaded.project.alarmDefinitions.size() == 1U | |||
| && loaded.project.alarmDefinitions.front().condition | |||
| == AlarmCondition::MOff | |||
| && loaded.project.alarmDefinitions.front().address | |||
| == RegisterAddress{RegisterArea::M, 5}, | |||
| "the M OFF condition and address must survive JSON round trip"); | |||
| } | |||
| void testStrictVersionAndRequiredFields() | |||
| { | |||
| QTemporaryDir directory; | |||
| @@ -390,6 +419,7 @@ int main() | |||
| { | |||
| testEmptyProjectRoundTrip(); | |||
| testGridProjectRoundTrip(); | |||
| testMOffAlarmRoundTrip(); | |||
| testStrictVersionAndRequiredFields(); | |||
| testInvalidGridAndConnectionsAreRejected(); | |||
| testProjectServiceStateAndConfiguredLimits(); | |||
| @@ -1,4 +1,5 @@ | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "services/alarm_editor_service.h" | |||
| #include "services/alarm_service.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_navigation_service.h" | |||
| @@ -9,6 +10,7 @@ | |||
| #include "services/register_monitor_service.h" | |||
| #include "services/runtime_mode_service.h" | |||
| #include "support/test_support.h" | |||
| #include "ui/alarm_configuration_dialog.h" | |||
| #include "ui/hmi_editor_widget.h" | |||
| #include "ui/logic_editor_widget.h" | |||
| #include "ui/runtime_monitor_widget.h" | |||
| @@ -18,12 +20,14 @@ | |||
| #include <QCoreApplication> | |||
| #include <QEventLoop> | |||
| #include <QGraphicsLineItem> | |||
| #include <QGraphicsRectItem> | |||
| #include <QGraphicsScene> | |||
| #include <QGraphicsSimpleTextItem> | |||
| #include <QImage> | |||
| #include <QKeyEvent> | |||
| #include <QLabel> | |||
| #include <QLineEdit> | |||
| #include <QComboBox> | |||
| #include <QMouseEvent> | |||
| #include <QPainter> | |||
| #include <QWidget> | |||
| @@ -66,6 +70,103 @@ ControlLogic makeAlwaysOnLogic() | |||
| return logic; | |||
| } | |||
| void testAlarmConfigurationOffersMOnAndMOff() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| AlarmEditorService alarm_editor_service(project_service); | |||
| AlarmConfigurationDialog dialog(alarm_editor_service); | |||
| QComboBox *area_combo = dialog.findChild<QComboBox *>( | |||
| QStringLiteral("areaComboBox")); | |||
| QComboBox *condition_combo = dialog.findChild<QComboBox *>( | |||
| QStringLiteral("conditionComboBox")); | |||
| require(area_combo != nullptr && condition_combo != nullptr, | |||
| "alarm configuration must expose its area and condition inputs"); | |||
| require(area_combo->currentData().toInt() | |||
| == static_cast<int>(RegisterArea::M) | |||
| && condition_combo->count() == 2 | |||
| && condition_combo->findData( | |||
| static_cast<int>(AlarmCondition::MOn)) >= 0 | |||
| && condition_combo->findData( | |||
| static_cast<int>(AlarmCondition::MOff)) >= 0 | |||
| && condition_combo->findText(QStringLiteral("M 为 ON")) >= 0 | |||
| && condition_combo->findText(QStringLiteral("M 为 OFF")) >= 0, | |||
| "M alarm configuration must offer both ON and OFF conditions"); | |||
| } | |||
| void testAlarmListKeepsFixedGeometryWhileRecordsChange() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| VirtualRegisterRepository virtual_repository; | |||
| HmiEditorService hmi_editor_service(project_service); | |||
| HmiRuntimeService hmi_runtime_service(virtual_repository); | |||
| AlarmService alarm_service(project_service, virtual_repository); | |||
| const HmiEditorResult page = hmi_editor_service.ensureDefaultPage(); | |||
| const HmiEditorResult alarm_list = hmi_editor_service.addControl( | |||
| page.id, HmiControlType::AlarmList); | |||
| require(page.succeeded && alarm_list.succeeded, | |||
| "alarm-list fixture must create a page and control"); | |||
| project_service.editProject().alarmDefinitions.push_back({ | |||
| "fixed-alarm", | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| AlarmCondition::MOn, | |||
| 0, | |||
| "Fixed alarm"}); | |||
| HmiEditorWidget widget( | |||
| hmi_editor_service, hmi_runtime_service, alarm_service); | |||
| widget.setPageId(page.id); | |||
| widget.setEditingEnabled(false); | |||
| widget.setRuntimeActive(true); | |||
| widget.refreshRuntimeValues(); | |||
| QGraphicsItem *alarm_item = nullptr; | |||
| for (QGraphicsItem *item : widget.scene()->items()) | |||
| { | |||
| if (dynamic_cast<QGraphicsRectItem *>(item) == nullptr) | |||
| { | |||
| alarm_item = item; | |||
| break; | |||
| } | |||
| } | |||
| const HmiControl *control = hmi_editor_service.findControl( | |||
| page.id, alarm_list.id); | |||
| require(alarm_item != nullptr && control != nullptr, | |||
| "runtime HMI scene must contain the alarm-list item"); | |||
| const QRectF fixed_bounds = alarm_item->boundingRect(); | |||
| require(alarm_item->isVisible() | |||
| && qFuzzyCompare( | |||
| fixed_bounds.width() + 1.0, | |||
| static_cast<qreal>(control->bounds.width) + 1.0) | |||
| && qFuzzyCompare( | |||
| fixed_bounds.height() + 1.0, | |||
| static_cast<qreal>(control->bounds.height) + 1.0), | |||
| "an empty runtime alarm list must remain visible at its configured size"); | |||
| require(virtual_repository.writeBit( | |||
| RegisterAddress{RegisterArea::M, 0}, true).succeeded, | |||
| "alarm-list fixture must activate M0"); | |||
| alarm_service.refresh(); | |||
| widget.refreshRuntimeValues(); | |||
| require(alarm_service.records().size() == 1U | |||
| && alarm_item->isVisible() | |||
| && alarm_item->boundingRect() == fixed_bounds, | |||
| "adding an alarm row must not resize or hide the alarm control"); | |||
| require(virtual_repository.writeBit( | |||
| RegisterAddress{RegisterArea::M, 0}, false).succeeded, | |||
| "alarm-list fixture must restore M0"); | |||
| alarm_service.refresh(); | |||
| widget.refreshRuntimeValues(); | |||
| require(alarm_service.records().empty() | |||
| && alarm_item->isVisible() | |||
| && alarm_item->boundingRect() == fixed_bounds, | |||
| "removing the last alarm row must keep the fixed alarm control visible"); | |||
| } | |||
| void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -856,6 +957,8 @@ int main(int argc, char *argv[]) | |||
| QApplication application(argc, argv); | |||
| try | |||
| { | |||
| testAlarmConfigurationOffersMOnAndMOff(); | |||
| testAlarmListKeepsFixedGeometryWhileRecordsChange(); | |||
| testQueuedOfflineTraceIsIgnoredAfterReturningToEditing(); | |||
| testLogicEditorGridSelectionAndDeletion(); | |||
| testLadderLayoutAndDragDeletion(); | |||
| @@ -15,6 +15,7 @@ SOURCES += \ | |||
| $$SERVICE_OFFLINE_SOURCES \ | |||
| $$SERVICE_RUNTIME_SOURCES \ | |||
| $$SERVICE_MONITOR_SOURCES \ | |||
| ../src/ui/alarm_configuration_dialog.cpp \ | |||
| ../src/ui/hmi_editor_widget.cpp \ | |||
| ../src/ui/logic_editor_widget.cpp \ | |||
| ../src/ui/free_monitor_widget.cpp \ | |||
| @@ -33,6 +34,7 @@ HEADERS += \ | |||
| $$SERVICE_RUNTIME_HEADERS \ | |||
| $$SERVICE_MONITOR_HEADERS \ | |||
| ../src/services/plc_communication_gateway.h \ | |||
| ../src/ui/alarm_configuration_dialog.h \ | |||
| ../src/ui/hmi_editor_widget.h \ | |||
| ../src/ui/logic_editor_widget.h \ | |||
| ../src/ui/free_monitor_widget.h \ | |||
| @@ -43,6 +45,7 @@ HEADERS += \ | |||
| $$TEST_SUPPORT_HEADERS | |||
| FORMS += \ | |||
| ../src/ui/alarm_configuration_dialog.ui \ | |||
| ../src/ui/free_monitor_widget.ui \ | |||
| ../src/ui/runtime_monitor_window.ui \ | |||
| ../src/ui/runtime_monitor_widget.ui | |||