From 9be85587fc24f3cd9712ab9b50b055cbadd98c52 Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Thu, 27 Aug 2026 13:12:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20HMI=20=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=E5=9F=BA=E7=A1=80=E5=B8=83=E5=B1=80=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/domain/register_address.cpp | 18 +++ app/src/domain/register_address.h | 30 +++-- app/src/services/hmi_editor_service.cpp | 144 ++++++++++++++++++++++++ app/src/services/hmi_editor_service.h | 26 +++++ app/src/ui/hmi_editor_widget.cpp | 27 +++++ app/src/ui/hmi_editor_widget.h | 2 + app/src/ui/main_window.cpp | 85 +++++++++++++- app/src/ui/main_window.h | 12 ++ app/src/ui/toolbar_icon_factory.cpp | 9 ++ app/src/ui/toolbar_icon_factory.h | 1 + app/tests/hmi_editor_service_tests.cpp | 125 ++++++++++++++++++++ 11 files changed, 465 insertions(+), 14 deletions(-) diff --git a/app/src/domain/register_address.cpp b/app/src/domain/register_address.cpp index 57f52f1..a94208c 100644 --- a/app/src/domain/register_address.cpp +++ b/app/src/domain/register_address.cpp @@ -6,6 +6,7 @@ namespace { +// 判断寄存器区域是否为项目支持的 M 区或 D 区 bool isSupportedArea(RegisterArea area) { // 地址值对象只承认项目定义的 M 区和 D 区 @@ -14,8 +15,10 @@ bool isSupportedArea(RegisterArea area) } // namespace +// 解析文本中的 M/D 区域和数字下标,并返回具体失败原因 RegisterAddressParseResult parseRegisterAddress(const std::string &text) { + // 从两端找到第一个非空白字符,后续解析只处理去掉首尾空白后的内容 const auto first = std::find_if_not( text.begin(), text.end(), [](unsigned char value) { return std::isspace(value); }); const auto last = std::find_if_not( @@ -23,9 +26,11 @@ RegisterAddressParseResult parseRegisterAddress(const std::string &text) .base(); if (first >= last) { + // 首尾空白之外没有任何内容,说明用户没有输入地址 return {false, {RegisterArea::M, 0}, RegisterAddressParseError::Empty}; } + // 将首字符统一转成大写,使 m10 和 M10 的解析结果保持一致 const char area_character = static_cast( std::toupper(static_cast(*first))); RegisterArea area = RegisterArea::M; @@ -39,57 +44,68 @@ RegisterAddressParseResult parseRegisterAddress(const std::string &text) } else { + // 首字符不是 M 或 D,返回“不支持的区域”错误并保留默认地址 return {false, {RegisterArea::M, 0}, RegisterAddressParseError::UnsupportedArea}; } if (first + 1 == last) { + // 只有区域字母而没有数字下标,例如单独输入 M 或 D return {false, {area, 0}, RegisterAddressParseError::InvalidFormat}; } int index = 0; + // 逐字符读取下标,避免直接调用字符串转数字函数带来格式不透明的问题 for (auto current = first + 1; current != last; ++current) { const unsigned char character = static_cast(*current); if (!std::isdigit(character)) { + // 下标部分只能由十进制数字组成 return {false, {area, 0}, RegisterAddressParseError::InvalidFormat}; } const int digit = *current - '0'; if (index > (std::numeric_limits::max() - digit) / 10) { + // 在执行 index * 10 + digit 前先检查,防止整数溢出 return {false, {area, 0}, RegisterAddressParseError::OutOfRange}; } index = index * 10 + digit; } RegisterAddress address{area, index}; + // 解析完成后再检查项目边界,区分格式正确但下标超范围的地址 return address.isValid() ? RegisterAddressParseResult{true, address, RegisterAddressParseError::None} : RegisterAddressParseResult{false, address, RegisterAddressParseError::OutOfRange}; } +// 使用指定区域和下标创建寄存器地址对象 RegisterAddress::RegisterAddress(RegisterArea area, int index) : area_(area), index_(index) { } +// 返回地址所属的寄存器区域 RegisterArea RegisterAddress::area() const { return area_; } +// 返回地址的数字下标 int RegisterAddress::index() const { return index_; } +// 检查区域和下标是否符合项目支持的地址范围 bool RegisterAddress::isValid() const { return isSupportedArea(area_) && index_ >= kMinimumIndex && index_ <= kMaximumIndex; } +// 将地址转换为 M10 或 D4000 这样的显示文本 std::string RegisterAddress::toString() const { const char *areaName = nullptr; @@ -109,11 +125,13 @@ std::string RegisterAddress::toString() const return std::string(areaName) + std::to_string(index_); } +// 判断当前地址与另一个地址是否完全相同 bool RegisterAddress::operator==(const RegisterAddress &other) const { return area_ == other.area_ && index_ == other.index_; } +// 判断当前地址与另一个地址是否不同 bool RegisterAddress::operator!=(const RegisterAddress &other) const { return !(*this == other); diff --git a/app/src/domain/register_address.h b/app/src/domain/register_address.h index 7373c23..a097a6c 100644 --- a/app/src/domain/register_address.h +++ b/app/src/domain/register_address.h @@ -5,18 +5,18 @@ // 解析用户输入的 M/D 地址时可能出现的失败原因 enum class RegisterAddressParseError { - None, - Empty, - UnsupportedArea, - InvalidFormat, - OutOfRange + None, // 地址文本解析成功 + Empty, // 输入文本为空或只包含空白 + UnsupportedArea, // 地址区域不是项目支持的 M 或 D + InvalidFormat, // 地址文本格式不符合要求 + OutOfRange // 地址下标超出 0~4000 范围 }; // PLC 中支持的寄存器区域:M 是位,D 是 16 位有符号字 enum class RegisterArea { - M, - D + M, // M 位寄存器区域 + D // D 16 位数据寄存器区域 }; // 表示一个已经拆分为“区域 + 下标”的 M/D 地址 @@ -24,33 +24,39 @@ enum class RegisterArea class RegisterAddress { public: - static constexpr int kMinimumIndex = 0; - static constexpr int kMaximumIndex = 4000; + static constexpr int kMinimumIndex = 0; // 地址允许使用的最小下标 + static constexpr int kMaximumIndex = 4000; // 地址允许使用的最大下标 // 构造地址对象;非法下标不会在构造时抛异常,应通过 isValid() 检查 RegisterAddress(RegisterArea area, int index); - // 返回地址区域和数字下标 + // 返回地址所属的寄存器区域 RegisterArea area() const; + // 返回地址的数字下标 int index() const; // 判断区域和下标是否满足项目边界 bool isValid() const; // 转成界面和日志中使用的形式,例如 M10 或 D4000 std::string toString() const; + // 判断两个地址的区域和下标是否都相同 bool operator==(const RegisterAddress &other) const; + // 判断两个地址是否存在区域或下标差异 bool operator!=(const RegisterAddress &other) const; private: - RegisterArea area_; - int index_; + RegisterArea area_; // 保存地址所属的寄存器区域 + int index_; // 保存地址的数字下标 }; // 文本地址解析结果;失败时 address 保持默认值,具体原因见 error struct RegisterAddressParseResult { + // 表示文本是否成功解析为有效地址 bool succeeded = false; + // 保存解析出的地址或失败时的默认地址 RegisterAddress address{RegisterArea::M, 0}; + // 保存成功状态或具体失败原因 RegisterAddressParseError error = RegisterAddressParseError::InvalidFormat; }; diff --git a/app/src/services/hmi_editor_service.cpp b/app/src/services/hmi_editor_service.cpp index fac09cf..197ba20 100644 --- a/app/src/services/hmi_editor_service.cpp +++ b/app/src/services/hmi_editor_service.cpp @@ -578,6 +578,150 @@ HmiEditorResult HmiEditorService::moveControl( return {true, HmiEditorError::None, {}, control_id}; } +HmiEditorResult HmiEditorService::alignControls( + const std::string &page_id, + const std::vector &control_ids, + HmiAlignment alignment) +{ + const HmiPage *page = findPage(page_id); + if (page == nullptr) + { + return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); + } + if (control_ids.size() < 2U) + { + return failure( + HmiEditorError::InvalidOperation, + "请至少选择两个 HMI 控件"); + } + + std::set selected_ids; + for (const std::string &control_id : control_ids) + { + if (!selected_ids.insert(control_id).second) + { + return failure( + HmiEditorError::InvalidOperation, + "对齐列表中存在重复控件"); + } + if (findControl(page_id, control_id) == nullptr) + { + return failure(HmiEditorError::ControlNotFound, "未找到 HMI 控件"); + } + } + + const HmiControl *first = findControl(page_id, control_ids.front()); + int left = first->bounds.x; + int top = first->bounds.y; + int right = first->bounds.x + first->bounds.width; + int bottom = first->bounds.y + first->bounds.height; + for (std::size_t index = 1U; index < control_ids.size(); ++index) + { + const HmiControl *control = findControl(page_id, control_ids[index]); + left = std::min(left, control->bounds.x); + top = std::min(top, control->bounds.y); + right = std::max( + right, control->bounds.x + control->bounds.width); + bottom = std::max( + bottom, control->bounds.y + control->bounds.height); + } + + bool supported = true; + switch (alignment) + { + case HmiAlignment::Left: + case HmiAlignment::HorizontalCenter: + case HmiAlignment::Right: + case HmiAlignment::Top: + case HmiAlignment::VerticalCenter: + case HmiAlignment::Bottom: + break; + default: + supported = false; + break; + } + if (!supported) + { + return failure(HmiEditorError::InvalidOperation, "不支持的 HMI 对齐方式"); + } + + HmiPage candidate_page = *page; + const int horizontal_center = (left + right) / 2; + const int vertical_center = (top + bottom) / 2; + bool changed = false; + for (HmiControl &control : candidate_page.controls) + { + if (selected_ids.find(control.id) == selected_ids.end()) + { + continue; + } + + const HmiRect original = control.bounds; + switch (alignment) + { + case HmiAlignment::Left: + control.bounds.x = left; + break; + case HmiAlignment::HorizontalCenter: + control.bounds.x = horizontal_center - control.bounds.width / 2; + break; + case HmiAlignment::Right: + control.bounds.x = right - control.bounds.width; + break; + case HmiAlignment::Top: + control.bounds.y = top; + break; + case HmiAlignment::VerticalCenter: + control.bounds.y = vertical_center - control.bounds.height / 2; + break; + case HmiAlignment::Bottom: + control.bounds.y = bottom - control.bounds.height; + break; + default: + break; + } + changed = changed || original.x != control.bounds.x + || original.y != control.bounds.y; + } + + if (!changed) + { + return { + true, + HmiEditorError::None, + {}, + control_ids.front()}; + } + + std::string error; + if (!candidate_page.validate(project_service_.projectLimits(), &error)) + { + return failure(HmiEditorError::InvalidControl, error); + } + + const HistoryState before = captureState(); + Project &project = project_service_.editProject(); + auto target_page = std::find_if( + project.hmiPages.begin(), project.hmiPages.end(), + [&page_id](const HmiPage &item) { return item.id == page_id; }); + for (const HmiControl &candidate : candidate_page.controls) + { + if (selected_ids.find(candidate.id) == selected_ids.end()) + { + continue; + } + auto editable = std::find_if( + target_page->controls.begin(), target_page->controls.end(), + [&candidate](const HmiControl &item) + { + return item.id == candidate.id; + }); + editable->bounds = candidate.bounds; + } + recordHistory(before); + return {true, HmiEditorError::None, {}, control_ids.front()}; +} + // 更新控件全部属性 HmiEditorResult HmiEditorService::updateControl( const std::string &page_id, diff --git a/app/src/services/hmi_editor_service.h b/app/src/services/hmi_editor_service.h index 7abc6e8..1b36381 100644 --- a/app/src/services/hmi_editor_service.h +++ b/app/src/services/hmi_editor_service.h @@ -47,6 +47,19 @@ struct HmiEditorResult std::string id; // 成功时返回新建或更新后的页面/控件 ID }; +/** + * @brief HMI 控件批量对齐方向 + */ +enum class HmiAlignment +{ + Left, + HorizontalCenter, + Right, + Top, + VerticalCenter, + Bottom +}; + /** * @brief 编排 HMI 页面和控件的编辑操作,不依赖 Qt 视图 * @@ -174,6 +187,19 @@ public: const std::string &page_id, const std::string &control_id, const HmiRect &bounds); + /** + * @brief 批量对齐同一页面中的 HMI 控件 + * @param page_id 控件所属页面唯一标识 + * @param control_ids 待对齐控件 ID 列表,至少包含两个不重复 ID + * @param alignment 对齐方向,按所选控件整体外接矩形计算 + * @return 页面、控件、选择列表或对齐结果无效时返回失败结果 + * + * 对齐只修改控件位置,整批操作只产生一条撤销记录 + */ + HmiEditorResult alignControls( + const std::string &page_id, + const std::vector &control_ids, + HmiAlignment alignment); /** * @brief 更新控件可编辑属性 * @param page_id 所属页面唯一标识 diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index 7cfe007..d1adc47 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -1046,6 +1046,33 @@ void HmiEditorWidget::selectControl(const std::string &control_id) } } +void HmiEditorWidget::selectControls( + const std::vector &control_ids) +{ + scene_->clearSelection(); + HmiGraphicsItem *first_selected = nullptr; + for (QGraphicsItem *item : scene_->items()) + { + HmiGraphicsItem *control_item = asHmiItem(item); + if (control_item == nullptr + || std::find( + control_ids.cbegin(), control_ids.cend(), + control_item->controlId()) == control_ids.cend()) + { + continue; + } + control_item->setSelected(true); + if (first_selected == nullptr) + { + first_selected = control_item; + } + } + if (first_selected != nullptr) + { + ensureVisible(first_selected); + } +} + std::string HmiEditorWidget::selectedControlId() const { const std::vector ids = selectedControlIds(); diff --git a/app/src/ui/hmi_editor_widget.h b/app/src/ui/hmi_editor_widget.h index afe23a9..0f0ce80 100644 --- a/app/src/ui/hmi_editor_widget.h +++ b/app/src/ui/hmi_editor_widget.h @@ -70,6 +70,8 @@ public: * @param control_id 待选中控件标识,不存在时不改变当前选择 */ void selectControl(const std::string &control_id); + /** 选中指定的多个控件并滚动到第一个可见控件 */ + void selectControls(const std::vector &control_ids); /** 返回当前选中的单个控件标识 */ std::string selectedControlId() const; /** 返回当前选中的多个控件标识 */ diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 626ca65..1014d5b 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -655,6 +655,42 @@ void MainWindow::configureActions() [this] { addHmiControl(HmiControlType::AlarmList); }); connect(ui_->deleteControlAction, &QAction::triggered, this, &MainWindow::deleteSelectedControl); + align_left_action_ = new QAction(tr("左对齐"), this); + align_horizontal_center_action_ = new QAction(tr("水平居中"), this); + align_right_action_ = new QAction(tr("右对齐"), this); + align_top_action_ = new QAction(tr("顶部对齐"), this); + align_vertical_center_action_ = new QAction(tr("垂直居中"), this); + align_bottom_action_ = new QAction(tr("底部对齐"), this); + hmi_layout_button_ = addToolbarMenu( + ui_->hmiToolBar, + tr("布局"), + QStringLiteral("hmiLayoutMenuButton"), + makeUiIcon(UiIcon::Layout), + QList{ + align_left_action_, + align_horizontal_center_action_, + align_right_action_, + align_top_action_, + align_vertical_center_action_, + align_bottom_action_}); + connect(align_left_action_, &QAction::triggered, + this, [this] { alignSelectedHmiControls(HmiAlignment::Left); }); + connect(align_horizontal_center_action_, &QAction::triggered, + this, [this] + { + alignSelectedHmiControls(HmiAlignment::HorizontalCenter); + }); + connect(align_right_action_, &QAction::triggered, + this, [this] { alignSelectedHmiControls(HmiAlignment::Right); }); + connect(align_top_action_, &QAction::triggered, + this, [this] { alignSelectedHmiControls(HmiAlignment::Top); }); + connect(align_vertical_center_action_, &QAction::triggered, + this, [this] + { + alignSelectedHmiControls(HmiAlignment::VerticalCenter); + }); + connect(align_bottom_action_, &QAction::triggered, + this, [this] { alignSelectedHmiControls(HmiAlignment::Bottom); }); addToolbarMenu( ui_->hmiToolBar, tr("更多控件"), @@ -1494,8 +1530,10 @@ void MainWindow::updateEditActions() ui_->redoAction->setEnabled( editable && ((hmi_active && hmi_editor_service_.canRedo()) || (logic_active && logic_editor_service_.canRedo()))); - const bool hmi_selection = hmi_active - && !hmi_editor_widget_->selectedControlIds().empty(); + const std::vector hmi_selected_ids = hmi_editor_widget_ == nullptr + ? std::vector{} + : hmi_editor_widget_->selectedControlIds(); + const bool hmi_selection = hmi_active && !hmi_selected_ids.empty(); const bool logic_selection = logic_active && logic_editor_widget_->hasCopyableSelection(); ui_->copyAction->setEnabled( @@ -1507,6 +1545,25 @@ void MainWindow::updateEditActions() || (logic_active && logic_clipboard))); ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active)); ui_->clearSelectionAction->setEnabled(editable && (hmi_active || logic_active)); + const bool hmi_alignment_enabled = + editable && hmi_active && hmi_selected_ids.size() >= 2U; + for (QAction *action : { + align_left_action_, + align_horizontal_center_action_, + align_right_action_, + align_top_action_, + align_vertical_center_action_, + align_bottom_action_}) + { + if (action != nullptr) + { + action->setEnabled(hmi_alignment_enabled); + } + } + if (hmi_layout_button_ != nullptr) + { + hmi_layout_button_->setEnabled(hmi_alignment_enabled); + } ui_->insertHorizontalWireAction->setEnabled(editable && logic_active); ui_->insertVerticalWireAction->setEnabled(editable && logic_active); ui_->insertRungAboveAction->setEnabled(editable && logic_active); @@ -1660,6 +1717,30 @@ void MainWindow::deleteSelectedControl() property_panel_controller_->deleteSelectedControl(); } +void MainWindow::alignSelectedHmiControls(HmiAlignment alignment) +{ + if (!runtime_mode_service_.policy().allowsProjectEditing + || hmi_editor_widget_ == nullptr) + { + return; + } + const std::vector control_ids = + hmi_editor_widget_->selectedControlIds(); + const HmiEditorResult result = hmi_editor_service_.alignControls( + current_hmi_page_id_, control_ids, alignment); + if (!result.succeeded) + { + showProjectResult(tr("对齐 HMI 控件"), fromUtf8(result.message), false); + return; + } + + hmi_editor_widget_->reloadPage(); + hmi_editor_widget_->selectControls(control_ids); + refreshProjectUi(); + statusBar()->showMessage( + tr("已对齐 %1 个 HMI 控件").arg(control_ids.size()), 3000); +} + void MainWindow::addLogicCondition(const LogicNodeConfig &config) { const LogicEditorResult result = logic_editor_widget_->addCondition(config); diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index 1137f96..985a00b 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -29,6 +29,7 @@ class QCloseEvent; class QEvent; class QLabel; class QTimer; +class QToolButton; namespace Ui { class MainWindow; @@ -38,6 +39,7 @@ QT_END_NAMESPACE class RuntimeModeService; class ProjectService; class HmiEditorService; +enum class HmiAlignment; class HmiRuntimeService; class AlarmEditorService; class AlarmService; @@ -172,6 +174,8 @@ private: void addHmiControl(HmiControlType type); /** 删除当前选中的 HMI 控件 */ void deleteSelectedControl(); + /** 对当前选中的 HMI 控件执行批量对齐 */ + void alignSelectedHmiControls(HmiAlignment alignment); /** 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 */ void addLogicCondition(const LogicNodeConfig &config); /** 为当前选中的连续逻辑范围建立并联支路 */ @@ -304,6 +308,14 @@ private: std::unique_ptr runtime_panel_controller_; /** 运行模式互斥动作组 */ QActionGroup *mode_action_group_ = nullptr; + /** HMI 控件布局菜单按钮和对齐动作 */ + QToolButton *hmi_layout_button_ = nullptr; + QAction *align_left_action_ = nullptr; + QAction *align_horizontal_center_action_ = nullptr; + QAction *align_right_action_ = nullptr; + QAction *align_top_action_ = nullptr; + QAction *align_vertical_center_action_ = nullptr; + QAction *align_bottom_action_ = nullptr; /** 主界面的 HMI 编辑器 */ HmiEditorWidget *hmi_editor_widget_ = nullptr; /** 主界面的梯形图编辑器 */ diff --git a/app/src/ui/toolbar_icon_factory.cpp b/app/src/ui/toolbar_icon_factory.cpp index 12f7998..e7e8144 100644 --- a/app/src/ui/toolbar_icon_factory.cpp +++ b/app/src/ui/toolbar_icon_factory.cpp @@ -475,6 +475,15 @@ QPixmap renderIcon(UiIcon icon, int size) painter.drawLine(QPointF(14, 19), QPointF(21, 12)); break; } + case UiIcon::Layout: + { + painter.drawRect(QRectF(3, 5, 7, 7)); + painter.drawRect(QRectF(14, 5, 7, 7)); + painter.drawRect(QRectF(3, 15, 7, 5)); + painter.drawLine(QPointF(11, 8), QPointF(13, 8)); + painter.drawLine(QPointF(11, 17), QPointF(13, 17)); + break; + } case UiIcon::More: { painter.setPen(Qt::NoPen); diff --git a/app/src/ui/toolbar_icon_factory.h b/app/src/ui/toolbar_icon_factory.h index 2b6d119..9977ebb 100644 --- a/app/src/ui/toolbar_icon_factory.h +++ b/app/src/ui/toolbar_icon_factory.h @@ -54,6 +54,7 @@ enum class UiIcon Subtract, // SUB 指令 Compare, // 比较指令 Comment, // 注释 + Layout, // HMI 控件布局 SyntaxCheck, // 梯形图语法检查与规整 More, // 更多操作 HmiPage, // HMI 页面 diff --git a/app/tests/hmi_editor_service_tests.cpp b/app/tests/hmi_editor_service_tests.cpp index 342f1ec..c3baada 100644 --- a/app/tests/hmi_editor_service_tests.cpp +++ b/app/tests/hmi_editor_service_tests.cpp @@ -68,6 +68,130 @@ void testControlEditing() "deleted controls must not remain in the model"); } +void testBatchControlAlignment() +{ + TestProjectStorage storage; + ProjectService project_service(storage, defaultProjectLimitSettings()); + HmiEditorService service(project_service, HmiDefaultSettings{}); + const std::string page_id = service.ensureDefaultPage().id; + const HmiEditorResult first = service.addControl(page_id, HmiControlType::Label); + const HmiEditorResult second = service.addControl(page_id, HmiControlType::Label); + const HmiEditorResult third = service.addControl(page_id, HmiControlType::Label); + require(first.succeeded && second.succeeded && third.succeeded, + "controls for alignment testing must be created"); + + const std::vector ids{first.id, second.id, third.id}; + const std::vector original_bounds{ + {100, 80, 80, 30}, + {240, 120, 100, 40}, + {380, 160, 60, 20}}; + for (std::size_t index = 0U; index < ids.size(); ++index) + { + HmiControl control = *service.findControl(page_id, ids[index]); + control.bounds = original_bounds[index]; + require(service.updateControl(page_id, ids[index], control).succeeded, + "alignment fixtures must be positioned successfully"); + } + + service.clearHistory(); + require(service.alignControls(page_id, ids, HmiAlignment::Left).succeeded, + "left alignment must succeed"); + require(service.findControl(page_id, first.id)->bounds.x == 100 + && service.findControl(page_id, second.id)->bounds.x == 100 + && service.findControl(page_id, third.id)->bounds.x == 100, + "left alignment must use the selected group left edge"); + require(service.findControl(page_id, second.id)->bounds.y == 120, + "alignment must preserve the non-aligned coordinate"); + require(service.undo().succeeded + && service.findControl(page_id, second.id)->bounds + .x == original_bounds[1].x, + "alignment must be restored by one undo step"); + require(service.redo().succeeded + && service.findControl(page_id, third.id)->bounds.x == 100, + "alignment redo must restore the whole batch"); + + const auto resetBounds = [&service, &page_id, &ids, &original_bounds] + { + for (std::size_t index = 0U; index < ids.size(); ++index) + { + HmiControl control = *service.findControl(page_id, ids[index]); + control.bounds = original_bounds[index]; + require(service.updateControl(page_id, ids[index], control).succeeded, + "alignment fixtures must be reset successfully"); + } + }; + const auto assertBounds = [&service, &page_id, &ids, &resetBounds]( + HmiAlignment alignment, const std::vector &expected, + const char *message) + { + resetBounds(); + service.clearHistory(); + require(service.alignControls(page_id, ids, alignment).succeeded, message); + for (std::size_t index = 0U; index < ids.size(); ++index) + { + const HmiControl *control = service.findControl(page_id, ids[index]); + require(control != nullptr + && control->bounds.x == expected[index].x + && control->bounds.y == expected[index].y, + "alignment must calculate deterministic target coordinates"); + } + }; + + assertBounds( + HmiAlignment::HorizontalCenter, + {{230, 80, 0, 0}, {220, 120, 0, 0}, {240, 160, 0, 0}}, + "horizontal center alignment must succeed"); + assertBounds( + HmiAlignment::Right, + {{360, 80, 0, 0}, {340, 120, 0, 0}, {380, 160, 0, 0}}, + "right alignment must succeed"); + assertBounds( + HmiAlignment::Top, + {{100, 80, 0, 0}, {240, 80, 0, 0}, {380, 80, 0, 0}}, + "top alignment must succeed"); + assertBounds( + HmiAlignment::VerticalCenter, + {{100, 115, 0, 0}, {240, 110, 0, 0}, {380, 120, 0, 0}}, + "vertical center alignment must succeed"); + assertBounds( + HmiAlignment::Bottom, + {{100, 150, 0, 0}, {240, 140, 0, 0}, {380, 160, 0, 0}}, + "bottom alignment must succeed"); + + service.clearHistory(); + const std::vector before_invalid{ + service.findControl(page_id, first.id)->bounds, + service.findControl(page_id, second.id)->bounds, + service.findControl(page_id, third.id)->bounds}; + require(!service.alignControls( + page_id, {first.id, "missing-control"}, HmiAlignment::Left) + .succeeded, + "alignment must reject an unknown control before mutation"); + require(!service.canUndo() + && service.findControl(page_id, first.id)->bounds.x + == before_invalid[0].x + && service.findControl(page_id, second.id)->bounds.y + == before_invalid[1].y, + "failed alignment must be atomic and leave history unchanged"); + require(!service.alignControls(page_id, {first.id}, HmiAlignment::Left).succeeded, + "alignment must require at least two controls"); + + HmiControl no_op = *service.findControl(page_id, first.id); + no_op.bounds.x = 100; + HmiControl no_op_second = *service.findControl(page_id, second.id); + no_op_second.bounds.x = 100; + HmiControl no_op_third = *service.findControl(page_id, third.id); + no_op_third.bounds.x = 100; + require(service.updateControl(page_id, first.id, no_op).succeeded + && service.updateControl(page_id, second.id, no_op_second).succeeded + && service.updateControl(page_id, third.id, no_op_third).succeeded, + "alignment no-op fixtures must be positioned successfully"); + service.clearHistory(); + require(service.alignControls(page_id, ids, HmiAlignment::Left).succeeded + && !service.canUndo(), + "a no-op alignment must not create an undo step"); +} + void testRuntimeUsesRegisterRepository() { // 运行服务只能经由仓库接口读写 M/D,不依赖具体离线实现 @@ -588,6 +712,7 @@ int main() { // 编辑和运行场景分别验证服务层两条独立职责 testControlEditing(); + testBatchControlAlignment(); testHistoryAndAtomicBatchDelete(); testBatchPasteControls(); testRuntimeValidationAndPasteBoundaries();