feat(saves): 截图缩略图墙(M251 · D54 全套:解码/缓存/上限/可见失败)

按 D54 落地,解码在 palette-extract、缓存在 presenter、像素随快照走、壳层只画。

palette-extract(上一提交)→ presenter:
- dto/saves/preview.rs:ScreenshotPreview{NotGenerated,Ready,Failed} + ThumbnailView
  (带 is_consistent:长度 ≠ w×h×4 的像素不许进渲染器)+ ScreenshotThumbKind;
  ScreenshotView 新增 preview 字段(像素不落盘,随 IPC 面走);
- host/saves/thumbs.rs:attach() 在后台线程(spawn_list 同一条线程)按 mtime 倒序解前
  60 张、单张 >8 MiB 先查大小不进解码器、失败也逐条带原因;进程级 memo 键 =
  (路径, mtime, 大小),覆盖/替换都会重解;超过 240 条只留本拍碰到的键;
- presenter 新增依赖 unlucky-palette-extract(塔已批,方向 presenter → palette-extract)。

app-iced:
- saves.rs 拆成 model / rows / wall(原文件 385 行,加完墙必破 400):model 里建一次
  iced Handle(渲染回调零解码、零重复上传),wall 3 列排格子,没像素的格子给
  160×90 占位框 + 逐条原因;text.rs 拆出 text/saves.rs 承载存档/截图一族文案;
- i18n 加 4 条「这一格为什么没有图」(未生成/太大/读不出/解不出)+ 金表同步;
- 出图夹具(saves_shot)合成两种场景的像素,并摆出四种格子(真图×2/未生成/超限失败)。

用例:presenter 9 条(含缓存命中不重解、mtime 变了必重解、第 61 张不解、
>8MiB 不进解码器、缓存有界)+ app-iced 3 条(三种格子的副文案、坏像素降级)。
This commit is contained in:
AskaEth committed 2026-11-11 16:33:56 +08:00
1 parent 798bfb279b
commit 843f6d08f9
21 files changed
+1519 -360

No files matched your search

