Files
Unlucky-Launcher/crates/app-iced/src/pages/appearance/background/tests.rs
T
AskaEth c4c3c15285 feat(i18n): 外观页 40 条文案进词表(M180 I18NAPPEAR)
- 卡片内 22 处硬编码英文(整窗/面板透明度、背景图开关+路径、填充、暗化、配色来源)全部改走 Key
- 运行时诊断改成 key + 参数:`background::Note`(读盘/解码/过大/小图平铺)、`state::backend_note`、
  `palette::note` 全部返回 Key;`state.rs` 只留 key、不放文案表(390 → 391 行)
- 上限/下限常量走占位符 `{floor}` / `{max}` / `{limit}` / `{requested}`;`{:.2}` 在 Rust 侧格式化好
  再套模板(`Catalog::render` 只认 `{name}`)
- 新增 40 条 key(中英各一条),按装配链锚点插在 `SettingsAppearanceCornerSuspended` 之后,
  金表 `golden/tail.rs` 同位置插入 40 行(`rows() == len() == Key::ALL.len()`)
- 守卫三条:中文渲染全非 ASCII、卡片 key 全覆盖(改回写死英文即红)、外观 key 逐条真翻过
- probe 加第二条取证路(`record_rendered`),`take()` 签名不动(M179 的守卫在 scope 外)
2026-11-08 21:42:11 +08:00

