Files
Unlucky-Launcher/crates/app-iced/src/pages/tests.rs
T
AskaEth f499961e5b test(app-iced): M102 r3 护栏补到「页面调用点」——回退 body/软断点接线一律红,等价重构不再假红
r2 复审抓的两条 p1 都是「守卫只到函数内部」:把页面调用点改回 frame::single_column(变体 G)、
或把日志页两处 wrap::breakable 接线摘掉(变体 I),420 条全绿,而视觉复审 ① / ③ 原样回来。
r3 用 r2 自建的 layout/source.rs 把守卫推进到调用点,并把三条 p2 的假红/假强一并修掉:

- 新增 `single_column_pages_call_the_body_box_not_the_centred_one`:四个页面文件
  (download / logs / instances / instances/detail,共 7 个调用点)必须出现 `frame::body(`、
  不许出现 `frame::single_column(`;narrow_detail 单独再断言一次。(变体 G → 红)
- 新增 `the_page_wires_the_soft_breaks_into_both_rendering_paths`:扫 `lines::entry` 与
  `diagnostic::summary_block`,必须含 `wrap::breakable(`(变体 I → 红);
  同时把 `log_text_carries_soft_breaks_but_keeps_the_original` 的 docstring 改成它实际守的东西
  (断点表 + 原文可还原),不再冒充「抓得住接线」。
- p2-1:`frame_body_is_built_from_the_block_quantities` 改用新的
  `source::function_region`(函数体 + 本文件内被转调的函数,最多两层)⇒ 把 body 拆成
  `body` → `body_impl` 的等价重构不再假红(H1 → 绿),换回 centered 仍红(A → 红)。
  顺带修掉 region 的泛型签名 bug:早先拿 `fn {name}(` 当定义 needle,
  遇到 `fn body_impl<'a>(` 会 panic(护栏自己假红);现在按 `fn {name}` 找定义,并补了一条
  `the_region_follows_delegation_into_a_generic_function` 自测。
- p2-2:`list_column_spaces_rows_only_from_the_shared_constant` 的禁词收窄到
  `let mut rows = column(…)` **这一条**构建链(新工具 `source::statement`,停在第一个 `;`),
  于是 `column![head, scroller]` 上加合法间距不再假红(J → 绿),行容器叠间距仍红(D → 红)。
- p2-3:`frame::body` 的 align_x 改成「出现就必须是 `.align_x(Alignment::Start)`」,
  不再被 `.align_y(Alignment::Start)` 顺手满足;「干脆不写 align_x」(默认 Start)视为等价重构放行。
- p2-4(措辞):`layout/block.rs` 文档写明 1440 上限是**有意行为变化**(≥1576 窗宽),
  1180/900 上才是零像素变化。

变异矩阵(本轮自跑,逐条回滚,`git status` 已核):A/C/D/E/G/I 全红且各自只红对应那一条;
H1(body_impl 转调)/H2b(删显式 align_x)/H3(ROW_SPACING 经局部变量)/J(head/scroller 加合法间距)
全绿。基线 `cargo test -p unlucky-app-iced --lib` = 426 passed / 0 failed。
2026-11-04 19:53:43 +08:00

365 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.
//! 页面层的**数值判据**: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_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::function_region`](函数体 + 本文件内被它转调的函数),
/// 所以「拆成 `body` → `body_impl` 两层再转调」这种**等价重构不假红**(r2 复审的变异 H1)。
///
/// **抓得住**:`body()` 被换回居中(变异 A)、宽度改回 `metrics.column`、`align_x` 改成 `Center`;
/// **抓不住**:跨文件转调、或先赋局部变量再建盒的写法(见 `layout/source.rs` 的边界说明)。
#[test]
fn frame_body_is_built_from_the_block_quantities() {
let body = layout::source::function_region(include_str!("frame.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)视为等价重构,放行。
if let Some(at) = body.find(".align_x(") {
assert!(
body[at..].starts_with(".align_x(Alignment::Start)"),
"`frame::body` 的 align_x 只许是 Start:\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 的块」不会红任何数值断言;这条盯接线。
#[test]
fn frame_prose_is_built_from_the_prose_width() {
let prose = layout::source::function_body(include_str!("frame.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)");
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()
);
}
}