+5
View File
@@ -24,6 +24,11 @@ serde_json.workspace = true
# host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。
unlucky-core = { path = "../core", version = "0.2.3" }
# M251(D54 第 1/4 条):截图缩略图在**后台线程**里解码,解码能力只能来自 palette-extract
# (`image` 一个字节都不进 presenter,否则「解码集中在一处」这条边界当场作废)。
# 方向只能是 presenter → palette-extract(反向不许);那边也不许反过来依赖 core/presenter。
unlucky-palette-extract = { path = "../palette-extract", version = "0.2.3" }
[lints]
workspace = true
+3 -2
View File
@@ -86,10 +86,11 @@ pub use pack::{
pub use settings::{
find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION,
};
// M235: 实例存档 / 截图的只读列表
// M235: 实例存档 / 截图的只读列表(M251 加逐张缩略图预览)
pub use saves::{
SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView,
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotPreview, ScreenshotThumbFailure,
ScreenshotThumbKind, ScreenshotView, ThumbnailView, WorldView,
};
// M159: 自更新
pub use update::{
+13 -1
View File
@@ -39,6 +39,12 @@ use serde::{Deserialize, Serialize};
use crate::dto::InstanceId;
mod preview;
pub use preview::{
ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ThumbnailView,
};
/// app → host 的存档指令。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesCommand {
@@ -135,7 +141,7 @@ pub struct WorldView {
pub modified_ms: Option<u64>,
}
/// 一张截图(**只给元数据**,不给像素:解码图片要引依赖,U3 明确不做)。
/// 一张截图(元数据 + **预览**)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScreenshotView {
/// 磁盘文件名(含扩展名)。
@@ -144,6 +150,12 @@ pub struct ScreenshotView {
pub bytes: u64,
/// 文件 mtime(UTC 毫秒;`None` = 取不到)。
pub modified_ms: Option<u64>,
/// 预览(缩略图像素 / 没生成 / 具名失败原因),见 [`ScreenshotPreview`]。
///
/// 像素**不落盘**(D54 第 2 条):解码在 host 的后台线程里做完,随这份快照一次性交给
/// 壳层。`(file, modified_ms, bytes)` 同时也是 host 侧缓存键的三段——三者任一变了就是
/// 另一张图,必须重解。
pub preview: ScreenshotPreview,
}
/// 被跳过的条目来自哪个目录。
+89
View File
@@ -0,0 +1,89 @@
//! 一张截图的**预览**(M251 · SAVESHOTS,D54 第 2/6 条)。
//!
//! ## 像素为什么走 DTO
//!
//! D54 定「**不落盘缩略图**」:写 PNG 要编码器,而编码器只为一份磁盘缓存引入不划算。于是像素
//! 就直接躺在快照里跟着 IPC 面走,由壳层用 `iced::widget::image::Handle::from_rgba` 建句柄。
//! 代价是快照变大(60 张 × 160² × 4B ≈ 6MB),收益是零编码、零临时文件、零清理。
//!
//! ## 三种状态,一种都不许静默
//!
//! | 状态 | 什么时候 | 界面 |
//! |---|---|---|
//! | [`ScreenshotPreview::Ready`] | 解出来了 | 缩略图 |
//! | [`ScreenshotPreview::NotGenerated`] | 本次刷新超出 60 张上限(按 mtime 倒序只解前 60) | 「未生成预览」占位 |
//! | [`ScreenshotPreview::Failed`] | 超过 8 MiB / 读不出来 / 解不出来 | 占位 + **逐条**原因 |
//!
//! **不拼人读文案**(M161):这里只有结构与 [`ScreenshotThumbKind`],句子由 app 层按 i18n key 现求。
//!
//! ## 为什么失败也带 `detail`
//!
//! 「读不出来」有五种完全不同的原因(权限 / 被占用 / 文件已删 / 是目录 / 盘掉了),用户能自己
//! 处理的那几种全靠这句话。原文只进括号,不进主句(与 [`super::SavesFailureView`] 同一条口径)。
use serde::{Deserialize, Serialize};
/// 一张截图的预览状态。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScreenshotPreview {
/// 还没生成:本次刷新超出上限(按 mtime 倒序只解前 60 张,上限常量在
/// `crate::host::saves::thumbs::MAX_PER_REFRESH`——策略住在解码那一侧,DTO 只表达状态)。
NotGenerated,
/// 解出来了(RGBA8,行主序,`width × height × 4`)。
Ready(ThumbnailView),
/// 没解出来(逐条可见的原因 + 原文)。
Failed(ScreenshotThumbFailure),
}
impl ScreenshotPreview {
/// 这一拍有没有可画的像素。
#[must_use]
pub fn thumbnail(&self) -> Option<&ThumbnailView> {
match self {
Self::Ready(view) => Some(view),
Self::NotGenerated | Self::Failed(_) => None,
}
}
}
/// 一张缩略图的像素(**未落盘**,见模块文档)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThumbnailView {
/// 宽(像素)。
pub width: u32,
/// 高(像素)。
pub height: u32,
/// RGBA8 字节,行主序,长度必须是 `width × height × 4`。
pub pixels: Vec<u8>,
}
impl ThumbnailView {
/// 像素数与宽高对得上吗(壳层建 `Handle` 之前**必须**成立:`from_rgba` 不校验长度,
/// 长度不对就是一张花屏图或者一次 panic 后的空窗)。
#[must_use]
pub fn is_consistent(&self) -> bool {
let expected = u64::from(self.width) * u64::from(self.height) * 4;
expected == self.pixels.len() as u64
}
}
/// 一张截图的预览为什么没出来(**每一种在 UI 上都有一句话**)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScreenshotThumbFailure {
/// 大类。
pub kind: ScreenshotThumbKind,
/// 底层原文(OS 错误 / 解码器原文);只进括号,不进主句。
pub detail: String,
}
/// 预览失败的大类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScreenshotThumbKind {
/// 单张超过解码上限(8 MiB):**不解码**,直接跳过(D54 第 5 条)。
TooLarge,
/// 读不出来(不存在 / 无权限 / 被占用)。
Unreadable,
/// 读到了但解不出来(坏 PNG / 不支持的格式)。
Decode,
}
+66
View File
@@ -18,6 +18,12 @@ fn view() -> SavesListView {
file: "2024-05-01_10.00.00.png".to_string(),
bytes: 1024,
modified_ms: None,
// M251:像素真的能过 IPC(4 字节 = 1×1 RGBA),这是「不落盘直接交给壳层」的前提。
preview: ScreenshotPreview::Ready(ThumbnailView {
width: 1,
height: 1,
pixels: vec![0x2E, 0x7D, 0x4F, 0xFF],
}),
}],
skipped: vec![SavesSkipView {
area: SavesArea::Screenshots,
@@ -121,6 +127,66 @@ fn empty_means_no_worlds_and_no_screenshots() {
file: "a.png".to_string(),
bytes: 0,
modified_ms: None,
preview: ScreenshotPreview::NotGenerated,
});
assert!(!view.is_empty());
}
/// 预览的三种状态都要能过 IPC,且**失败原因逐条带得回来**(M251 · D54 第 6 条)。
#[test]
fn previews_round_trip_in_every_state() {
let failure = ScreenshotThumbFailure {
kind: ScreenshotThumbKind::TooLarge,
detail: "9000000 > 8388608 字节".to_string(),
};
for preview in [
ScreenshotPreview::NotGenerated,
ScreenshotPreview::Ready(ThumbnailView {
width: 2,
height: 1,
pixels: vec![1, 2, 3, 4, 5, 6, 7, 8],
}),
ScreenshotPreview::Failed(failure),
] {
let text = serde_json::to_string(&preview).expect("序列化");
assert_eq!(
serde_json::from_str::<ScreenshotPreview>(&text).expect("反序列化"),
preview
);
}
}
/// 「像素长度与宽高对得上」这条判据本身(壳层建 `Handle` 前要它成立)。
#[test]
fn thumbnail_consistency_catches_a_short_buffer() {
let good = ThumbnailView {
width: 2,
height: 2,
pixels: vec![0; 16],
};
assert!(good.is_consistent());
let short = ThumbnailView {
pixels: vec![0; 15],
..good.clone()
};
assert!(!short.is_consistent(), "少一个字节也必须判不一致");
}
/// 只有 `Ready` 才有像素可画(另外两支都是占位,界面不许把它们当成「黑图」)。
#[test]
fn only_ready_hands_out_pixels() {
assert!(ScreenshotPreview::Ready(ThumbnailView {
width: 1,
height: 1,
pixels: vec![0, 0, 0, 255],
})
.thumbnail()
.is_some());
assert!(ScreenshotPreview::NotGenerated.thumbnail().is_none());
assert!(ScreenshotPreview::Failed(ScreenshotThumbFailure {
kind: ScreenshotThumbKind::Unreadable,
detail: "access denied".to_string(),
})
.thumbnail()
.is_none());
}
+22 -11
View File
@@ -73,13 +73,16 @@ use unlucky_core::platform::opener;
use crate::dto::{
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp,
SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView,
WorldView,
SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotPreview,
ScreenshotView, WorldView,
};
use super::env::HostEnv;
use super::job::{spawn, JobSender};
/// 截图的缩略图:后台解码 + 进程级 memo(D54 第 3/4/5 条,见模块文档)
pub mod thumbs;
mod write;
/// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。
@@ -254,7 +257,23 @@ pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathB
}
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
///
/// **截图带缩略图**(M251):像素在这里(后台线程里)解好、随快照一起交出去,UI 只画。
/// D54 明写「不许在渲染回调里解码」——这条纪律的落点就是「`view` 里 attach,`view()` 只在
/// 后台线程被调」。解码失败/超限/超预算的每一张都带自己的状态,不留白(D54 第 6 条)。
fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesListView {
let mut screenshots: Vec<ScreenshotView> = scan
.screenshots
.iter()
.map(|shot| ScreenshotView {
file: shot.file.clone(),
bytes: shot.bytes,
modified_ms: shot.modified_ms,
// 先摆占位,再让 thumbs 按 mtime 倒序挑前 60 张去解(顺序与上限都在那一个模块里)。
preview: ScreenshotPreview::NotGenerated,
})
.collect();
thumbs::attach(&scan.screenshots_dir, &mut screenshots);
SavesListView {
instance: instance.to_string(),
dir: scan.dir.display().to_string(),
@@ -270,15 +289,7 @@ fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesLi
.collect(),
screenshots_dir: scan.screenshots_dir.display().to_string(),
screenshots_exists: scan.screenshots_exists,
screenshots: scan
.screenshots
.iter()
.map(|shot| ScreenshotView {
file: shot.file.clone(),
bytes: shot.bytes,
modified_ms: shot.modified_ms,
})
.collect(),
screenshots,
skipped: scan
.skipped
.iter()
+175
View File
@@ -0,0 +1,175 @@
//! 截图的**后台解码 + 进程级 memo**(M251 · SAVESHOTS,落地 D54 第 3/4/5/6 条)。
//!
//! ## 为什么解码在后台线程
//!
//! 一屏 60 张 PNG 解码是几百毫秒量级的工作。D54 明写「**不许**在渲染回调里解码」:把这段放进
//! `iced` 的 `view()` 会直接把帧率打穿。所以解码发生在 [`super::spawn_list`] 那条后台线程里
//! (`job::spawn`,与 `host/saves.rs` 同一模式),UI 那一侧只收一份**带像素的快照**。
//!
//! ## 缓存键为什么必须是三段
//!
//! `(路径, mtime, 大小)`——**只按路径**是错的,而且是那种「测试不会红」的错:
//!
//! | 场景 | 只按路径的后果 |
//! |---|---|
//! | 游戏又存了一张同名截图(覆盖) | 永远显示上一张的缩略图 |
//! | 用户拿同名文件替换(修图后放回) | 同上,且没有任何日志 |
//!
//! 带上 mtime 与大小之后,覆盖 ⇒ 键变了 ⇒ 重解。这条由
//! `tests::a_changed_file_is_decoded_again` 钉住(把 mtime 从键里去掉,它必红)。
//!
//! ## 为什么是**进程级** memo 而不是状态字段
//!
//! 解码发生在 `spawn_list` 起的线程里,而那条线程拿到的东西只有 `HostEnv` + `tx`(见
//! `host/saves.rs`)。把缓存挂到 `CoreHost` 上要改 `host/mod.rs`(贴着 400 行)与
//! `host/dispatch.rs`(M251 的 scope 外),换来的语义与这里**完全一样**:键是全路径,多实例
//! 共享一份不会串味;它也不是业务状态,只是一份「同样的键不再重解」的记忆。
//!
//! ## 两条硬上限(D54 第 5 条)
//!
//! | 上限 | 值 | 为什么 |
//! |---|---|---|
//! | 单张字节数 | [`MAX_SOURCE_BYTES`] = 8 MiB | 一张 200MB 的 PNG 解码要几 GB 内存;**解码前**查大小,超了就不进解码器,并留一条可见原因 |
//! | 每次刷新张数 | [`MAX_PER_REFRESH`] = 60 | 按 mtime 倒序只解前 60 张(最近玩的那几屏最有用),其余给「未生成预览」占位 |
//!
//! 上限是**逐条可见**的:跳过的那张在快照里带 [`ScreenshotPreview::Failed`] 或
//! [`ScreenshotPreview::NotGenerated`],不是静默少一张(D54 第 6 条)。
//!
//! ## 缓存不会无限长
//!
//! 键里有 mtime,覆盖一次就多一个键。所以超过 [`MAX_CACHE_ENTRIES`] 时只保留**这一次刷新碰到
//! 的键**(换目录、删截图之后旧键一起掉)。代价是极端情况下重解一次,收益是常驻内存有界。
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use unlucky_palette_extract::{thumbnail, ExtractError, THUMBNAIL_MAX_EDGE};
use crate::dto::{
ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ScreenshotView, ThumbnailView,
};
/// 单张截图的解码上限(字节)。超过就**不解码**(D54 第 5 条)。
pub const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
/// 每次刷新最多解几张(按 mtime 倒序取前这么多)。
pub const MAX_PER_REFRESH: usize = 60;
/// 缓存的条目上限(超过就只留这一拍碰到的键)。
pub const MAX_CACHE_ENTRIES: usize = 240;
/// 缓存键:**(完整路径, mtime 毫秒, 字节数)**。
///
/// 三段缺一不可,理由见模块文档的表格。`mtime` 是 `Option`:取不到时间的文件用 `None` 占位
/// (那种文件本来就没有「变了」的可靠判据,大小仍然兜住一部分)。
type Key = (String, Option<u64>, u64);
/// 缓存里存的是**结局**(成功或失败)。
///
/// 失败也缓存:一张坏 PNG 不该每次刷新都重解一遍(那是「滚动一次卡一次」的另一种来源)。
/// 键里带 mtime ⇒ 用户修好文件(mtime 变)之后自然会重解。
type Cache = HashMap<Key, Result<ThumbnailView, ScreenshotThumbFailure>>;
fn cache() -> &'static Mutex<Cache> {
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 给一批截图挂上预览(**在后台线程里调**,`super::view` 的唯一调用点)。
pub(super) fn attach(dir: &Path, views: &mut [ScreenshotView]) {
attach_with(dir, views, &mut decode);
}
/// [`attach`] 的可测内核:解码器是一个参数,于是用例能数「到底进了几次解码器」。
///
/// 这类断言正是 D54 要求的那条「**缓存命中不重复解码**」:数的是解码器的调用次数,不是
/// 「界面上看起来一样」。
fn attach_with(
dir: &Path,
views: &mut [ScreenshotView],
decode: &mut impl FnMut(&Path, u64) -> Result<ThumbnailView, ScreenshotThumbFailure>,
) {
if views.is_empty() {
return;
}
// ① 排序:mtime 倒序(取不到的排最后),同刻按文件名倒序兜底(两次刷新之间不抖)。
let mut order: Vec<usize> = (0..views.len()).collect();
order.sort_by(|&left, &right| {
views[right]
.modified_ms
.cmp(&views[left].modified_ms)
.then_with(|| views[right].file.cmp(&views[left].file))
});
let mut store = cache().lock().unwrap_or_else(|poison| poison.into_inner());
let mut touched: Vec<Key> = Vec::with_capacity(order.len().min(MAX_PER_REFRESH));
for (rank, &index) in order.iter().enumerate() {
// ② 超出这一拍的解码预算:占位,不碰盘、不进解码器。
if rank >= MAX_PER_REFRESH {
views[index].preview = ScreenshotPreview::NotGenerated;
continue;
}
let (file, bytes, modified) = {
let shot = &views[index];
(shot.file.clone(), shot.bytes, shot.modified_ms)
};
// ③ 单张太大:**解码前**就拒,理由逐条可见。
if bytes > MAX_SOURCE_BYTES {
views[index].preview = ScreenshotPreview::Failed(ScreenshotThumbFailure {
kind: ScreenshotThumbKind::TooLarge,
detail: format!("{bytes} > {MAX_SOURCE_BYTES} 字节"),
});
continue;
}
// ④ 缓存 → 解码 → 回填。
let path = dir.join(&file);
let key: Key = (path.display().to_string(), modified, bytes);
let outcome = match store.get(&key) {
Some(cached) => cached.clone(),
None => {
let computed = decode(&path, bytes);
store.insert(key.clone(), computed.clone());
computed
}
};
touched.push(key);
views[index].preview = match outcome {
Ok(view) => ScreenshotPreview::Ready(view),
Err(failure) => ScreenshotPreview::Failed(failure),
};
}
// ⑤ 有界:只留这一拍碰到的键(旧的一律掉)。
if store.len() > MAX_CACHE_ENTRIES {
store.retain(|key, _| touched.contains(key));
}
}
/// 真解码(唯一碰 `image` 的地方;错都在 `palette-extract` 里分好类了)。
fn decode(path: &Path, _bytes: u64) -> Result<ThumbnailView, ScreenshotThumbFailure> {
match thumbnail(path, THUMBNAIL_MAX_EDGE) {
Ok(image) => Ok(ThumbnailView {
width: image.width(),
height: image.height(),
pixels: image.pixels().to_vec(),
}),
Err(error) => Err(failure(&error)),
}
}
/// `palette-extract` 的错误 → 预览失败的大类 + 原文(不翻译,翻译在 app 层)。
fn failure(error: &ExtractError) -> ScreenshotThumbFailure {
let kind = match error {
ExtractError::Io(_) => ScreenshotThumbKind::Unreadable,
// 取色失败那条支在本路径上不可能出现(`thumbnail` 不做量化),但穷尽 match 比
// 通配兜底诚实:真出现了也算「解不出来」,不会被当成读到了。
ExtractError::Decode(_) | ExtractError::Palette(_) => ScreenshotThumbKind::Decode,
};
ScreenshotThumbFailure {
kind,
detail: error.to_string(),
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,284 @@
//! `host::saves::thumbs` 的用例:**数解码器调用次数**,不靠"看起来一样"。
//!
//! 每一条都按 D54 的判据写,并且都带一句「抓得住什么 / 抓不住什么」。T27 要求的两条变异都有对应用例:
//!
//! | 变异 | 必红的用例 |
//! |---|---|
//! | 缓存键去掉 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) |
//!
//! 抓不住:缩略图**画质**(盒式采样的观感只能靠出图看);缓存淘汰策略的观感(`MAX_CACHE_ENTRIES`
//! 只有一条「不会无限长」的结构性断言,没有逐条淘汰用例)。
use std::path::{Path, PathBuf};
use super::*;
/// 一张真的 8×6 PNG(143 字节,RGBA,逐像素渐变)。
///
/// 内嵌字节而不是引素材文件:presenter 的 dev-dependencies 里没有图片编码器,而这条用例要的
/// 正是「**真**解码器能读的真 PNG」——手写的 143 个字节比任何 mock 都硬。
const PNG_8X6: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 6, 8, 6, 0,
0, 0, 254, 5, 223, 251, 0, 0, 0, 86, 73, 68, 65, 84, 120, 218, 21, 202, 33, 1, 195, 64, 12, 0,
192, 87, 50, 92, 17, 47, 162, 56, 74, 2, 171, 34, 184, 34, 34, 98, 56, 134, 182, 123, 112,
236, 214, 90, 207, 239, 195, 38, 72, 138, 102, 88, 235, 18, 216, 4, 73, 209, 204, 117, 194,
45, 176, 9, 146, 162, 153, 251, 132, 20, 216, 4, 73, 209, 76, 158, 240, 10, 108, 130, 164,
104, 230, 61, 225, 43, 176, 9, 146, 162, 25, 254, 48, 255, 110, 65, 247, 55, 89, 206, 0, 0, 0,
0, 73, 69, 78, 68, 174, 66, 96, 130,
];
/// 一次性临时目录(本 crate 不引第三方 tempfile)。
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn temp_dir(tag: &str) -> TempDir {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("ull-thumbs-{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
TempDir(path)
}
/// 目录里放一张真 PNG,返回它的字节数。
fn write_png(dir: &Path, file: &str) -> u64 {
let path = dir.join(file);
std::fs::write(&path, PNG_8X6).expect("写 PNG");
std::fs::metadata(&path).expect("读元数据").len()
}
/// 一张「快照里的截图」(`preview` 由被测代码填)。
fn shot(file: &str, bytes: u64, modified_ms: Option<u64>) -> ScreenshotView {
ScreenshotView {
file: file.to_string(),
bytes,
modified_ms,
preview: ScreenshotPreview::NotGenerated,
}
}
/// **判据 ①**:真 PNG 出缩略图(像素 + 宽高 + 长度自洽)。
///
/// 抓得住:解码这条路断了(回 `NotGenerated`)、缩略图边长没按 `THUMBNAIL_MAX_EDGE` 走、
/// `pixels` 长度与宽高对不上(壳层 `from_raw_rgba` 会画出花屏)。
#[test]
fn a_real_png_becomes_a_thumbnail() {
let dir = temp_dir("ready");
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
let mut views = [shot("2024-05-01_10.00.00.png", bytes, Some(1_714_557_600_000))];
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
attach_with(&dir.0, &mut views, &mut counting);
assert_eq!(calls.get(), 1, "一张新图应当正好进一次解码器");
let ready = views[0].preview.thumbnail().expect("真 PNG 应当出缩略图");
assert_eq!((ready.width, ready.height), (8, 6), "8×6 的图不放大");
assert!(ready.is_consistent(), "像素长度与宽高对不上");
assert_eq!(&ready.pixels[..4], &[0, 0, 128, 255], "左上角那一格的颜色");
}
/// **判据 ②**:坏文件给**结构化错误**(逐条可见),不 panic、不静默少一张。
#[test]
fn a_broken_file_is_a_visible_reason() {
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")).expect("元数据").len();
let mut views = [shot("bad.png", bytes, Some(1))];
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
attach_with(&dir.0, &mut views, &mut counting);
assert_eq!(calls.get(), 1, "坏文件也要真的试一次解码(不许提前判死)");
match &views[0].preview {
ScreenshotPreview::Failed(failure) => {
assert_eq!(failure.kind, ScreenshotThumbKind::Decode);
assert!(!failure.detail.is_empty(), "原因被吞掉了");
}
other => panic!("坏 PNG 应当给 Failed,实际 {other:?}"),
}
}
/// **判据 ③**:超过 8 MiB 的图**不进解码器**,且原因可见(T27 变异的目标)。
///
/// 抓得住:把上限改成「无上限」或把判断挪到解码之后(`calls` 会从 0 变成 1)。
/// 抓不住:8 MiB 这个数字本身合不合理(那是 D54 定的产品取舍)。
#[test]
fn a_file_over_the_size_cap_never_reaches_the_decoder() {
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("写大文件");
let mut views = [shot(file, MAX_SOURCE_BYTES + 1, Some(1))];
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
attach_with(&dir.0, &mut views, &mut counting);
assert_eq!(calls.get(), 0, "超过 8 MiB 的图一个字节都不该进解码器");
match &views[0].preview {
ScreenshotPreview::Failed(failure) => {
assert_eq!(failure.kind, ScreenshotThumbKind::TooLarge);
assert!(
failure.detail.contains(&MAX_SOURCE_BYTES.to_string()),
"括号里要带上限,实际 {}",
failure.detail
);
}
other => panic!("超限的图应当给 Failed(TooLarge),实际 {other:?}"),
}
}
/// **判据 ④**:第 61 张不解码(按 mtime 倒序)。
///
/// 抓得住:上限被去掉(`calls` 会变 61)、排序反了(最早的那张反而被解、最新的给占位)、
/// 占位被画成失败(`NotGenerated` 与 `Failed` 是两种状态:一个是「没轮到」,一个是「试过但不行」)。
#[test]
fn the_sixty_first_screenshot_is_not_decoded() {
let dir = temp_dir("cap");
let mut views: Vec<ScreenshotView> = (0..MAX_PER_REFRESH + 1)
.map(|index| {
let file = format!("2024-05-01_10.00.{index:02}.png");
let bytes = write_png(&dir.0, &file);
// index 越小越新:排序之后 index 0 应当是 rank 0。
shot(&file, bytes, Some(1_714_557_600_000 - index as u64))
})
.collect();
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
attach_with(&dir.0, &mut views, &mut counting);
assert_eq!(calls.get(), MAX_PER_REFRESH as u32, "只该解前 {MAX_PER_REFRESH} 张");
assert!(
views[..MAX_PER_REFRESH]
.iter()
.all(|view| view.preview.thumbnail().is_some()),
"最新的 {MAX_PER_REFRESH} 张都该有缩略图"
);
assert_eq!(
views[MAX_PER_REFRESH].preview,
ScreenshotPreview::NotGenerated,
"第 61 张应当是「未生成预览」占位"
);
}
/// **判据 ⑤**:缓存命中不重复解码(同一 (路径, mtime, 大小) 第二次不进解码器)。
///
/// 抓得住:缓存根本没接上(`calls` 变 2)、键里多加了会让命中失效的东西(例如把「本次调用
/// 序号」混进键)。
#[test]
fn the_second_identical_refresh_does_not_redecode() {
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);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
let mut first = [shot("2024-05-01_10.00.00.png", bytes, Some(7))];
attach_with(&dir.0, &mut first, &mut counting);
let mut second = [shot("2024-05-01_10.00.00.png", bytes, Some(7))];
attach_with(&dir.0, &mut second, &mut counting);
assert_eq!(calls.get(), 1, "第二次刷新不许再进解码器");
assert_eq!(first[0].preview, second[0].preview, "两拍的缩略图必须一致");
}
/// **判据 ⑥(T27 的第一条变异靶子)**:文件被覆盖(mtime 变了)必须重解。
///
/// 这是「截图被覆盖后仍显示旧缩略图」那个 bug 的唯一自动化抓手:把 mtime 从缓存键里去掉,
/// 本用例立刻红(`calls` 停在 1)。大小故意保持不变——只改 mtime,才能把「大小也在键里」这条
/// 排除掉。
#[test]
fn a_changed_file_is_decoded_again() {
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);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
let mut first = [shot("2024-05-01_10.00.00.png", bytes, Some(1_000))];
attach_with(&dir.0, &mut first, &mut counting);
assert_eq!(calls.get(), 1);
// 同名文件被覆盖:**只有 mtime 变了**(大小相同)。
let mut second = [shot("2024-05-01_10.00.00.png", bytes, Some(2_000))];
attach_with(&dir.0, &mut second, &mut counting);
assert_eq!(calls.get(), 2, "mtime 变了就是另一张图,必须重解");
}
/// **判据 ⑦**:同名同刻但**大小变了**(例如用户替换了文件并保了 mtime)也必须重解。
///
/// 抓得住:缓存键只剩路径(`calls` 停在 1)。与用例 ⑥ 一起把三段键的两段独立钉住。
#[test]
fn a_changed_size_is_decoded_again() {
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);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
let mut first = [shot("2024-05-01_10.00.00.png", bytes, Some(5))];
attach_with(&dir.0, &mut first, &mut counting);
let mut second = [shot("2024-05-01_10.00.00.png", bytes + 1, Some(5))];
attach_with(&dir.0, &mut second, &mut counting);
assert_eq!(calls.get(), 2, "大小变了必须重解");
}
/// 空快照不碰缓存、不 panic(新实例的常态)。
#[test]
fn an_empty_list_is_a_no_op() {
let dir = temp_dir("empty");
let mut views: Vec<ScreenshotView> = Vec::new();
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
attach_with(&dir.0, &mut views, &mut counting);
assert_eq!(calls.get(), 0);
}
/// 缓存有界:跑一轮超过 [`MAX_CACHE_ENTRIES`] 的键之后,缓存不会无限长。
///
/// 抓得住:淘汰那段被删掉(`store.len()` 会一路涨到 keys 数)。本用例只断言"有界",
/// **不**断言淘汰了哪一条(那是实现细节,钉死会让下一版换 LRU 时无谓地红)。
#[test]
fn the_cache_stays_bounded() {
let dir = temp_dir("bounded");
let bytes = write_png(&dir.0, "one.png");
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
// 同一张图、每次一个新 mtime ⇒ 每次一个新键。
for round in 0..(MAX_CACHE_ENTRIES + 10) {
let mut views = [shot("one.png", bytes, Some(round as u64))];
attach_with(&dir.0, &mut views, &mut counting);
}
let store = cache().lock().unwrap_or_else(|poison| poison.into_inner());
assert!(
store.len() <= MAX_CACHE_ENTRIES,
"缓存长了 {} 条,上限是 {MAX_CACHE_ENTRIES}",
store.len()
);
}