Files
Unlucky-Launcher/crates/app-iced/src/pages/tests.rs
T
AskaEth bbf96de9b3 feat(app-iced): M108 下载页显示名改成「版本管理 / Version management」
用户令(2026-11-04):当前下载页改名叫版本管理,下载页只负责下载整合包和 Mod/光影。
本 mission 只改显示层,标识符(Route::Download / Screen::Download / NavItem::Download /
文件名)一个都没动,标识符改名另登 finding 后置。

i18n(catalog/nav.rs + catalog/download.rs,金表同步):
- NavDownload = Versions/版本管理 —— 88px 收起轨用短名
- NavDownloadExpanded = Version management/版本管理 —— 240px 展开抽屉用全名
- DownloadTitle = Version management/版本管理 —— 页标题
- DownloadSubtitle = Manage versions and loaders: install, upgrade, repair/
  管理版本与加载器:安装、升级、修复
拆两条 key 的理由:18 字符英文 ≈101px 塞不进 88px 轨(实测两通道都被裁:产品通道只显示
Version,组件版轨被切成 Version manage);中文 4 字实测 48.0px(四种 CJK 字体同值)放得下,
所以中文两条同值、不缩。

- pages/nav.rs:NAV 第 3 列 &str → Key;label_key(label, expanded) 是两态切换点
  (用 bool 参数而非 enum:240 态在产品里还没有渲染路径,枚举变体永远构造不到会被
  -D warnings 的 dead_code 拦下)。
- shell/nav.rs:NavItem::Download 显示 Versions(产品路径的轨;i18n 接线归 M106,
  本 mission 只换文案值)。
- 守门:pages/tests.rs 断言导航/页标题取值 + 两态切换 + 目录里凡指向这一页的文案都不许再说
  「下载」(豁免 DownloadStageDownloading);shell/nav.rs 断言标签是 Versions 且 ≤12 字符。
2026-11-05 02:54:04 +08:00

