p2-1(唯一 p2):`Route::Agent` 与 `RailState::Detached` 之前没有绑定——从左侧导航第五项 (本 mission 点名的入口)进独立页时档位还是 Collapsed/Expanded,于是页头出现「Open assistant」 「Collapse/Pop out」、挂着不化的未读胶囊、Esc 也收不走页面(复审用产品 bin 复现)。 - **单一入口**:`App::sync_agent_route()`(update/agent.rs)在 `refresh_view` 开头调用, 两条对向规则:在独立页 ⇒ Detached(顺带清未读);离开独立页而档位还是 Detached ⇒ 收回右栗。 每一次路由变化都经过 refresh_view,所以导航 / ULL_START_ROUTE / detach / back 共用这一处。 - **结构性兜底**:`conversation::actions` 先按宿主分流,`Host::Page` 的动作恒为「Dock back」。 - **启动顺序**:`boot_agent()`(窗口尺寸 adopt + 拆出档直达独立页)必须在 `dispatch(RefreshAll)` 之前——那次 dispatch 会 pump→auto_select 把路由拉到实例详情,同步器随即把档位收回(实测踩到)。 - 测试:`entering_the_agent_route_detaches_the_rail`(走 `Message::Select(NavItem::Ai)` 真路径, 含未读清零 / Esc 离开 / 从页上点别的导航收回)、`the_page_navigation_entry_also_detaches`。 变异自证:把 `sync_agent_route` 改成 no-op ⇒ 3 红(含这两条)。 - 图 13/14:导航入口(折叠档带 2 条未读 / 展开档)进页后都是 Dock back、无未读胶囊、副标题单行。 P3-2 Enter/Shift+Enter 有测试了:分流抽成纯函数 `composer::binding_for`, `enter_sends_and_shift_enter_breaks_the_line` 覆盖「发送 / 换行 / 未聚焦不抢 / 其它键交回默认」; `AgentUi::edit` 里与 fallthrough 等价的冗余 Enter 分支删掉;三档测试改走 `agent.edit(...)`。 P3-4 自证式断言换字面量(320+8+852=1180 等);变异 RESIZE_GUTTER 8→0 ⇒ 6 红(含这条)。 P3-5 两条死文案真用上:8px 拖拽缝有了 tooltip(AgentResize + AgentWidthLabel「当前多宽」)。 P3-6 `on_boot` 的两块逻辑搬进 `update/agent.rs::boot_agent`:update.rs 372→383(+11,见报告逐行)。 P3-7 拖拽层补 on_press/on_exit:窗外松手不再僵住(窗内按下→清态;离窗→收在当前值)。 P3-9 顺手:只有几何/路由会变的消息才重建视图模型(每个字符、每次指针移动不再重建)。 门禁:fmt 0 / clippy(workspace,-D warnings) 0 / pages_demo 0 / 全文件 ≤400(最大 view.rs 373 之外 conversation.rs 383、update.rs 383)
381 lines
15 KiB
Rust
381 lines
15 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, "出厂是折叠档");
|
||
assert_eq!(app.agent.unread, 2, "出厂带两条未读");
|
||
|
||
// ① 外壳导航第五项(真实入口)
|
||
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.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);
|
||
app.agent.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.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 优先级最高,系统主题不许盖"
|
||
);
|
||
}
|