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 与失败横幅。
383 lines
16 KiB
Rust
383 lines
16 KiB
Rust
//! `app` 的单测:主题切换、窗口尺寸、截图闸门、gallery 能构图,以及**presenter 接线的闭环**。
|
||
//!
|
||
//! 闭环测试用的是 `MockBackend`(presenter 自带的假后端):它走的是与真 host 完全相同的
|
||
//! `dispatch → drain_events → AppState::apply → 视图模型` 通道,所以「点 Play → 状态真的变」
|
||
//! 这件事在这里就能判死,不必等图形环境。
|
||
|
||
use unlucky_design::color::SchemeFlavor;
|
||
use unlucky_presenter::mock::MockBackend;
|
||
use unlucky_presenter::router::Route;
|
||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||
|
||
#[cfg(test)]
|
||
mod appearance;
|
||
#[cfg(test)]
|
||
mod presenter;
|
||
|
||
use super::message::Message;
|
||
use super::state::App;
|
||
use super::{APP_TITLE, WINDOW_SIZE};
|
||
use crate::cli::{Options, Screen};
|
||
use crate::pages;
|
||
use crate::shell::agent::RailState;
|
||
use crate::shell::NavItem;
|
||
use crate::theme::Families;
|
||
|
||
const FAMILIES: Families = Families {
|
||
text: "Segoe UI",
|
||
display: "Segoe UI",
|
||
mono: "Consolas",
|
||
};
|
||
|
||
/// 不碰磁盘的 App(mock 后端 + 内存设置桥)。
|
||
fn headless(options: Options, flavor: SchemeFlavor) -> App {
|
||
App::with_presenter(
|
||
options,
|
||
FAMILIES,
|
||
flavor,
|
||
Box::new(MockBackend::new()),
|
||
SettingsBridge::memory_only(),
|
||
)
|
||
}
|
||
|
||
fn screenshot_options() -> Options {
|
||
Options {
|
||
screenshot: Some(std::path::PathBuf::from("x.png")),
|
||
..Options::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
|
||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||
app.state.navigate(Route::Logs);
|
||
app.refresh_view();
|
||
assert_eq!(app.ui().name(), "ull-m3-light");
|
||
|
||
app.set_flavor(SchemeFlavor::Dark);
|
||
|
||
assert_eq!(app.ui().name(), "ull-m3-dark", "切主题要换主题名");
|
||
assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态");
|
||
assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由");
|
||
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
|
||
}
|
||
|
||
#[test]
|
||
fn window_size_is_the_acceptance_size() {
|
||
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
|
||
assert_eq!(APP_TITLE, "Unlucky Launcher");
|
||
}
|
||
|
||
#[test]
|
||
fn screenshot_mode_never_waits_for_the_system_theme() {
|
||
// 门禁依赖:CI 的 runtime-smoke 不带主题,xvfb 里也没有主题会话。
|
||
let options = screenshot_options();
|
||
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
|
||
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
|
||
|
||
let app = headless(options, SchemeFlavor::Light);
|
||
assert!(app.has_screenshot() && app.ui().name() == "ull-m3-light");
|
||
}
|
||
|
||
#[test]
|
||
fn gallery_screen_builds_a_tree() {
|
||
assert_eq!(Options::default().screen, Screen::Shell, "默认仍是产品骨架");
|
||
let options = Options {
|
||
screen: Screen::Gallery,
|
||
..Options::default()
|
||
};
|
||
let app = headless(options, SchemeFlavor::Light);
|
||
// 真实像素由 `--screen=gallery --screenshot` 出图 + `gallery::probe` 量
|
||
let _ = app.view();
|
||
}
|
||
|
||
#[test]
|
||
fn frames_are_only_requested_in_screenshot_mode() {
|
||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||
assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘");
|
||
|
||
app.options.screenshot = Some(std::path::PathBuf::from("x.png"));
|
||
assert!(
|
||
app.wants_frames(),
|
||
"截图模式必须订阅帧事件才能在第 3 帧出图"
|
||
);
|
||
}
|
||
|
||
// --------------------------------------------------------------- P2-1 按需节拍
|
||
|
||
/// 空闲态**不要每帧节拍**(但要有 1 Hz 心跳)。
|
||
///
|
||
/// 意图与 r2 那条 `an_idle_app_asks_for_no_clock` 一致:空闲时不许按帧循环跑(那会以每秒几百帧
|
||
/// 的速度重绘整窗),改成「低频心跳」。断言拆成两半:
|
||
///
|
||
/// 1. `needs_frames()` 为假 ⇒ **不订阅** `window::frames()`;
|
||
/// 2. 空闲时钟的周期 = [`IDLE_TICK`] = 1 s,且心跳每拍推进的 tick 预算 = 30(= 1 s / 33 ms),
|
||
/// 这样 `theme::config_poll_due`(30 拍)仍是「约 1 秒比对一次 mtime」。
|
||
///
|
||
/// 抓得住:有人把「无条件订阅帧」改回来(第 1 半红);有人把心跳周期改快/改慢(第 2 半红)。
|
||
/// 抓不住:真实窗口的重绘次数(那要 `ULL_TICK_TRACE=1` 跑一次交互模式看日志,见报告)。
|
||
#[test]
|
||
fn an_idle_app_uses_a_slow_heartbeat_instead_of_frames() {
|
||
let app = headless(screenshot_options(), SchemeFlavor::Light);
|
||
assert!(app.needs_frames(), "截图通道永远要帧(第 3 帧落盘)");
|
||
|
||
let mut idle = headless(Options::default(), SchemeFlavor::Light);
|
||
let _ = idle.update(Message::Boot);
|
||
assert!(
|
||
idle.needs_frames(),
|
||
"Boot 刚派发过 RefreshAll:3 秒观察窗内要节拍(等真 host 的首批事件)"
|
||
);
|
||
|
||
// 观察窗过期、也没有忙碌实例 ⇒ 空闲态不订阅帧事件(改走 1 Hz 心跳)
|
||
idle.watch_until = None;
|
||
assert!(!idle.needs_frames(), "空闲交互态:不订阅帧事件(只有心跳)");
|
||
|
||
// 派发之后观察窗重新打开
|
||
let _ = idle.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||
"survival".to_string(),
|
||
))));
|
||
let _ = idle.update(Message::Page(pages::Message::Launch(
|
||
"survival".to_string(),
|
||
)));
|
||
assert!(idle.needs_frames(), "派发后观察窗内要有节拍");
|
||
|
||
// 进入 Busy 之后由「忙碌实例」这个条件自己续订(不靠观察窗)
|
||
idle.watch_until = None;
|
||
assert!(
|
||
idle.needs_frames(),
|
||
"有 Busy 实例时即便观察窗过期也要继续 tick"
|
||
);
|
||
}
|
||
|
||
/// 心跳的**节奏**:1 Hz,且每拍折算 30 个 33 ms 单位 ⇒ `config_poll_due` ≈ 每秒一次。
|
||
///
|
||
/// 抓得住:心跳周期常量、以及 `advance()` 里「按流逝时间折算 ticks」这条记账(改回
|
||
/// `ticks += 1` 会让空闲态的配置轮询变成 30 秒一次,这条就红)。
|
||
/// 抓不住:`Task::perform` 那条自续链真的每秒触发一次(那是运行期 trace 的事,见报告)。
|
||
#[test]
|
||
fn the_idle_heartbeat_budgets_one_config_poll_per_second() {
|
||
use unlucky_presenter::theme::config_poll_due;
|
||
|
||
assert_eq!(
|
||
super::state::IDLE_TICK,
|
||
std::time::Duration::from_secs(1),
|
||
"空闲心跳周期必须是 1 s"
|
||
);
|
||
|
||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||
let _ = app.update(Message::Boot);
|
||
app.watch_until = None;
|
||
|
||
// 一次空闲心跳(1 s)之后:ticks 增加 30,且正好落在 config 轮询点上
|
||
let before = app.ticks;
|
||
app.advance(super::state::IDLE_TICK);
|
||
assert_eq!(app.ticks - before, 30, "1 s 应折算 30 个 33 ms 拍");
|
||
assert!(
|
||
config_poll_due(app.ticks),
|
||
"空闲心跳每拍都要落在 config 轮询点上(ticks={})",
|
||
app.ticks
|
||
);
|
||
}
|
||
|
||
/// 空闲态每拍**不再重建视图模型**(P2-1 的另一半:`refresh_view` 的 30 Hz × N 实例白克隆)。
|
||
///
|
||
/// 抓得住:把 `advance()` 里的 `if self.view_dirty` 去掉(改回无条件 `refresh_view()`)时,
|
||
/// 这条断言里的 `refreshes` 会从「不增长」变成「每拍 +1」。
|
||
/// 抓不住:「重建一次花了多少微秒」(没有基准测试;量级判断见报告)。
|
||
#[test]
|
||
fn idle_ticks_do_not_rebuild_the_view_model() {
|
||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||
let _ = app.update(Message::Boot);
|
||
|
||
let before = app.refreshes;
|
||
for _ in 0..100 {
|
||
app.advance(super::state::TICK);
|
||
}
|
||
assert_eq!(
|
||
app.refreshes,
|
||
before,
|
||
"空闲 100 拍:视图模型一次都不该重建(实际 +{})",
|
||
app.refreshes - before
|
||
);
|
||
|
||
// 有事件进来时仍然要重建(否则页面不刷新)
|
||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Logs)));
|
||
assert!(app.refreshes > before, "导航之后必须重建一次");
|
||
}
|
||
/// 视图模型跟着窗口宽度走(交互模式下 600 / 840 两条断点靠 resize 事件生效)。
|
||
///
|
||
/// M104 起 `view.window_width` 装的是**中栗可用宽度**(窗口 − 右栗 − 拖拽缝),所以这里按三档
|
||
/// 各钉一个**字面量**:48 的折叠条与 320 的面板各自的减法都得对。
|
||
/// (断言里不调 `agent.content_width(..)`——那是同义反复,实现错一处它照样绿。)
|
||
#[test]
|
||
fn resizing_the_window_rebuilds_the_view_model() {
|
||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||
let _ = app.update(Message::Boot);
|
||
|
||
// 出厂是 Collapsed(48) 档:中栗 = 窗口 − 48
|
||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||
iced::Size::new(1180.0, 760.0),
|
||
)));
|
||
assert!(
|
||
(app.view.window_width - 1132.0).abs() < f32::EPSILON,
|
||
"1180 − 48(折叠条)= 1132,实际 {}",
|
||
app.view.window_width
|
||
);
|
||
|
||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||
iced::Size::new(700.0, 600.0),
|
||
)));
|
||
assert!(
|
||
(app.view.window_width - 652.0).abs() < f32::EPSILON,
|
||
"700 − 48(折叠条)= 652,实际 {}",
|
||
app.view.window_width
|
||
);
|
||
|
||
// Expanded(320) 档:中栗 = 窗口 − 320(面板)− 8(拖拽缝)= 852
|
||
app.agent.expand();
|
||
app.view_dirty = true;
|
||
app.refresh_view();
|
||
assert!(
|
||
(app.view.window_width - 652.0).abs() < f32::EPSILON,
|
||
"700 上展开档不成立(上限 700 − 88 − 472 − 8 = 132 < 280)⇒ 实际按折叠条 48 算: 700 − 48 = 652,实际 {}",
|
||
app.view.window_width
|
||
);
|
||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||
iced::Size::new(1180.0, 760.0),
|
||
)));
|
||
assert!(
|
||
(app.view.window_width - 852.0).abs() < f32::EPSILON,
|
||
"1180 − 320 − 8 = 852,实际 {}",
|
||
app.view.window_width
|
||
);
|
||
|
||
// Detached 档:右栗不占宽,中栗 = 窗口 = 1180。
|
||
// **必须走路由**(`Select(Ai)`):r1 之后「档位 = Detached」与「路由 = Agent」是一条不变式,
|
||
// 直接 `agent.detach()` 再手工 refresh 会被 `sync_agent_route` 立刻收回右栗(那正是要的语义)。
|
||
let _ = app.update(Message::Select(NavItem::Ai));
|
||
app.view_dirty = true;
|
||
app.refresh_view();
|
||
assert!(
|
||
(app.view.window_width - 1180.0).abs() < f32::EPSILON,
|
||
"拆出后右栗不占宽:1180 − 0 = 1180,实际 {}",
|
||
app.view.window_width
|
||
);
|
||
// 页面照旧能构图(窄窗退化到单列,不 panic)
|
||
let _ = app.view();
|
||
}
|
||
|
||
/// **不变式(复审 r1 的 P2-1)**:`Route::Agent` ⟺ `RailState::Detached`。
|
||
///
|
||
/// 走的是**真实路由入口**(外壳导航第五项 `Assistant` → `Message::Select(NavItem::Ai)`),不是
|
||
/// 直接调 `agent.detach()`:r1 复现的 bug 正是「导航进页面时档位没变」,只测 `detach()` 抓不到它。
|
||
///
|
||
/// 抓得住:①进独立页后档位 = Detached(页头才会是 `Dock back` 而不是 `Open assistant`/`Pop out`);
|
||
/// ②未读在进页时清零;③`Esc` 在独立页真的离开(不是只把档位改成折叠);④从页上点别的导航,
|
||
/// 会话收回右栗(档位不再停在 Detached)。
|
||
/// 抓不住:按钮在像素上长什么样(那要出图,见 `13/14` 两张)。
|
||
#[test]
|
||
fn entering_the_agent_route_detaches_the_rail() {
|
||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||
let _ = app.update(Message::Boot);
|
||
assert_eq!(app.agent.rail, RailState::Collapsed, "出厂是折叠档");
|
||
// M119:出厂没有样本会话,未读由 presenter 按真事件累加 ⇒ 出厂就是 0
|
||
assert_eq!(app.agent_host.rail().unread, 0, "出厂 0 条未读");
|
||
|
||
// ① 外壳导航第五项(真实入口)
|
||
let _ = app.update(Message::Select(NavItem::Ai));
|
||
assert_eq!(app.state.route(), &Route::Agent);
|
||
assert_eq!(
|
||
app.agent.rail,
|
||
RailState::Detached,
|
||
"进了独立页,档位必须是 Detached——否则页头会画右栗才有的动作"
|
||
);
|
||
assert_eq!(app.agent_host.rail().unread, 0, "进独立页即读");
|
||
|
||
// ③ Esc 真的离开这一页
|
||
let _ = app.update(Message::Agent(crate::shell::agent::Message::Escape));
|
||
assert_ne!(app.state.route(), &Route::Agent, "独立页上 Esc 必须离开");
|
||
assert_eq!(app.agent.rail, RailState::Expanded, "收回到右栗展开档");
|
||
|
||
// ④ 从独立页点别的导航:会话收回右栗(档位不能停在 Detached)
|
||
let _ = app.update(Message::Select(NavItem::Ai));
|
||
assert_eq!(app.agent.rail, RailState::Detached);
|
||
let _ = app.update(Message::Select(NavItem::Instances));
|
||
assert_eq!(app.state.route(), &Route::Instances);
|
||
assert_eq!(
|
||
app.agent.rail,
|
||
RailState::Expanded,
|
||
"离开独立页 ⇒ 会话收回右栗(否则页走了、栏还是 0 宽)"
|
||
);
|
||
}
|
||
|
||
/// 同一条不变式的另一个入口:页面层的 `Navigate(Route::Agent)`(右栗里的 `pages::Message` 路径)。
|
||
#[test]
|
||
fn the_page_navigation_entry_also_detaches() {
|
||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||
let _ = app.update(Message::Boot);
|
||
// 走真消息(不是直接改字段):这样「展开即读」的 `agent_mark_read` 也会真的发生
|
||
let _ = app.update(Message::Agent(crate::shell::agent::Message::Expand));
|
||
assert_eq!(app.agent.rail, RailState::Expanded);
|
||
|
||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Agent)));
|
||
assert_eq!(app.state.route(), &Route::Agent);
|
||
assert_eq!(app.agent.rail, RailState::Detached);
|
||
assert_eq!(app.agent_host.rail().unread, 0);
|
||
|
||
// 页面层回退(不是 Dock 消息)也要把档位收回来
|
||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
|
||
assert_eq!(app.agent.rail, RailState::Expanded);
|
||
}
|
||
|
||
/// 截图模式不读设置文件里的主题(D30 可复现:两台机器出同一张图)。
|
||
#[test]
|
||
fn screenshot_mode_ignores_the_settings_file_theme() {
|
||
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
|
||
app.apply_config_theme();
|
||
assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行");
|
||
}
|
||
|
||
/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。
|
||
///
|
||
/// r2 之前 `Message::SystemTheme` 无条件 `set_flavor`,于是「配置文件写 dark、系统是浅色」会
|
||
/// 出现 `light -> dark -> light` 的一次抖动、配置永远不生效(r3 做 P2-2 的配置热切实测时暴露
|
||
/// 出来,一并修)。
|
||
///
|
||
/// 抓得住:系统主题不许盖掉显式配置/命令行给出的主题(三条分支各一条断言)。
|
||
/// 抓不住:真实桌面切换系统主题时的事件时序(只有消息级验证)。
|
||
#[test]
|
||
fn the_system_theme_never_overrides_an_explicit_one() {
|
||
use unlucky_presenter::prefs::ThemeMode;
|
||
|
||
// ① auto(内存桥默认):系统主题说了算
|
||
let mut auto = headless(Options::default(), SchemeFlavor::Light);
|
||
assert_eq!(auto.theme_mode, ThemeMode::Auto);
|
||
let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark));
|
||
assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统");
|
||
|
||
// ② 配置文件显式 dark:系统给浅色也不许改
|
||
let mut pinned = headless(Options::default(), SchemeFlavor::Dark);
|
||
pinned.theme_mode = ThemeMode::Dark;
|
||
let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light));
|
||
assert_eq!(
|
||
pinned.ui().name(),
|
||
"ull-m3-dark",
|
||
"配置文件说 dark,系统说 light:不许改"
|
||
);
|
||
|
||
// ③ 命令行 `--theme` 优先级最高
|
||
let mut explicit = headless(
|
||
Options {
|
||
theme: Some(SchemeFlavor::Dark),
|
||
..Options::default()
|
||
},
|
||
SchemeFlavor::Dark,
|
||
);
|
||
let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light));
|
||
assert_eq!(
|
||
explicit.ui().name(),
|
||
"ull-m3-dark",
|
||
"--theme 优先级最高,系统主题不许盖"
|
||
);
|
||
}
|