债务台账 P18(M250 + M247 的共同缺口):iced 0.14 的 text_input 有 on_submit、 没有 on_unfocus(builder 逐条核过:on_input/on_input_maybe/on_submit/on_submit_maybe/ on_paste/on_paste_maybe,on_unfocus 零命中),而本仓的 text_field 只透出 on_input ⇒ 行内重命名只能点按钮、强调色拿不到「用户确认」这一下。 - field.rs:Field 变 `Field<'a, M>`(M 只被新加的 on_submit 用到,调用点的类型由 text_field 的 on_input 一并推出,一行都不用改),回车消息唯一从 submit_message() 取,`on_submit_maybe(None)` 是默认档 ⇒ 既有调用点逐位不变;禁用态与 on_input 同一 条规矩被摘掉。 - 存档改名:回车与「改名」按钮共用 commit_rename() 一个消息产地,空名/原名不变/ 非法字符那几道校验因此对回车同样成立。 - 强调色:回车发 Change::AccentCommit(不是又一次 Accent(原文))⇒ 立即 commit(), 把「打了一半就放着」的 #12 亮成红框 + 错行;逐键不报短错的规矩不变,落盘仍取 上一次合法值。 - 判据:submit_is_opt_in(未接就是 None)、text_field_forwards_the_submit_callback、 submit_is_wired_only_at_the_two_call_sites(白名单扫源码)、 enter_takes_the_same_message_as_the_rename_button、the_rename_field_wires_enter、 the_accent_field_wires_enter_to_commit、enter_commits_a_half_typed_value_and_ persists_only_legal_ones。真键盘回车量不到(Element 无内省、造不出 iced::Renderer 的测试替身),报告里单列。
280 lines
13 KiB
Rust
280 lines
13 KiB
Rust
//! `instances` 的断言:演示数据、窄窗退化,以及**页面摆法**(源码结构断言)。
|
||
use super::*;
|
||
|
||
/// M120 的文案守门(中文无英文词 / 切语言真的变 / 页面里不许写死英文案·白名单)。
|
||
mod i18n;
|
||
|
||
/// M157 的门限判据(三态 chips 在版本不够时谁可点)。
|
||
mod launch_args;
|
||
|
||
/// M235 的存档区模型判据(快照 → 行 / 计数 / 失败线 / UTC 时间)。
|
||
mod saves;
|
||
|
||
/// M252 / P18 的**回车**接线(行内重命名:回车 = 点「改名」)。
|
||
mod rename;
|
||
|
||
use super::super::layout::source;
|
||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
||
|
||
/// **M102 几何判据**:列表可视区正好放下整数行——末行不会被卡片底边切半行。
|
||
///
|
||
/// 算式里的三个页面真源**直接引用**(不写字面量),所以改页面常量会让这条立刻红:
|
||
/// [`LIST_BOTTOM_INSET`](滚动区底部内缩)、[`ROW_SPACING`](页级行距,`None` = 0)。
|
||
///
|
||
/// ```text
|
||
/// 卡片高 = 760 − 标题栏 48 − 两行页头 84 − 2×gutter 24 = 580
|
||
/// 列头 = 上 12 + LabelMedium 16 + 下 4 = 32
|
||
/// 内缩 = LIST_BOTTOM_INSET = Space::X2 = 8
|
||
/// 可视区 = 580 − 32 − 8 = 540
|
||
/// 行高 = ListItem 两行档 44 + 16 = 60
|
||
/// pitch = 行高 + ROW_SPACING(None ⇒ 0) = 60
|
||
/// 整行数 = 540 / 60 = 9 行整
|
||
/// ```
|
||
///
|
||
/// **抓得住**:页面把内缩换档(`LIST_BOTTOM_INSET` → `X1` 立刻除不尽)、行距真源被改、
|
||
/// 列头算错、演示数据被缩到不够滚(独立滚动条消失);
|
||
/// **抓不住**:`list_column()` 里被另写一层 `.spacing(…)`(那一条由
|
||
/// `list_column_spaces_rows_only_from_the_shared_constant` 扫源码守)。
|
||
#[test]
|
||
fn list_viewport_fits_whole_rows_and_still_overflow() {
|
||
use unlucky_design::spacing::LayoutMetrics;
|
||
use unlucky_design::typography::TypeRole;
|
||
|
||
let lm = LayoutMetrics::DESKTOP;
|
||
let gutter = WidthClass::Expanded.gutter();
|
||
|
||
let card = 760.0 - lm.titlebar_height - lm.header_height(true) - 2.0 * gutter;
|
||
assert_eq!(card, 580.0, "1180×760 下列表卡片的内部高度");
|
||
|
||
let head = Space::X3.dp() + TypeRole::LabelMedium.style().line_height_px + Space::X1.dp();
|
||
let viewport = card - head - LIST_BOTTOM_INSET.dp();
|
||
assert_eq!(viewport, 540.0, "列表滚动区高度必须以页面的内缩真源算");
|
||
|
||
// 页级行距的**唯一真源**:None = 这一层不加间距
|
||
let gap = ROW_SPACING.map_or(0.0, Space::dp);
|
||
let row = lm.row_height + Space::X4.dp();
|
||
let pitch = row + gap;
|
||
assert_eq!(pitch, 60.0, "行节奏 = ListItem 两行档 60(不留页级行距)");
|
||
assert_eq!(
|
||
viewport % pitch,
|
||
0.0,
|
||
"可视区 {viewport} 不是 {pitch} 的整数倍 ⇒ 末行会被切半行(ROW_SPACING={ROW_SPACING:?})"
|
||
);
|
||
|
||
// 同时必须还能滚:12 行内容高过可视区,截图里才看得到独立滚动条
|
||
let content = DEMO.len() as f32 * pitch;
|
||
assert!(
|
||
content > viewport,
|
||
"列表内容 {content} 不比可视区 {viewport} 高,截图里看不到独立滚动条"
|
||
);
|
||
}
|
||
|
||
/// **源码结构**:`list_column()` 里对行容器的间距**只能**来自 [`ROW_SPACING`]。
|
||
///
|
||
/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 加回行容器后,
|
||
/// 几何断言照样绿——它算的是常量,看不见页面又叠了一层。iced 的 Element 又没有内省接口,
|
||
/// 所以只能断言这段代码的形状。
|
||
///
|
||
/// ## 两层判据(r4 复审的 M4 订正)
|
||
///
|
||
/// | 层 | 判据 | 抓的是 |
|
||
/// |---|---|---|
|
||
/// | ① 构建链 | `let mut rows = column(…);` 这条语句链上不许有 `.spacing(` | 直接写在链上的间距(变异 D) |
|
||
/// | ② 语句序列 | `list_column()` 里**每一处** `rows.spacing(…)` 的实参都要**顺绑定追回 `ROW_SPACING`** | **另起一条语句**再叠间距(变异 M4,含「先把令牌挪进一条 `let`」的写法) |
|
||
///
|
||
/// 为什么②要追绑定而不是比字面:合法写法有几种形状——`rows = rows.spacing(ui.space(spacing))`
|
||
/// 直接取自 `if let Some(spacing) = ROW_SPACING`,或先 `let spacing = ROW_SPACING;` 再中转
|
||
/// (变异 H3)。`layout/source.rs::derived_from` 顺 `let` / `Some(…) =` 两种绑定往回追最多三跳,
|
||
/// 于是「真源」放行、「中转了一层 `Space::X1`」判否。
|
||
///
|
||
/// **抓得住**:构建链上的字面量间距(D)、另起语句的行距(M4)、`ROW_SPACING` / `LIST_BOTTOM_INSET`
|
||
/// 两个真源被摘掉;`column![head, scroller]` 上的合法间距不受影响(变异 J,绿);
|
||
/// **抓不住**:把间距算进别处再以非令牌形式传进 `list_row()`(那要改组件几何,代码评审能看出来)、
|
||
/// 以及绕过 `rows.spacing(…)` 这个调用形状的写法。
|
||
#[test]
|
||
fn list_column_spaces_rows_only_from_the_shared_constant() {
|
||
let file = include_str!("../instances.rs");
|
||
let body = source::function_region(file, "fn list_column");
|
||
let rows_chain = source::statement(file, "let mut rows = column(");
|
||
|
||
assert!(
|
||
body.contains("ROW_SPACING"),
|
||
"列表行距必须从 ROW_SPACING 取,不许另写:\n{body}"
|
||
);
|
||
assert!(
|
||
body.contains("LIST_BOTTOM_INSET"),
|
||
"底部内缩必须从 LIST_BOTTOM_INSET 取:\n{body}"
|
||
);
|
||
|
||
// ① 行容器的构建链上不许叠间距(`column![head, scroller]` 上的合法间距不在这条链里)
|
||
assert!(
|
||
!rows_chain.contains(".spacing("),
|
||
"行容器的构建链上又出现了间距(M102 ② 的页级附加量):\n{rows_chain}"
|
||
);
|
||
|
||
// ② 语句序列:每一处 `rows.spacing(…)` 的实参都必须**顺绑定追回 `ROW_SPACING`**
|
||
// (直接用它、或 `let spacing = ROW_SPACING` 再中转都算;先 `let extra = Space::X1;`
|
||
// 再 `rows.spacing(ui.space(extra))` 则红——r4 复审的变异 M4)
|
||
let applied = source::call_arguments(&body, "rows.spacing(");
|
||
assert!(
|
||
!applied.is_empty(),
|
||
"`list_column()` 里没有把 ROW_SPACING 应用到行容器的调用:\n{body}"
|
||
);
|
||
for argument in &applied {
|
||
let ident = argument
|
||
.split_once("ui.space(")
|
||
.map(|(_, rest)| rest)
|
||
.unwrap_or(argument);
|
||
let ident: String = ident
|
||
.chars()
|
||
.take_while(|ch| ch.is_alphanumeric() || *ch == '_')
|
||
.collect();
|
||
|
||
assert!(
|
||
argument.contains("ui.space("),
|
||
"行容器的间距实参必须走令牌栅格(`ui.space(…)`),收到 {argument:?}"
|
||
);
|
||
assert!(
|
||
!argument.contains("Space::"),
|
||
"行容器的间距实参里直接写了令牌档位:{argument:?}\n{body}"
|
||
);
|
||
assert!(
|
||
source::derived_from(&body, &ident, "ROW_SPACING"),
|
||
"行容器的间距不是从 ROW_SPACING 来的(`{ident}` 追不到 ROW_SPACING)——\
|
||
M102 ② 的页级附加量不许另起一条语句塞回来:{argument:?}\n{body}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **散文接线**:详情页的正文段必须进 `frame::prose`(424),不许直接塞进 `frame::body`(1044)。
|
||
///
|
||
/// M102 把「块宽」与「散文列宽」拆成两个量之后,接错一行代码就会让 361 字符的正文在 1180 窗宽下
|
||
/// 排成 170 字符一行,而不红任何测试。这条断言把接线本身钉住。
|
||
#[test]
|
||
fn the_detail_note_goes_through_the_prose_box() {
|
||
let body = source::function_body(include_str!("../instances/detail.rs"), "fn push_text");
|
||
|
||
assert!(
|
||
body.contains("frame::prose("),
|
||
"正文段没有走 frame::prose:\n{body}"
|
||
);
|
||
assert!(
|
||
!body.contains("frame::body("),
|
||
"正文段被塞进了整页块(会失去 72 字符行宽承诺):\n{body}"
|
||
);
|
||
}
|
||
|
||
/// 元信息卡必须装得下**卡内最长的那条值**(视觉 r2:136 漏算 padding;M229 复审 P1-2 换成
|
||
/// ELEVATED CARD 之后内距 24 + 值 16px 又让它折行,同排卡底不齐)。
|
||
///
|
||
/// ## 判据是「墨水宽 ≤ 内宽」,数值是**量出来的**(不是算出来的)
|
||
///
|
||
/// 最长的那条值是 LAST PLAYED 的时戳 `2026-10-29 19:22 UTC`(20 字符,比加载器版本串长)。
|
||
/// 量法:`page-instances-detail-dark.png`(1180×760,2 列布局)右卡的值行 y 440–448,
|
||
/// 墨水横向 706…823 = **118px**,而卡内宽 = 外框 − 2×内距。两个档位都量过:
|
||
///
|
||
/// | 值字级 | 时戳墨水宽 | 160 外框(内宽 120)下 |
|
||
/// |---|---|---|
|
||
/// | `BodyMedium` 14 | **138px** | 放不下(折行 ⇒ 卡底不齐) |
|
||
/// | `BodySmall` **12** | **118px** | 放得下(余 2px;实际 2 列宽 206 余 62px) |
|
||
///
|
||
/// 所以 `elevated::TITLE_ROLE` 是 `BodySmall`、`elevated::PAD` 是 20 —— 这两件事**必须一起改**,
|
||
/// 本测试把它们与卡片外框绑在同一条不等式上;任何一项回退(值改回 14、内距改回 24)都会红。
|
||
#[test]
|
||
fn meta_card_value_fits_on_one_line_at_the_narrowest_card() {
|
||
use super::super::layout::{measure_cap_px, META_CARD_MIN_WIDTH, META_GAP};
|
||
use crate::app::components::surfaces::elevated;
|
||
|
||
/// 时戳在 `BodySmall`(12px) 下的墨水宽(量法见函数文档)。
|
||
const TIMESTAMP_PX_AT_12: f32 = 118.0;
|
||
/// 同一个串在 `BodyMedium`(14px) 下的墨水宽——留在这里是为了让「改回 14」的人一眼看到代价。
|
||
const TIMESTAMP_PX_AT_14: f32 = 138.0;
|
||
|
||
// 量测对象是**产品那条格式化函数的输出**(`app::view::instances::last_played`:
|
||
// `{date} {time} UTC`),它的格式与两个边界样例由 `app/view/tests.rs::…last_played…` 钉着,
|
||
// 所以这里只钉「这条串有多少字」——20 字符,是全站最长的元信息值。
|
||
const LONGEST_VALUE: &str = "2026-10-29 19:22 UTC";
|
||
assert_eq!(
|
||
LONGEST_VALUE.chars().count(),
|
||
20,
|
||
"最长元信息值的字符数变了就要重新量墨水宽"
|
||
);
|
||
|
||
// ① 字级与内距是**一对**:先钉住两个常量,再钉不等式(否则改字级不会红)。
|
||
let role = elevated::TITLE_ROLE;
|
||
assert_eq!(role, unlucky_design::typography::TypeRole::BodySmall);
|
||
assert_eq!(
|
||
role.style().size_px,
|
||
12.0,
|
||
"值字级必须是 12px(14px 下时戳 138px 放不下)"
|
||
);
|
||
assert_eq!(
|
||
elevated::PAD,
|
||
20.0,
|
||
"卡内距是 20(规格卡的 24 会挤到 166 > 160)"
|
||
);
|
||
assert_eq!(
|
||
elevated::PAD,
|
||
unlucky_design::spacing::LayoutMetrics::DESKTOP.card_padding,
|
||
"内距与描边卡同源(20)"
|
||
);
|
||
// 绑定成局部变量再过断言:全是常量的话 clippy 会判「常量断言」(评审集里的既有写法)。
|
||
let measured_14 = TIMESTAMP_PX_AT_14;
|
||
let inner_min_width = META_CARD_MIN_WIDTH - 2.0 * elevated::PAD;
|
||
assert!(
|
||
measured_14 > inner_min_width,
|
||
"如果 14px 也能放下({measured_14} ≤ {inner_min_width}),这条登记就该删掉"
|
||
);
|
||
|
||
// ② 最窄的外框(160)减掉两侧内距,必须放得下 12px 的时戳。
|
||
let inner_min = META_CARD_MIN_WIDTH - 2.0 * elevated::PAD;
|
||
assert!(
|
||
inner_min >= TIMESTAMP_PX_AT_12,
|
||
"最窄卡内宽 {inner_min}px 装不下 {TIMESTAMP_PX_AT_12}px 的时戳(外框 {META_CARD_MIN_WIDTH} − 2×{})",
|
||
elevated::PAD
|
||
);
|
||
|
||
// ③ 实际版面(2 列,行宽上限 424)也要放得下——这是出图那一档。
|
||
let two_columns = (measure_cap_px() - META_GAP) / 2.0;
|
||
assert!(
|
||
two_columns - 2.0 * elevated::PAD >= TIMESTAMP_PX_AT_12,
|
||
"两列布局下卡内可用 {}px 装不下 {TIMESTAMP_PX_AT_12}px",
|
||
two_columns - 2.0 * elevated::PAD
|
||
);
|
||
// 卡片宽度契约没动(复审要求「不破卡片宽度契约」)。
|
||
assert_eq!(META_CARD_MIN_WIDTH, 160.0);
|
||
}
|
||
|
||
#[test]
|
||
fn every_demo_entry_has_name_version_and_loader() {
|
||
for entry in &DEMO {
|
||
assert!(!entry.name.is_empty());
|
||
assert!(!entry.version.is_empty());
|
||
assert!(!entry.loader.is_empty());
|
||
}
|
||
assert!(SELECTED < DEMO.len());
|
||
}
|
||
|
||
#[test]
|
||
fn detail_paragraph_is_long_enough_to_wrap() {
|
||
// 行宽上限 ≈ 72 字符/行;写够几行才看得出断行位置
|
||
assert!(NOTE.len() > 200, "正文档太短,看不出行宽上限");
|
||
}
|
||
|
||
#[test]
|
||
fn narrow_widths_switch_the_page_to_one_column() {
|
||
// 1180 / 900 → 两列;700 → 单列(整页切换)
|
||
assert_eq!(
|
||
frame_metrics(PaneKit::ListDetail, 1180.0).layout,
|
||
PageLayout::ListDetail
|
||
);
|
||
assert_eq!(
|
||
frame_metrics(PaneKit::ListDetail, 700.0).layout,
|
||
PageLayout::SingleColumn
|
||
);
|
||
// 窄窗下详情不吃满窗口:正文列仍 ≤ 行宽上限
|
||
let narrow = frame_metrics(PaneKit::ListDetail, 700.0);
|
||
assert!(narrow.column <= measure_cap_px() + f32::EPSILON);
|
||
assert!(narrow.secondary.is_none());
|
||
}
|