feat(app-iced): M90 —— app.rs 拆成 src/app/ + presenter 真接线 + 实例页端到端

拆分(行为不变,截图四件套逐字节相同):
- 400 行的 app.rs 拆成 src/app/{mod,state,message,update,subscription,view,boot,tests}.rs,
  每个文件 ≤ 300 行;components 的 #[path] 跟着搬到 src/app/mod.rs(逻辑路径仍是 crate::app::components),
  可见性放到 pub(crate) 让页面层能用真组件。
- 拆分前后各出 shell/gallery 浅深四张图,cmp 全部 IDENTICAL。

presenter 接线(crate 文档 §4):
- App 持有 Box<dyn Backend> + SettingsBridge + AppState;启动 build_backend + SettingsBridge::open + AppState::new。
- 截图通道强制 mock(D30 可复现);交互模式走真 host,--data-dir 从 M90 起真的被消费。
- Boot 抽干首屏快照(第 3 帧落盘,等节拍会把空页面拍进图);节拍 = 帧事件 + 33ms 时间闸门
  (iced::time::every 要 tokio/smol,见 finding),tick → drain_events → AppState::apply。
- 用户动作不乐观回显:Launch/Stop 走 Backend::dispatch,状态等事件回来才变。
- 路由:pages::Message::Navigate(Route) 直接进 AppState::navigate,选中项 = 路由栈顶(不再有本地副本);
  首屏自动选中第一行(list-detail 桌面惯例),只做一次。

实例页打通(其余三页保留占位视觉 + Message/update + 路由):
- 列表行换成 components::navigation::ListItem(真五态/选中),页头与详情动作换成 components::buttons
  (Play 是唯一的实心 accent),分隔线换成 components::divider。
- 数据全部来自 presenter 快照:VERSION/LOADER/ISOLATION/VERSION ID/FILES/LAST PLAYED + 运行状态行。
- 导航轨换成真 NavigationRail(88),5 项各自压/替换路由。