380 lines
17 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.
//! 页面层的**数值判据**:M90 接真组件后,「M84 的版面约束一条没破」这件事在这里钉死。
//!
//! 每条断言都注明了「抓得住什么 / 抓不住什么」——只测到纯函数,渲染真像素另见报告里的图与量测。
use super::layout::{self, frame_metrics, measure_cap_px, PaneKit, WidthClass};
use super::nav::NAV;
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use unlucky_i18n::Lang;
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 不许再有列表列");
}
/// **M102 的对齐判据**:正文块左缘 = 页头标题左缘(`body_offset == 0`),块宽 = pane 宽(封顶 1440)。
///
/// 页头标题的左缘 = `rail + gutter`(`frame::header_bar` 的 padding 与 `frame::page` 的内容
/// padding 是同一个 `metrics.gutter`);正文块宽 = `frame::body_width`。
///
/// **抓得住**:块宽被改回 424(视觉复审 ①:左缘 422 对标题 112)、偏移不再是 0(居中/右对齐)、
/// 上限被挪动;
/// **抓不住**:`frame::body` 到底有没有照这两个数建盒子(那由
/// [`frame_body_is_built_from_the_block_quantities`] 扫源码守)。
#[test]
fn body_block_starts_at_the_header_title_and_fills_the_page() {
for width in [600.0, 840.0, 900.0, 1180.0] {
let metrics = frame_metrics(PaneKit::Single, width);
let title_x = layout::rail_width() + metrics.gutter;
let block = frame::body_width(metrics);
assert_eq!(
block, metrics.available,
"{width}: 单列页块宽必须是可用宽度"
);
assert_eq!(
layout::block::body_offset(metrics),
0.0,
"{width}: 块必须贴左"
);
assert!(
(title_x + block + metrics.gutter - width).abs() < f32::EPSILON,
"{width}: 块从 {title_x} 起、宽 {block},没铺到右页边距"
);
// 块变宽不等于一句话变宽:散文列仍 ≤424
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
}
// 验收尺寸 1180:标题左缘 112、块宽 1044(旧版居中时正文左缘是 422)
let wide = frame_metrics(PaneKit::Single, 1180.0);
assert_eq!(layout::rail_width() + wide.gutter, 112.0, "页头标题左缘");
assert_eq!(frame::body_width(wide), 1044.0, "正文块宽度");
// 极宽窗:块宽封顶 1440(tower 拍板),仍然贴左 ⇒ 右侧留白而不是居中
let huge = frame_metrics(PaneKit::Single, 2560.0);
assert_eq!(frame::body_width(huge), layout::block::BODY_MAX_WIDTH);
assert_eq!(frame::body_width(huge), 1440.0);
assert_eq!(layout::block::body_offset(huge), 0.0);
assert!(112.0 + frame::body_width(huge) < 2560.0);
// 两列版面的块宽 = 主列 pane(实例页正文在 panes 的主列里,不铺整页)
let two = frame_metrics(PaneKit::ListDetail, 1180.0);
assert_eq!(frame::body_width(two), two.main_pane);
assert_eq!(two.main_pane, 1180.0 - 88.0 - 48.0 - 336.0 - 24.0);
}
/// **源码结构**:`frame::body` 的盒子必须从「块宽 + 块偏移 + 贴左」这三个数建。
///
/// 为什么读源码:iced 的 `Element` 没有几何内省接口,`frame::body` 换成
/// `centered(metrics.column, content)`(**就是视觉复审 ① 的改前写法**)不会红任何数值断言 ——
/// M102 r1 的变异实测就是这样漏掉的(`pages/tests.rs` 那条只喂 `frame_metrics()` 与
/// `body_width()`)。所以这条盯的是**摆法**:宽度来自 `body_width(metrics)`、左内边距来自
/// `body_offset(metrics)`、`align_x` 只许是 `Start`(不写 = container 默认 Start,等价),
/// 且不出现居中/散文列宽。
///
/// 扫的是 [`layout::source::region_across`]:`frame.rs` **加上** `layout/block.rs`,
/// 于是「拆成 `body` → `body_impl`」(变异 H1)与「把盒子原样搬进 `block::body_box`」(变异 M6)
/// 这两种**等价重构都不假红**,而「换成居中 424 盒子」照样红(变异 A)。
///
/// **抓得住**:盒子的**语义**变了(居中 / 用散文列宽 / 用 `centered`);
/// **抓不住**:`frame::centered(metrics.column, …)` 这种**与 `frame::body` 逐像素等价**的另写
/// (r4 复审的 M1/M2 —— 按塔的裁定那是等价重构,护栏不该为此红),也不抓「先赋局部变量再建盒」。
#[test]
fn frame_body_is_built_from_the_block_quantities() {
let body = layout::source::region_across(
&[include_str!("frame.rs"), include_str!("layout/block.rs")],
"pub fn body<'a>(",
);
for needle in ["body_width(metrics)", "body_offset(metrics)"] {
assert!(
body.contains(needle),
"`frame::body` 必须从 {needle} 建盒(贴左 + 块宽 + 零偏移):\n{body}"
);
}
// `align_x` 出现就必须是 Start:`.align_y(Alignment::Start)` 单独存在不算数(r2 复审 P2-3),
// 而「干脆不写 align_x」(container 默认 Start)视为等价重构,放行。
// 判的是**实参**而不是整段文本,所以 `Alignment::Start` 与 `iced::Alignment::Start` 同等放行
// (把盒子搬进别的模块后路径要写全,那是等价重构,不该假红 —— r4 复审的变异 M6)。
for argument in layout::source::call_arguments(&body, ".align_x(") {
assert!(
argument.trim().ends_with("Alignment::Start"),
"`frame::body` 的 align_x 只许是 Start,收到 {argument:?}:\n{body}"
);
}
for forbidden in [
"centered(",
"single_column(",
"metrics.column",
"Alignment::Center",
] {
assert!(
!body.contains(forbidden),
"`frame::body` 里出现了 {forbidden:?} —— 那是居中 / 散文列宽的老写法:\n{body}"
);
}
}
/// **源码结构**:`frame::prose` 的宽度必须来自 `prose_width`(= `metrics.column`),不来自块宽。
///
/// M102 把两个量拆开后,「把散文塞进 1044 的块」不会红任何数值断言;这条盯接线。
/// 与 `frame::body` 那条一样用 [`layout::source::region_across`](`frame.rs` + `layout/block.rs`),
/// 所以把盒子搬进 `layout/block.rs` 的等价重构不假红;抓的是**语义**(用块宽/可用宽度就红)。
#[test]
fn frame_prose_is_built_from_the_prose_width() {
let prose = layout::source::region_across(
&[include_str!("frame.rs"), include_str!("layout/block.rs")],
"pub fn prose<'a>(",
);
assert!(
prose.contains("prose_width(metrics)"),
"`frame::prose` 必须从 prose_width 取宽:\n{prose}"
);
for forbidden in ["body_width(", "metrics.available", "metrics.main_pane"] {
assert!(
!prose.contains(forbidden),
"`frame::prose` 里出现了 {forbidden:?} —— 散文会失去 72 字符行宽承诺:\n{prose}"
);
}
}
/// **源码结构(调用点)**:单列页必须调 `frame::body`,不许退回 `frame::single_column`。
///
/// r2 复审的变异 G:把四个页面文件里的 7 个调用点(`download.rs` 2 / `logs.rs` 3 /
/// `instances.rs` 1 / `instances/detail.rs` 1)一键改回 `frame::single_column` ⇒ 420 条全绿,
/// 而视觉复审 ① 原样回来。r2 的护栏只盯 `frame::body` **内部**,`single_column` 又是为设置页
/// 合法保留的,所以「哪个页面用它」必须单独守。
///
/// 逐文件断言(而不是只数一条调用):**同一个文件里退一个调用点**也会让
/// `frame::single_column(` 出现,立刻红。
///
/// **抓得住**:页面退回居中的老写法(视觉复审 ① 原样);
/// **抓不住**:新增的页面文件(它不在这个清单里——新页要自己登记)。
#[test]
fn single_column_pages_call_the_body_box_not_the_centred_one() {
for (name, source) in [
("download.rs", include_str!("download.rs")),
("logs.rs", include_str!("logs.rs")),
("instances.rs", include_str!("instances.rs")),
("instances/detail.rs", include_str!("instances/detail.rs")),
] {
assert!(
source.contains("frame::body("),
"{name} 没有调用 frame::body —— 正文块不再从块宽/块偏移建盒"
);
assert!(
!source.contains("frame::single_column("),
"{name} 退回了 frame::single_column(= 424 居中,视觉复审 ① 的原样)"
);
}
// 实例页窄窗那一屏的唯一入口也得走 body
let narrow =
layout::source::function_body(include_str!("instances/detail.rs"), "fn narrow_detail");
assert!(
narrow.contains("frame::body("),
"narrow_detail 没走 frame::body:\n{narrow}"
);
}
/// **M84 的行宽红线**:任何页、任何窗口宽度、任何版面,正文列都不许超过 424px。
///
/// **抓得住**:`frame_metrics` 的 `column`(页面唯一的列宽来源)不会越界;
/// **抓不住**:页面自己绕过 metrics 写死宽度(那条由 `tests/no_hardcoded_tokens.rs` 的
/// 「`.width(` 后面不许跟数字字面量」守门——它扫的是 `src/shell` + `src/pages` + `src/app`
/// (M90 r2/P3-3 把 `app` 加进去了)。
#[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`)声明的宽度必须与布局系统同值。
///
/// 注意这不是产品路径的实现(M90 r3 订正):产品页的 rail 由 `shell/nav.rs` 画,组件版只服务
/// `pages::app`(harness 出口图)与单测。这条守的是「组件与布局系统没分叉」,**不是**「产品 rail
/// 没问题」——两套实现的漂移风险见 `pages/mod.rs` 偏离表 #10。
///
/// **抓得住**:`NavPaneGeometry::rail().width` == `layout::rail_width()` == 88;
/// **抓不住**:容器圆角/药丸摆放的像素级正确性(组件自己的 probe 管),也抓不住 `shell/nav.rs`。
#[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)");
let catalog = Lang::En.catalog();
for (section, _icon, label, route) in NAV {
assert!(!catalog.text(label).is_empty(), "{} 没有文案", label.name());
assert_eq!(
route.nav(),
section,
"{} 指向的 {route:?} 高亮的是 {:?}",
label.name(),
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()
);
}
}