test(app-iced): M123 安装选项区 × 三栏同屏的宽度与行裁剪守门

安装选项区(`download/options`)与三栏(`download/rails`)各自都有断言,
但它们**同屏**这件事从来没验过:选项区一高列表就矮,窗口一窄两条栏一收列表
就窄,这两种挤压都不会让任何既有断言变红。

- `tests/options_screen.rs`:七档宽度(1180/1024/900/880/812/736/700)× 三种
  选项态(原版 / Fabric / NeoForge 不兼容)。三栏数字按 `layout/rails.rs` 的
  数值表逐格钉死;列表列在每一档都 ≥ 424(行内四样仍在一行);两条栏与选项
  面板在同一份模型里、树能一次构建。
- `tests/options_shot.rs`:真 PNG 上的像素断言(需 `ULL_DL_OPTIONS_SHOT`)。
  两栏同屏且像素宽度 = 裁决值 140/140、栏间 24 的缝真空着、第一行版本行整行
  44px、选项区最后一行墨迹离底边还有余量。另有一条 `#[ignore]` 的 Chips 档
  回归:700×760 下列表被压成 18px(现状红,缺陷已单列报告与 finding)。

出图(38 张)在 `DevDocs/artifacts/m123/`(git 忽略,不入库);旧的
`DevDocs/artifacts/m109/`(内容库页早于 M108 改名,导航轨写的是 `Download`)
已按任务 ② 删除,内容库 12 态在 m123 里按当前 tip 重出。

不动产品行为:本提交只有测试与模块声明。
This commit is contained in:
AskaEth committed 2026-11-05 15:46:26 +08:00
1 parent c25e85b461
commit 4a41263357
3 files changed
+485

No files matched your search

