refactor(app-iced): M125 主版本栏与选项区像素守门各拆一层(守 400 行红线)

两处拆分,只为过 `gate::every_source_file_stays_under_the_line_limit`:

- `download/rails.rs` 435 行 → `rails.rs` 311 + `rails/major.rs` 157:主版本桶那一栏
  (`major_rail` / `major_entries` / `major_shape` / `MajorEntry` / `MajorShape`)搬进子模块,
  `rail_title` 留在父模块(两条栏共用)。`Key::VersionMajorTitle` 的落点跟着搬,
  `tests/i18n.rs` 的死 key 扫描表补上 `rails/major.rs`(漏扫会把活 key 判成死 key)。
- `tests/options_shot.rs` 475 行 → 345 + `options_shot/chips.rs` 75 + `options_shot/compact.rs` 67。

顺带修 `options_shot::bands()` 的一处既有缺口:它按三栏算列表列左缘(112+0+24+0+24 = 160),
chips 档没有侧栏时这个左缘是错的,右缘跟着冲出画面 22px——M125 的 chips 守门正好踩到
「扫描越界读到下一行像素」,`first_rule_y` 因此恒返回 None。现在 chips 档直接取页左缘
(112),三栏三档的数值一个不变(M123 那些 140/140、缝 24 的断言照旧)。

守门实跑(本机,tiny-skia):
- 解 ignore 的 `chips::a_chips_tier_shot_…` 在修后图绿、**修前图红**(实测 18px ≠ 44);
- `compact::a_compact_tier_shot_…` 修后图绿、修前图红(实测 44px 文字行 ≠ 40px 图标按钮);
- chips 那条换深色图同样绿(断言不依赖主题色)。
This commit is contained in:
AskaEth committed 2026-11-05 16:01:03 +08:00
1 parent 822f98022d
commit 70dea995b7
8 files changed
+394 -297

No files matched your search

