Files
Unlucky-Launcher/crates/app-iced/src/pages/agent/tests/drag.rs
T
AskaEth a22baebe70 fix(app-iced): M119 r1 —— 常驻「模拟」标识 + 确认条的键盘通路
复审 r1 的两条 p2,都是「说成真的、其实是假的」与「能走进死路」这个级别的问题。

## P2-1:界面必须自己说清「这是模拟」

原判:生产默认后端是**脚本化 rig**(`ULL_AGENT_SCRIPT` 的 6 种剧情),而「后端未接 / 模型还没
接上」两句诚实文案被我改成了中性话,理由写着「由端点标签说」——**那条端点标签在壳里根本不存在**。
于是默认配置下用户敲一句话得到一段像是模型编的回答。判据:没读过代码的用户,打开右栏、敲一句话,
必须能自己看出来这不是真模型(不接受 tooltip、不接受藏在设置里)。

修法(①恢复诚实文案 + ②常驻可见标记,并用):

| 位置 | 文案 / 形态 | 谁给的 |
|---|---|---|
| 标题右侧**常驻胶囊**(`SurfaceVariant` tonal)| `Key::AgentSimulated`(Simulated / 模拟)| `AgentHost::is_simulated()` → `Session.simulated` → 视图 |
| 标题下副标题 | 「模拟助手 · 还没接真实模型」 | `Key::AgentSubtitle`(原本是「未接」被改成中性的「会话」,现在写回来且更明确)|
| 空会话正文 | 「…当前是模拟助手,没有接真实模型。」 | `Key::AgentEmptyBody` |
| 输入框占位符 | 「给模拟助手发消息…」 | `Key::AgentPlaceholder` |

**标记不是硬编码在视图里**:`AgentHost.simulated` 是事实字段(今天 transport 只有 rig ⇒ 恒 true)。
接真 `AgentTransport` 那天把它传 `false`,四个位置的「模拟」措辞会一起消失——这是为什么用字段而不是
写死一句话。

## P2-2:确认条必须有键盘通路

原判:Allow/Deny 只有鼠标路径 ⇒ 键盘用户在写操作确认处**永久卡死**(`busy` 锁发送、
`Stop` 走主后端、清不掉 presenter 的 pending confirm)。

- 焦点环多两格:`Focus::ConfirmAllow` / `Focus::ConfirmDeny`,**只在有待确认时**存在
  (鼠标用户不会 Tab 到不存在的按钮);
- 待确认出现 ⇒ `sync_confirm_focus()` 把焦点直接落到「允许」并**打开键盘巡航**(阻塞态主动亮环),
  消失 ⇒ 焦点收回输入框、巡航关闭;
- `Enter` 与 `Space` 都映射到 `Activate`(`Space` 不抢输入框:`keyboard_mode` 那道闸在
  鼠标点进输入框时是关的);
- 焦点环不是 10% 状态层——在**实心主色**按钮上那层几乎看不见(出图实测过一版就是没有)。
  改成 2dp `Primary` 描边 + 3px 留白,两个按钮恒定加这层 padding,所以焦点变化不移动布局。

测试(`crates/app-iced/src/app/update/agent/tests.rs`):
`a_blocking_confirm_can_be_decided_with_the_keyboard_alone` —— 全程只有键盘消息:
确认到达后断言 `focus == ConfirmAllow && keyboard_mode`,发一条 `KeyPressed(Enter)` 完成允许;
第二轮 `Tab`(走到 Deny)+ `KeyPressed(Space)` 完成拒绝;两轮各记一条审计、焦点都收回 Composer。
另有 `the_default_backend_reports_itself_as_simulated` 钉住 P2-1 的事实来源。

## 视图入参顺带收成结构体

`docked`/`page` 本来是 6 个形参(`ui, agent, rail, failure, catalog, width`),这一轮还要加
`simulated` ⇒ 7 个。按塔台登记的可维护性口径改成 `Session { agent, rail, failure, simulated }`:
两个宿主各 3 个形参,以后再加展示事实只改 `Session`。

## 出图(18 张,浅深各一)

`DevDocs/artifacts/m119/`:新增 `08-agent-simulated-*`(空会话 + 四处「模拟」措辞)与
`09-agent-confirm-focus-*`(确认条 + 焦点环落在「允许」);其余 7 组全部重出(文案与焦点环都变了)。

## 门禁(真实退出码)

| 命令 | 退出码 |
|---|---|
| `cargo fmt --all --check` | 0 |
| `cargo clippy -p unlucky-app-iced -p unlucky-presenter -p unlucky-agent -p unlucky-i18n --all-targets -- -D warnings` | 0(0 warning)|
| `cargo test -p unlucky-app-iced --lib` | 0(601 passed / 0 failed / 2 ignored)|
| `cargo test --workspace --exclude unlucky-app` | 0(58 套 2277 passed / 0 failed / 11 ignored)|

`golden.rs` 400 行(+1 条 `AgentSimulated` 正好顶线);全树受检文件无 >400。
2026-11-05 09:17:50 +08:00

