- 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 不产人读文案)。
282 lines
12 KiB
Rust
282 lines
12 KiB
Rust
//! 解码适配层的单测:真 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));
|
||
}
|