@@ -111,6 +111,40 @@ impl Shot {
Some((top, bottom - top + 1))
}
/// `[from, to)` 里最长的一段**同色**横条:`(y, 长度)`(颜色必须与底色不同)。
///
/// 用它找面板里那条实心「安装」按钮:它是列表列里唯一一条 ≥ 80px 的实色带。
fn longest_run(
&self,
left: usize,
right: usize,
from: usize,
to: usize,
background: [u8; 3],
) -> (usize, usize) {
let mut best = (0, 0);
for y in from..to.min(self.height) {
let mut run = 0;
let mut color = background;
for x in left..right {
let pixel = self.at(x, y);
if pixel == background {
color = background;
run = 0;
} else if pixel == color {
run += 1;
} else {
color = pixel;
run = 1;
}
if run > best.1 {
best = (y, run);
}
}
}
best
}
/// 列表列里最后一行**有内容**的 y(`None` = 整列都是底色)。
fn last_ink_row(&self, left: usize, right: usize, background: [u8; 3]) -> Option<usize> {
(0..self.height).rev().find(|y| {
@@ -153,7 +187,13 @@ impl Shot {
///
/// 用它量「版本列表到哪儿为止」:尾部面板上沿有一条通栏分隔线,它上面的高度就是列表拿到的。
/// 只认**薄**线(上下两行都与它不同色)——选中行的底色带也是通栏的,那是 44px 的块,不是线。
fn first_rule_y(&self, left: usize, right: usize, from: usize, background: [u8; 3]) -> Option<usize> {
fn first_rule_y(
&self,
left: usize,
right: usize,
from: usize,
background: [u8; 3],
) -> Option<usize> {
let thin = |y: usize| {
let color = self.at(left + 2, y);
color != background
@@ -194,10 +234,17 @@ fn bands(width: f32) -> Bands {
left + rails.type_rail as usize + RAIL_GAP as usize,
rails.major_rail,
);
let list = (
major_rail.0 + rails.major_rail as usize + RAIL_GAP as usize,
rails.list,
);
// chips 档没有两条侧栏(宽度都是 0):列表就是整条可用宽,左缘 = 页左缘。
// 不分支的话会算成 `112 + 0 + 24 + 0 + 24 = 160`,右缘跟着冲出画面 22px
// (M125 缺陷 1 的守门就是在这里踩到「扫描越界读下一行像素」的)。
let list = if rails.tier.is_three_column() {
(
major_rail.0 + rails.major_rail as usize + RAIL_GAP as usize,
rails.list,
)
} else {
(left, rails.list)
};
Bands {
type_rail,
major_rail,
@@ -298,138 +345,8 @@ fn an_options_shot_shows_both_rails_and_the_whole_panel() {
);
}
/// `02-…-chips700.png`(700×760,`ULL_DL_CHIPS_SHOT`)——**M125 缺陷 1 的回归守门**。
///
/// 修前(M123 取证,2026-11-05,tiny-skia,700×760,`ULL_DL_DEMO=options-fabric`):
/// chips 档多出两行 chip,选项区又整块钉在列表下面(316px),于是列表列只剩一条 **18px** 的缝,
/// 选中行的底色带实测 19px(行高 44)——版本号一个字读不出来,用户换不了版本。
/// 修法见 `rows::TailShare`:chips 档改成「列表与面板各吃一半,面板自己可滚」。
///
/// ```text
/// ULL_DL_CHIPS_SHOT=02-options-fabric-en-light-chips700.png \
/// cargo test -p unlucky-app-iced --lib options_shot
/// ```
///
/// 抓得住:①首行版本行的底色带 = 44(整行在屏);②**至少 3 整行**读得出来(数墨迹条数);
/// ③选项面板仍在屏内(列表列最后一行墨迹明显低于版本行区,且没贴到底边)——
/// 「能换版本」与「能看见选项」是这条断言的成对验收。
///
/// 抓不住:文本有没有被**横向**裁掉(那要看字形宽度,见 `tests/rails.rs` 的标签断言与
/// 报告里的量测表)、hover/tooltip 层(静态图拍不到)、面板内部滚动到底后长什么样
/// (静态图只有一帧,只能拍到 scroll offset 0)。
#[test]
fn a_chips_tier_shot_keeps_a_whole_version_row_readable() {
let Some(path) = shot_path(CHIPS_SHOT) else {
eprintln!("[options-shot] SKIPPED:未设置 {CHIPS_SHOT}。");
return;
};
let shot = Shot::load(&path);
let bands = bands(700.0);
let canvas = shot.at(bands.list.0 + 2, 12);
let metrics = ui().metrics();
// ---- chips 档(缺陷 1)与 Compact 档(缺陷 2)的像素守门(拆出来守 400 行红线)
// ① 首行整行 44:这是 M123 原本写的那条(修好之后解 ignore,不许改期望值迁就现状)
let (row_top, row_height) = shot
.first_filled_band(bands.list.0 + 60, 360, 440, canvas)
.expect("列表列里应当有版本行");
assert_eq!(
row_height as f32, metrics.row_height,
"Chips 档的版本行被压成 {row_height}px(应为 {})",
metrics.row_height
);
mod chips;
// ② 版本列表那一块至少 3 整行高:尾部面板上沿的通栏分隔线就是列表的下界
let right = bands.list.0 + bands.list.1 as usize - 2;
let rule = shot
.first_rule_y(bands.list.0, right, row_top, canvas)
.expect("找不到尾部面板上沿的分隔线(列表区的下界量不出来)");
let rows_px = rule.saturating_sub(row_top) as f32;
assert!(
rows_px >= 3.0 * metrics.row_height,
"Chips 档的列表区只有 {rows_px}px(y={row_top}..{rule}),不足 3 整行({}):\
尾部面板把列表压扁了",
3.0 * metrics.row_height
);
// ③ 面板在屏内:最下面一行墨迹明显低于版本行区(不是列表自己),也没贴到窗口底边
let last = shot
.last_ink_row(
bands.list.0,
bands.list.0 + bands.list.1 as usize - 2,
canvas,
)
.expect("列表列里应当有内容");
assert!(
last > row_top + 300,
"选项面板没画出来:最后一行墨迹在 y={last},版本行从 y={row_top} 起"
);
assert!(
last + 8 < shot.height,
"面板被窗口底边切掉:最后一行墨迹在 y={last}(图高 {})",
shot.height
);
}
/// `04-…-compact736.png`(736×760,`ULL_DL_COMPACT_SHOT`)——**Compact 档主版本栏(缺陷 2)**。
///
/// 修前(M123 取证):主版本栏宽 64,`ListItem` 两侧内边距各 16 ⇒ 文本格 32,
/// 而首项「All versions」要 ≈84 ⇒ 图上只剩「**All v**」。中文更糟:「全部版本」= 4 全角字 = 64,
/// 32 里只剩「全部」而且正好顶到裁切边。
///
/// 修后:图标条档的「全部版本」**不画文字**(`rails::major_shape` 的 `Icon` 分支)——
/// 画成与本页第一级那个「全部」同一个网格图标(40×40 `Tonal` 药丸 + 全称 tooltip)。
///
/// 抓得住:①首项是 **40×40 的图标药丸**(不是 64 宽 × 44 高的通栏文字行)——谁把它改回
/// 「画文字」(全称或短标签都会是通栏 44 行)这条立刻红;②药丸在 64 栏里**居中**(左右各 12)。
/// 抓不住:图标字形对不对、tooltip 长什么样(静态图拍不到 tooltip;字形要看图)。
///
/// ## 为什么不用「文字墨迹右边界 ≤ 栏右内边界」这条(复审建议的那条)
///
/// 它**在坏图上也是绿的**:修前那张图实测首项文字墨迹 x=216..244,而栏右内边界是
/// `200 + 64 − 16 = 248` ⇒ 244 ≤ 248 成立——「All **v**」明明被裁掉了,断言却过。
/// 原因是 `ListItem` 的 `clip(true)` 让墨迹**永远**停在裁切边之前,边界的比较量不出「被裁」。
/// 所以这里改钉**形状**:文字行是 64×44 的通栏带,图标按钮是 40×40 的居中药丸,两者像素可分。
///
/// **中文为什么没有同档图**:本仓的截图通道 `pages::demo_model` 把 `Model::new(..)` 的
/// `lang` 写死成 `Lang::En`(`pages/model.rs:120`),`ULL_LANG` 只作用到 app 的 `App`,
/// 拍不出中文页面 —— 所以中文的结论走「数据面断言 + 算术」:
/// `tests/rails.rs` 的 `only_the_first_major_entry_switches_to_an_icon_in_the_icon_rail`
/// (中文与英文走**同一条**图标分支,键判据 `entry.key.is_none()` 与语言无关)与
/// `the_icon_rail_text_box_is_32_and_only_bucket_labels_fit`(「全部版本」= 64 > 32)。
#[test]
fn a_compact_tier_shot_draws_an_icon_for_the_first_major_entry() {
let Some(path) = shot_path(COMPACT_SHOT) else {
eprintln!("[options-shot] SKIPPED:未设置 {COMPACT_SHOT}。");
return;
};
let shot = Shot::load(&path);
assert_eq!(shot.width, 736, "Compact 档那张必须是 736 宽");
let bands = bands(736.0);
let ui = ui();
let rail_left = bands.major_rail.0;
let rail_right = rail_left + bands.major_rail.1 as usize;
let center = rail_left + bands.major_rail.1 as usize / 2;
// 栏的底色(`MaterialLayer::Card`):取栏内空白处,不取窗口画布
let card = shot.at(rail_left + 2, 500);
// ① 首项的竖向量:40(图标按钮的边长)——文字行会是 44
let (top, height) = shot
.first_filled_band(center, 156, 300, card)
.expect("主版本栏里应当有第一项");
assert_eq!(
height as f32,
ui.metrics().control_height,
"Compact 档主版本栏首项应当是 40 高的图标按钮(y={top}),实测 {height}px —— \
回到「ListItem 文字行」就是 44"
);
// ② 横向:40 宽的药丸,在 64 栏里居中(左右各 12)
let (start, len) = shot.fill_run(top + height / 2, rail_left, rail_right, center, card);
assert_eq!(
(start, len as f32),
(rail_left + 12, ui.metrics().control_height),
"首项应当是 40 宽的居中药丸(栏 {rail_left}..{rail_right}),实测 x={start} 宽 {len} —— \
64 宽的通栏行说明它又画成文字了"
);
}
mod compact;