151 lines
5.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 拖拽与「缝」的判据(从 `tests.rs` 拆出来:那边加完复审 r1 的断言后顶到 436 行,
//! 而 AGENTS.md §3-1 是 400 行硬线)。
//!
//! 拆的位置是「指针/几何」这一族:拖拽三条收尾路径、吸附宽度写回、缝的预算、缝与折叠条的标签。
use unlucky_presenter::agent::AgentRail;
use super::super::metrics::{layout_for, PRESETS, RESIZE_GUTTER};
use super::super::state::{AgentUi, RailState};
use super::super::{docked, drag_overlay};
use super::ui;
/// 拖拽层只在拖拽期间存在(否则它会吃掉整窗的点击)。
#[test]
fn the_drag_overlay_only_exists_while_dragging() {
let mut agent = AgentUi::new();
agent.expand();
assert!(drag_overlay(&agent).is_none());
agent.drag_start();
assert!(agent.is_dragging());
assert!(drag_overlay(&agent).is_some());
agent.drag_end();
assert!(drag_overlay(&agent).is_none());
}
/// 拆出档右栗必须**不占地**(否则会话会在两个地方各画一份)。
#[test]
fn the_detached_state_frees_the_rail_slot() {
let ui = ui();
let mut agent = AgentUi::new();
agent.detach();
let session = super::super::Session {
agent: &agent,
rail: &AgentRail::new(),
failure: None,
simulated: true,
};
assert!(docked(
&ui,
session,
unlucky_i18n::Catalog::of(unlucky_i18n::Lang::En),
1180.0
)
.is_none());
assert_eq!(agent.rail_width(1180.0), 0.0);
assert_eq!(agent.content_width(1180.0), 1180.0);
}
/// 拖拽把「展开宽度」写进状态(松手后保有该宽度)。
#[test]
fn the_drag_writes_back_the_snapped_width() {
let mut agent = AgentUi::new();
agent.expand();
agent.drag_start();
agent.drag_to(1180.0, 1180.0 - 280.0);
agent.drag_end();
assert_eq!(agent.expanded(), 280.0);
assert!(!agent.is_dragging());
// 没按下就收到的 DragTo(例如上一步松手后的残留)不该改宽度
let before = agent.expanded();
agent.drag_to(1180.0, 100.0);
assert_eq!(agent.expanded(), before);
// 三档预设都能被拖到(1180 下上限 612 > 360)
for preset in PRESETS {
agent.drag_start();
agent.drag_to(1180.0, 1180.0 - preset);
agent.drag_end();
assert_eq!(agent.expanded(), preset);
}
}
/// 面板与中栗之间的缝在布局里被真的留出来了。
///
/// **全用字面量**(复审 P3-4:早先写成 `rail + GUTTER == window − content`,两边同一组常量,
/// 把 `RESIZE_GUTTER` 改成 0 照样绿 = 自证)。现在的算式是写死的:
/// `320(面板)+ 8(缝)+ 852(中栗)= 1180`。
#[test]
fn the_resize_gutter_is_part_of_the_budget() {
let mut agent = AgentUi::new();
agent.expand();
assert_eq!(agent.rail_width(1180.0), 320.0, "1180 下展开档 = 令牌 320");
assert_eq!(agent.content_width(1180.0), 852.0, "中栗 = 1180 − 320 − 8");
assert_eq!(320.0 + 8.0 + 852.0, 1180.0, "三段之和必须回到窗口宽");
assert_eq!(RESIZE_GUTTER, 8.0, "缝宽就是 8(`Space::X2`)");
// 折叠档 48 / 拆出档 0:这两档**没有**缝(中栗 = 1180 − 48 / 1180 − 0)
let collapsed = layout_for(1180.0, RailState::Collapsed, 320.0);
assert_eq!(
(collapsed.rail, collapsed.gutter, collapsed.content),
(48.0, 0.0, 1132.0)
);
let detached = layout_for(1180.0, RailState::Detached, 320.0);
assert_eq!(
(detached.rail, detached.gutter, detached.content),
(0.0, 0.0, 1180.0)
);
}
/// 拖拽的三条收尾路径里,**能测的两条**(复审 P3-7)。
///
/// 真实鼠标在窗口外松手无法注入(这台机器连 `SetCursorPos` 都失败),所以这里只钉状态机:
/// ①松手(`DragEnd`)之后指针再动不改宽度;②拖拽中「再按一次」(叠层的 `on_press`)能清态,
/// 也就是上一条事件丢了的兜底。叠层本身挂了 `on_exit`(指针离窗 = 收在当前值),那条只能真窗口验。
#[test]
fn the_drag_recovers_when_a_release_is_missed() {
let mut agent = AgentUi::new();
agent.expand();
// ① 正常松手
agent.drag_start();
agent.drag_to(1180.0, 1180.0 - 360.0);
agent.drag_end();
assert_eq!(agent.expanded(), 360.0);
agent.drag_to(1180.0, 900.0);
assert_eq!(agent.expanded(), 360.0, "松手后指针再动不该改宽度");
// ② 拖拽中「再按一次」= 叠层的 on_press 兜底:清态,后续 DragTo 不再改宽度
agent.drag_start();
agent.drag_to(1180.0, 1180.0 - 280.0);
assert_eq!(agent.expanded(), 280.0);
agent.drag_end(); // ← 叠层 on_press → DragEnd
assert!(
!agent.is_dragging(),
"再按一次必须能清态(窗内松手丢失时的兜底)"
);
agent.drag_to(1180.0, 100.0);
assert_eq!(agent.expanded(), 280.0);
assert!(drag_overlay(&agent).is_none(), "清态后叠层必须消失");
}
/// 折叠条与拖拽缝都有可读文字(复审 P3-5:两条 key 以前在视图里一次没用)。
///
/// 抓得住:谁把 `AgentResize` / `AgentWidthLabel` 从视图里删掉(那样它们又变回空头支票)。
/// 抓不住:tooltip 真弹出来长什么样(那要 hover 出图)。
#[test]
fn the_gutter_and_strip_are_labelled() {
let hosts = include_str!("../mod.rs");
let strip = include_str!("../strip.rs");
for key in ["Key::AgentResize", "Key::AgentWidthLabel"] {
assert!(hosts.contains(key), "视图里没用上 {key}");
}
// M119:折叠条整段搬去了 `strip.rs`(`mod.rs` 顶到 400 行)
assert!(strip.contains("Key::AgentStripLabel"), "折叠条上没画标签");
}