复审(代码)r1 的两条 P1 + 两条必修。 P1-1 `SetSetting` 写不回 Float 键(整窗透明度重启回旧值) - `presenter/src/host/settings.rs::parse_value` 改成**按 schema 的 `ValueKind` 取类型**, 不再硬编码 Int/Bool 键名。历史:`appearance.window_opacity`(Float)被当 String 送进 `store.set` → schema 的 `as_f64()` 返回 None → `InvalidValue` → **`save()` 根本没执行**, α 只在内存生效、重启回旧值、每次拖动弹一条 Error toast。 - 测试(presenter 层,`host/settings_tests.rs`): ① `float_key_round_trips_through_set_setting`:set 0.8 → **新开 store(= 重启)** 读回 0.8; ② `every_float_key_in_the_schema_can_be_written_back`:遍历 schema 里**所有** `ValueKind::Float` 的键逐个写回并读回(集合级守门,下次新增 Float 键漏接会立刻红)。 P1-2 `presenter/src/prefs.rs` 404 行 → 拆测试 - `#[cfg(test)] mod tests` 整体挪到 `crates/presenter/src/prefs/tests.rs`,结构体与 re-export 一字未动。`wc -l`:**404 → 315**(tests.rs 90 行)。 P2-2 CPU 档判定只在 `--renderer=cpu` 时成立 - 新增 `app/backend.rs`:`effective_backend()` 把 `probe::apply` 写下的 `ICED_BACKEND` 候选列表翻回档位(`tiny-skia` / `tiny-skia,wgpu` → CPU;`wgpu,tiny-skia` → GPU;未设 → 请求值)。 `App::with_presenter` 用它喂 `appearance::State`,于是**默认 Auto 档在没有独显的机器上** 也会走「面板 α / 玻璃强制 1.0」。 - 测试:`every_iced_backend_list_maps_to_a_tier` + `auto_with_a_software_renderer_forces_the_panel_opaque`。 - 实跑:`ICED_BACKEND=tiny-skia`(= probe 在无独显机器上写下的值)→ `[appearance] boot … panel_alpha=1.000 (requested 0.550) backend=cpu`。 P2-1 平铺退化成「一条竖条」——根因不是几何,是**渲染层没吃 scale**(r3 的编辑没落到 `tiled()`) - `picture::tiled()` 原来仍用 `ContentFit::None` + 原图尺寸,所以 `tiles()` 算出的 `scale` 完全没被消费:小图被按 32dp 画在 129dp 的格子里 ⇒ 到处是缝。 - 现在:每块 `源尺寸 × tiles.scale`、`ContentFit::Fill`、行高显式 `Fixed`、整列 `Fill`。 - 诊断:新增 `background::small_tile_note()`(短边 < 200dp 的图在 tile 档下给一句 「scaled up so the window stays covered」),已接进设置页诊断块(首屏可见)。 - 测试:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 32×32 / 16×16 / 1×1, 断言覆盖 ≥ 视口且块数 ≤ 预算)、`small_images_get_a_tile_diagnostic_instead_of_silently_degrading`。 - 出图:`r5-tile-tiny32.png`(32×32 铺满全窗、右缘无裸白、提示可见)、`r5-tile-120.png`、 `r5-tile-big.png`。 顺手(复审 P2-3/P2-4/P2-5) - P2-4:`MAX_SOURCE_EDGE` 16384 → **8192**,并给解码器加 `image::Limits`(宽/高 + `max_alloc` 320MB)作第二道闸;补 `an_over_sized_image_reports_too_large_instead_of_trying_to_decode` (`TooLarge` 这条分支此前无测试)。 - P2-5:`transparency.rs` 补一段「为什么 `LWA_ALPHA` + `WS_EX_LAYERED` 不影响命中测试与 自绘标题栏拖动」(穿透要 `WS_EX_TRANSPARENT` 或 `LWA_COLORKEY`,两者都没用;`window::drag` 走 `WM_NCLBUTTONDOWN(HTCAPTION)`)。 - P2-3:断链 `[background::dimmed]` → `dim_alpha`;`iced/image` → `image-without-codecs`; 测试文档 204 → 205;马赛克残留说明只保留 r1→r2 的历史一句。 - 解码在 UI 线程同步做:**登记欠账**(挪后台线程超出本 mission)。
201 lines
8.7 KiB
Rust
201 lines
8.7 KiB
Rust
//! `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_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(不许静默回默认色),且 describe() 是人话。
|
||
// 抓不住:webp/tiff 这类没开编码器的格式具体报什么字符串(只钉分类,不钉解码库原文)。
|
||
let missing = temp_file("missing", "bg.png");
|
||
let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错");
|
||
assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}");
|
||
assert!(error
|
||
.describe()
|
||
.starts_with("Background image could not be read"));
|
||
|
||
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:?}");
|
||
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:?}"
|
||
);
|
||
assert!(
|
||
error.describe().contains("too large"),
|
||
"提示要人话:{}",
|
||
error.describe()
|
||
);
|
||
// 上限不许再放宽(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!(note.contains("32×32"), "{note}");
|
||
assert!(note.contains("scaled up"), "{note}");
|
||
|
||
// 非 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());
|
||
}
|