Files
Unlucky-Launcher/crates/app-iced/src/pages/instances/tests.rs
T
AskaEth f7cfc8ded6 feat(app-iced): 单行输入框透出 on_submit,回车接上存档改名与强调色提交
债务台账 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
  的测试替身),报告里单列。
2026-11-11 19:55:21 +08:00

280 lines
13 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.
//! `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());
}