Files
Unlucky-Launcher/crates/palette-extract/src/tests.rs
T
AskaEth dd9962e610 test(presenter)+fix(i18n): 淘汰策略与共享缓存解耦;注释里的 ASCII 引号
- prune() 抽成独立函数:全局缓存被所有用例共享,谁往里面灌 240+ 条都会顺手清掉
  别的用例刚缓存的键(实测:并行跑时 the_second_identical_refresh_... 假红)。淘汰
  策略改在本地 Cache 上测,两条判据各自确定;
- clippy -D warnings:from_ref 代替 &[x.clone()];
- model.rs 注释里的 "原因 + OS 原文" 用 ASCII 引号 → 页面字面量白名单守门报红,改
  中文引号;core/events 的三处文档失真登记为 TowerFinding(scope 外);
- 出处文案口径:TooLarge 的 detail 只留数字(presenter 不产人读文案)。
2026-11-11 16:52:47 +08:00

282 lines
12 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.
//! 解码适配层的单测:真 PNG / 真 JPEG 字节进,种子或**具体错误**出。
//!
//! 这里刻意不 mock 解码器:本 crate 存在的意义就是「真解码」,mock 掉它等于不测。
//! 测试用内存里的字节(`encode_*` 现造),不依赖任何外部素材文件。
use std::io::Cursor;
use super::*;
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
fn solid_image(width: u32, height: u32, rgb: [u8; 3]) -> RgbaImage {
RgbaImage::from_pixel(width, height, Rgba([rgb[0], rgb[1], rgb[2], 0xFF]))
}
fn encode_png(image: &RgbaImage) -> Vec<u8> {
let mut bytes = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(image.clone())
.write_to(&mut bytes, ImageFormat::Png)
.expect("PNG 编码应当成功");
bytes.into_inner()
}
fn encode_jpeg(image: &RgbaImage, quality: u8) -> Vec<u8> {
let mut bytes = Cursor::new(Vec::new());
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality);
encoder
.encode_image(&DynamicImage::ImageRgba8(image.clone()))
.expect("JPEG 编码应当成功");
bytes.into_inner()
}
#[test]
fn solid_png_keeps_the_exact_seed_colour() {
let png = encode_png(&solid_image(32, 32, [0xC0, 0x39, 0x2B]));
let seed = seed_from_bytes(&png).expect("砖红纯色图应当有种子");
// 32×32 不触发缩放;design 侧对纯色图做桶内原值平均 → 逐通道相等。
assert_eq!(seed.color.r, 0xC0);
assert_eq!(seed.color.g, 0x39);
assert_eq!(seed.color.b, 0x2B);
assert!(seed.chroma > 8.0);
}
#[test]
fn jpeg_decoding_is_wired_up() {
// 只验证「jpeg feature 真的接上了」:JPEG 会动色,只要求色相别跑偏太多。
let jpeg = encode_jpeg(&solid_image(48, 48, [0xC0, 0x39, 0x2B]), 92);
let seed = seed_from_bytes(&jpeg).expect("JPEG 应当能解码并取色");
assert!(seed.chroma > 8.0, "彩度 {}", seed.chroma);
assert!(
(seed.hue - 30.0).abs() < 25.0,
"砖红的 CAM16 色相应当在 30° 附近,实际 {}",
seed.hue
);
}
#[test]
fn extension_is_ignored_and_format_is_sniffed() {
// 用户把 .jpg 改成 .png 的情况很常见:按魔数判断,不按扩展名。
let jpeg = encode_jpeg(&solid_image(16, 16, [0x2E, 0x7D, 0x4F]), 95);
let decoded = decode_bytes(&jpeg).expect("魔数嗅探应当认得 JPEG");
assert_eq!((decoded.width(), decoded.height()), (16, 16));
}
#[test]
fn bmp_is_supported() {
// M73 的 Windows 壁纸探测不筛扩展名,`.bmp` 壁纸会真的出现在取色路径上。
// BMP 编码不是无损于色的(24 bit 存 RGB,这里是 32 bit Rgba),所以逐通道比对即可。
let mut bytes = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(solid_image(32, 32, [0xB2, 0x3A, 0x48]))
.write_to(&mut bytes, ImageFormat::Bmp)
.expect("BMP 编码应当成功");
let seed = seed_from_bytes(&bytes.into_inner()).expect("BMP 应当能解码并取色");
assert_eq!(
seed.color,
unlucky_design::color::Color::rgb(0xB2, 0x3A, 0x48)
);
assert!(seed.chroma > 8.0);
}
#[test]
fn garbage_bytes_fail_and_map_to_undecodable() {
let loaded = decode_bytes(b"this is definitely not an image");
assert!(
matches!(
loaded,
Err(ExtractError::Decode(_)) | Err(ExtractError::Io(_))
),
"非图片字节应当解码失败"
);
assert_eq!(input(&loaded), ImageInput::Undecodable);
}
#[test]
fn truncated_png_fails_instead_of_returning_half_an_image() {
let png = encode_png(&solid_image(64, 64, [0x3C, 0x8D, 0xBC]));
let truncated = &png[..png.len() / 2];
let loaded = decode_bytes(truncated);
assert!(loaded.is_err(), "截断的 PNG 不许当成有效图");
assert_eq!(input(&loaded), ImageInput::Undecodable);
}
#[test]
fn missing_file_is_an_io_error() {
let loaded = load("Z:/definitely/not/here/background.png");
match loaded {
Err(ExtractError::Io(_)) => {}
other => panic!("应当报 IO 错误,实际 {other:?}"),
}
}
#[test]
fn one_pixel_image_is_reported_as_too_small() {
let png = encode_png(&solid_image(1, 1, [0xC0, 0x39, 0x2B]));
match seed_from_bytes(&png) {
Err(ExtractError::Palette(palette::Failure::TooSmall { width, height })) => {
assert_eq!((width, height), (1, 1));
}
other => panic!("1×1 图应当取色失败,实际 {other:?}"),
}
}
#[test]
fn gray_png_falls_back_instead_of_inventing_a_colour() {
// D40 的反证:灰白图**不许**变成青色。这里必须报低彩度,而不是返回一个种子。
let png = encode_png(&solid_image(24, 24, [0xEE, 0xEE, 0xEC]));
match seed_from_bytes(&png) {
Err(ExtractError::Palette(palette::Failure::LowChroma { chroma, .. })) => {
assert!(chroma < 8.0, "彩度 {chroma}");
}
other => panic!("灰白图应当报低彩度,实际 {other:?}"),
}
}
#[test]
fn large_image_is_scaled_to_the_max_edge() {
let png = encode_png(&solid_image(1024, 512, [0xB2, 0x6A, 0x00]));
let decoded = decode_bytes(&png).expect("大图应当解码成功");
assert_eq!(decoded.width(), MAX_EDGE);
assert_eq!(decoded.height(), MAX_EDGE / 2);
assert_eq!(
decoded.pixels().len(),
(MAX_EDGE * MAX_EDGE / 2 * 4) as usize
);
// 缩放后仍是那个橙色:取色的结论不该被「缩了多少」改变。
let seed = seed_from_rgba8(&decoded).expect("琥珀橙有种子");
assert!(seed.chroma > 8.0);
}
#[test]
fn absurd_dimensions_are_rejected_before_allocating() {
// 头里写着 20000 宽的 PNG:超过 MAX_SOURCE_EDGE,必须在解码前拒掉。
let png = encode_png(&solid_image(20_000, 1, [0x3C, 0x8D, 0xBC]));
match decode_bytes(&png) {
Err(ExtractError::Decode(_)) => {}
other => panic!("超限尺寸应当被拒,实际 {other:?}"),
}
}
#[test]
fn max_edge_stays_within_the_design_sample_budget() {
// 结构性防线(复审 r2 要求):`unlucky_design` 只在「像素数 ≤ MAX_SAMPLES」时才不做块平均。
// 当前 256² = 65536 = MAX_SAMPLES,**余量为零**:谁把 MAX_EDGE 调到 512,本适配层交出去的
// 缓冲就会超过 design 的采样预算,design 会**在适配层已经缩过之后再叠一层块平均**
// (色彩被二次抹平),而这条退化没有任何测试会报错——两个常数此前是各写各的。
//
// 注意别把这条读成「≤ 预算 ⇒ 种子一定是原图真实颜色」:本适配层的 `thumbnail` 本身就会平均
// (400×400 红蓝棋盘在 MAX_EDGE=256 下取到的 `#704070` 也不在原图上)。这条只保证
// 「design 侧不再叠第二层平均」,逐像素语义见 `Seed::color` 的文档。
let budget = unlucky_design::palette::MAX_SAMPLES;
let edge = MAX_EDGE as usize;
assert!(
edge * edge <= budget,
"MAX_EDGE={MAX_EDGE} 的平方({})超过 design 的采样预算 {budget}: 调大它之前先读 `unlucky_design::palette::Seed::color` 的文档——超过预算后 design 会在本适配层缩放之上再叠一层块平均",
edge * edge
);
}
#[test]
fn input_borrows_without_copying() {
let loaded = decode_bytes(&encode_png(&solid_image(4, 4, [0x67, 0x50, 0xA4])));
match input(&loaded) {
ImageInput::Pixels(source) => {
assert_eq!((source.width, source.height), (4, 4));
// 零拷贝:视图指向的就是 `Rgba8` 自己的缓冲。
assert_eq!(source.pixels, loaded.as_ref().expect("解码成功").pixels());
}
ImageInput::Undecodable => panic!("刚解码成功却报了 undecodable"),
}
}
// ---------------------------------------------------------------- 缩略图(M251 · D54)
/// 一次性临时目录(本 crate 不引第三方 tempfile:整仓都没有)。
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 写一个真 PNG 到临时目录,返回 `(目录守卫, 文件路径)`。
fn png_on_disk(tag: &str, image: &RgbaImage) -> (TempDir, std::path::PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("palette-thumb-{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
let path = dir.join("shot.png");
std::fs::write(&path, encode_png(image)).expect("写 PNG");
(TempDir(dir), path)
}
/// 真 PNG → 等比缩到给定长边。
///
/// 抓得住:`thumbnail` 拿错目标边长(例如误用取色那条路的 `MAX_EDGE = 256`)、宽高比被拉平、
/// 缩放后字节数对不上 `width × height × 4`。
/// 抓不住:缩放**画质**(盒式采样的观感只能靠出图看);像素内容是否正确(只查尺寸与不透明通道)。
#[test]
fn thumbnail_scales_a_real_png_to_the_asked_edge() {
let (_dir, path) = png_on_disk("scale", &solid_image(40, 20, [0x2E, 0x7D, 0x4F]));
let thumb = thumbnail(&path, 10).expect("真 PNG 应当出缩略图");
assert_eq!((thumb.width(), thumb.height()), (10, 5), "等比缩到长边 10");
assert_eq!(thumb.pixels().len(), 10 * 5 * 4);
// 纯色图缩完还是那个色(盒式平均不该把纯色抹成别的色)。
assert_eq!(&thumb.pixels()[..4], &[0x2E, 0x7D, 0x4F, 0xFF]);
}
/// 小图**不放大**:图本身比墙上的格子小就原样交付(放大只会糊)。
///
/// 抓得住:有人在 `fit` 里把「长边超限才缩」改成无条件缩(那会把 8×6 的图拉成 160×120)。
#[test]
fn thumbnail_never_upscales() {
let (_dir, path) = png_on_disk("small", &solid_image(8, 6, [0x67, 0x50, 0xA4]));
let thumb = thumbnail(&path, THUMBNAIL_MAX_EDGE).expect("小图也应当成功");
assert_eq!((thumb.width(), thumb.height()), (8, 6));
}
/// 坏文件给**结构化错误**,不 panic、不返回灰图。
///
/// 抓得住:把 `?` 换成 `unwrap`/`unwrap_or_default`(前者 panic 让用例挂,后者返回一张图让
/// `expect_err` 挂)。
/// 抓不住:错误文案的措辞(那是 `image` 的原文,只进括号)。
#[test]
fn a_broken_png_is_a_decode_error_not_a_panic() {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("palette-thumb-bad-{}-{seq}", std::process::id()));
std::fs::create_dir_all(&dir).expect("建临时目录");
let guard = TempDir(dir.clone());
let path = dir.join("2024-05-01_10.00.00.png");
std::fs::write(&path, b"\x89PNG\r\n\x1a\n truncated garbage").expect("写坏文件");
match thumbnail(&path, THUMBNAIL_MAX_EDGE) {
Err(ExtractError::Decode(_)) => {}
other => panic!("坏 PNG 应当给 Decode 错误,实际 {other:?}"),
}
drop(guard);
}
/// 文件不存在 ⇒ `Io`(与「解不出来」分开:一个是没读到,一个是读到了但读不懂)。
#[test]
fn a_missing_file_is_an_io_error() {
let path = std::env::temp_dir().join("palette-thumb-does-not-exist.png");
let _ = std::fs::remove_file(&path);
match thumbnail(&path, THUMBNAIL_MAX_EDGE) {
Err(ExtractError::Io(_)) => {}
other => panic!("不存在的文件应当给 Io 错误,实际 {other:?}"),
}
}
/// `max_edge = 0` 按 1 处理(防调用方传 0 得到 0×0 的图,墙上一格空着还查不出原因)。
#[test]
fn a_zero_edge_is_treated_as_one() {
let (_dir, path) = png_on_disk("zero", &solid_image(40, 20, [0x3C, 0x8D, 0xBC]));
let thumb = thumbnail(&path, 0).expect("0 也应当能出图");
assert_eq!((thumb.width(), thumb.height()), (1, 1));
}