feat(app-iced)+test(presenter): 句柄按 (目录,mtime,大小) 复用;缓存可清空;端到端出图

复审 r1 的三条:

1. 端到端图:DevDocs/artifacts/m251/saves-real-decode-light.png —— 真 host
   (ULL_SCREENSHOT_REAL_HOST=1 + --data-dir 一个临时数据根)→ 后台列 screenshots/ →
   palette_extract::thumbnail 真解码 → DTO → Handle::from_rgba → 上屏。三格是三张真 PNG
   的真像素,副文案的 2.3 KB / 2.2 KB / 24.1 KB 也是真扫描值。造根脚本与复现命令进
   artifacts/m251/{README.md,make-real-root.py},并写明「哪张是排版、哪张是端到端」。
2. 每帧重建 Handle 的成本:改成**缓存**(不是实测)。model.rs 新增 process-level
   memo,键 = (截图目录, 文件, mtime, 字节数)(与 presenter 的缓存键同源,目录单列一格),
   上限 120 条(≈12MB)。判据:the_same_screenshot_reuses_one_handle 断言两次
   from_state 拿到**同一个 Handle id**;变异(换回 Handle::from_rgba)即红。
   a_changed_screenshot_gets_a_new_handle 钉住「文件变了必须换句柄」。
3. 进程级 memo 的副作用:两层各加 #[cfg(test)] clear_cache()/clear_handles(),用例从
   已知状态起跑;两条上限从 240 收到 120(≈12MB/层,算术写进常量文档)。

顺带:句柄键改用 push 拼(format! 模板串会被页面字面量守门当成英文文案抓走);
preview 的页面模型用例拆到 tests/saves/preview.rs(saves.rs 破 400)。
This commit is contained in:
AskaEth committed 2026-11-11 17:09:17 +08:00
1 parent dd9962e610
commit 779a2ae254
6 files changed
+288 -110

No files matched your search

