Files
Unlucky-Launcher/crates/app-iced/src/pages/appearance/background/tests.rs
T
AskaEth 22e4c423fa feat(app-iced): M93 r2 —— 背景图真图直出 + 面板 α 收敛到 Ui::layer
复审前补齐两处(tower 扩 scope:Cargo.toml / pages/frame.* / theme.rs / presenter::prefs)。

1) 背景图**真图直出**
- `iced` 开 `image-without-codecs`(刻意不用 `image`:后者会打开 `image/default` 全套编解码器);
  编解码能力由本 crate 的 `image` features 决定,补了 `jpeg` / `bmp`(用户挑的背景图/壁纸多半是 jpg)。
- `pages/appearance/picture.rs`:`Cover`/`Contain` 走渲染器 `ContentFit`,`Tile` 按 `background::tiles`
  算行列(上限 64 块)真的铺 N 个图块;暗化 = 一层「版面底色 @ α=dim」叠在图上。
- 36dp 马赛克那条路**已删除**(不再是主路径,也不留死代码)。
- 踩坑记录:`Handle::from_bytes` 的纹理分配赶不上截图第 3 帧(实测画面全空),改用
  `Handle::from_path`(渲染器自己解码),已在 `picture::image_handle` 的文档里写明。

2) 面板 α **收敛到唯一来源**
- `Ui::layer()` 直接乘上面板 α(新增 `Ui::panel_alpha` / `with_panel_alpha`,默认 1.0、
  `scale_alpha(1.0)` 逐字节不变),`app::rebuild_ui` 注入生效值。
- 于是外壳(根容器/标题栏/导航轨)、`frame::card`(实例/下载/日志三页卡片)、
  `frame::header_bar`(新增底色)、外观分组卡片、空态图标块**全部同源**,
  调用点不再有任何 `panel_color(..)` 自乘(该 helper 已删)。
- 有背景图时根容器不再盖一层实色画布(否则 panel α=1 时背景图整块被挡)。

3) `presenter::prefs` 补重导出 `PANEL_ALPHA_MIN` / `BG_DIM_MAX`,app-iced 里两份 0.4 / 0.95
   字面量改为转发常量。

验收:11+3 张图(`C:\Temp\ull-shots\wt-93\r2-*.png`)——三档填充肉眼可分、`.jpg` 能加载、
GPU 档 panel55 时卡片/分组卡/页头带都透出背景、CPU 档仍强制 1.0 且背景在面板空隙可见;
另附实例/下载/日志三页 panel55 各一张,证明三页都吃到同一个 α。
2026-11-02 18:07:32 +08:00

125 lines
5.4 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, 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 重导出)");
}
#[test]
fn tile_geometry_counts_enough_tiles_and_stays_within_budget() {
// 抓得住:①图比视口小时按原尺寸铺够(ceil);②图比视口大时是 1×1(= 原尺寸裁切);
// ③总块数永远 ≤ MAX_TILES(小图不会拼出上万个控件)。
// 抓不住:铺出来的缝(渲染层的事,看截图)。
let small = tiles((200, 200), (640.0, 480.0));
assert_eq!((small.cols, small.rows), (4, 3));
assert_eq!(small.count(), 12);
let big = tiles((1600, 900), (1180.0, 760.0));
assert_eq!((big.cols, big.rows), (1, 1), "图比视口大 ⇒ 以原尺寸裁切");
let tiny = tiles((16, 16), (3840.0, 2160.0));
assert!(tiny.count() <= MAX_TILES, "{tiny:?} 超预算");
assert!(tiny.cols >= 1 && tiny.rows >= 1, "至少铺一块");
}
/// 编译期钉住:填充档解析走 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);
}