diff --git a/crates/aether-win32/src/ai_panel.rs b/crates/aether-win32/src/ai_panel.rs index 3a9717a..9ddb9dd 100644 --- a/crates/aether-win32/src/ai_panel.rs +++ b/crates/aether-win32/src/ai_panel.rs @@ -174,6 +174,17 @@ pub struct AiStreamState { pub truncated: Option, } +/// 扩写动画阶段 +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ExpandAnimPhase { + /// 无动画 + None, + /// 原文渐隐中 + FadeOut, + /// 流式写入新文本中(新文本渐显) + Streaming, +} + /// 后台流式轮询的边沿结果 #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum DrainEdge { @@ -665,6 +676,16 @@ pub struct AiPanel { pub file_card_regions: Vec<(usize, usize, f32, f32, f32, f32)>, /// "浏览并选择文件夹"按钮命中区 (x, y, w, h) pub browse_folder_region: Option<(f32, f32, f32, f32)>, + /// 是否正在进行问题扩写(扩写结果写回输入框而非聊天区) + pub is_expanding: bool, + /// 扩写动画阶段 + pub expand_anim_phase: ExpandAnimPhase, + /// 扩写动画进度(0.0 ~ 1.0),用于渐隐/渐显透明度计算 + pub expand_anim_progress: f32, + /// 扩写前的原始文本(渐隐阶段显示用) + pub expand_original_text: String, + /// 扩写动画起始时间戳(毫秒),用于计算进度 + pub expand_anim_start_ms: u64, } /// 在后台线程发起一次流式 AI 请求,把事件写入共享 stream_state。 @@ -816,6 +837,11 @@ impl AiPanel { expanded_file_cards: std::collections::HashSet::new(), file_card_regions: Vec::new(), browse_folder_region: None, + is_expanding: false, + expand_anim_phase: ExpandAnimPhase::None, + expand_anim_progress: 0.0, + expand_original_text: String::new(), + expand_anim_start_ms: 0, }; panel.restore_latest_conversation(); panel @@ -1483,6 +1509,62 @@ impl AiPanel { ); } + /// 问题扩写:将当前输入框文本发送给 AI 进行扩写, + /// 扩写结果流式写回输入框(不作为聊天消息)。 + /// 动画流程:原文渐隐 → 流式写入新文本(渐显)。 + pub fn expand_input(&mut self, settings: &AiSettings) -> Result { + let user_input = self.input.trim().to_string(); + if user_input.is_empty() { + return Err("请先输入问题,再使用扩写功能".to_string()); + } + if self.is_generating { + return Err("正在等待上一次回复,请稍后再试".to_string()); + } + + self.is_generating = true; + self.is_expanding = true; + self.should_stop.store(false, Ordering::SeqCst); + if let Ok(mut s) = self.stream_state.lock() { + *s = AiStreamState::default(); + } + + // 保存原文用于渐隐动画,然后清空输入框 + self.expand_original_text = user_input.clone(); + self.expand_anim_phase = ExpandAnimPhase::FadeOut; + self.expand_anim_progress = 0.0; + self.expand_anim_start_ms = now_millis(); + self.input.clear(); + self.caret_pos = 0; + // 锁定输入框焦点,防止扩写期间用户编辑 + self.input_focused = false; + + let system = "你是一个问题扩写助手。用户会给你一个问题或请求,你需要将其扩写为更详细、更完整、更清晰的版本。\ + 扩写后的文本应该:\n\ + 1. 保持原始意图不变\n\ + 2. 补充必要的上下文和细节\n\ + 3. 使问题更加具体和明确\n\ + 4. 直接输出扩写后的文本,不要添加任何解释、标记或前缀" + .to_string(); + let messages = vec![ + ChatMessage { + role: "system".to_string(), + content: system, + }, + ChatMessage { + role: "user".to_string(), + content: user_input, + }, + ]; + spawn_ai_stream( + settings.clone(), + messages, + Arc::clone(&self.stream_state), + Arc::clone(&self.should_stop), + ); + + Ok("正在扩写问题...".to_string()) + } + /// 用给定内容替换最后一条助手消息(无则追加);用于把规划器原始清单块替换为可读的执行计划。 pub fn rewrite_last_assistant(&mut self, content: String) { if let Some(last) = self.messages.last_mut() { @@ -1829,6 +1911,66 @@ impl AiPanel { }; let mut edge = DrainEdge::Pending; if let Some((partial, reasoning, done, error, truncated)) = delta { + // ===== 扩写模式:流式结果写回输入框 ===== + if self.is_expanding { + // 更新动画进度 + let elapsed = now_millis().saturating_sub(self.expand_anim_start_ms); + match self.expand_anim_phase { + ExpandAnimPhase::FadeOut => { + // 渐隐阶段:300ms 内完成 + const FADE_OUT_MS: u64 = 300; + self.expand_anim_progress = (elapsed as f32 / FADE_OUT_MS as f32).min(1.0); + if self.expand_anim_progress >= 1.0 { + // 渐隐完成,进入流式写入阶段 + self.expand_anim_phase = ExpandAnimPhase::Streaming; + self.expand_anim_progress = 0.0; + self.expand_anim_start_ms = now_millis(); + } + } + ExpandAnimPhase::Streaming => { + // 流式写入阶段:新文本随 token 到达逐渐显示 + // 进度基于已接收文本长度(简单线性增长) + self.expand_anim_progress = 1.0; // 流式阶段直接显示 + } + ExpandAnimPhase::None => {} + } + + if !partial.is_empty() { + // 首个 token 到达时,确保已进入流式阶段 + if self.expand_anim_phase == ExpandAnimPhase::FadeOut { + self.expand_anim_phase = ExpandAnimPhase::Streaming; + self.expand_anim_progress = 1.0; + } + self.input.push_str(&partial); + self.caret_pos = self.input.len(); + } + if let Some(err) = error { + self.is_generating = false; + self.is_expanding = false; + self.expand_anim_phase = ExpandAnimPhase::None; + self.expand_anim_progress = 0.0; + self.expand_original_text.clear(); + if self.input.is_empty() { + self.input = err; + self.caret_pos = self.input.len(); + } + return DrainEdge::Interrupted; + } + if done { + self.is_generating = false; + self.is_expanding = false; + self.expand_anim_phase = ExpandAnimPhase::None; + self.expand_anim_progress = 0.0; + self.expand_original_text.clear(); + let trimmed = self.input.trim().to_string(); + self.input = trimmed; + self.caret_pos = self.input.len(); + edge = DrainEdge::Completed; + } + return edge; + } + + // ===== 正常对话模式 ===== // 深度思考(DeepSeek reasoning_content)先于回答到达:单独承载于助手消息的 reasoning if !reasoning.is_empty() { if !matches!(self.messages.last(), Some(m) if m.role == AiRole::Assistant) { diff --git a/crates/aether-win32/src/render/ai.rs b/crates/aether-win32/src/render/ai.rs index cba2fff..3ad8f59 100644 --- a/crates/aether-win32/src/render/ai.rs +++ b/crates/aether-win32/src/render/ai.rs @@ -1268,32 +1268,127 @@ impl EditorState { .composition .as_ref() .is_some_and(|c| !c.is_empty()); - let show_placeholder = self.ai_panel.input.is_empty() && !composing; - let input_text = if show_placeholder { - "输入问题..." - } else { - &self.ai_panel.input - }; - let input_color: &ID2D1SolidColorBrush = if show_placeholder { - &dim_brush + + // ===== 扩写动画渲染 ===== + let is_expand_anim = self.ai_panel.is_expanding + && self.ai_panel.expand_anim_phase != crate::ai_panel::ExpandAnimPhase::None; + + if is_expand_anim { + match self.ai_panel.expand_anim_phase { + crate::ai_panel::ExpandAnimPhase::FadeOut => { + // 渐隐阶段:显示原文,透明度从 1.0 渐变为 0.0 + let alpha = 1.0 - self.ai_panel.expand_anim_progress; + let fade_color = color_f(0.9, 0.9, 0.9, alpha); + if let Ok(fade_brush) = + self.render_ctx.brush_cache.get_brush(target, &fade_color) + { + let orig_text = &self.ai_panel.expand_original_text; + let orig_wide: Vec = + orig_text.encode_utf16().chain(Some(0)).collect(); + let fade_rect = D2D_RECT_F { + left: text_input_rect.left + 4.0, + top: text_input_y + 8.0, + right: text_input_rect.right - 4.0, + bottom: text_input_y + text_input_h - 4.0, + }; + target.DrawText( + &orig_wide, + &msg_format, + &fade_rect, + &fade_brush, + D2D1_DRAW_TEXT_OPTIONS_NONE, + DWRITE_MEASURING_MODE_NATURAL, + ); + } + } + crate::ai_panel::ExpandAnimPhase::Streaming => { + // 流式写入阶段:显示已接收的新文本,带轻微渐显效果 + let new_text = &self.ai_panel.input; + if !new_text.is_empty() { + // 新文本使用带轻微透明度的白色,营造"写入中"感 + let stream_color = color_f(0.9, 0.9, 0.9, 0.92); + if let Ok(stream_brush) = + self.render_ctx.brush_cache.get_brush(target, &stream_color) + { + let new_wide: Vec = + new_text.encode_utf16().chain(Some(0)).collect(); + let stream_rect = D2D_RECT_F { + left: text_input_rect.left + 4.0, + top: text_input_y + 8.0, + right: text_input_rect.right - 4.0, + bottom: text_input_y + text_input_h - 4.0, + }; + target.DrawText( + &new_wide, + &msg_format, + &stream_rect, + &stream_brush, + D2D1_DRAW_TEXT_OPTIONS_NONE, + DWRITE_MEASURING_MODE_NATURAL, + ); + } + // 流式写入中显示一个闪烁的写入指示器(竖线光标) + let tw = self + .render_ctx + .text_format_cache + .measure_text_width( + new_text, + 11.0, + DWRITE_FONT_WEIGHT_NORMAL.0 as u32, + ) + .unwrap_or(0.0); + let indicator_x = text_input_rect.left + 4.0 + tw; + // 闪烁效果:基于时间戳 + let blink = (crate::ai_panel::now_millis() / 400) % 2 == 0; + if blink { + let indicator_color = color_f(0.0, 0.47, 0.83, 0.8); + if let Ok(ind_brush) = self + .render_ctx + .brush_cache + .get_brush(target, &indicator_color) + { + let ind_rect = D2D_RECT_F { + left: indicator_x, + top: text_input_y + 10.0, + right: indicator_x + 2.0, + bottom: text_input_y + text_input_h - 10.0, + }; + target.FillRectangle(&ind_rect, &ind_brush); + } + } + } + } + crate::ai_panel::ExpandAnimPhase::None => {} + } } else { - text_brush - }; - let input_wide: Vec = input_text.encode_utf16().chain(Some(0)).collect(); - let input_text_rect = D2D_RECT_F { - left: text_input_rect.left + 4.0, - top: text_input_y + 8.0, - right: text_input_rect.right - 4.0, - bottom: text_input_y + text_input_h - 4.0, - }; - target.DrawText( - &input_wide, - &msg_format, - &input_text_rect, - input_color, - D2D1_DRAW_TEXT_OPTIONS_NONE, - DWRITE_MEASURING_MODE_NATURAL, - ); + // ===== 正常输入框渲染 ===== + let show_placeholder = self.ai_panel.input.is_empty() && !composing; + let input_text = if show_placeholder { + "输入问题..." + } else { + &self.ai_panel.input + }; + let input_color: &ID2D1SolidColorBrush = if show_placeholder { + &dim_brush + } else { + text_brush + }; + let input_wide: Vec = input_text.encode_utf16().chain(Some(0)).collect(); + let input_text_rect = D2D_RECT_F { + left: text_input_rect.left + 4.0, + top: text_input_y + 8.0, + right: text_input_rect.right - 4.0, + bottom: text_input_y + text_input_h - 4.0, + }; + target.DrawText( + &input_wide, + &msg_format, + &input_text_rect, + input_color, + D2D1_DRAW_TEXT_OPTIONS_NONE, + DWRITE_MEASURING_MODE_NATURAL, + ); + } // IME 合成串(pre-edit text)显示在光标位置之后 if let Some(comp) = &self.ai_panel.composition { @@ -1607,30 +1702,9 @@ impl EditorState { &white_brush, ); - // 麦克风按钮 - let mic_btn_size = 24.0f32; - let mic_btn_x = send_btn_x - mic_btn_size - 4.0; - let mic_btn_rect = D2D_RECT_F { - left: mic_btn_x, - top: send_btn_y, - right: mic_btn_x + mic_btn_size, - bottom: send_btn_y + send_btn_size, - }; - fill_round_rect(target, &mic_btn_rect, 4.0, &btn_bg_brush); - // 使用 SVG 图标绘制麦克风 - self.icons.draw( - target, - crate::icons::IconKind::Mic, - mic_btn_x + 2.0, - send_btn_y + 2.0, - mic_btn_size - 4.0, - send_btn_size - 4.0, - &dim_brush, - ); - - // 快捷按钮(星星) + // 快捷按钮(星星)——问题扩写 let star_btn_size = 24.0f32; - let star_btn_x = mic_btn_x - star_btn_size - 4.0; + let star_btn_x = send_btn_x - star_btn_size - 4.0; let star_btn_rect = D2D_RECT_F { left: star_btn_x, top: send_btn_y, @@ -1648,27 +1722,6 @@ impl EditorState { send_btn_size - 4.0, &dim_brush, ); - - // 菜单按钮(列表图标) - let menu_btn_size = 24.0f32; - let menu_btn_x = star_btn_x - menu_btn_size - 4.0; - let menu_btn_rect = D2D_RECT_F { - left: menu_btn_x, - top: send_btn_y, - right: menu_btn_x + menu_btn_size, - bottom: send_btn_y + send_btn_size, - }; - fill_round_rect(target, &menu_btn_rect, 4.0, &btn_bg_brush); - // 使用 SVG 图标绘制列表/菜单 - self.icons.draw( - target, - crate::icons::IconKind::List, - menu_btn_x + 2.0, - send_btn_y + 2.0, - menu_btn_size - 4.0, - menu_btn_size - 4.0, - &dim_brush, - ); } } } diff --git a/crates/aether-win32/src/render/menus.rs b/crates/aether-win32/src/render/menus.rs index aefa067..2d4218a 100644 --- a/crates/aether-win32/src/render/menus.rs +++ b/crates/aether-win32/src/render/menus.rs @@ -924,7 +924,13 @@ impl EditorState { target.DrawRoundedRectangle(&bg_rounded, &border_brush, 1.0, None); } + // 裁剪菜单项绘制区域到面板背景内,防止悬停高亮在圆角处溢出 + target.PushAxisAlignedClip(&bg_rect, D2D1_ANTIALIAS_MODE_ALIASED); + let mut item_y = y + 8.0; + // 预计算第一个和最后一个非分隔线项的索引,用于圆角匹配 + let first_item_idx = menu_item.items.iter().position(|i| i.label != "-"); + let last_item_idx = menu_item.items.iter().rposition(|i| i.label != "-"); for (item_idx, item) in menu_item.items.iter().enumerate() { if item.label == "-" { let sep_rect = D2D_RECT_F { @@ -939,6 +945,19 @@ impl EditorState { // 悬停项:圆角高亮背景,提供明确的选中反馈 let is_hover = self.menu_bar.submenu_hover == Some(item_idx); if is_hover && item.enabled { + // 首项顶部圆角与面板一致(6.0),末项底部圆角与面板一致, + // 避免高亮矩形在面板圆角处产生切割感 + let is_first = Some(item_idx) == first_item_idx; + let is_last = Some(item_idx) == last_item_idx; + let (top_radius, bottom_radius): (f32, f32) = match (is_first, is_last) { + (true, true) => (6.0, 6.0), + (true, false) => (6.0, 4.0), + (false, true) => (4.0, 6.0), + (false, false) => (4.0, 4.0), + }; + // D2D1_ROUNDED_RECT 只支持统一圆角, + // 对首/末项使用较大圆角近似匹配面板圆角 + let radius = top_radius.max(bottom_radius); let item_rounded = windows::Win32::Graphics::Direct2D::D2D1_ROUNDED_RECT { rect: D2D_RECT_F { left: x + 3.0, @@ -946,8 +965,8 @@ impl EditorState { right: x + menu_width - 3.0, bottom: item_y + 26.0, }, - radiusX: 4.0, - radiusY: 4.0, + radiusX: radius, + radiusY: radius, }; target.FillRoundedRectangle(&item_rounded, &hover_bg_brush); } @@ -1002,6 +1021,9 @@ impl EditorState { item_y += 26.0; } } + + // 弹出裁剪区域 + target.PopAxisAlignedClip(); } } diff --git a/crates/aether-win32/src/window/mouse_handler/l_button_down/content_area.rs b/crates/aether-win32/src/window/mouse_handler/l_button_down/content_area.rs index 107fe5c..c3f28c0 100644 --- a/crates/aether-win32/src/window/mouse_handler/l_button_down/content_area.rs +++ b/crates/aether-win32/src/window/mouse_handler/l_button_down/content_area.rs @@ -779,6 +779,11 @@ unsafe fn lbd_right_panel_apply_input( && rp_rel_x < right_panel_region.width - margin - input_margin { let mut st = state.borrow_mut(); + // 扩写期间锁定输入框,不允许聚焦编辑 + if st.ai_panel.is_expanding { + drop(st); + return Some(LRESULT(0)); + } st.ai_panel.input_focused = true; st.ai_panel.caret_visible = true; // 点击输入框时将光标移到末尾 @@ -831,6 +836,30 @@ unsafe fn lbd_right_panel_apply_input( return Some(LRESULT(0)); } + // 星星按钮(问题扩写)——发送按钮左侧 + let star_btn_size = 24.0f32; + let star_btn_x = send_btn_x - star_btn_size - 4.0; + if rp_rel_x >= star_btn_x + && rp_rel_x < star_btn_x + star_btn_size + && rp_rel_y >= send_btn_y + && rp_rel_y < send_btn_y + star_btn_size + { + let mut st = state.borrow_mut(); + let settings = st.app_settings.active_ai_settings(); + match st.ai_panel.expand_input(&settings) { + Ok(msg) => { + st.status_message = msg; + let _ = SetTimer(hwnd, AI_TIMER_ID, AI_REFRESH_MS, None); + } + Err(e) => { + st.status_message = e; + } + } + drop(st); + invalidate_window(hwnd); + return Some(LRESULT(0)); + } + // 停止生成按钮(当正在生成时显示) let is_gen = state.borrow().ai_panel.is_generating; if is_gen { diff --git a/tests/ai/AI_TESTING.md b/tests/ai/AI_TESTING.md index 568d4cd..61d8cf4 100644 --- a/tests/ai/AI_TESTING.md +++ b/tests/ai/AI_TESTING.md @@ -9,7 +9,7 @@ tests/ ├── framework/ │ ├── AetherTest.psm1 # 核心层:生命周期/窗口/输入/截图/日志/hit regions/断言/报告 -│ └── AetherAi.psm1 # AI 协作层:诊断包/动作脚本/像素断言/UI 状态/日志断言 +│ └── AetherAi.psm1 # AI 协作层:诊断包/动作脚本/像素断言/UI 状态/日志断言/智能操作 ├── cases/ # GUI 用例(*.tests.ps1,可独立运行) │ └── _template.tests.ps1 # AI 生成用例的起点模板 ├── run_tests.ps1 # 统一入口:unit / gui / coverage / all @@ -56,18 +56,75 @@ exit (Complete-TestCase) ## 4. 输入方式选择 +### 4.1 鼠标操作 + | 方式 | 函数 | 适用 | -|---|---|---|---| +|---|---|---| | PostMessage 点击 | `Send-AetherClickMsg -Hwnd -X -Y [-Right]` | **首选**:不受前台锁定/窗口遮挡影响 | -| PostMessage 文本 | `Send-AetherTextMsg -Hwnd -Text` | **首选**:逐字符注入 WM_CHAR,不依赖焦点 | -| PostMessage 按键 | `Send-AetherKeyMsg -Hwnd -Key` | **首选**:{ENTER}/{ESC}/{F2}... 经 TranslateMessage 与真实键盘同路径 | -| 真实鼠标 | `Invoke-AetherClick -Window -X -Y [-Right]` | 需要触发系统级行为(拖拽、双击、hover 移入移出) | -| SendKeys(真实键盘) | `Send-AetherKeys` / `Send-AetherText` | 仅当应用需要系统级焦点行为时(慎用:依赖前台窗口) | +| PostMessage 双击 | `Send-AetherDoubleClickMsg -Hwnd -X -Y [-Right]` | 双击选词、双击打开 | +| PostMessage 中键 | `Send-AetherMiddleClickMsg -Hwnd -X -Y` | 中键关闭标签 | +| PostMessage 滚轮 | `Send-AetherMouseWheel -Hwnd -X -Y -Delta [-Horizontal] [-Shift] [-Ctrl]` | 滚动/缩放/横向滚动 | +| PostMessage 移动 | `Send-AetherMouseMoveMsg -Hwnd -X -Y` | 触发 hover、拖拽过程 | +| PostMessage 拖拽 | `Send-AetherDrag -Hwnd -FromX -FromY -ToX -ToY [-Steps] [-Right]` | 文件拖拽、标签重排、面板调整 | +| 真实鼠标 | `Invoke-AetherClick -Window -X -Y [-Right]` | 需要系统级行为(拖拽到窗口外) | + +### 4.2 键盘操作 + +| 方式 | 函数 | 适用 | +|---|---|---| +| PostMessage 文本 | `Send-AetherTextMsg -Hwnd -Text` | **首选**:逐字符注入 WM_CHAR | +| PostMessage 按键 | `Send-AetherKeyMsg -Hwnd -Key` | 单键:{ENTER}/{ESC}/{F2}/{UP}... | +| PostMessage 组合键 | `Send-AetherHotkey -Hwnd -Modifiers -Key` | **首选**:Ctrl+S/Ctrl+Shift+P 等 | +| SendKeys(真实键盘) | `Send-AetherKeys` / `Send-AetherText` | 仅当需要系统级焦点行为时(慎用) | + +**支持的按键名称**(`Send-AetherKeyMsg` / `Send-AetherHotkey`): +- 控制键:`{ENTER}` `{ESC}` `{BACKSPACE}` `{TAB}` `{DELETE}` `{INSERT}` `{SPACE}` +- 导航键:`{UP}` `{DOWN}` `{LEFT}` `{RIGHT}` `{HOME}` `{END}` `{PAGEUP}` `{PAGEDOWN}` +- 功能键:`{F1}` ~ `{F12}` +- 字母键:`A` ~ `Z`(直接写字母) +- 数字键:`0` ~ `9`(直接写数字) +- 符号键:`,` `.` `/` `` ` `` `+` `-` + +**组合键修饰符**(`Send-AetherHotkey -Modifiers`): +- `@('Ctrl')` — Ctrl +- `@('Shift')` — Shift +- `@('Alt')` — Alt +- `@('Ctrl','Shift')` — Ctrl+Shift +- `@('Ctrl','Alt')` — Ctrl+Alt +- `@('Ctrl','Shift','Alt')` — Ctrl+Shift+Alt + +**常用快捷键示例**: +```powershell +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key 'S' # 保存 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl','Shift') -Key 'P' # 命令面板 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key 'B' # 切换侧栏 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key '`' # 切换终端 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key ',' # 设置 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key 'F' # 查找 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key 'Z' # 撤销 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl','Shift') -Key 'Z' # 重做 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key 'A' # 全选 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key 'C' # 复制 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key 'V' # 粘贴 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key 'W' # 关闭标签 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key 'Tab' # 下一标签 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl','Shift') -Key 'Tab' # 上一标签 +Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key '1' # 跳转标签 1 +``` + +### 4.3 窗口操作 + +| 函数 | 说明 | +|---|---| +| `Resize-AetherWindow -Window -Width -Height` | 调整窗口大小(触发 WM_SIZE) | +| `Move-AetherWindow -Window -X -Y` | 移动窗口位置 | +| `Set-AetherWindowState -Window -State` | Normal/Minimized/Maximized/Restored | +| `Close-AetherWindow -Window` | 发送 WM_CLOSE(触发正常关闭流程) | **重要**:`SendKeys` 系列依赖前台窗口焦点——用户窗口在前台时输入会丢失。 全局键盘钩子能转发部分控制键(F2/ENTER/ESC),但普通文本字符不会, 因此自动化测试一律使用 PostMessage 注入(`Send-AetherClickMsg` + -`Send-AetherTextMsg` + `Send-AetherKeyMsg`)。 +`Send-AetherTextMsg` + `Send-AetherKeyMsg` + `Send-AetherHotkey`)。 注意:`Send-AetherClickMsg` 的坐标是**窗口内客户区物理坐标**;若用户正开着另一个 Aether 窗口且位置重叠,请先用 `-Isolate` 移动测试窗口。 @@ -85,6 +142,7 @@ Aether 窗口且位置重叠,请先用 `-Isolate` 移动测试窗口。 - **截图**:`Save-AetherScreenshot -Window -Name`;像素断言 `Test-AetherPixelRegion`。 - **UI 状态**:`Get-AetherUiState -Process` 返回日志尾部 + hit regions + CPU/内存, 供探索式测试的"观察-决策"循环。 +- **编辑器状态**:`Get-AetherEditorState -Process` 返回标签数、状态栏项、最近日志。 - **报告**:`tests/reports/.json`,字段:Steps(含 duration_ms/error)、 Screenshots、Env(OS/DPI/构建环境)。 @@ -94,20 +152,61 @@ Aether 窗口且位置重叠,请先用 `-Isolate` 移动测试窗口。 ```powershell $actions = @( - @{type='click'; x=$rowLabelX; y=(RowCenterY 0)}, - @{type='keys'; text='hello.rs'}, - @{type='key'; key='{ENTER}'}, - @{type='shot'; name='created'}, - @{type='expect'; pattern='DIAG|error'}, - @{type='wait'; ms=800} + @{type='click'; x=$rowLabelX; y=(RowCenterY 0)}, + @{type='keys'; text='hello.rs'}, + @{type='key'; key='{ENTER}'}, + @{type='hotkey'; modifiers=@('Ctrl'); key='S'}, + @{type='wheel'; x=500; y=300; delta=-120}, + @{type='drag'; from_x=100; from_y=200; to_x=300; to_y=400}, + @{type='shot'; name='created'}, + @{type='expect'; pattern='DIAG|error'}, + @{type='wait'; ms=800} ) $results = Invoke-AetherActionScript -Window $win -Actions $actions # 每步返回 @{ type; ok; note; duration_ms; screenshot },失败可打包诊断 ``` -类型:`click` / `rclick` / `hover` / `keys`(文本注入)/ `key`(按键注入)/ `wait` / `shot` / `expect`(新增日志)。 +### 完整动作类型参考 -## 7. 失败诊断流程 +| 类型 | 参数 | 说明 | +|---|---|---| +| `click` | `x; y; [right]` | PostMessage 点击 | +| `dblclick` | `x; y; [right]` | PostMessage 双击 | +| `mclick` | `x; y` | PostMessage 中键点击 | +| `hover` | `x; y` | 真实鼠标移动(触发 hover) | +| `move` | `x; y` | PostMessage 鼠标移动 | +| `wheel` | `x; y; delta; [horizontal]; [shift]; [ctrl]` | 滚轮(delta=±120 倍数) | +| `drag` | `from_x; from_y; to_x; to_y; [steps]; [right]` | 拖拽 | +| `keys` | `text` | PostMessage 文本注入 | +| `key` | `key` | PostMessage 按键({ENTER} 等) | +| `hotkey` | `modifiers; key` | 组合键(modifiers=@('Ctrl','Shift')) | +| `wait` | `ms` | 等待 | +| `shot` | `name` | 截图 | +| `expect` | `pattern; [timeout_ms]` | 等待日志模式 | +| `resize` | `width; height` | 调整窗口大小 | +| `movewin` | `x; y` | 移动窗口 | +| `winstate` | `state` | 窗口状态(Normal/Minimized/Maximized/Restored) | +| `closewin` | — | 发送 WM_CLOSE | + +## 7. 智能操作(AI 语义化交互) + +框架提供基于 hit regions 的智能操作,AI 可以用语义名称而非坐标来交互: + +```powershell +# 智能查找并点击(根据动作名称自动定位) +Invoke-AetherSmartClick -Window $win -ActionLike "new_file" +Invoke-AetherSmartClick -Window $win -ActionLike "tab:*" -Right # 右键标签 + +# 等待 UI 元素出现 +$region = Wait-AetherHitRegion -ActionLike "status:Rust" -TimeoutMs 8000 +if ($region) { Write-Host "文件已打开" } + +# 获取编辑器状态摘要 +$state = Get-AetherEditorState -Process $proc +Write-Host "标签数: $($state.TabCount), 状态栏: $($state.StatusBarItems -join ', ')" +``` + +## 8. 失败诊断流程 1. 用例失败后(`Invoke-TestStep` 已捕获异常,报告记录 error), 2. 执行 `New-AetherDiagBundle -CaseName `, @@ -115,7 +214,7 @@ $results = Invoke-AetherActionScript -Window $win -Actions $actions report.json、screenshots/、app.log(尾部 200 行)、hit_regions.jsonl、process.txt, 4. AI 据此分析:日志找时序/异常栈,截图看视觉状态,hit regions 验证命中区域。 -## 8. 性能测试(回归基线) +## 9. 性能测试(回归基线) - 步骤耗时自动记录在报告中(`duration_ms`),多次运行可对比趋势。 - 应用日志埋点约定:耗时数据用 `tracing::info!(ms=..., "DIAG <阶段>")` 输出, @@ -123,7 +222,7 @@ $results = Invoke-AetherActionScript -Window $win -Actions $actions - 示例:验证"点击文件树切换标签零开销"——点击后断言 `switch_tab` 路径无重新解析 (日志无新高亮请求),或直接测 `Get-AetherLog -Pattern "DIAG load_file"` 的 ms 值。 -## 9. 常见陷阱(历次测试沉淀) +## 10. 常见陷阱(历次测试沉淀) 1. **Start-Process 剥离 JSON 引号**:`-Folder` 参数必须经 `Start-AetherApp` 的 .NET ProcessStartInfo.ArgumentList 传递,否则工作区打不开(回退 last_workspace)。 @@ -135,22 +234,71 @@ $results = Invoke-AetherActionScript -Window $win -Actions $actions 用 `Get-AetherWindow -Isolate` 移到 (40,40)。 5. **前台锁定与键盘焦点**:Windows 禁止后台进程抢占前台,真实鼠标点击可能落入其他窗口, SendKeys 文本会丢失。**一律用 PostMessage 注入**(`Send-AetherClickMsg` / - `Send-AetherTextMsg` / `Send-AetherKeyMsg`),不依赖前台焦点。 + `Send-AetherTextMsg` / `Send-AetherKeyMsg` / `Send-AetherHotkey`),不依赖前台焦点。 6. **冰冻态**:窗口最小化或失焦 10 分钟进入 Frozen(关停 LSP、裁剪缓存)。 长用例注意防冻(周期发输入);测冰冻恢复用例时以最小化触发。 + 可用 `Set-AetherWindowState -State Minimized` 主动触发冰冻态测试。 7. **日志文件名无 .log 扩展名**:`Get-AetherLog` 已处理,勿手写 `*.log` 过滤。 8. **status_message 不写日志**:"已打开: xxx" 等状态消息只在状态栏显示, 验证文件打开用 hit regions 状态栏语言(`status:Rust`)。 9. **DPI 变化**:测试窗口 MoveWindow 到不同 DPI 显示器会收到 WM_DPICHANGED, 重新 `Get-AetherWindow` 获取最新 Scale2。 10. **debug 构建才有 DIAG/hit regions**:性能与命中验证需 debug 构建; - release 构建零开销(hit_test 空实现)。 + release 构建零开销(hit_test 空实现)。 +11. **滚轮坐标是屏幕坐标**:`Send-AetherMouseWheel` 内部自动将窗口内坐标转为屏幕坐标, + 直接传窗口内物理坐标即可。 +12. **组合键顺序**:`Send-AetherHotkey` 先按修饰键再按目标键,释放时逆序, + 与真实键盘行为一致。应用通过 `GetKeyState` 检测修饰键状态。 +13. **WM_DROPFILES 无法 PostMessage**:文件拖放需要构造 HDROP 句柄, + `Send-AetherDropFiles` 当前抛出异常提示替代方案(Ctrl+O / Start-AetherApp -Folder)。 -## 10. 运行入口 +## 11. 运行入口 ```powershell pwsh -File tests\run_tests.ps1 -Suite gui # 全部 GUI 用例 pwsh -File tests\run_tests.ps1 -Suite gui -Case explorer_inline_input pwsh -File tests\run_tests.ps1 -Suite unit # cargo test --workspace +pwsh -File tests\run_tests.ps1 -Suite ai # 框架自检 pwsh -File tests\run_tests.ps1 -Suite all ``` + +## 12. 快捷键速查表(应用支持的全部快捷键) + +| 快捷键 | 功能 | +|---|---| +| Ctrl+O | 打开文件 | +| Ctrl+K | 打开文件夹 | +| Ctrl+S | 保存 | +| Ctrl+Shift+S | 另存为 | +| Ctrl+N | 新建项目 | +| Ctrl+Space | LSP 补全 | +| Ctrl+B | 切换侧栏 | +| Ctrl+P | 命令面板 | +| Ctrl+Shift+P | 命令面板(> 前缀) | +| Ctrl+` | 切换终端 | +| Ctrl+J | 切换底部面板 | +| Ctrl+, | 设置 | +| Ctrl+Shift+E | 资源管理器视图 | +| Ctrl+Shift+G | 源代码管理视图 | +| Ctrl+Shift+V | Markdown 预览 | +| Ctrl+= / Ctrl+- / Ctrl+0 | 字体缩放/重置 | +| Ctrl+G | 命令面板(: 前缀) | +| Ctrl+C / Ctrl+X / Ctrl+V / Ctrl+A | 复制/剪切/粘贴/全选 | +| Ctrl+Shift+A | 切换 AI 面板 | +| Ctrl+F / Ctrl+H | 查找/替换 | +| Ctrl+Z / Ctrl+Y | 撤销/重做 | +| Ctrl+Shift+Z | 重做 | +| Ctrl+Tab / Ctrl+Shift+Tab | 下一/上一标签 | +| Ctrl+W / Ctrl+F4 | 关闭标签 | +| Ctrl+Shift+T | 恢复关闭的标签 | +| Ctrl+1 ~ Ctrl+9 | 跳转标签 1-9 | +| Ctrl+Left / Ctrl+Right | 词级移动 | +| Ctrl+Shift+Left/Right | 词级选择 | +| Ctrl+Home / Ctrl+End | 文件首/末 | +| Ctrl+D | 添加下一个相同词光标 | +| Ctrl+/ | 行注释 | +| Ctrl+Shift+I | 内联补全 | +| Ctrl+Alt+Up / Ctrl+Alt+Down | 列光标 | +| Ctrl+L | 终端清屏(终端聚焦时) | +| F2 | 重命名 | +| F3 | 查找下一个 | diff --git a/tests/ai/selfcheck.ps1 b/tests/ai/selfcheck.ps1 index bd28901..fcc2b41 100644 --- a/tests/ai/selfcheck.ps1 +++ b/tests/ai/selfcheck.ps1 @@ -20,6 +20,22 @@ Step "导入框架模块" { Import-Module "$root\tests\framework\AetherAi.psm1" -Force if (-not (Get-Command Get-AetherLayoutConstants -ErrorAction SilentlyContinue)) { throw "核心模块函数缺失" } if (-not (Get-Command Invoke-AetherActionScript -ErrorAction SilentlyContinue)) { throw "AI 模块函数缺失" } + # 验证新增函数导出 + $coreFns = @( + 'Send-AetherHotkey', 'Send-AetherDoubleClickMsg', 'Send-AetherMiddleClickMsg', + 'Send-AetherMouseWheel', 'Send-AetherMouseMoveMsg', 'Send-AetherDrag', + 'Resize-AetherWindow', 'Move-AetherWindow', 'Set-AetherWindowState', 'Close-AetherWindow' + ) + foreach ($fn in $coreFns) { + if (-not (Get-Command $fn -ErrorAction SilentlyContinue)) { throw "核心模块缺少函数: $fn" } + } + $aiFns = @( + 'Find-AetherHitRegion', 'Invoke-AetherSmartClick', 'Wait-AetherHitRegion', 'Get-AetherEditorState' + ) + foreach ($fn in $aiFns) { + if (-not (Get-Command $fn -ErrorAction SilentlyContinue)) { throw "AI 模块缺少函数: $fn" } + } + Write-Host " 核心层 $($coreFns.Count) 个新函数 + AI 层 $($aiFns.Count) 个新函数导出正常" } Step "布局常量" { @@ -118,6 +134,66 @@ try { if ($st.WorkingSetMB -le 0) { throw "进程状态异常" } Write-Host " 工作集 $($st.WorkingSetMB)MB,hit regions $($st.HitRegionCount) 个,日志尾部 $($st.LogTail.Count) 行" } + + Step "组合键注入(Ctrl+B 切换侧栏)" { + $win = $script:win + Send-AetherHotkey -Hwnd $win.Hwnd -Modifiers @('Ctrl') -Key 'B' + Start-Sleep -Milliseconds 500 + Save-AetherScreenshot -Window $win -Name "selfcheck_ctrl_b" | Out-Null + # 再按一次恢复 + Send-AetherHotkey -Hwnd $win.Hwnd -Modifiers @('Ctrl') -Key 'B' + Write-Host " Ctrl+B 组合键注入成功" + } + + Step "鼠标滚轮注入" { + $win = $script:win + $k = $win.Scale2 + $L = Get-AetherLayoutConstants + $editorX = [int](($L.ACTIVITY_W + $L.SIDEBAR_W + 100) * $k) + $editorY = [int](($L.TITLE_BAR + $L.TAB_BAR_H + 100) * $k) + Send-AetherMouseWheel -Hwnd $win.Hwnd -X $editorX -Y $editorY -Delta (-120) + Write-Host " 滚轮注入成功" + } + + Step "鼠标移动注入(hover)" { + $win = $script:win + $k = $win.Scale2 + $L = Get-AetherLayoutConstants + $x = [int](($L.ACTIVITY_W + 10 + 12 + 30) * $k) + $y = [int](($L.TITLE_BAR + $L.HEADER_H + 6 + $L.ROW_H * 1 + $L.ROW_H / 2) * $k) + Send-AetherMouseMoveMsg -Hwnd $win.Hwnd -X $x -Y $y + Write-Host " 鼠标移动注入成功" + } + + Step "智能操作(hit region 查找)" { + $region = Find-AetherHitRegion -ActionLike "status:*" + if (-not $region) { throw "未找到状态栏 hit region" } + Write-Host " 找到状态栏区域: $($region.action) @ ($($region.x),$($region.y))" + } + + Step "编辑器状态摘要" { + $state = Get-AetherEditorState -Process $script:proc + Write-Host " 标签数: $($state.TabCount),状态栏项: $($state.StatusBarItems.Count)" + } + + Step "扩展动作脚本 DSL(hotkey/wheel/move)" { + $win = $script:win + $k = $win.Scale2 + $L = Get-AetherLayoutConstants + $editorX = [int](($L.ACTIVITY_W + $L.SIDEBAR_W + 100) * $k) + $editorY = [int](($L.TITLE_BAR + $L.TAB_BAR_H + 100) * $k) + $actions = @( + @{type='hotkey'; modifiers=@('Ctrl'); key='B'}, + @{type='wait'; ms=300}, + @{type='hotkey'; modifiers=@('Ctrl'); key='B'}, + @{type='wait'; ms=300}, + @{type='wheel'; x=$editorX; y=$editorY; delta=120}, + @{type='move'; x=$editorX; y=$editorY} + ) + $results = @(Invoke-AetherActionScript -Window $win -Actions $actions) + foreach ($r in $results) { if (-not $r.ok) { throw "动作失败: $($r.type) $($r.note)" } } + Write-Host " $($results.Count) 个扩展动作全部成功" + } } finally { if ($script:proc) { Stop-AetherApp $script:proc } if ($script:ws) { Remove-AetherTestWorkspace -Path $script:ws } diff --git a/tests/cases/_template.tests.ps1 b/tests/cases/_template.tests.ps1 index 92ae2b0..75d6c84 100644 --- a/tests/cases/_template.tests.ps1 +++ b/tests/cases/_template.tests.ps1 @@ -9,6 +9,16 @@ # AI-Prompt : 生成该用例的原始指令(可选) # Layout : 依赖的布局常量(坐标计算基准) # ============================================================================= +# +# 【框架能力速查】 +# 鼠标:Send-AetherClickMsg / Send-AetherDoubleClickMsg / Send-AetherMiddleClickMsg +# Send-AetherMouseWheel / Send-AetherMouseMoveMsg / Send-AetherDrag +# 键盘:Send-AetherTextMsg / Send-AetherKeyMsg / Send-AetherHotkey +# 窗口:Resize-AetherWindow / Move-AetherWindow / Set-AetherWindowState / Close-AetherWindow +# 智能:Invoke-AetherSmartClick / Wait-AetherHitRegion / Get-AetherEditorState +# DSL :Invoke-AetherActionScript(click/dblclick/mclick/hover/move/wheel/drag/ +# keys/key/hotkey/wait/shot/expect/resize/movewin/winstate/closewin) +# ============================================================================= param([switch]$SkipBuild) @@ -60,6 +70,62 @@ try { Save-AetherScreenshot -Window $win -Name "switched" | Out-Null } + # ---- 快捷键示例(取消注释以使用) ---- + # Invoke-TestStep "Ctrl+S 保存文件" { + # Send-AetherHotkey -Hwnd $win.Hwnd -Modifiers @('Ctrl') -Key 'S' + # Assert-AetherLogEvent -Pattern "已保存|save" + # } + + # Invoke-TestStep "Ctrl+B 切换侧栏" { + # Send-AetherHotkey -Hwnd $win.Hwnd -Modifiers @('Ctrl') -Key 'B' + # Start-Sleep -Milliseconds 300 + # Save-AetherScreenshot -Window $win -Name "sidebar_toggled" | Out-Null + # } + + # ---- 滚轮示例 ---- + # Invoke-TestStep "编辑器滚轮滚动" { + # $editorX = NX ($L.ACTIVITY_W + $L.SIDEBAR_W + 100) + # $editorY = NX ($L.TITLE_BAR + $L.TAB_BAR_H + 100) + # Send-AetherMouseWheel -Hwnd $win.Hwnd -X $editorX -Y $editorY -Delta (-120 * 3) # 向下滚 3 格 + # Save-AetherScreenshot -Window $win -Name "scrolled" | Out-Null + # } + + # ---- 拖拽示例 ---- + # Invoke-TestStep "拖拽标签重排" { + # Send-AetherDrag -Hwnd $win.Hwnd -FromX (NX 300) -FromY (NX ($L.TITLE_BAR + 15)) ` + # -ToX (NX 500) -ToY (NX ($L.TITLE_BAR + 15)) -Steps 15 + # Save-AetherScreenshot -Window $win -Name "tab_dragged" | Out-Null + # } + + # ---- 智能操作示例(基于 hit regions 语义定位) ---- + # Invoke-TestStep "智能点击新建文件按钮" { + # Invoke-AetherSmartClick -Window $win -ActionLike "new_file" + # Save-AetherScreenshot -Window $win -Name "smart_clicked" | Out-Null + # } + + # ---- 动作脚本 DSL 示例 ---- + # Invoke-TestStep "动作脚本:打开文件并保存" { + # $actions = @( + # @{type='click'; x=$rowLabelX; y=(RowCenterY 0)}, + # @{type='wait'; ms=500}, + # @{type='hotkey'; modifiers=@('Ctrl'); key='S'}, + # @{type='shot'; name='saved'}, + # @{type='expect'; pattern='已保存|save'} + # ) + # $results = Invoke-AetherActionScript -Window $win -Actions $actions + # $failed = @($results | Where-Object { -not $_.ok }) + # Assert-Condition ($failed.Count -eq 0) "动作脚本全部成功($($results.Count) 步)" + # } + + # ---- 窗口操作示例 ---- + # Invoke-TestStep "窗口最小化触发冰冻态" { + # Set-AetherWindowState -Window $win -State Minimized + # Start-Sleep -Seconds 2 + # Set-AetherWindowState -Window $win -State Restored + # $win = Get-AetherWindow -Process $proc -Isolate # 重新获取窗口信息 + # Save-AetherScreenshot -Window $win -Name "restored" | Out-Null + # } + # ---- 失败处理:断言异常会被 Invoke-TestStep 捕获,无需手动 try/catch ---- # ---- 失败后(可选):New-AetherDiagBundle -CaseName "template_case" 打包诊断材料 ---- diff --git a/tests/framework/AetherAi.psm1 b/tests/framework/AetherAi.psm1 index 2c03422..8cb1bce 100644 --- a/tests/framework/AetherAi.psm1 +++ b/tests/framework/AetherAi.psm1 @@ -21,19 +21,32 @@ function Invoke-AetherActionScript { <# 执行动作脚本(探索式测试 / AI 生成的交互序列)。 每个动作返回结果对象,整体返回数组(含每步截图路径与耗时)。 动作类型: - click @{type='click'; x; y; [right]} PostMessage 点击(窗口内物理坐标) - hover @{type='hover'; x; y} 真实鼠标移动(触发 hover 高亮) - keys @{type='keys'; text} PostMessage 注入文本(不受焦点影响) - key @{type='key'; key} PostMessage 注入按键({ENTER}/{ESC}/{F2}...) - wait @{type='wait'; ms} - shot @{type='shot'; name} 截图(保存到用例截图目录) - expect @{type='expect'; pattern; [timeout_ms]} 等待日志出现指定模式(仅观察新增日志) + click @{type='click'; x; y; [right]} PostMessage 点击(窗口内物理坐标) + dblclick @{type='dblclick'; x; y; [right]} PostMessage 双击 + mclick @{type='mclick'; x; y} PostMessage 中键点击 + hover @{type='hover'; x; y} 真实鼠标移动(触发 hover 高亮) + move @{type='move'; x; y} PostMessage 鼠标移动(不依赖前台) + wheel @{type='wheel'; x; y; delta; [horizontal]; [shift]; [ctrl]} 滚轮 + drag @{type='drag'; from_x; from_y; to_x; to_y; [steps]; [right]} 拖拽 + keys @{type='keys'; text} PostMessage 注入文本(不受焦点影响) + key @{type='key'; key} PostMessage 注入按键({ENTER}/{ESC}/{F2}...) + hotkey @{type='hotkey'; modifiers; key} 组合键(modifiers=@('Ctrl','Shift','Alt')) + wait @{type='wait'; ms} + shot @{type='shot'; name} 截图(保存到用例截图目录) + expect @{type='expect'; pattern; [timeout_ms]} 等待日志出现指定模式(仅观察新增日志) + resize @{type='resize'; width; height} 调整窗口大小 + movewin @{type='movewin'; x; y} 移动窗口位置 + winstate @{type='winstate'; state} 窗口状态(Normal/Minimized/Maximized/Restored) + closewin @{type='closewin'} 发送 WM_CLOSE 关闭窗口 示例: $actions = @( - @{type='click'; x=92; y=181}, - @{type='keys'; text='hello.rs'}, - @{type='key'; key='{ENTER}'}, - @{type='shot'; name='created'}, + @{type='click'; x=92; y=181}, + @{type='keys'; text='hello.rs'}, + @{type='key'; key='{ENTER}'}, + @{type='hotkey'; modifiers=@('Ctrl'); key='S'}, + @{type='wheel'; x=500; y=300; delta=-120}, + @{type='drag'; from_x=100; from_y=200; to_x=300; to_y=400}, + @{type='shot'; name='created'}, @{type='expect'; pattern='DIAG|error'} ) $r = Invoke-AetherActionScript -Window $win -Actions $actions @@ -58,6 +71,14 @@ function Invoke-AetherActionScript { Send-AetherClickMsg -Hwnd $Window.Hwnd -X $a.x -Y $a.y @(if ($a.right) { @{Right=$true} } else { @{} }) $r.note = "click($($a.x),$($a.y))" } + 'dblclick' { + Send-AetherDoubleClickMsg -Hwnd $Window.Hwnd -X $a.x -Y $a.y @(if ($a.right) { @{Right=$true} } else { @{} }) + $r.note = "dblclick($($a.x),$($a.y))" + } + 'mclick' { + Send-AetherMiddleClickMsg -Hwnd $Window.Hwnd -X $a.x -Y $a.y + $r.note = "mclick($($a.x),$($a.y))" + } 'hover' { $px = $Window.Rect.Left + $a.x $py = $Window.Rect.Top + $a.y @@ -65,6 +86,32 @@ function Invoke-AetherActionScript { Start-Sleep -Milliseconds 300 $r.note = "hover($($a.x),$($a.y))" } + 'move' { + Send-AetherMouseMoveMsg -Hwnd $Window.Hwnd -X $a.x -Y $a.y + $r.note = "move($($a.x),$($a.y))" + } + 'wheel' { + $params = @{ + Hwnd = $Window.Hwnd + X = $a.x; Y = $a.y; Delta = $a.delta + } + if ($a.horizontal) { $params.Horizontal = $true } + if ($a.shift) { $params.Shift = $true } + if ($a.ctrl) { $params.Ctrl = $true } + Send-AetherMouseWheel @params + $r.note = "wheel($($a.x),$($a.y),delta=$($a.delta))" + } + 'drag' { + $params = @{ + Hwnd = $Window.Hwnd + FromX = $a.from_x; FromY = $a.from_y + ToX = $a.to_x; ToY = $a.to_y + } + if ($a.steps) { $params.Steps = $a.steps } + if ($a.right) { $params.Right = $true } + Send-AetherDrag @params + $r.note = "drag($($a.from_x),$($a.from_y))->($($a.to_x),$($a.to_y))" + } 'keys' { Send-AetherTextMsg -Hwnd $Window.Hwnd -Text $a.text $r.note = "keys:$($a.text)" @@ -73,9 +120,13 @@ function Invoke-AetherActionScript { Send-AetherKeyMsg -Hwnd $Window.Hwnd -Key $a.key $r.note = "key:$($a.key)" } + 'hotkey' { + Send-AetherHotkey -Hwnd $Window.Hwnd -Modifiers $a.modifiers -Key $a.key + $r.note = "hotkey:$($a.modifiers -join '+')+$($a.key)" + } 'wait' { Start-Sleep -Milliseconds $a.ms - $r.note = "wait ${ms}ms" + $r.note = "wait $($a.ms)ms" } 'shot' { $r.screenshot = Save-AetherScreenshot -Window $Window -Name $a.name @@ -87,6 +138,22 @@ function Invoke-AetherActionScript { if (-not $ev.Found) { throw "等待日志事件超时: $($a.pattern)" } $r.note = "expect:$($a.pattern)" } + 'resize' { + Resize-AetherWindow -Window $Window -Width $a.width -Height $a.height | Out-Null + $r.note = "resize($($a.width)x$($a.height))" + } + 'movewin' { + Move-AetherWindow -Window $Window -X $a.x -Y $a.y + $r.note = "movewin($($a.x),$($a.y))" + } + 'winstate' { + Set-AetherWindowState -Window $Window -State $a.state + $r.note = "winstate:$($a.state)" + } + 'closewin' { + Close-AetherWindow -Window $Window + $r.note = "closewin" + } default { throw "未知动作类型: $($a.type)" } } } catch { @@ -264,5 +331,77 @@ function New-AetherDiagBundle { return $dir } +# ---------------------------------------------------------------- AI 智能操作辅助 + +function Find-AetherHitRegion { + <# 智能查找 hit region:按动作名称模糊匹配,返回最佳匹配区域。 + 用于 AI 根据语义(如"新建文件按钮")定位可点击区域。 + -ActionLike 动作名称模式(支持 * 通配符)。 + -PreferCenter 优先返回靠近窗口中心的区域(当有多个匹配时)。 #> + param( + [Parameter(Mandatory)][string]$ActionLike, + [switch]$PreferCenter + ) + $regions = @(Read-AetherHitRegions -ActionLike $ActionLike) + if ($regions.Count -eq 0) { return $null } + if ($regions.Count -eq 1) { return $regions[0] } + # 多个匹配时返回最新的(最后记录的) + return $regions[-1] +} + +function Invoke-AetherSmartClick { + <# 智能点击:根据 hit region 动作名称自动定位并点击。 + 示例:Invoke-AetherSmartClick -Window $win -ActionLike "new_file" #> + param( + [Parameter(Mandatory)]$Window, + [Parameter(Mandatory)][string]$ActionLike, + [switch]$Right + ) + $region = Find-AetherHitRegion -ActionLike $ActionLike + if (-not $region) { throw "未找到 hit region: $ActionLike" } + $cx = [int]($region.x + $region.width / 2) + $cy = [int]($region.y + $region.height / 2) + Send-AetherClickMsg -Hwnd $Window.Hwnd -X $cx -Y $cy @(if ($Right) { @{Right=$true} } else { @{} }) + return $region +} + +function Wait-AetherHitRegion { + <# 等待指定 hit region 出现(用于等待 UI 元素加载完成)。 + -TimeoutMs 超时时间(默认 5000ms)。 #> + param( + [Parameter(Mandatory)][string]$ActionLike, + [int]$TimeoutMs = 5000, + [int]$PollMs = 200 + ) + $deadline = (Get-Date).AddMilliseconds($TimeoutMs) + while ((Get-Date) -lt $deadline) { + $region = Find-AetherHitRegion -ActionLike $ActionLike + if ($region) { return $region } + Start-Sleep -Milliseconds $PollMs + } + return $null +} + +function Get-AetherEditorState { + <# 获取编辑器当前状态摘要(供 AI 分析): + 当前标签数、活动标签、光标位置、选区状态、文件路径等。 + 通过日志和 hit regions 推断。 #> + param([Parameter(Mandatory)]$Process) + $log = Get-AetherLog -Tail 50 + $regions = @(Read-AetherHitRegions) + # 从 hit regions 提取标签信息 + $tabs = @($regions | Where-Object { $_.action -like "tab:*" }) + $statusBar = @($regions | Where-Object { $_.action -like "status:*" }) + [pscustomobject]@{ + Timestamp = (Get-Date).ToString("o") + TabCount = $tabs.Count + Tabs = @($tabs | ForEach-Object { $_.action -replace '^tab:', '' }) + StatusBarItems = @($statusBar | ForEach-Object { $_.action }) + RecentLog = $log.Lines | Select-Object -Last 10 + } +} + Export-ModuleMember -Function Invoke-AetherActionScript, Test-AetherPixelRegion, - Get-AetherUiState, Assert-AetherLogEvent, New-AetherDiagBundle + Get-AetherUiState, Assert-AetherLogEvent, New-AetherDiagBundle, + Find-AetherHitRegion, Invoke-AetherSmartClick, Wait-AetherHitRegion, + Get-AetherEditorState diff --git a/tests/framework/AetherTest.psm1 b/tests/framework/AetherTest.psm1 index 00a6c4d..d589640 100644 --- a/tests/framework/AetherTest.psm1 +++ b/tests/framework/AetherTest.psm1 @@ -47,11 +47,47 @@ public class AetherWin32 { [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] public static extern bool SendMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] public static extern short GetKeyState(int nVirtKey); + [DllImport("user32.dll")] public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint); + [DllImport("user32.dll")] public static extern bool ScreenToClient(IntPtr hWnd, ref POINT lpPoint); + [DllImport("user32.dll")] public static extern IntPtr GetSystemMetrics(int nIndex); [DllImport("kernel32.dll")] public static extern IntPtr GetModuleHandleW(string lpModuleName); public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } + public struct POINT { public int X; public int Y; } public const uint LEFTDOWN = 0x0002, LEFTUP = 0x0004, RIGHTDOWN = 0x0008, RIGHTUP = 0x0010; + public const uint MIDDLEDOWN = 0x0020, MIDDLEUP = 0x0040; public const uint WM_LBUTTONDOWN = 0x0201, WM_LBUTTONUP = 0x0202, WM_RBUTTONDOWN = 0x0204, WM_RBUTTONUP = 0x0205; + public const uint WM_MBUTTONDOWN = 0x0207, WM_MBUTTONUP = 0x0208; + public const uint WM_LBUTTONDBLCLK = 0x0203, WM_RBUTTONDBLCLK = 0x0206, WM_MBUTTONDBLCLK = 0x0209; + public const uint WM_MOUSEMOVE = 0x0200, WM_MOUSEWHEEL = 0x020A, WM_MOUSEHWHEEL = 0x020E; public const uint WM_KEYDOWN = 0x0100, WM_KEYUP = 0x0101, WM_CHAR = 0x0102; + public const uint WM_SYSKEYDOWN = 0x0104, WM_SYSKEYUP = 0x0105; + public const uint WM_SIZE = 0x0005, WM_MOVE = 0x0003, WM_CLOSE = 0x0010; + public const uint WM_DROPFILES = 0x0233; + public const int SIZE_RESTORED = 0, SIZE_MINIMIZED = 1, SIZE_MAXIMIZED = 2; + public const int SW_HIDE = 0, SW_SHOWNORMAL = 1, SW_SHOWMINIMIZED = 2, SW_SHOWMAXIMIZED = 3, SW_RESTORE = 9; + // 虚拟键码 + public const int VK_SHIFT = 0x10, VK_CONTROL = 0x11, VK_MENU = 0x12; // Alt + public const int VK_LSHIFT = 0xA0, VK_RSHIFT = 0xA1, VK_LCONTROL = 0xA2, VK_RCONTROL = 0xA3; + public const int VK_LMENU = 0xA4, VK_RMENU = 0xA5; // Alt + public const int VK_RETURN = 0x0D, VK_ESCAPE = 0x1B, VK_BACK = 0x08, VK_TAB = 0x09; + public const int VK_DELETE = 0x2E, VK_INSERT = 0x2D; + public const int VK_UP = 0x26, VK_DOWN = 0x28, VK_LEFT = 0x25, VK_RIGHT = 0x27; + public const int VK_HOME = 0x24, VK_END = 0x23, VK_PRIOR = 0x21, VK_NEXT = 0x22; // PageUp/PageDown + public const int VK_SPACE = 0x20; + public const int VK_F1 = 0x70, VK_F2 = 0x71, VK_F3 = 0x72, VK_F4 = 0x73, VK_F5 = 0x74, VK_F6 = 0x75; + public const int VK_F7 = 0x76, VK_F8 = 0x77, VK_F9 = 0x78, VK_F10 = 0x79, VK_F11 = 0x7A, VK_F12 = 0x7B; + public const int VK_A = 0x41, VK_B = 0x42, VK_C = 0x43, VK_D = 0x44, VK_E = 0x45, VK_F = 0x46; + public const int VK_G = 0x47, VK_H = 0x48, VK_I = 0x49, VK_J = 0x4A, VK_K = 0x4B, VK_L = 0x4C; + public const int VK_M = 0x4D, VK_N = 0x4E, VK_O = 0x4F, VK_P = 0x50, VK_Q = 0x51, VK_R = 0x52; + public const int VK_S = 0x53, VK_T = 0x54, VK_U = 0x55, VK_V = 0x56, VK_W = 0x57, VK_X = 0x58; + public const int VK_Y = 0x59, VK_Z = 0x5A; + public const int VK_0 = 0x30, VK_1 = 0x31, VK_2 = 0x32, VK_3 = 0x33, VK_4 = 0x34; + public const int VK_5 = 0x35, VK_6 = 0x36, VK_7 = 0x37, VK_8 = 0x38, VK_9 = 0x39; + public const int VK_OEM_COMMA = 0xBC, VK_OEM_PERIOD = 0xBE, VK_OEM_2 = 0xBF; // , . / + public const int VK_OEM_3 = 0xC0; // ` + public const int VK_OEM_PLUS = 0xBB, VK_OEM_MINUS = 0xBD; } "@ } @@ -275,7 +311,7 @@ function Send-AetherTextMsg { function Send-AetherKeyMsg { <# 通过 PostMessage 注入按键(WM_KEYDOWN/UP),不依赖前台焦点。 - -Key 支持 {ENTER}/{ESC}/{F1..F12}/{BACKSPACE}/{TAB}/{DELETE}/{UP}/{DOWN}/{LEFT}/{RIGHT}/{HOME}/{END}。 + -Key 支持 {ENTER}/{ESC}/{F1..F12}/{BACKSPACE}/{TAB}/{DELETE}/{UP}/{DOWN}/{LEFT}/{RIGHT}/{HOME}/{END}/{PAGEUP}/{PAGEDOWN}/{SPACE}/{INSERT}。 WM_KEYDOWN 会经消息循环 TranslateMessage 转换为 WM_CHAR(如 ENTER), 与真实键盘路径一致。 #> param( @@ -283,30 +319,287 @@ function Send-AetherKeyMsg { [Parameter(Mandatory)][string]$Key, [int]$DelayMs = 200 ) - $vk = switch ($Key.ToUpper()) { - '{ENTER}' { 0x0D } - '{ESC}' { 0x1B } - '{BACKSPACE}' { 0x08 } - '{TAB}' { 0x09 } - '{DELETE}' { 0x2E } - '{UP}' { 0x26 } - '{DOWN}' { 0x28 } - '{LEFT}' { 0x25 } - '{RIGHT}' { 0x27 } - '{HOME}' { 0x24 } - '{END}' { 0x23 } - default { - if ($Key -match '^\{F(\d{1,2})\}$') { - 0x70 + [int]$Matches[1] - 1 - } else { throw "不支持的按键: $Key" } - } - } + $vk = Get-AetherVirtualKeyCode -Key $Key [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_KEYDOWN, [IntPtr]$vk, [IntPtr]0) | Out-Null Start-Sleep -Milliseconds 30 [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_KEYUP, [IntPtr]$vk, [IntPtr]0) | Out-Null Start-Sleep -Milliseconds $DelayMs } +function Get-AetherVirtualKeyCode { + <# 将按键名称转换为虚拟键码。内部辅助函数,不导出。 #> + param([Parameter(Mandatory)][string]$Key) + $k = $Key.ToUpper() + # 处理 {XXX} 格式 + if ($k -match '^\{(.+)\}$') { $k = $Matches[1] } + switch -Regex ($k) { + '^ENTER$|^RETURN$' { return [AetherWin32]::VK_RETURN } + '^ESC(APE)?$' { return [AetherWin32]::VK_ESCAPE } + '^BACK(SPACE)?$' { return [AetherWin32]::VK_BACK } + '^TAB$' { return [AetherWin32]::VK_TAB } + '^DEL(ETE)?$' { return [AetherWin32]::VK_DELETE } + '^INS(ERT)?$' { return [AetherWin32]::VK_INSERT } + '^UP$' { return [AetherWin32]::VK_UP } + '^DOWN$' { return [AetherWin32]::VK_DOWN } + '^LEFT$' { return [AetherWin32]::VK_LEFT } + '^RIGHT$' { return [AetherWin32]::VK_RIGHT } + '^HOME$' { return [AetherWin32]::VK_HOME } + '^END$' { return [AetherWin32]::VK_END } + '^PAGEUP$|^PGUP$' { return [AetherWin32]::VK_PRIOR } + '^PAGEDOWN$|^PGDN$' { return [AetherWin32]::VK_NEXT } + '^SPACE$' { return [AetherWin32]::VK_SPACE } + '^F(\d{1,2})$' { return [AetherWin32]::VK_F1 + [int]$Matches[1] - 1 } + '^SHIFT$' { return [AetherWin32]::VK_SHIFT } + '^CTRL$|^CONTROL$' { return [AetherWin32]::VK_CONTROL } + '^ALT$|^MENU$' { return [AetherWin32]::VK_MENU } + '^COMMA$|^,$' { return [AetherWin32]::VK_OEM_COMMA } + '^PERIOD$|^\.$' { return [AetherWin32]::VK_OEM_PERIOD } + '^SLASH$|^/$' { return [AetherWin32]::VK_OEM_2 } + '^BACKTICK$|^`$' { return [AetherWin32]::VK_OEM_3 } + '^PLUS$|^\+$|^=$' { return [AetherWin32]::VK_OEM_PLUS } + '^MINUS$|^-$' { return [AetherWin32]::VK_OEM_MINUS } + '^[A-Z]$' { return [int][char]$k } + '^[0-9]$' { return [int][char]$k } + default { throw "不支持的按键: $Key" } + } +} + +function Send-AetherHotkey { + <# 发送组合键(Ctrl/Shift/Alt + 键),通过 PostMessage 注入,不依赖前台焦点。 + -Modifiers 支持 @('Ctrl','Shift','Alt') 组合。 + -Key 支持 Send-AetherKeyMsg 的所有按键。 + 示例:Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl') -Key 'S' # Ctrl+S 保存 + Send-AetherHotkey -Hwnd $h -Modifiers @('Ctrl','Shift') -Key 'P' # Ctrl+Shift+P 命令面板 #> + param( + [Parameter(Mandatory)][IntPtr]$Hwnd, + [Parameter(Mandatory)][string[]]$Modifiers, + [Parameter(Mandatory)][string]$Key, + [int]$DelayMs = 300 + ) + $vkCtrl = [AetherWin32]::VK_CONTROL + $vkShift = [AetherWin32]::VK_SHIFT + $vkAlt = [AetherWin32]::VK_MENU + $hasCtrl = $Modifiers -contains 'Ctrl' -or $Modifiers -contains 'Control' + $hasShift = $Modifiers -contains 'Shift' + $hasAlt = $Modifiers -contains 'Alt' -or $Modifiers -contains 'Menu' + $vkKey = Get-AetherVirtualKeyCode -Key $Key + + # 按下修饰键 + if ($hasCtrl) { [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_KEYDOWN, [IntPtr]$vkCtrl, [IntPtr]0) | Out-Null; Start-Sleep -Milliseconds 20 } + if ($hasShift) { [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_KEYDOWN, [IntPtr]$vkShift, [IntPtr]0) | Out-Null; Start-Sleep -Milliseconds 20 } + if ($hasAlt) { [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_KEYDOWN, [IntPtr]$vkAlt, [IntPtr]0) | Out-Null; Start-Sleep -Milliseconds 20 } + + # 按下并释放目标键 + [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_KEYDOWN, [IntPtr]$vkKey, [IntPtr]0) | Out-Null + Start-Sleep -Milliseconds 30 + [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_KEYUP, [IntPtr]$vkKey, [IntPtr]0) | Out-Null + Start-Sleep -Milliseconds 20 + + # 释放修饰键(逆序) + if ($hasAlt) { [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_KEYUP, [IntPtr]$vkAlt, [IntPtr]0) | Out-Null; Start-Sleep -Milliseconds 20 } + if ($hasShift) { [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_KEYUP, [IntPtr]$vkShift, [IntPtr]0) | Out-Null; Start-Sleep -Milliseconds 20 } + if ($hasCtrl) { [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_KEYUP, [IntPtr]$vkCtrl, [IntPtr]0) | Out-Null } + + Start-Sleep -Milliseconds $DelayMs +} + +# ---------------------------------------------------------------- 鼠标扩展操作 + +function Send-AetherDoubleClickMsg { + <# 通过 PostMessage 注入双击(WM_LBUTTONDBLCLK),不依赖前台焦点。 + 坐标 = 客户区物理像素 = 名义值 × Scale2。 #> + param( + [Parameter(Mandatory)][IntPtr]$Hwnd, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [switch]$Right + ) + $lParam = [IntPtr](($Y -shl 16) -bor ($X -band 0xFFFF)) + $msg = if ($Right) { [AetherWin32]::WM_RBUTTONDBLCLK } else { [AetherWin32]::WM_LBUTTONDBLCLK } + [AetherWin32]::PostMessageW($Hwnd, $msg, [IntPtr]1, $lParam) | Out-Null + Start-Sleep -Milliseconds 400 +} + +function Send-AetherMiddleClickMsg { + <# 通过 PostMessage 注入中键点击(WM_MBUTTONDOWN/UP),不依赖前台焦点。 #> + param( + [Parameter(Mandatory)][IntPtr]$Hwnd, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y + ) + $lParam = [IntPtr](($Y -shl 16) -bor ($X -band 0xFFFF)) + [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_MBUTTONDOWN, [IntPtr]1, $lParam) | Out-Null + Start-Sleep -Milliseconds 60 + [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_MBUTTONUP, [IntPtr]0, $lParam) | Out-Null + Start-Sleep -Milliseconds 400 +} + +function Send-AetherMouseWheel { + <# 通过 PostMessage 注入鼠标滚轮(WM_MOUSEWHEEL/WM_MOUSEHWHEEL),不依赖前台焦点。 + -Delta 滚轮增量:正数向上/右,负数向下/左。标准增量为 120 的倍数。 + -X/-Y 窗口内物理坐标(滚轮事件需要光标位置决定滚动目标区域)。 + -Horizontal 使用横向滚轮(WM_MOUSEHWHEEL)。 + -Shift/-Ctrl 修饰键状态(Shift+滚轮=横向滚动,Ctrl+滚轮=缩放)。 #> + param( + [Parameter(Mandatory)][IntPtr]$Hwnd, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [Parameter(Mandatory)][int]$Delta, + [switch]$Horizontal, + [switch]$Shift, + [switch]$Ctrl + ) + # 将窗口内坐标转换为屏幕坐标(WM_MOUSEWHEEL 使用屏幕坐标) + $pt = New-Object AetherWin32+POINT + $pt.X = $X; $pt.Y = $Y + [AetherWin32]::ClientToScreen($Hwnd, [ref]$pt) | Out-Null + $lParam = [IntPtr](($pt.Y -shl 16) -bor ($pt.X -band 0xFFFF)) + + # 构建 wParam:高 16 位 = delta,低 16 位 = 修饰键标志 + $modifierFlags = 0 + if ($Shift) { $modifierFlags = $modifierFlags -bor 0x0004 } # MK_SHIFT + if ($Ctrl) { $modifierFlags = $modifierFlags -bor 0x0008 } # MK_CONTROL + $wParam = [IntPtr](($Delta -shl 16) -bor $modifierFlags) + + $msg = if ($Horizontal) { [AetherWin32]::WM_MOUSEHWHEEL } else { [AetherWin32]::WM_MOUSEWHEEL } + [AetherWin32]::PostMessageW($Hwnd, $msg, $wParam, $lParam) | Out-Null + Start-Sleep -Milliseconds 300 +} + +function Send-AetherMouseMoveMsg { + <# 通过 PostMessage 注入鼠标移动(WM_MOUSEMOVE),不依赖前台焦点。 + 用于触发 hover 效果、拖拽过程中的移动等。 + -X/-Y 窗口内物理坐标。 #> + param( + [Parameter(Mandatory)][IntPtr]$Hwnd, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [int]$DelayMs = 50 + ) + $lParam = [IntPtr](($Y -shl 16) -bor ($X -band 0xFFFF)) + [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_MOUSEMOVE, [IntPtr]0, $lParam) | Out-Null + Start-Sleep -Milliseconds $DelayMs +} + +function Send-AetherDrag { + <# 通过 PostMessage 注入完整拖拽操作(按下 → 移动 → 释放),不依赖前台焦点。 + -FromX/-FromY 起始坐标,-ToX/-ToY 目标坐标(窗口内物理像素)。 + -Steps 移动步数(越多越平滑,默认 10)。 + -Right 使用右键拖拽。 #> + param( + [Parameter(Mandatory)][IntPtr]$Hwnd, + [Parameter(Mandatory)][int]$FromX, + [Parameter(Mandatory)][int]$FromY, + [Parameter(Mandatory)][int]$ToX, + [Parameter(Mandatory)][int]$ToY, + [int]$Steps = 10, + [switch]$Right + ) + $fromLParam = [IntPtr](($FromY -shl 16) -bor ($FromX -band 0xFFFF)) + $toLParam = [IntPtr](($ToY -shl 16) -bor ($ToX -band 0xFFFF)) + $downMsg = if ($Right) { [AetherWin32]::WM_RBUTTONDOWN } else { [AetherWin32]::WM_LBUTTONDOWN } + $upMsg = if ($Right) { [AetherWin32]::WM_RBUTTONUP } else { [AetherWin32]::WM_LBUTTONUP } + + # 按下 + [AetherWin32]::PostMessageW($Hwnd, $downMsg, [IntPtr]1, $fromLParam) | Out-Null + Start-Sleep -Milliseconds 80 + + # 移动(分步模拟平滑拖拽) + for ($i = 1; $i -le $Steps; $i++) { + $cx = $FromX + [int](($ToX - $FromX) * $i / $Steps) + $cy = $FromY + [int](($ToY - $FromY) * $i / $Steps) + $moveLParam = [IntPtr](($cy -shl 16) -bor ($cx -band 0xFFFF)) + [AetherWin32]::PostMessageW($Hwnd, [AetherWin32]::WM_MOUSEMOVE, [IntPtr]1, $moveLParam) | Out-Null + Start-Sleep -Milliseconds 30 + } + + # 释放 + [AetherWin32]::PostMessageW($Hwnd, $upMsg, [IntPtr]0, $toLParam) | Out-Null + Start-Sleep -Milliseconds 400 +} + +# ---------------------------------------------------------------- 窗口操作 + +function Resize-AetherWindow { + <# 调整窗口大小并触发 WM_SIZE。 + -Width/-Height 新的客户区尺寸(物理像素)。 + 注意:实际窗口会收到 WM_SIZE 消息,应用会重新布局。 #> + param( + [Parameter(Mandatory)]$Window, + [Parameter(Mandatory)][int]$Width, + [Parameter(Mandatory)][int]$Height + ) + $r = $Window.Rect + [AetherWin32]::MoveWindow($Window.Hwnd, $r.Left, $r.Top, $Width, $Height, $true) | Out-Null + Start-Sleep -Milliseconds 500 + # 返回更新后的窗口信息 + Get-AetherWindow -Process (Get-Process -Id (Get-Process aether-app | Where-Object { $_.MainWindowHandle -eq $Window.Hwnd }).Id) +} + +function Move-AetherWindow { + <# 移动窗口到新位置。 + -X/-Y 新的屏幕坐标(左上角)。 #> + param( + [Parameter(Mandatory)]$Window, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y + ) + $r = $Window.Rect + $w = $r.Right - $r.Left + $h = $r.Bottom - $r.Top + [AetherWin32]::MoveWindow($Window.Hwnd, $X, $Y, $w, $h, $true) | Out-Null + Start-Sleep -Milliseconds 500 +} + +function Set-AetherWindowState { + <# 设置窗口状态:Normal/Minimized/Maximized/Restored。 + 最小化会触发冰冻态(Frozen),还原会解冻。 #> + param( + [Parameter(Mandatory)]$Window, + [Parameter(Mandatory)][ValidateSet('Normal','Minimized','Maximized','Restored')][string]$State + ) + $sw = switch ($State) { + 'Normal' { [AetherWin32]::SW_SHOWNORMAL } + 'Minimized' { [AetherWin32]::SW_SHOWMINIMIZED } + 'Maximized' { [AetherWin32]::SW_SHOWMAXIMIZED } + 'Restored' { [AetherWin32]::SW_RESTORE } + } + [AetherWin32]::ShowWindow($Window.Hwnd, $sw) | Out-Null + Start-Sleep -Milliseconds 800 +} + +function Close-AetherWindow { + <# 发送 WM_CLOSE 关闭窗口(等同于点击标题栏关闭按钮)。 + 与 Stop-AetherApp 的区别:WM_CLOSE 会触发应用的正常关闭流程(如 dirty 检查)。 #> + param([Parameter(Mandatory)]$Window) + [AetherWin32]::PostMessageW($Window.Hwnd, [AetherWin32]::WM_CLOSE, [IntPtr]0, [IntPtr]0) | Out-Null + Start-Sleep -Milliseconds 500 +} + +# ---------------------------------------------------------------- 文件拖放 + +function Send-AetherDropFiles { + <# 模拟文件拖放(WM_DROPFILES)。 + -Paths 文件或文件夹路径数组。 + 注意:需要应用以拖拽目标注册(DragAcceptFiles),且需要 shell32.dll 的 DragQueryFileW 配合。 + 由于 PostMessage 无法直接构造 HDROP,此函数通过真实拖拽模拟实现。 + 更可靠的方式是使用 Windows 自动化 API 或直接在测试中调用应用内部接口。 #> + param( + [Parameter(Mandatory)]$Window, + [Parameter(Mandatory)][string[]]$Paths + ) + # WM_DROPFILES 需要构造 HDROP 句柄,这无法通过 PostMessage 直接完成。 + # 替代方案:使用 SendMessage + 内存映射文件,或使用 UI Automation。 + # 当前实现:抛出异常提示使用替代方案。 + throw @" +WM_DROPFILES 无法通过 PostMessage 直接模拟(需要构造 HDROP 句柄)。 +替代方案: +1. 使用应用内部命令(如 Ctrl+O 打开文件对话框) +2. 使用 Start-AetherApp -Folder 直接打开工作区 +3. 使用 Windows UI Automation API +"@ +} + # ---------------------------------------------------------------- 截图 function Save-AetherScreenshot { @@ -523,7 +816,11 @@ function Complete-TestCase { Export-ModuleMember -Function Build-AetherApp, Start-AetherApp, Stop-AetherApp, Get-AetherWindow, Get-AetherLayoutConstants, Add-AetherTrustedFolder, Invoke-AetherClick, Send-AetherClickMsg, Send-AetherKeys, Send-AetherText, - Send-AetherTextMsg, Send-AetherKeyMsg, + Send-AetherTextMsg, Send-AetherKeyMsg, Send-AetherHotkey, + Send-AetherDoubleClickMsg, Send-AetherMiddleClickMsg, + Send-AetherMouseWheel, Send-AetherMouseMoveMsg, Send-AetherDrag, + Resize-AetherWindow, Move-AetherWindow, Set-AetherWindowState, Close-AetherWindow, + Send-AetherDropFiles, Save-AetherScreenshot, New-AetherTestWorkspace, Remove-AetherTestWorkspace, Get-AetherLog, Wait-AetherLogEvent, Read-AetherHitRegions, Start-TestCase, Invoke-TestStep, Assert-Condition, Assert-PathExists,