From f2f3fa7358af6a8aa1c78a450655b4797e9cac3b Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Mon, 17 Aug 2026 15:34:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0HMI=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E6=9D=A1=E6=8E=A7=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/domain/hmi_control_registry.cpp | 11 +- app/src/domain/hmi_model.cpp | 41 ++++++- app/src/domain/hmi_model.h | 18 ++- .../infrastructure/json_project_storage.cpp | 38 ++++++ app/src/services/hmi_editor_service.cpp | 4 + app/src/ui/hmi_editor_widget.cpp | 42 +++++++ app/src/ui/main_window.cpp | 5 + app/src/ui/main_window.ui | 63 +++++++++- app/src/ui/property_panel_controller.cpp | 39 +++++- app/tests/domain_tests.cpp | 40 +++++++ app/tests/hmi_editor_service_tests.cpp | 43 ++++++- app/tests/main_window_tests.cpp | 113 ++++++++++++++++++ app/tests/project_management_tests.cpp | 46 ++++++- 13 files changed, 487 insertions(+), 16 deletions(-) diff --git a/app/src/domain/hmi_control_registry.cpp b/app/src/domain/hmi_control_registry.cpp index 963ca3b..aa76c14 100644 --- a/app/src/domain/hmi_control_registry.cpp +++ b/app/src/domain/hmi_control_registry.cpp @@ -69,7 +69,16 @@ constexpr std::array kControlDescriptors = { {0, 0, 360, 180}, HmiBindingKind::None, HmiRuntimeValueKind::None, - false} + false}, + HmiControlDescriptor{HmiControlType::ProgressBar, + "progressBar", + "进度条", + "progress-bar", + "进度", + {0, 0, 240, 36}, + HmiBindingKind::Word, + HmiRuntimeValueKind::Word, + true} }; static_assert( diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp index ed30dee..55e737e 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -17,6 +17,23 @@ void setError(std::string *error, const std::string &message) } // namespace +bool HmiProgressBarConfig::isValid() const +{ + return minimumValue < maximumValue; +} + +int HmiProgressBarConfig::percentageForValue(std::int16_t value) const +{ + if (!isValid()) + { + return 0; + } + const int minimum = minimumValue; + const int maximum = maximumValue; + const int bounded_value = std::clamp(static_cast(value), minimum, maximum); + return (bounded_value - minimum) * 100 / (maximum - minimum); +} + bool HmiControl::validate(std::string *error) const { const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); @@ -47,7 +64,7 @@ bool HmiControl::validate(std::string *error) const hmiBindingArea(descriptor->bindingKind); if (binding.has_value() && !binding_area.has_value()) { - setError(error, "文本、页面跳转和报警列表控件不能绑定寄存器"); + setError(error, "该 HMI 控件不能绑定寄存器"); return false; } if (binding.has_value() && binding_area.has_value() @@ -55,11 +72,11 @@ bool HmiControl::validate(std::string *error) const { if (descriptor->bindingKind == HmiBindingKind::Bit) { - setError(error, "按钮和指示灯必须绑定 M 区地址"); + setError(error, "HMI 位控件必须绑定 M 区地址"); } else { - setError(error, "数值控件必须绑定 D 区地址"); + setError(error, "HMI 字控件必须绑定 D 区地址"); } return false; } @@ -81,6 +98,19 @@ bool HmiControl::validate(std::string *error) const setError(error, "非页面跳转控件不能包含跳转配置"); return false; } + if (type == HmiControlType::ProgressBar) + { + if (!progressBar.has_value() || !progressBar->isValid()) + { + setError(error, "进度条最小值必须小于最大值"); + return false; + } + } + else if (progressBar.has_value()) + { + setError(error, "非进度条控件不能包含进度条配置"); + return false; + } return true; } @@ -91,6 +121,11 @@ bool HmiControl::isConfigured() const { return false; } + if (type == HmiControlType::ProgressBar + && (!progressBar.has_value() || !progressBar->isValid())) + { + return false; + } if (!descriptor->requiresBindingForRunning && type != HmiControlType::PageJump) { diff --git a/app/src/domain/hmi_model.h b/app/src/domain/hmi_model.h index 69e48da..ad1c844 100644 --- a/app/src/domain/hmi_model.h +++ b/app/src/domain/hmi_model.h @@ -9,6 +9,7 @@ #include "register_address.h" +#include #include #include #include @@ -44,6 +45,7 @@ enum class HmiControlType Label, // 标签 PageJump, // 页面跳转 AlarmList, // 报警列表 + ProgressBar, // 进度条 Count // 已注册控件类型数量,不作为实际控件使用 }; @@ -63,6 +65,19 @@ struct HmiPageJumpConfig std::string targetPageId; }; +/** + * @brief 进度条的有效值范围和文字显示方式 + */ +struct HmiProgressBarConfig +{ + std::int16_t minimumValue = 0; + std::int16_t maximumValue = 100; + bool showValue = true; + + bool isValid() const; + int percentageForValue(std::int16_t value) const; +}; + /** * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 * @@ -78,13 +93,14 @@ struct HmiControl std::map properties; HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; std::optional pageJump; + std::optional progressBar; /** * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 * @param error 可选错误输出,失败时写入首个校验原因 * @return 配置完整且满足控件类型绑定规则时返回 true * - * 按钮和指示灯必须绑定有效 M 地址,数值显示和数值输入必须绑定有效 D 地址 + * 位控件必须绑定有效 M 地址,字控件必须绑定有效 D 地址 */ bool validate(std::string *error = nullptr) const; bool isConfigured() const; diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 49079ac..34938d7 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -696,6 +696,14 @@ QJsonObject serializeHmiControl(const HmiControl &control) ? control.pageJump->targetPageId : std::string{})); } + if (control.type == HmiControlType::ProgressBar) + { + const HmiProgressBarConfig config = control.progressBar.value_or( + HmiProgressBarConfig{}); + object.insert(QStringLiteral("minimumValue"), config.minimumValue); + object.insert(QStringLiteral("maximumValue"), config.maximumValue); + object.insert(QStringLiteral("showValue"), config.showValue); + } return object; } @@ -749,6 +757,36 @@ bool parseHmiControl( } control->pageJump = HmiPageJumpConfig{std::move(target_page_id)}; } + if (control->type == HmiControlType::ProgressBar) + { + int minimum_value = 0; + int maximum_value = 0; + bool show_value = true; + if (!readInt( + object, + "minimumValue", + context, + std::numeric_limits::min(), + std::numeric_limits::max(), + &minimum_value, + state) + || !readInt( + object, + "maximumValue", + context, + std::numeric_limits::min(), + std::numeric_limits::max(), + &maximum_value, + state) + || !readBool(object, "showValue", context, &show_value, state)) + { + return false; + } + control->progressBar = HmiProgressBarConfig{ + static_cast(minimum_value), + static_cast(maximum_value), + show_value}; + } // binding 允许为 null,其余非空值必须是合法的寄存器地址对象 if (binding.isNull()) diff --git a/app/src/services/hmi_editor_service.cpp b/app/src/services/hmi_editor_service.cpp index 6100056..45f860a 100644 --- a/app/src/services/hmi_editor_service.cpp +++ b/app/src/services/hmi_editor_service.cpp @@ -461,6 +461,10 @@ HmiControl HmiEditorService::makeControl( { control.pageJump = HmiPageJumpConfig{}; } + if (descriptor.type == HmiControlType::ProgressBar) + { + control.progressBar = HmiProgressBarConfig{}; + } const int offset = static_cast(page.controls.size()) * 16; control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index 6dafe57..cf5f8fa 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -180,6 +180,48 @@ public: textWithValue()); break; } + case HmiControlType::ProgressBar: + { + const HmiProgressBarConfig config = control_.progressBar.value_or( + HmiProgressBarConfig{}); + const int percentage = has_runtime_value_ + ? config.percentageForValue(word_value_) : 0; + + painter->setPen(Qt::NoPen); + painter->setBrush(QColor(QStringLiteral("#e4e9e7"))); + painter->drawRoundedRect(rect, 4, 4); + + const QRectF track = rect.adjusted(2, 2, -2, -2); + if (has_runtime_value_ && percentage > 0) + { + QRectF fill = track; + fill.setWidth(track.width() * percentage / 100.0); + painter->setBrush(QColor(QStringLiteral("#3c8c62"))); + painter->drawRoundedRect(fill, 3, 3); + } + + painter->setBrush(Qt::NoBrush); + painter->setPen(QPen(QColor(QStringLiteral("#617069")), 1)); + painter->drawRoundedRect(rect, 4, 4); + + QString display_text = QString::fromUtf8( + control_.text.data(), static_cast(control_.text.size())); + if (config.showValue) + { + const QString percentage_text = has_runtime_value_ + ? QString::number(percentage) + QLatin1Char('%') + : runtime_active_ ? QStringLiteral("--") + : QStringLiteral("0%"); + display_text = display_text.isEmpty() + ? percentage_text + : display_text + QStringLiteral(": ") + percentage_text; + } + painter->setPen(QColor(QStringLiteral("#20342a"))); + painter->drawText(rect.adjusted(6, 0, -6, 0), + Qt::AlignCenter, + display_text); + break; + } case HmiControlType::PageJump: { const QColor fill = runtime_active_ && page_hovered_ diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 36c1744..cee1941 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -279,6 +279,8 @@ void MainWindow::configureActions() [this] { addHmiControl(HmiControlType::NumericDisplay); }); connect(ui_->addNumericInputAction, &QAction::triggered, this, [this] { addHmiControl(HmiControlType::NumericInput); }); + connect(ui_->addProgressBarAction, &QAction::triggered, this, + [this] { addHmiControl(HmiControlType::ProgressBar); }); connect(ui_->addLabelAction, &QAction::triggered, this, [this] { addHmiControl(HmiControlType::Label); }); connect(ui_->addPageJumpAction, &QAction::triggered, this, @@ -576,6 +578,8 @@ void MainWindow::configureAppearance() style()->standardIcon(QStyle::SP_FileDialogInfoView)); ui_->addNumericInputAction->setIcon( style()->standardIcon(QStyle::SP_FileDialogContentsView)); + ui_->addProgressBarAction->setIcon( + style()->standardIcon(QStyle::SP_MediaSeekForward)); ui_->addLabelAction->setIcon( style()->standardIcon(QStyle::SP_FileDialogInfoView)); ui_->addPageJumpAction->setIcon( @@ -1189,6 +1193,7 @@ void MainWindow::updateModeUi(const QString &message) ui_->addIndicatorAction->setEnabled(policy.allowsProjectEditing); ui_->addNumericDisplayAction->setEnabled(policy.allowsProjectEditing); ui_->addNumericInputAction->setEnabled(policy.allowsProjectEditing); + ui_->addProgressBarAction->setEnabled(policy.allowsProjectEditing); ui_->addLabelAction->setEnabled(policy.allowsProjectEditing); ui_->addPageJumpAction->setEnabled(policy.allowsProjectEditing); ui_->addAlarmListAction->setEnabled(policy.allowsProjectEditing); diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index d8ee498..34994dc 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -303,6 +303,7 @@ + @@ -324,6 +325,7 @@ + @@ -342,7 +344,6 @@ - @@ -632,7 +633,55 @@ - + + + + 最小值 + + + + + + + -32768 + + + 32767 + + + + + + + 最大值 + + + + + + + -32768 + + + 32767 + + + + + + + 数值显示 + + + + + + + 显示百分比 + + + + 应用属性 @@ -928,6 +977,14 @@ 添加数值输入控件 + + + 进度条 + + + 添加进度条控件 + + 文本 @@ -1098,7 +1155,7 @@ - 删除 + 删除节点/网络 删除选中的逻辑节点或网络 diff --git a/app/src/ui/property_panel_controller.cpp b/app/src/ui/property_panel_controller.cpp index cf4d472..f135cef 100644 --- a/app/src/ui/property_panel_controller.cpp +++ b/app/src/ui/property_panel_controller.cpp @@ -9,6 +9,7 @@ #include "services/project_service.h" #include "ui_main_window.h" +#include #include #include #include @@ -155,6 +156,9 @@ void PropertyPanelController::showControlProperties(const std::string &control_i static_cast(ui_.bindingIndexSpinBox), static_cast(ui_.targetPageComboBox), static_cast(ui_.buttonOperationComboBox), + static_cast(ui_.progressMinimumSpinBox), + static_cast(ui_.progressMaximumSpinBox), + static_cast(ui_.progressShowValueCheckBox), static_cast(ui_.applyPropertiesButton)}) { widget->setEnabled(has_control); @@ -170,6 +174,12 @@ void PropertyPanelController::showControlProperties(const std::string &control_i ui_.targetPageComboBox->setVisible(false); ui_.buttonOperationLabel->setVisible(false); ui_.buttonOperationComboBox->setVisible(false); + ui_.progressMinimumLabel->setVisible(false); + ui_.progressMinimumSpinBox->setVisible(false); + ui_.progressMaximumLabel->setVisible(false); + ui_.progressMaximumSpinBox->setVisible(false); + ui_.progressShowValueLabel->setVisible(false); + ui_.progressShowValueCheckBox->setVisible(false); return; } @@ -215,6 +225,22 @@ void PropertyPanelController::showControlProperties(const std::string &control_i ui_.targetPageComboBox->setCurrentIndex( ui_.targetPageComboBox->findData(fromUtf8(target_id))); } + + const bool is_progress_bar = control->type == HmiControlType::ProgressBar; + ui_.progressMinimumLabel->setVisible(is_progress_bar); + ui_.progressMinimumSpinBox->setVisible(is_progress_bar); + ui_.progressMaximumLabel->setVisible(is_progress_bar); + ui_.progressMaximumSpinBox->setVisible(is_progress_bar); + ui_.progressShowValueLabel->setVisible(is_progress_bar); + ui_.progressShowValueCheckBox->setVisible(is_progress_bar); + ui_.progressMinimumSpinBox->setEnabled(is_progress_bar); + ui_.progressMaximumSpinBox->setEnabled(is_progress_bar); + ui_.progressShowValueCheckBox->setEnabled(is_progress_bar); + const HmiProgressBarConfig progress_config = control->progressBar.value_or( + HmiProgressBarConfig{}); + ui_.progressMinimumSpinBox->setValue(progress_config.minimumValue); + ui_.progressMaximumSpinBox->setValue(progress_config.maximumValue); + ui_.progressShowValueCheckBox->setChecked(progress_config.showValue); } void PropertyPanelController::showLogicNodeProperties(const std::string &node_id) @@ -470,6 +496,13 @@ void PropertyPanelController::applySelectedControlProperties() control.pageJump = HmiPageJumpConfig{ toUtf8(ui_.targetPageComboBox->currentData().toString())}; } + if (control.type == HmiControlType::ProgressBar) + { + control.progressBar = HmiProgressBarConfig{ + static_cast(ui_.progressMinimumSpinBox->value()), + static_cast(ui_.progressMaximumSpinBox->value()), + ui_.progressShowValueCheckBox->isChecked()}; + } const HmiEditorResult result = hmi_editor_service_.updateControl( current_page_id_(), selected_control_id_, control); if (!result.succeeded) @@ -477,9 +510,11 @@ void PropertyPanelController::applySelectedControlProperties() reportFailure(QObject::tr("应用属性"), result.message); return; } - selected_control_id_ = result.id; + const std::string updated_control_id = result.id; hmi_editor_widget_->reloadPage(); - hmi_editor_widget_->selectControl(selected_control_id_); + selected_control_id_ = updated_control_id; + hmi_editor_widget_->selectControl(updated_control_id); + showControlProperties(updated_control_id); refresh_project_ui_(); status_reporter_(QObject::tr("控件属性已更新"), 3000); } diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index 1b42271..c3f410d 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -164,6 +164,45 @@ void testHmiControlRegistryCompleteness() "controls without bindings must not resolve a register area"); } +void testProgressBarConfigurationBoundaries() +{ + const HmiProgressBarConfig config{-20, 80, true}; + require(config.isValid(), "a progress bar range must accept increasing bounds"); + require(config.percentageForValue(-20) == 0, + "progress bar minimum must map to zero percent"); + require(config.percentageForValue(30) == 50, + "progress bar midpoint must map to fifty percent"); + require(config.percentageForValue(80) == 100, + "progress bar maximum must map to one hundred percent"); + require(config.percentageForValue(-100) == 0 + && config.percentageForValue(100) == 100, + "progress bar percentages must clamp values outside the range"); + + HmiProgressBarConfig invalid_range{10, 10, true}; + require(!invalid_range.isValid() + && invalid_range.percentageForValue(10) == 0, + "progress bar must reject an empty range defensively"); + + HmiControl progress; + progress.id = "progress"; + progress.type = HmiControlType::ProgressBar; + progress.progressBar = config; + require(progress.validate(), + "an unbound progress bar with valid configuration must remain a valid draft"); + require(!progress.isConfigured(), + "an unbound progress bar must not be ready for running"); + progress.binding = RegisterAddress{RegisterArea::D, 0}; + require(progress.validate() && progress.isConfigured(), + "a progress bar with a D binding must be ready for running"); + progress.progressBar->maximumValue = progress.progressBar->minimumValue; + require(!progress.validate(), + "a progress bar with an invalid range must be rejected"); + progress.progressBar = config; + progress.binding = RegisterAddress{RegisterArea::M, 0}; + require(!progress.validate(), + "a progress bar must reject an M binding"); +} + Project makeValidProject() { // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线 @@ -673,6 +712,7 @@ int main() testRegisterAddressParsing(); testRegisterRepositorySeparatesAreas(); testHmiControlRegistryCompleteness(); + testProgressBarConfigurationBoundaries(); testLogicNodeConfigurationBoundaries(); testTimerAndCommentBoundaries(); testTimerReferencesForRunning(); diff --git a/app/tests/hmi_editor_service_tests.cpp b/app/tests/hmi_editor_service_tests.cpp index 920c0fd..3579e5d 100644 --- a/app/tests/hmi_editor_service_tests.cpp +++ b/app/tests/hmi_editor_service_tests.cpp @@ -50,12 +50,26 @@ void testControlEditing() const HmiEditorResult display = service.addControl( page_id, HmiControlType::NumericDisplay); const HmiEditorResult input = service.addControl(page_id, HmiControlType::NumericInput); - require(button.succeeded && indicator.succeeded && display.succeeded && input.succeeded, - "four basic HMI controls must be added"); + const HmiEditorResult progress = service.addControl( + page_id, HmiControlType::ProgressBar); + require(button.succeeded && indicator.succeeded && display.succeeded + && input.succeeded && progress.succeeded, + "basic HMI controls must be added"); const HmiPage *page = service.findPage(page_id); - require(page != nullptr && page->controls.size() == 4, + require(page != nullptr && page->controls.size() == 5, "all added controls must be kept in the page model"); + const HmiControl *progress_control = service.findControl(page_id, progress.id); + require(progress_control != nullptr + && progress_control->id == "progress-bar-1" + && progress_control->text == "进度" + && progress_control->bounds.width == 240 + && progress_control->bounds.height == 36 + && progress_control->progressBar.has_value() + && progress_control->progressBar->minimumValue == 0 + && progress_control->progressBar->maximumValue == 100 + && progress_control->progressBar->showValue, + "new progress bars must use the registered defaults"); require(service.moveControl(page_id, button.id, {120, 80, 120, 40}).succeeded, "a valid control move must succeed"); require(!service.moveControl(page_id, button.id, {790, 460, 120, 40}).succeeded, @@ -80,6 +94,20 @@ void testControlEditing() "deleting a selected control must succeed"); require(service.findControl(page_id, indicator.id) == nullptr, "deleted controls must not remain in the model"); + + HmiControl configured_progress = *service.findControl(page_id, progress.id); + configured_progress.binding = RegisterAddress{RegisterArea::D, 8}; + configured_progress.progressBar->minimumValue = -10; + configured_progress.progressBar->maximumValue = 90; + require(service.updateControl(page_id, progress.id, configured_progress).succeeded, + "a progress bar must accept a valid D binding and value range"); + configured_progress.progressBar->maximumValue = -10; + require(!service.updateControl(page_id, progress.id, configured_progress).succeeded, + "a progress bar must reject a non-increasing value range"); + configured_progress.progressBar->maximumValue = 90; + configured_progress.binding = RegisterAddress{RegisterArea::M, 8}; + require(!service.updateControl(page_id, progress.id, configured_progress).succeeded, + "a progress bar must reject an M binding"); } void testRuntimeUsesRegisterRepository() @@ -147,6 +175,15 @@ void testRuntimeUsesRegisterRepository() const HmiRuntimeReadResult numeric_value = runtime_service.readControl(numeric_display); require(numeric_value.succeeded && numeric_value.word_value == -18, "numeric display must read D values through the repository"); + + HmiControl progress; + progress.id = "progress"; + progress.type = HmiControlType::ProgressBar; + progress.binding = RegisterAddress{RegisterArea::D, 9}; + progress.progressBar = HmiProgressBarConfig{-20, 80, true}; + const HmiRuntimeReadResult progress_value = runtime_service.readControl(progress); + require(progress_value.succeeded && progress_value.word_value == -18, + "progress bars must read D values through the repository"); } void testPageLifecycleAndNavigation() diff --git a/app/tests/main_window_tests.cpp b/app/tests/main_window_tests.cpp index 72db520..447c8a7 100644 --- a/app/tests/main_window_tests.cpp +++ b/app/tests/main_window_tests.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -350,6 +351,70 @@ void testRuntimeButtonMouseInteraction() "a disabled runtime button must not write its register"); } +void testRuntimeProgressBarRendering() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + HmiEditorService editor_service(project_service); + VirtualRegisterRepository repository; + HmiRuntimeService runtime_service(repository); + AlarmService alarm_service(project_service, repository); + const std::string page_id = editor_service.ensureDefaultPage().id; + const HmiEditorResult progress_result = editor_service.addControl( + page_id, HmiControlType::ProgressBar); + require(progress_result.succeeded, + "runtime progress test must create a ProgressBar control"); + + HmiControl progress = *editor_service.findControl( + page_id, progress_result.id); + progress.binding = RegisterAddress{RegisterArea::D, 0}; + progress.progressBar = HmiProgressBarConfig{0, 100, true}; + require(editor_service.updateControl( + page_id, progress.id, progress).succeeded, + "runtime progress test must configure a D value range"); + require(repository.writeWord( + {RegisterArea::D, 0}, static_cast(25)).succeeded, + "runtime progress test must seed D0"); + + HmiEditorWidget view(editor_service, runtime_service, alarm_service); + view.resize(900, 560); + view.setPageId(page_id); + view.setEditingEnabled(false); + view.setRuntimeActive(true); + view.refreshRuntimeValues(); + view.show(); + QApplication::processEvents(); + + const QPoint sample = view.mapFromScene(QPointF( + progress.bounds.x + progress.bounds.width * 0.6, + progress.bounds.y + 6.0)); + const QColor background_color = renderedColorAt(view, sample); + QGraphicsItem *progress_item = view.itemAt(sample); + require(progress_item != nullptr + && progress_item->acceptedMouseButtons() == Qt::NoButton, + "runtime ProgressBar must be visible and read-only"); + + require(repository.writeWord( + {RegisterArea::D, 0}, static_cast(75)).succeeded, + "runtime progress test must update D0"); + view.refreshRuntimeValues(); + QApplication::processEvents(); + const QColor fill_color = renderedColorAt(view, sample); + require(fill_color != background_color, + "ProgressBar fill must expand when the D value increases"); + + require(repository.writeWord( + {RegisterArea::D, 0}, static_cast(200)).succeeded, + "runtime progress test must accept an out-of-range source value"); + view.refreshRuntimeValues(); + QApplication::processEvents(); + const QPoint near_end = view.mapFromScene(QPointF( + progress.bounds.x + progress.bounds.width * 0.95, + progress.bounds.y + 6.0)); + require(renderedColorAt(view, near_end) == fill_color, + "ProgressBar rendering must clamp values above its maximum"); +} + void testRuntimePageJumpDoesNotRequireRegisterWritePermission() { TestProjectStorage storage; @@ -533,6 +598,8 @@ void testModeActionsControlEditingAvailability() QAction *online_action = requiredChild(window, "onlineModeAction"); QAction *add_button_action = requiredChild(window, "addButtonAction"); QAction *add_indicator_action = requiredChild(window, "addIndicatorAction"); + QAction *add_progress_bar_action = requiredChild( + window, "addProgressBarAction"); QAction *delete_control_action = requiredChild(window, "deleteControlAction"); QAction *add_normally_open_action = requiredChild( window, "addNormallyOpenAction"); @@ -546,6 +613,16 @@ void testModeActionsControlEditingAvailability() QLineEdit *text_edit = requiredChild(window, "controlTextEdit"); QComboBox *button_operation = requiredChild( window, "buttonOperationComboBox"); + QComboBox *binding_area = requiredChild( + window, "bindingAreaComboBox"); + QSpinBox *binding_index = requiredChild( + window, "bindingIndexSpinBox"); + QSpinBox *progress_minimum = requiredChild( + window, "progressMinimumSpinBox"); + QSpinBox *progress_maximum = requiredChild( + window, "progressMaximumSpinBox"); + QCheckBox *progress_show_value = requiredChild( + window, "progressShowValueCheckBox"); QPushButton *apply_properties = requiredChild( window, "applyPropertiesButton"); HmiEditorWidget *hmi_editor = requiredChild( @@ -603,6 +680,37 @@ void testModeActionsControlEditingAvailability() require(editor_service.findPage(page_id)->controls.empty(), "deleting a selected control must update the HMI page model"); + 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"); + add_indicator_action->trigger(); HmiControl runtime_indicator = *editor_service.findControl(page_id, "indicator-1"); runtime_indicator.binding = RegisterAddress{RegisterArea::M, 3}; @@ -663,6 +771,8 @@ void testModeActionsControlEditingAvailability() "properties dock must be disabled while running"); 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(runtime_tab->isVisible() && runtime_hmi->isVisible() @@ -704,6 +814,8 @@ void testModeActionsControlEditingAvailability() "properties dock must be restored after returning to editing"); require(add_button_action->isEnabled(), "HMI add controls must be restored after returning to editing"); + require(add_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_tab->isVisible(), @@ -1187,6 +1299,7 @@ int main(int argc, char *argv[]) try { testRuntimeButtonMouseInteraction(); + testRuntimeProgressBarRendering(); testRuntimePageJumpDoesNotRequireRegisterWritePermission(); testRuntimeAlarmListInteraction(); testModeActionsControlEditingAvailability(); diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 689a6af..eda5681 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -80,6 +80,14 @@ Project makeExampleProject() alarm_list.bounds = {10, 200, 360, 180}; alarm_list.text = "Alarms"; + HmiControl progress_bar; + progress_bar.id = "progress-bar"; + progress_bar.type = HmiControlType::ProgressBar; + progress_bar.bounds = {400, 20, 240, 36}; + progress_bar.text = "Completion"; + progress_bar.binding = RegisterAddress{RegisterArea::D, 4}; + progress_bar.progressBar = HmiProgressBarConfig{-20, 80, true}; + HmiPage page; page.id = "main-page"; page.name = "Main"; @@ -90,6 +98,7 @@ Project makeExampleProject() page.controls.push_back(title_label); page.controls.push_back(settings_jump); page.controls.push_back(alarm_list); + page.controls.push_back(progress_bar); HmiPage settings_page; settings_page.id = "settings-page"; @@ -379,6 +388,8 @@ void testExampleProjectRoundTrip() const QString invalid_operation_path = directory.filePath("invalid-operation.json"); const QString missing_initial_path = directory.filePath("missing-initial.json"); const QString missing_target_path = directory.filePath("missing-target.json"); + const QString missing_progress_range_path = directory.filePath( + "missing-progress-range.json"); const QString missing_alarms_path = directory.filePath("missing-alarms.json"); const QString missing_register_comments_path = directory.filePath( "missing-register-comments.json"); @@ -411,7 +422,11 @@ void testExampleProjectRoundTrip() && saved_json.contains("\"operation\": \"add\"") && saved_json.contains("\"operation\": \"subtract\"") && saved_json.contains("\"comment\": \"启动条件与温度检查\"") - && saved_json.contains("\"type\": \"alarmList\""), + && saved_json.contains("\"type\": \"alarmList\"") + && saved_json.contains("\"type\": \"progressBar\"") + && saved_json.contains("\"minimumValue\": -20") + && saved_json.contains("\"maximumValue\": 80") + && saved_json.contains("\"showValue\": true"), "version 1.0 projects must persist pages and alarm definitions"); require(!saved_json.contains("\"stages\"") && !saved_json.contains("\"branches\""), @@ -427,8 +442,8 @@ void testExampleProjectRoundTrip() require(project.hmiPages.size() == 2, "HMI page count must survive round trip"); require(project.initialHmiPageId == "main-page", "the initial HMI page id must survive round trip"); - require(project.hmiPages.front().controls.size() == 7, - "register, navigation and AlarmList controls must survive round trip"); + require(project.hmiPages.front().controls.size() == 8, + "register, navigation, AlarmList and ProgressBar controls must survive round trip"); require(project.hmiPages.front().controls.front().binding->area() == RegisterArea::M, "HMI M binding must survive round trip"); @@ -450,6 +465,13 @@ void testExampleProjectRoundTrip() require(project.hmiPages.front().controls.at(6).type == HmiControlType::AlarmList, "AlarmList control type must survive round trip"); + require(project.hmiPages.front().controls.at(7).type + == HmiControlType::ProgressBar + && project.hmiPages.front().controls.at(7).progressBar.has_value() + && project.hmiPages.front().controls.at(7).progressBar->minimumValue == -20 + && project.hmiPages.front().controls.at(7).progressBar->maximumValue == 80 + && project.hmiPages.front().controls.at(7).progressBar->showValue, + "ProgressBar configuration must survive round trip"); require(project.alarmDefinitions.size() == 2 && project.alarmDefinitions.front().condition == AlarmCondition::MOn @@ -614,6 +636,24 @@ void testExampleProjectRoundTrip() require(!missing_result.succeeded && missing_result.storageError == ProjectStorageError::MissingField, "the 1.0 schema must require PageJump targetPageId"); + + QJsonObject missing_progress_range = QJsonDocument::fromJson(saved_json).object(); + pages = missing_progress_range.value(QStringLiteral("hmiPages")).toArray(); + main_page = pages.at(0).toObject(); + controls = main_page.value(QStringLiteral("controls")).toArray(); + QJsonObject progress = controls.at(7).toObject(); + progress.remove(QStringLiteral("maximumValue")); + controls.replace(7, progress); + main_page.insert(QStringLiteral("controls"), controls); + pages.replace(0, main_page); + missing_progress_range.insert(QStringLiteral("hmiPages"), pages); + writeText( + missing_progress_range_path, + QJsonDocument(missing_progress_range).toJson(QJsonDocument::Compact)); + missing_result = service.load(missing_progress_range_path.toStdString()); + require(!missing_result.succeeded + && missing_result.storageError == ProjectStorageError::MissingField, + "the 1.0 schema must require the ProgressBar value range"); } void testInvalidFiles()