Files
Unlucky-Launcher/crates/app-iced/src/app/tests.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

383 lines
16 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.
//! `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 优先级最高,系统主题不许盖"
);
}