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 与失败横幅。
139 lines
5.4 KiB
Rust
139 lines
5.4 KiB
Rust
//! 拖拽与「缝」的判据(从 `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"), "折叠条上没画标签");
|
||
}
|