@@ -0,0 +1,289 @@
//! 选项区 × 三栏的**像素**验收(M123 ①):真 PNG 上的几何断言。
//!
//! ## 为什么还要一条像素测试
//!
//! [`super::options_screen`] 断的是「喂给树的模型 + 裁决出来的数字」,它抓不住**画出来**:
//! 两栏是不是真同屏、栏宽有没有按裁决落地、版本行有没有被压成一条缝、确认键是不是还在屏内。
//! 那些只有图能回答——所以这里直接量 `pages_demo` 出的 PNG,按**产品自己的裁决函数**
//! ([`VersionRails::of_metrics`] + [`frame_metrics`])推出每一栏该在的 x 区间,再要求像素同意。
//!
//! ## 开关与跑法(与 `components/*/probe.rs` 同一族范式:没有图就 SKIP,不假绿)
//!
//! ```text
//! # ① 1180 宽(Wide 档,两栏都展开)
//! ULL_DL_DEMO=options-fabric cargo run -p unlucky-app-iced --example pages_demo -- \
//! --screen=download --width=1180 --height=760 --screenshot=opt.png \
//! --screenshot-theme=light --software-renderer
//! ULL_DL_OPTIONS_SHOT=opt.png cargo test -p unlucky-app-iced --lib options_shot
//! ```
//!
//! 颜色**不**写死:`run_at` 只要求「同一段颜色连续」,栏与栏的边界按「颜色变了」判,
//! 于是浅色/深色都能跑同一条断言(实测浅色下 Card=(235,245,255)、Canvas=(246,250,255))。
use std::path::PathBuf;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::pages::layout::rails::RAIL_GAP;
use crate::pages::layout::{available_width, frame_metrics, PaneKit, VersionRails};
use crate::theme::{Families, Ui};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 1180 宽那张(`01-options-fabric-en-light.png`)。
const WIDE_SHOT: &str = "ULL_DL_OPTIONS_SHOT";
/// 700 宽那张(`03-options-fabric-en-light-chips700.png`)——只在 `--ignored` 那条里用。
const CHIPS_SHOT: &str = "ULL_DL_CHIPS_SHOT";
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
fn shot_path(key: &str) -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os(key)?);
if path.is_absolute() || path.exists() {
return Some(path);
}
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
Some(root.join(path))
}
/// 一张 RGB 图(只留本文件要用的那两件事:宽高 + 取点)。
struct Shot {
width: usize,
height: usize,
pixels: Vec<[u8; 3]>,
}
impl Shot {
fn load(path: &std::path::Path) -> Self {
let bytes = std::fs::read(path).expect("读截图");
let image = image::load_from_memory(&bytes).expect("解 PNG").to_rgb8();
let (width, height) = (image.width() as usize, image.height() as usize);
Self {
width,
height,
pixels: image.pixels().map(|pixel| pixel.0).collect(),
}
}
fn at(&self, x: usize, y: usize) -> [u8; 3] {
self.pixels[y * self.width + x]
}
/// `(起点, 长度)`:`(x, y)` 所在的那一段**同色**横条。
fn run_at(&self, x: usize, y: usize) -> (usize, usize) {
let color = self.at(x, y);
let mut start = x;
while start > 0 && self.at(start - 1, y) == color {
start -= 1;
}
let mut end = x;
while end + 1 < self.width && self.at(end + 1, y) == color {
end += 1;
}
(start, end - start + 1)
}
/// 在 `x` 这一竖列上,`[from, to)` 里**第一段**与 `background` 不同的连续区:`(top, height)`。
///
/// 用它量「第一行版本行有多高」:选中行有底色,行外是页面底色,两段一夹就是行高。
fn first_filled_band(
&self,
x: usize,
from: usize,
to: usize,
background: [u8; 3],
) -> Option<(usize, usize)> {
let filled = |y: usize| self.at(x, y) != background;
let top = (from..to.min(self.height)).find(|y| filled(*y))?;
let mut bottom = top;
while bottom + 1 < to.min(self.height) && filled(bottom + 1) {
bottom += 1;
}
Some((top, bottom - top + 1))
}
/// 列表列里最后一行**有内容**的 y(`None` = 整列都是底色)。
fn last_ink_row(&self, left: usize, right: usize, background: [u8; 3]) -> Option<usize> {
(0..self.height).rev().find(|y| {
(left..right).any(|x| {
let pixel = self.at(x, *y);
pixel
.iter()
.zip(background)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum::<u32>()
> 12
})
})
}
}
/// 三栏的 x 区间,全部从**产品自己的裁决函数**推出来(不是手抄的像素坐标)。
struct Bands {
type_rail: (usize, f32),
major_rail: (usize, f32),
list: (usize, f32),
}
fn bands(width: f32) -> Bands {
let ui = ui();
let rails = VersionRails::of_metrics(frame_metrics(PaneKit::Single, width));
// 可用宽 = 窗口 − 导航轨 − 两侧页边距 ⇒ 页边距反推出来(只有左侧那一份进左缘)
let avail = available_width(width);
let gutter = (width - avail - ui.metrics().nav_rail_width) / 2.0;
assert!(
(gutter - 24.0).abs() < 0.01,
"页边距应当是一格 24:{gutter}"
);
let left = (width - avail - gutter).round() as usize;
assert_eq!(
left,
(ui.metrics().nav_rail_width + gutter).round() as usize,
"页左缘 = 导航轨 + 一格页边距"
);
assert_eq!(left, 112, "harness 的导航轨 88 + 页边距 24");
let type_rail = (left, rails.type_rail);
let major_rail = (
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,
);
Bands {
type_rail,
major_rail,
list,
}
}
/// `01-options-fabric-en-light.png`(1180×760)上的四条断言。
///
/// 抓得住:①两条侧栏**同时**在同一条扫描线上画出来(不是只有一条、也不是其中一条塌成 0 宽);
/// ②两条栏各自的像素宽度 = `VersionRails` 裁决出来的 140/140,栏间 24 的缝真的空着(栏没有重叠);
/// ③版本行至少有一行**完整**(44 = `row_height`),即列表没有被面板压成「缝」;
/// ④选项区的确认键与「实例名」都在屏内(列表列最下面一行墨迹离窗口底边还有余量)。
///
/// 抓不住:文本有没有被裁(那要看字形宽度,见报告「没验的部分」)、hover/tooltip 层(静态图拍不到)、
/// 深色主题下的对比度(本条只跑浅色图;深色另用同一 argv 换 `--screenshot-theme` 出图人看)。
#[test]
fn an_options_shot_shows_both_rails_and_the_whole_panel() {
let Some(path) = shot_path(WIDE_SHOT) else {
eprintln!(
"[options-shot] SKIPPED:未设置 {WIDE_SHOT}。这是**渲染层**几何断言,\
需要先出图(见本文件模块文档的 1180 那张)。"
);
return;
};
let shot = Shot::load(&path);
assert_eq!(
(shot.width, shot.height),
(1180, 760),
"验收图必须 1180×760"
);
let bands = bands(1180.0);
let ui = ui();
let y = 400;
// ① 两条栏同时画在同一条扫描线上,且宽度 = 裁决值(像素边界一条不差)
for (name, (start, width)) in [("类型栏", bands.type_rail), ("主版本栏", bands.major_rail)]
{
let (run_start, run_len) = shot.run_at(start + 8, y);
assert_eq!(
(run_start, run_len as f32),
(start, width),
"{name}:像素区间与 `VersionRails` 裁决不一致(y={y})"
);
assert_ne!(
shot.at(start, y),
shot.at(start.saturating_sub(1), y),
"{name}:左缘没有边界"
);
}
// 栏间 24 的缝真的空着(两条栏没有叠在一起),且列表列不是面板底色
let gap_x = bands.type_rail.0 + bands.type_rail.1 as usize + RAIL_GAP as usize / 2;
assert_eq!(
shot.at(gap_x, y),
shot.at(gap_x - 1, y),
"两栏之间的缝被填掉了(栏重叠或间距丢失)"
);
assert_ne!(
shot.at(gap_x, y),
shot.at(bands.type_rail.0, y),
"缝与栏同色 ⇒ 读不出「两条栏」"
);
assert_ne!(
shot.at(bands.list.0 + 8, y),
shot.at(bands.major_rail.0, y),
"列表列被侧栏底色盖住了"
);
// ③ 完整的一行版本行:第一行(选中行)的底色带必须正好一行高(`row_height` = 44)
let canvas = shot.at(bands.list.0 + 2, 12);
let (_, row_height) = shot
.first_filled_band(bands.list.0 + 60, 235, 520, canvas)
.expect("列表列里应当有版本行");
assert_eq!(
row_height as f32,
ui.metrics().row_height,
"版本行被裁/被压:实测可视行高 {row_height},应为 {}",
ui.metrics().row_height
);
// ④ 选项区整块在屏内:列表列最下面的墨迹离底边还有余量(被切的话它会贴到 759)
let last = shot
.last_ink_row(
bands.list.0,
bands.list.0 + bands.list.1 as usize - 2,
canvas,
)
.expect("列表列里应当有内容");
assert!(
last + 8 < shot.height,
"选项区被窗口底边切掉:最后一行墨迹在 y={last}(图高 {})",
shot.height
);
assert!(
last > 600,
"选项区不在屏上:最后一行墨迹在 y={last},解析应当有「实例名」那一格"
);
}
/// `02-options-fabric-en-light-chips700.png`(700×760)——**这条现在是红的**(M123 的缺陷登记)。
///
/// 现状(2026-11-05,tiny-skia,700×760,`ULL_DL_DEMO=options-fabric`):Chips 档多出两行 chip,
/// 选项区又整块钉在列表下面,于是列表列只剩 ~22px 的缝,选中行的底色带实测 19px(行高 44)。
/// 断言写的是**该有的**结果;修好之后删掉 `#[ignore]` 就是它的回归守门。
///
/// ```text
/// ULL_DL_CHIPS_SHOT=02-options-fabric-en-light-chips700.png \
/// cargo test -p unlucky-app-iced --lib options_shot -- --ignored
/// ```
#[test]
#[ignore = "M123 缺陷:Chips 档(窗口 <736)列表被压成 ~19px,见报告「发现的缺陷」第 1 条"]
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 (_, row_height) = shot
.first_filled_band(bands.list.0 + 60, 360, 440, canvas)
.expect("列表列里应当有版本行");
assert_eq!(
row_height as f32,
ui().metrics().row_height,
"Chips 档的版本行被压成 {row_height}px(应为 44)"
);
}