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。
365 lines
16 KiB
Rust
365 lines
16 KiB
Rust
//! 页面层的**数值判据**: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()
|
||
);
|
||
}
|
||
}
|