Files
Unlucky-Launcher/crates/app-iced/src/pages/agent/tests/drag.rs
T
AskaEth d4fd675935 feat(app-iced): M119 把 presenter 的 agent 四契约与账户窄入口接进 iced 壳
M103 把 AI 会话的投影(`AgentRail` + `submit/mark_read/confirm`)做完了,M114 把账户三轨做完,
但 iced 壳一直没接:M103 复审确认 `Presenter::` 零非测试消费者,M104 的右栏会话数据来自内置样本。
这一个提交就是那一步——**右栏与账户页吃 presenter 的真实状态**。

## 接线层(新增 `crates/app-iced/src/presenter/`)

| 文件 | 是什么 |
|---|---|
| `agent.rs` | `AgentHost`:presenter 句柄(四契约)+ 事件队列 + **具名**失败(`AgentFailure`:`unreachable` / `not_implemented`)|
| `transport.rs` | `AgentTransport` 的 iced 侧实现(脚本化 rig + 五种剧情 + 两种失败注入)|
| `rig.rs`(仅测试) | 账户轨的 `Backend`:`AppCommand` → host 形状的 `CoreEvent` |
| `mod.rs` | 逻辑路径说明 + **线程/通道边界** |

物理目录 `src/presenter/`,逻辑名字 `crate::app::state::presenter`——由 `app/state.rs` 用
`#[path]` 挂载(与 `shell::agent` 挂 `pages/agent/` 同一个先例:scope 不含 `lib.rs`)。

## 事件回流

`transport` 只**入队**、UI 只**出队**:`advance`→`pump` 里同拍抽干,抽出来的这批经
**既有 `Message::Agent(..)` 通道**(新变体 `Events(Vec<AppEvent>)`)回到 `on_agent`,再由
`Presenter::apply` 落地。同一条漏斗,所以用户点击与后端事件不会各改一次状态。

## 会话内容只留一份

`AgentUi` 里的 `transcript/busy/unread` **删掉**,右栏与独立页直接读 `agent_rail()`
(`pages/agent::spec` 零拷贝借)。M104 的样本会话(`demo::transcript()`)一并删除:出厂就是
空会话,取证夹具要内容就**跑一轮真提交**(`ULL_AGENT_FIXTURE=turn`),消息只能由 presenter 产生。

## 视图

- 气泡按 `ChatMessage.role` 画(User/Assistant/Tool/System),没有任何壳自己的会话类型;
- 新增两条横幅(`conversation/banners.rs`):**失败态**(catalog 标题 + 已脱敏正文)与
  **写操作确认**(摘要 + Allow/Deny → `agent_confirm`);
- 拖动缝与折叠条按 400 行硬线拆到 `mod.rs` 之外(`strip.rs`、`conversation/{transcript,banners}.rs`)。

## 未读口径

presenter 判「栏可见吗」用的是它自己的 router + `prefs.ai_collapsed`,而这两件的真源在 `App`。
`refresh_view` 用 presenter 的两个**窄入口**(`navigate` / `prefs_mut().ai_collapsed`)单点写回,
带幂等闸(相同就不重复写,避免 `navigate(Route::Agent)` 顺手 mark_read 吃掉用户攒的未读)。

## 账户页

账户列表与登录事件仍走 `AppCommand`/`CoreEvent`(M114 的形状没动),本轮把**登录指令与
列表消费**纳入同一套树测试:添加离线档案 → 列表出现 → 切换 → 删除;设备码「进行中可见 + 可取消」;
失败态(名字不合法 / 凭据失效)都给具名错误。

## 树测试(9 条,`cargo test -p unlucky-app-iced --lib` 全绿:599 passed / 0 failed)

- `app/update/agent/tests.rs`:提交 → 事件回流 → 右栏出现该轮 + `busy` 翻转 + 标记已读归零;
  确认条可裁决且记审计;三种失败各给具名横幅;草稿逐字同步。
- `app/update/account/tests.rs`:增/切/删端到端;设备码进行中与取消;两种失败态;
  M114 的三条回归闸(原内联测试搬出来)。
- `pages/agent/tests.rs`:视图契约(`spec` 原样借 rail)、a11y 标签、焦点环、两种宿主全态构图。

顺带:`app/tests.rs` 里三条 `agent.unread` 断言跟着 rail 走(出厂 0 未读),`view/account_page.rs`
的 `docked` 调用点补上 rail 与失败横幅。
2026-11-05 08:02:58 +08:00

139 lines
5.4 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();
assert!(docked(&ui, &agent, &AgentRail::new(), None, 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"), "折叠条上没画标签");
}