Files
Unlucky-Launcher/crates/app-iced/src/pages/instances/tests.rs
T
AskaEth 5479f25068 feat(presenter): 实例清单改并集(档案 ∪ versions/*)+ 空态接导入向导(M273)
用户实测:HMCL 能看见 `.minecraft/versions/樱落怡然客户端`,ULL 的实例页是空的
(日志 `instances=0`)。根因不是路径找错,而是 `host::game::instances()` 只列
`profiles.json` 里的档案,第三方启动器装好的版本在界面上根本不存在。

## 列表改并集(presenter/src/host/game.rs)

- `versions/*` 里**能解析出清单**(`<id>/<id>.json` 的 id 与目录名一致、继承链走到底、
  符号链接不越界)且没有被任何档案绑定的版本,就地合成一条实例视图:
  显示名 = 目录名、`version_id` = 目录名。扫描复用 `core::game::adopt::scan`,
  没有另写一套目录遍历。
- 只读:合成视图不建目录、不写 profiles.json、不碰 versions/ 一个字节。
- 档案优先:某版本已被档案绑定时只按档案出一条(名字/隔离档/启动参数都保留),不重复列。
- 坏目录(无清单 / JSON 坏 / id 不符 / 缺父 / 越界)不当实例;`versions/` 读不出来也只是
  退回「只有档案」的旧行为,不让实例页报错。
- 外部实例的视图 id 用 `ext-<fnv64>`:`is_valid_instance_id` 只收 `[A-Za-z0-9_-]`,
  而版本目录名可以是中文(现场就是),直接沿用版本 id 会让**启动**在读实例设置时就失败。

## 懒创建档案(只有第一次改它才写盘)

`set_isolation()` 与 `apply_launch_args()` 的「找不到实例」改成 `game::ensure_profile()`:
外部实例第一次改隔离档 / 启动参数时才写 profiles.json(幂等,id 与视图 id 相同)。

## 空态可见入口 + 文案(app-iced / i18n)

- 空态分两种:列表里有东西但没选中(原「未选择实例」)与**一个实例都没有**
  (新:「还没有实例」+ 实心主行动「导入已有版本」)。用户现场看不到任何入口正是前一种文案
  在误导。
- `adopt_action()` 成为「导入已有版本」意图的唯一产地,页头按钮与空态主行动共用它。

## 门禁

`cargo test -p unlucky-app-iced --lib` 971 passed / `cargo test -p unlucky-presenter
--all-targets` 384 passed / `cargo test -p unlucky-i18n` 36 passed /
`cargo fmt --all --check` 0 / `cargo clippy -p unlucky-presenter -p unlucky-app-iced
--all-targets -- -D warnings` 0。
2026-11-12 13:13:53 +08:00

288 lines
14 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;
/// M273 的**入口接线**(空态的「导入已有版本」⇒ `AdoptMessage::Open`)。
mod empty_entry;
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!(
source::code_text(&body).contains("ROW_SPACING"),
"列表行距必须从 ROW_SPACING 取(注释里写不算),不许另写:\n{body}"
);
assert!(
source::code_text(&body).contains("LIST_BOTTOM_INSET"),
"底部内缩必须从 LIST_BOTTOM_INSET 取(注释里写不算):\n{body}"
);
// ① 行容器的构建链上不许叠间距(`column![head, scroller]` 上的合法间距不在这条链里)。
// 判据走「代码行」(`source::code_text` 剥注释):注释掉的 `.spacing(..)` 等于没有间距。
assert!(
!source::code_text(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)
//
// 判据走**代码文本**(`source::code_text` 剥注释):注释掉的 `rows.spacing(..)` 不算「应用了间距」,
// 否则把那一行注释掉整条用例会绿着通过(T29 的同族退化)。
let body = source::code_text(&body);
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!(
source::code_text(body).contains("frame::prose("),
"正文段没有走 frame::prose(注释里写不算):\n{body}"
);
assert!(
!source::code_text(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());
}