236 lines
10 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.
//! `background` 的单测:解码(png + jpeg)、失败分类、暗化钳位、平铺网格。
//!
//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律:能断言的不许只靠肉眼。
use super::{dim_alpha, load, small_tile_note, tiles, LoadError, BG_DIM_MAX, MAX_TILES};
use std::path::{Path, PathBuf};
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::BgFill;
fn temp_file(label: &str, name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("ull-bg-{label}-{}-{}", std::process::id(), line!()));
std::fs::create_dir_all(&dir).expect("临时目录");
dir.join(name)
}
fn cleanup(path: &Path) {
if let Some(dir) = path.parent() {
std::fs::remove_dir_all(dir).ok();
}
}
#[test]
fn a_real_png_decodes_with_its_original_size_and_raw_bytes() {
// 抓得住:真磁盘 PNG 能读、原图尺寸按图头、缩略图不超预算、原始字节留着给渲染器。
// 抓不住:渲染器用这些字节画出来的颜色(那要看截图)。
let path = temp_file("png", "bg.png");
let source: image::RgbImage = image::RgbImage::from_pixel(300, 20, image::Rgb([12, 200, 90]));
source.save(&path).expect("写 png");
let image = load(path.to_str().unwrap()).expect("真 PNG 必须能读");
assert_eq!(image.source, (300, 20));
assert!(
image.width <= super::MAX_EDGE && image.height <= super::MAX_EDGE,
"缩略图 {}×{} 超预算",
image.width,
image.height
);
assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize);
assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize);
cleanup(&path);
}
/// M93 r2 的硬要求:`.jpg` 必须能加载(用户挑的背景图/壁纸多半是 jpeg)。
///
/// 抓得住:`image` 的 jpeg 编码器真的开了(没开时 `load` 会返回 `Undecodable`)。
/// 抓不住:渲染器那边(`iced/image`)解码 jpeg 的能力——那条由截图矩阵里那张 jpg 出图举证。
#[test]
fn a_real_jpeg_decodes_too() {
let path = temp_file("jpeg", "bg.jpg");
let source =
image::RgbImage::from_fn(64, 32, |x, y| image::Rgb([x as u8 * 4, y as u8 * 8, 90]));
source
.save_with_format(&path, image::ImageFormat::Jpeg)
.expect("写 jpeg");
let image = load(path.to_str().unwrap()).expect("jpeg 必须能读(M93 r2)");
assert_eq!(image.source, (64, 32));
assert_eq!(
image.rgba.len(),
(image.width * image.height * 4) as usize,
"像素解出来了"
);
cleanup(&path);
}
#[test]
fn a_missing_or_broken_file_reports_a_readable_reason() {
// 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。
// 都必须是 Err(不许静默回默认色),且 note() 给出**哪条 key + 哪些参数**(句子在词表里)。
// 抓不住:webp/tiff 这类没开编码器的格式具体报什么字符串(只钉分类,不钉解码库原文)。
let missing = temp_file("missing", "bg.png");
let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错");
assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}");
let note = error.note();
assert_eq!(note.key(), Key::SettingsAppearanceBgReadFailed);
assert!(
note.contains("could not be read"),
"英文基线要说出「读不出来」:{}",
note.render(Lang::En)
);
let broken = temp_file("broken", "bg.png");
std::fs::write(&broken, b"this is not an image").expect("写坏文件");
let error = load(broken.to_str().unwrap()).expect_err("非图片必须报错");
assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}");
assert_eq!(
error.note().key(),
Key::SettingsAppearanceBgDecodeFailed,
"解不开与读不出来必须是两条 key(措辞与修法都不同)"
);
cleanup(&broken);
let empty = load(" ").expect_err("空路径必须报错");
assert!(matches!(empty, LoadError::Io(_)));
}
#[test]
fn dim_is_clamped_to_the_supported_range() {
// 抓得住:暗化系数夹在 0..=0.95(下限允许 0 = 不暗化,上限防「背景看不见」),NaN 取 0。
// 抓不住:0.35 这个默认值在真实壁纸上的观感(视觉判断)。
assert_eq!(dim_alpha(0.0), 0.0);
assert_eq!(dim_alpha(0.35), 0.35);
assert_eq!(dim_alpha(-1.0), 0.0);
assert_eq!(dim_alpha(f32::NAN), 0.0);
assert_eq!(dim_alpha(9.0), BG_DIM_MAX);
assert_eq!(BG_DIM_MAX, 0.95, "与 core 同源(presenter 重导出)");
}
/// **P2-1 的守门**:任何尺寸/视口组合下,平铺网格铺出来的覆盖宽高都必须 ≥ 视口(不许留缝),
/// 同时块数不超预算(不留缝的代价只能是「放大图块」,不能是「少铺」)。
///
/// 抓得住:谁把 `tiles()` 改回「超预算就减列」——120×120 @ 1180×760 那条会立刻红
/// (r1 实测右缘裸白 100px)。
/// 抓不住:渲染器把单块图铺进格子时的实际像素对齐(有没有半像素缝由截图看)。
#[test]
fn tile_grid_always_covers_the_viewport_within_budget() {
let cases = [
((120, 120), (1180.0, 760.0)),
((300, 200), (1180.0, 760.0)),
((16, 16), (1920.0, 1080.0)),
((8, 8), (3840.0, 2160.0)),
((1600, 900), (1180.0, 760.0)),
((1, 1), (1180.0, 760.0)),
];
for (source, viewport) in cases {
let grid = tiles(source, viewport);
let (covered_w, covered_h) = grid.covered(source);
assert!(
covered_w >= viewport.0 - 0.01 && covered_h >= viewport.1 - 0.01,
"{source:?} @ {viewport:?} 覆盖 {covered_w}×{covered_h} 小于视口(会留缝):{grid:?}"
);
assert!(
grid.count() <= MAX_TILES,
"{source:?} @ {viewport:?} 块数 {} 超预算",
grid.count()
);
assert!(grid.scale >= 1.0, "图块只许放大,不许缩小:{grid:?}");
}
// 图比视口大 ⇒ 1×1 原尺寸裁切(scale 必须还是 1.0,否则等于缩小了原图)。
let big = tiles((1600, 900), (1180.0, 760.0));
assert_eq!((big.cols, big.rows, big.scale), (1, 1, 1.0));
// 小图按原尺寸铺够(不放大时)。
let small = tiles((300, 200), (1180.0, 760.0));
assert_eq!((small.cols, small.rows, small.scale), (4, 4, 1.0));
}
/// 编译期钉住:填充档解析走 core 的词表(`cover`/`contain`/`tile`)。
#[test]
fn fill_keys_are_the_schema_vocabulary() {
// 抓得住:有人改了落盘词表却没改这里(截图矩阵的 env 也按这三个词传)。
assert_eq!(BgFill::from_key_value("cover"), BgFill::Cover);
assert_eq!(BgFill::from_key_value("contain"), BgFill::Contain);
assert_eq!(BgFill::from_key_value("tile"), BgFill::Tile);
}
/// **复审 r1 P2-4**:解压炸弹的第二道闸——图头就超上限时要走 `TooLarge`,而且这条分支
/// 以前**没有任何测试**(三态诊断里唯一没被钉住的一态)。
///
/// 抓得住:`header_size` 的尺寸判据(构造一张 9000×4 的真 PNG,只花 ~100 KB 就能造出来)。
/// 抓不住:内存分配真的被 `image::Limits` 挡住(那要真喂一张巨图跑到 OOM,本机不做)。
#[test]
fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() {
let path = temp_file("huge", "huge.png");
let source = image::RgbImage::new(9_000, 4);
source.save(&path).expect("写大尺寸图");
let error = load(path.to_str().unwrap()).expect_err("超过 MAX_SOURCE_EDGE 必须报错");
assert!(
matches!(
error,
LoadError::TooLarge {
width: 9_000,
height: 4
}
),
"实际 {error:?}"
);
let note = error.note();
assert_eq!(note.key(), Key::SettingsAppearanceBgTooLarge);
assert!(
note.contains("too large"),
"英文基线要说出「太大」:{}",
note.render(Lang::En)
);
let args = note.args();
assert!(
args.contains(&("width", "9000")) && args.contains(&("height", "4")),
"宽高要作为参数传进去(不是写进译文):{args:?}"
);
assert!(
args.contains(&("limit", "8192")),
"上限走占位符而不是写进译文:{args:?}"
);
// 上限不许再放宽(r1 的 16384 等于允许 1 GB 的中间缓冲):常量级约束放在测试里钉。
const _: () = assert!(super::MAX_SOURCE_EDGE <= 8_192);
cleanup(&path);
}
/// **复审 r1 P2-1 的诊断面**:小图平铺会被放大(图块步长放大,见 `tiles`),必须说出来。
///
/// 抓得住:短边 < `SMALL_TILE_EDGE` 的图在 tile 档下一定有提示;cover/contain 档不提示;
/// 大图不提示。
/// 抓不住:提示文案在真实界面上的位置(由截图看)。
#[test]
fn small_images_get_a_tile_diagnostic_instead_of_silently_degrading() {
let small = super::Image::from_rgba((32, 32), 4, 4, vec![0; 64]);
let note = small_tile_note(&small, BgFill::Tile).expect("小图 tile 必须有提示");
assert_eq!(note.key(), Key::SettingsAppearanceBgSmallTile);
let args = note.args();
assert!(
args.contains(&("width", "32")) && args.contains(&("height", "32")),
"尺寸作为参数:{args:?}"
);
assert!(
args.contains(&("max", "64")),
"块数上限走占位符(`MAX_TILES` 改了不该动译文):{args:?}"
);
assert!(
note.render(Lang::En).contains("scaled up"),
"英文基线要说出「会被放大」:{}",
note.render(Lang::En)
);
// 非 tile 档不提示(cover/contain 会等比铺满,不存在「小图被放大铺」这件事)。
assert!(small_tile_note(&small, BgFill::Cover).is_none());
assert!(small_tile_note(&small, BgFill::Contain).is_none());
// 大图不提示。
let big = super::Image::from_rgba((1200, 800), 4, 4, vec![0; 64]);
assert!(small_tile_note(&big, BgFill::Tile).is_none());
}