+19 -1
View File
@@ -57,7 +57,12 @@ pub const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
pub const MAX_PER_REFRESH: usize = 60;
/// 缓存的条目上限(超过就只留这一拍碰到的键)。
pub const MAX_CACHE_ENTRIES: usize = 240;
///
/// **算过内存**:一张缩略图最多 `160×160×4 = 102.4 KB`,120 条 ⇒ **上限 ≈ 12 MB**;
/// 一次刷新最多碰 60 张(≈6 MB),所以 120 = 「当前这一屏 + 上一屏」的余量。
/// 定这条上限的背景:缓存是**进程级静态**(见 [`cache`]),不随实例/数据根释放,
/// 所以要有一个能算出来的天花板,而不是「反正删了就少一张」。
pub const MAX_CACHE_ENTRIES: usize = 120;
/// 缓存键:**(完整路径, mtime 毫秒, 字节数)**。
///
@@ -76,6 +81,19 @@ fn cache() -> &'static Mutex<Cache> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 清空缓存(**只给用例**:这是进程级静态,用例之间必须能从已知状态起跑)。
///
/// 生产路径没有任何调用点——键里带完整路径,两个数据根/两个实例共用一个静态表也不会串味
/// (见模块文档「为什么是进程级 memo」);但**用例**会反复用短路径造键,谁都不该依赖
/// 「上一个用例刚好留了/没留什么」。
#[cfg(test)]
pub(super) fn clear_cache() {
cache()
.lock()
.unwrap_or_else(|poison| poison.into_inner())
.clear();
}
/// 给一批截图挂上预览(**在后台线程里调**,`super::view` 的唯一调用点)。
pub(super) fn attach(dir: &Path, views: &mut [ScreenshotView]) {
attach_with(dir, views, &mut decode);
@@ -7,6 +7,8 @@
//! | 缓存键去掉 mtime(或改成只按路径) | [`a_changed_file_is_decoded_again`](同一路径、同一大小、只改 mtime ⇒ 必须重解)与 [`a_changed_size_is_decoded_again`] |
//! | 单张 8 MiB 上限改成无上限 | [`a_file_over_the_size_cap_never_reaches_the_decoder`](解码器调用次数必须为 0) |
//!
//! 每个用例开头都调 [`clear_cache`]:缓存是进程级静态,用例之间不该互相借光(M251 复审 r1)。
//!
//! 抓不住:缩略图**画质**(盒式采样的观感只能靠出图看);缓存淘汰策略的观感(`MAX_CACHE_ENTRIES`
//! 只有一条「不会无限长」的结构性断言,没有逐条淘汰用例)。
@@ -70,6 +72,7 @@ fn shot(file: &str, bytes: u64, modified_ms: Option<u64>) -> ScreenshotView {
/// `pixels` 长度与宽高对不上(壳层 `from_raw_rgba` 会画出花屏)。
#[test]
fn a_real_png_becomes_a_thumbnail() {
clear_cache();
let dir = temp_dir("ready");
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
let mut views = [shot(
@@ -94,6 +97,7 @@ fn a_real_png_becomes_a_thumbnail() {
/// **判据 ②**:坏文件给**结构化错误**(逐条可见),不 panic、不静默少一张。
#[test]
fn a_broken_file_is_a_visible_reason() {
clear_cache();
let dir = temp_dir("broken");
std::fs::write(dir.0.join("bad.png"), b"\x89PNG\r\n\x1a\n garbage").expect("写坏文件");
let bytes = std::fs::metadata(dir.0.join("bad.png"))
@@ -123,6 +127,7 @@ fn a_broken_file_is_a_visible_reason() {
/// 抓不住:8 MiB 这个数字本身合不合理(那是 D54 定的产品取舍)。
#[test]
fn a_file_over_the_size_cap_never_reaches_the_decoder() {
clear_cache();
let dir = temp_dir("toolarge");
let file = "huge.png";
std::fs::write(dir.0.join(file), vec![0u8; MAX_SOURCE_BYTES as usize + 1]).expect("写大文件");
@@ -154,6 +159,7 @@ fn a_file_over_the_size_cap_never_reaches_the_decoder() {
/// 占位被画成失败(`NotGenerated` 与 `Failed` 是两种状态:一个是「没轮到」,一个是「试过但不行」)。
#[test]
fn the_sixty_first_screenshot_is_not_decoded() {
clear_cache();
let dir = temp_dir("cap");
let mut views: Vec<ScreenshotView> = (0..MAX_PER_REFRESH + 1)
.map(|index| {
@@ -194,6 +200,7 @@ fn the_sixty_first_screenshot_is_not_decoded() {
/// 序号」混进键)。
#[test]
fn the_second_identical_refresh_does_not_redecode() {
clear_cache();
let dir = temp_dir("hit");
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
let calls = std::cell::Cell::new(0u32);
@@ -217,6 +224,7 @@ fn the_second_identical_refresh_does_not_redecode() {
/// 排除掉。
#[test]
fn a_changed_file_is_decoded_again() {
clear_cache();
let dir = temp_dir("mtime");
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
let calls = std::cell::Cell::new(0u32);
@@ -238,6 +246,7 @@ fn a_changed_file_is_decoded_again() {
/// 抓得住:缓存键只剩路径(`calls` 停在 1)。与用例 ⑥ 一起把三段键的两段独立钉住。
#[test]
fn a_changed_size_is_decoded_again() {
clear_cache();
let dir = temp_dir("size");
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
let calls = std::cell::Cell::new(0u32);
@@ -255,6 +264,7 @@ fn a_changed_size_is_decoded_again() {
/// 空快照不碰缓存、不 panic(新实例的常态)。
#[test]
fn an_empty_list_is_a_no_op() {
clear_cache();
let dir = temp_dir("empty");
let mut views: Vec<ScreenshotView> = Vec::new();
let calls = std::cell::Cell::new(0u32);