版面约束未破(实测):列表列 336、正文列 424(元信息卡 206+12+206,右缘 896)、rail 88(药丸 16..71)、
断点 600/840 走 frame_metrics;新增 pages/tests.rs 的 9 条数值断言钉住它们。
This commit is contained in:
AskaEth committed 2026-11-02 15:56:57 +08:00
1 parent 270026338f
commit b484998fc9
17 files changed
+1990 -676

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
//! 页面层的**数值判据**:M90 接真组件后,「M84 的版面约束一条没破」这件事在这里钉死。
//!
//! 每条断言都注明了「抓得住什么 / 抓不住什么」——只测到纯函数,渲染真像素另见报告里的图与量测。
use super::layout::{self, frame_metrics, measure_cap_px, PaneKit, WidthClass};
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use unlucky_presenter::router::{NavSection, Route};
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
#[test]
fn four_screens_answer_the_mission_list() {
assert_eq!(Screen::ALL.len(), 4);
let slugs: Vec<&str> = Screen::ALL.iter().map(|screen| screen.slug()).collect();
assert_eq!(slugs, vec!["instances", "download", "settings", "logs"]);
for screen in Screen::ALL {
assert_eq!(Screen::from_slug(screen.slug()), Some(screen));
}
assert_eq!(Screen::from_slug("gallery"), None);
}
#[test]
fn screens_declare_the_layout_their_page_needs() {
assert_eq!(Screen::Instances.kit(), PaneKit::ListDetail);
assert_eq!(Screen::Settings.kit(), PaneKit::Supporting);
assert_eq!(Screen::Download.kit(), PaneKit::Single);
assert_eq!(Screen::Logs.kit(), PaneKit::Single);
}
/// 四个页面 × 三个对照宽度都能构图(不 panic、不依赖运行时)。
///
/// **抓得住**:`view` 的每条分支都能产出一棵树(含真空列表、无选中详情)。
/// **抓不住**:像素级观感(那要出图,见报告)。
#[test]
fn every_screen_builds_at_the_three_comparison_widths() {
let ui = ui();
for width in [1180.0, 900.0, 700.0] {
for screen in Screen::ALL {
let model = demo_model(screen, width);
let _element: Element<'_, Message> = app(&ui, &model);
}
}
}
/// 空数据也要能构图(真实后端的「还没扫到实例」是常态,不能 panic)。
#[test]
fn an_empty_snapshot_still_builds() {
let ui = ui();
for screen in Screen::ALL {
let model = Model::new(screen, 1180.0, Some(screen.nav()));
let _element: Element<'_, Message> = app(&ui, &model);
}
}
/// 版面对照:同一页在 1180 / 900 / 700 三个宽度上必须**真的换版面**。
#[test]
fn the_width_comparison_really_changes_the_layout() {
let wide = frame_metrics(PaneKit::ListDetail, 1180.0);
let mid = frame_metrics(PaneKit::ListDetail, 900.0);
let narrow = frame_metrics(PaneKit::ListDetail, 700.0);
assert_eq!(wide.layout, PageLayout::ListDetail);
assert_eq!(mid.layout, PageLayout::ListDetail);
assert_eq!(narrow.layout, PageLayout::SingleColumn);
// 900 与 1180 同为 list-detail,但详情列必须真的变窄(不是硬编码 1180 的宽度)
assert!(mid.column < wide.column, "900 的详情列应窄于 1180");
assert!(narrow.secondary.is_none(), "700 不许再有列表列");
}
/// **M84 的行宽红线**:任何页、任何窗口宽度、任何版面,正文列都不许超过 424px。
///
/// **抓得住**:`frame_metrics` 的 `column`(页面唯一的列宽来源)不会越界;
/// **抓不住**:页面自己绕过 metrics 写死宽度(那条由 `tests/no_hardcoded_tokens.rs` 的
/// 「`.width(` 后面不许跟数字字面量」守门——注意它扫的是 `src/shell` + `src/pages`)。
#[test]
fn every_page_any_width_stays_under_the_measure_cap() {
let cap = measure_cap_px();
assert!((cap - 424.0).abs() < f32::EPSILON, "行宽上限必须是 424");
for screen in Screen::ALL {
for width in [416.0, 600.0, 700.0, 840.0, 900.0, 1180.0, 1600.0, 2560.0] {
let metrics = frame_metrics(screen.kit(), width);
assert!(
metrics.column <= cap + f32::EPSILON,
"{} @{width} 的正文列 {} 超过 424",
screen.slug(),
metrics.column
);
}
}
}
/// 断点 600 / 840 与 rail 88:M84 的三条硬值,接真组件后不许漂。
///
/// **抓得住**:布局系统的常量与 `WidthClass::of` 的分档;
/// **抓不住**:导航轨真画出来是不是 88(那条要量像素;组件自己声明 88 见下一个断言)。
#[test]
fn the_three_frozen_numbers_are_unchanged() {
assert!((layout::BREAKPOINT_MEDIUM - 600.0).abs() < f32::EPSILON);
assert!((layout::BREAKPOINT_EXPANDED - 840.0).abs() < f32::EPSILON);
assert!((layout::rail_width() - 88.0).abs() < f32::EPSILON);
assert_eq!(WidthClass::of(599.9), WidthClass::Compact);
assert_eq!(WidthClass::of(600.0), WidthClass::Medium);
assert_eq!(WidthClass::of(839.9), WidthClass::Medium);
assert_eq!(WidthClass::of(840.0), WidthClass::Expanded);
}
/// 导航轨用的**真组件**(M78 的 `NavigationRail`)声明的宽度必须与布局系统同值。
///
/// **抓得住**:`NavPaneGeometry::rail().width` == `layout::rail_width()` == 88(组件与布局系统没分叉);
/// **抓不住**:容器圆角/药丸摆放的像素级正确性(组件自己的 probe 管)。
#[test]
fn the_real_rail_agrees_with_the_layout_system() {
let ui = ui();
let geometry = crate::app::components::navigation::geometry::NavPaneGeometry::of(
&ui,
crate::app::components::navigation::NavLayout::Rail,
);
assert!(
(geometry.width - layout::rail_width()).abs() < f32::EPSILON,
"真组件 rail 宽 {} 与布局系统的 {} 分叉了",
geometry.width,
layout::rail_width()
);
}
/// 路由 → 页面:四个一级页各自落到自己那页,`Detail` 落在实例页,`Agent` 没有页面(app 层回退)。
#[test]
fn routes_map_onto_pages() {
assert_eq!(Screen::of(&Route::Instances), Some(Screen::Instances));
assert_eq!(
Screen::of(&Route::Detail("survival".to_string())),
Some(Screen::Instances),
"详情页仍是实例页的那一屏"
);
assert_eq!(Screen::of(&Route::Download), Some(Screen::Download));
assert_eq!(Screen::of(&Route::Logs), Some(Screen::Logs));
assert_eq!(Screen::of(&Route::Settings), Some(Screen::Settings));
assert_eq!(Screen::of(&Route::Agent), None, "AI 独立页还没有页面实现");
}
/// 导航轨的每一项都指向一个**它能高亮的**路由(点完不会出现「没有任何项高亮」的悬空态)。
///
/// **抓得住**:`NAV` 表的 5 项与 `Screen::nav` / `Route::nav` 三者没有错位;
/// **抓不住**:点下去真的换页(那是 `app/update.rs` 的 `Navigate` 分支 + 集成图)。
#[test]
fn every_rail_item_points_at_a_route_that_highlights_it() {
assert_eq!(NAV.len(), 5, "导航轨 5 项(4 个一级页 + AI)");
for (section, _icon, label, route) in NAV {
assert!(!label.is_empty());
assert_eq!(
route.nav(),
section,
"{label} 指向的 {route:?} 高亮的是 {:?}",
route.nav()
);
}
assert_eq!(
NAV[4].0, None,
"AI 项没有导航高亮(Route::Agent.nav() 是 None)"
);
// 四个一级页的 page-nav 与 route-nav 同源
assert_eq!(Screen::Instances.nav(), NavSection::Instances);
assert_eq!(Screen::Settings.nav(), NavSection::Settings);
}
/// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。
#[test]
fn the_demo_model_carries_the_m84_baseline() {
let model = demo_model(Screen::Instances, 1180.0);
assert_eq!(model.instances.rows.len(), 12);
let detail = model.instances.detail.as_ref().expect("演示数据有选中项");
assert_eq!(detail.meta.len(), 6, "元信息 6 格");
assert!(detail.note.is_some(), "演示数据带正文段");
assert_eq!(model.active, Some(NavSection::Instances));
// 其余三页不带实例数据(占位视觉不读它)
for screen in [Screen::Download, Screen::Settings, Screen::Logs] {
let model = demo_model(screen, 1180.0);
assert!(
model.instances.rows.is_empty(),
"{} 不该带实例数据",
screen.slug()
);
}
}