Files
Unlucky-Launcher/crates/app-iced/src/pages/download/tests/options_shot.rs
T
AskaEth 02c7910ad5 ci(app-iced): M127 把下载页四条像素守门接进 CI(仅 Windows),缺图在 CI 里从 SKIP 改成失败
- 新增 tests/options_shot/feed.rs:四条断言共用一条「喂图」策略
  · CI(CI 已设置)且 Windows 上缺 ULL_DL_*_SHOT ⇒ 失败并提示「守门未喂图」
  · 非 Windows 上喂了图 ⇒ CI 里也失败(断言按 Segoe UI 标定的行高/墨迹宽度)
  · 本机缺图仍可跳过,但每次都留一句提示
- ci.yml 的 test 作业(Windows 槽):pages_demo 现出 ready700/chips700/compact736/wide1180
  四张图 → 写进 $GITHUB_ENV → cargo test -p unlucky-app-iced --lib options_shot
  出图与断言两步都带 if: runner.os == 'Windows';失败时上传四张图便于复盘
- 顺带订正五处文档数字:main 7 整行 / r1 4 整行 / r2 7 整行,并写清第 8 条墨迹是 hint
  (rows.rs ×3、options_shot/ready.rs、tests/view.rs),不动任何断言期望值
2026-11-05 17:17:43 +08:00

376 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.
//! 选项区 × 三栏的**像素**验收(M123 ①):真 PNG 上的几何断言。
//!
//! ## 为什么还要一条像素测试
//!
//! [`super::options_screen`] 断的是「喂给树的模型 + 裁决出来的数字」,它抓不住**画出来**:
//! 两栏是不是真同屏、栏宽有没有按裁决落地、版本行有没有被压成一条缝、确认键是不是还在屏内。
//! 那些只有图能回答——所以这里直接量 `pages_demo` 出的 PNG,按**产品自己的裁决函数**
//! ([`VersionRails::of_metrics`] + [`frame_metrics`])推出每一栏该在的 x 区间,再要求像素同意。
//!
//! ## 开关与跑法(`ULL_DL_*_SHOT` 喂图,四条出图命令见 `feed.rs` 的模块文档)
//!
//! 四条断言都靠环境变量拿到一张真 PNG。M127 之前的写法是「没有图就 SKIP」,于是在 CI 里
//! **全是空跑**(`.github/` 里 grep 这些变量零命中)。现在按环境分档:
//!
//! - **只在 Windows runner 上跑**:断言量的行高/墨迹条数/药丸位置是按本仓字体栈
//! (`Segoe UI`)标定的,Linux/macOS 的回退字体会让这些数字变样 ⇒ 假红。这条限制同时写在
//! `.github/workflows/ci.yml` 的作业条件(`if: runner.os == 'Windows'`,出图与断言两步都带)
//! 和 `feed.rs` 的模块文档里,不靠「碰巧只配了一处」。
//! - **CI 里没喂图 = 失败**,提示「守门未喂图」:将来谁把 CI 里出图那步重构掉,构建会红,
//! 而不是静静退回手动档。本机(非 CI)才允许跳过,并且会留一句提示。
//!
//! 颜色**不**写死:`run_at` 只要求「同一段颜色连续」,栏与栏的边界按「颜色变了」判,
//! 于是浅色/深色都能跑同一条断言(实测浅色下 Card=(235,245,255)、Canvas=(246,250,255))。
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 宽那张(`02-options-fabric-en-light-chips700.png`)——chips 档回归守门用。
const CHIPS_SHOT: &str = "ULL_DL_CHIPS_SHOT";
/// 736 宽那张(`04-options-fabric-en-light-compact736.png`)——Compact 档主版本栏用。
const COMPACT_SHOT: &str = "ULL_DL_COMPACT_SHOT";
/// 700 宽、**不设 `ULL_DL_DEMO`**(= `ready`,还没选版本)那张——r1 复审 P2-1 的守门用。
const READY_SHOT: &str = "ULL_DL_READY_SHOT";
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
/// 一张 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))
}
/// `[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| {
(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
})
})
}
/// `y` 这一行上、含 `x` 的那段**连续非底色**区:`(起点, 长度)`。
///
/// 与 [`Shot::run_at`] 的区别:颜色可以变(药丸底色 + 里面的图标墨迹算同一块)。
fn fill_run(
&self,
y: usize,
left: usize,
right: usize,
x: usize,
background: [u8; 3],
) -> (usize, usize) {
let filled = |x: usize| self.at(x, y) != background;
let mut start = x;
while start > left && filled(start - 1) {
start -= 1;
}
let mut end = x;
while end + 1 < right && filled(end + 1) {
end += 1;
}
(start, end - start + 1)
}
/// `[from, to)` 里在 `[left, right)` 有墨迹的**条数**(两条之间空 ≥ 8 行才算两条)。
///
/// 用它数「读得出几个版本号」:一行版本号的文本墨迹约 11px 高、行距 44,中间空 30+ ⇒
/// 一条对应一行。被压扁时整块只剩一条。
fn ink_bands(
&self,
left: usize,
right: usize,
from: usize,
to: usize,
background: [u8; 3],
) -> usize {
let mut bands = 0;
let mut gap = usize::MAX;
for y in from..to.min(self.height) {
let inked = (left..right).any(|x| self.at(x, y) != background);
if inked {
if gap >= 8 {
bands += 1;
}
gap = 0;
} else {
gap = gap.saturating_add(1);
}
}
bands
}
/// 列表列里、`from` 起的**第一条薄通栏横线**的 y(`divider::full` 画的那条 1px 线)。
///
/// 用它量「版本列表到哪儿为止」:尾部面板上沿有一条通栏分隔线,它上面的高度就是列表拿到的。
/// 只认**薄**线(上下两行都与它不同色)——选中行的底色带也是通栏的,那是 44px 的块,不是线。
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
&& (left + 2..right.saturating_sub(2)).all(|x| self.at(x, y) == color)
&& (y == 0 || self.at(left + 2, y - 1) != color)
&& (y + 1 >= self.height || self.at(left + 2, y + 1) != color)
};
(from..self.height).find(|y| thin(*y))
}
}
/// 三栏的 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,
);
// 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,
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) = feed(WIDE_SHOT) else {
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},解析应当有「实例名」那一格"
);
}
// ---- chips 档(缺陷 1)与 Compact 档(缺陷 2)的像素守门(拆出来守 400 行红线)
mod chips;
mod compact;
mod feed;
mod ready;
use feed::feed;