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:
1 parent
dd9962e610
commit
779a2ae254
6 files changed
+288
-110
No files matched your search
@@ -87,6 +87,9 @@ mod model;
|
|||||||
mod rows;
|
mod rows;
|
||||||
mod wall;
|
mod wall;
|
||||||
|
|
||||||
|
/// 句柄 memo 的清空入口(只给用例:它是进程级 memo)。
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) use model::clear_handles;
|
||||||
pub use model::{failure_line, from_state, Row, Saves, Shot, ShotPreview};
|
pub use model::{failure_line, from_state, Row, Saves, Shot, ShotPreview};
|
||||||
/// 写操作成功那句话的产地(实现在 [`rows`] 里)。
|
/// 写操作成功那句话的产地(实现在 [`rows`] 里)。
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
//! 这也正是 D54「**不许在渲染回调里解码**」的同一条纪律的延伸:解码在 presenter 的后台线程,
|
//! 这也正是 D54「**不许在渲染回调里解码**」的同一条纪律的延伸:解码在 presenter 的后台线程,
|
||||||
//! 句柄在这里,`view()` 只借。
|
//! 句柄在这里,`view()` 只借。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
use iced::widget::image::Handle;
|
use iced::widget::image::Handle;
|
||||||
|
|
||||||
use unlucky_i18n::Catalog;
|
use unlucky_i18n::Catalog;
|
||||||
@@ -173,7 +176,7 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
|||||||
out.screenshots = view
|
out.screenshots = view
|
||||||
.screenshots
|
.screenshots
|
||||||
.iter()
|
.iter()
|
||||||
.map(|shot| shot_cell(shot, texts))
|
.map(|shot| shot_cell(&view.screenshots_dir, shot, texts))
|
||||||
.collect();
|
.collect();
|
||||||
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
|
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
|
||||||
out.skipped = view.skipped_count();
|
out.skipped = view.skipped_count();
|
||||||
@@ -189,6 +192,79 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 句柄缓存的条目上限(超过就整表清空)。
|
||||||
|
///
|
||||||
|
/// 一张 160×160 的 RGBA 最坏 ≈ 100KB ⇒ 120 条 ≈ **12MB 上限**;正常一屏 ≤60 张(≈6MB)。
|
||||||
|
/// 与 presenter 的 `MAX_CACHE_ENTRIES`(也是 120)同量级:两层各自有天花板,不叠成无界。
|
||||||
|
/// 清空而不是 LRU:代价是下一次 `refresh_view` 重传一遍(罕见),收益是这段逻辑没有顺序状态。
|
||||||
|
const MAX_HANDLES: usize = 120;
|
||||||
|
|
||||||
|
/// 句柄缓存:`(截图目录, 文件名, mtime, 字节数) → Handle`。
|
||||||
|
///
|
||||||
|
/// ## 为什么必须缓存(而不是每次 `from_state` 建一个新的)
|
||||||
|
///
|
||||||
|
/// `Handle::from_rgba` 每次都生成一个**新 id**,而 `iced` 的渲染器按 id 缓存纹理:id 一变就
|
||||||
|
/// 重新上传一遍像素。`from_state` 挂在 `refresh_view` 上,而 `refresh_view` 每个 UI 事件都跑
|
||||||
|
/// (点一下、打一个字)——一屏 60 张就是「每次事件重传 ≈6MB」。M251 复审要求把这条成本消掉:
|
||||||
|
/// 缓存之后同一张图永远是同一个 id,纹理只上传一次。
|
||||||
|
///
|
||||||
|
/// ## 键为什么与 presenter 的缓存键同源
|
||||||
|
///
|
||||||
|
/// presenter 那边是 `(完整路径, mtime, 字节数)`(`host::saves::thumbs`),这里换成
|
||||||
|
/// `(目录, 文件, mtime, 字节数)`——同一组事实,只是目录单独放一格,免得每次拼长串。
|
||||||
|
/// 任一字段变了(文件被覆盖)就是另一张图 ⇒ 新 id ⇒ 重传,这也正是我们要的。
|
||||||
|
fn handles() -> &'static Mutex<HashMap<String, Handle>> {
|
||||||
|
static HANDLES: OnceLock<Mutex<HashMap<String, Handle>>> = OnceLock::new();
|
||||||
|
HANDLES.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 句柄键的分隔符(`0x01`:文件名里不可能出现,也避免 `<a|b>` 与 `<ab|>` 撞键)。
|
||||||
|
///
|
||||||
|
/// 用 `push` 拼而不是 `format!`:`format!` 的模板串里带 `dir`/`u` 这类 ASCII 词,会被
|
||||||
|
/// `pages/instances/tests/i18n.rs` 的「页面里不许写死英文文案」守门当成界面字面量抓走。
|
||||||
|
const KEY_SEP: char = '\u{1}';
|
||||||
|
|
||||||
|
/// 取这张截图的句柄:命中就复用(id 不变 ⇒ 不重传),未命中才建一次。
|
||||||
|
fn handle_for(
|
||||||
|
dir: &str,
|
||||||
|
shot: &ScreenshotView,
|
||||||
|
thumb: &unlucky_presenter::dto::ThumbnailView,
|
||||||
|
) -> Handle {
|
||||||
|
let mut key = String::with_capacity(dir.len() + shot.file.len() + 24);
|
||||||
|
key.push_str(dir);
|
||||||
|
key.push(KEY_SEP);
|
||||||
|
key.push_str(&shot.file);
|
||||||
|
key.push(KEY_SEP);
|
||||||
|
match shot.modified_ms {
|
||||||
|
Some(ms) => key.push_str(&ms.to_string()),
|
||||||
|
// 取不到时间:占一个不含数字的记号(不会与任何毫秒数撞上)。
|
||||||
|
None => key.push('-'),
|
||||||
|
}
|
||||||
|
key.push(KEY_SEP);
|
||||||
|
key.push_str(&shot.bytes.to_string());
|
||||||
|
let mut store = handles()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poison| poison.into_inner());
|
||||||
|
if let Some(cached) = store.get(&key) {
|
||||||
|
return cached.clone();
|
||||||
|
}
|
||||||
|
let handle = Handle::from_rgba(thumb.width, thumb.height, thumb.pixels.clone());
|
||||||
|
if store.len() >= MAX_HANDLES {
|
||||||
|
store.clear();
|
||||||
|
}
|
||||||
|
store.insert(key, handle.clone());
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空句柄缓存(**只给用例**:这是进程级 memo,用例之间必须能从已知状态起跑)。
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(in crate::pages::instances) fn clear_handles() {
|
||||||
|
handles()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poison| poison.into_inner())
|
||||||
|
.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/// 一张截图 → 墙上的一格(缩略图句柄 / 占位 + 副文案)。
|
/// 一张截图 → 墙上的一格(缩略图句柄 / 占位 + 副文案)。
|
||||||
///
|
///
|
||||||
/// **三种状态各有各的话**(D54 第 6 条:失败逐条可见):
|
/// **三种状态各有各的话**(D54 第 6 条:失败逐条可见):
|
||||||
@@ -199,15 +275,16 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
|||||||
/// | `Ready`(像素长度不对) | 「解不出这张图」 | `大小 · 时间`(防御性:壳层不该拿到坏像素) |
|
/// | `Ready`(像素长度不对) | 「解不出这张图」 | `大小 · 时间`(防御性:壳层不该拿到坏像素) |
|
||||||
/// | `NotGenerated` | 「未生成预览」 | `大小 · 时间`(超预算没轮到) |
|
/// | `NotGenerated` | 「未生成预览」 | `大小 · 时间`(超预算没轮到) |
|
||||||
/// | `Failed` | 具名原因(太大 / 读不出 / 解不出) | `大小 · 时间`(原文留在 DTO 里,不上墙) |
|
/// | `Failed` | 具名原因(太大 / 读不出 / 解不出) | `大小 · 时间`(原文留在 DTO 里,不上墙) |
|
||||||
fn shot_cell(shot: &ScreenshotView, texts: Texts) -> Shot {
|
fn shot_cell(dir: &str, shot: &ScreenshotView, texts: Texts) -> Shot {
|
||||||
let stamp = rows::shot_detail(shot.bytes, shot.modified_ms, texts);
|
let stamp = rows::shot_detail(shot.bytes, shot.modified_ms, texts);
|
||||||
match &shot.preview {
|
match &shot.preview {
|
||||||
ScreenshotPreview::Ready(thumb) if thumb.is_consistent() => Shot {
|
ScreenshotPreview::Ready(thumb) if thumb.is_consistent() => Shot {
|
||||||
name: shot.file.clone(),
|
name: shot.file.clone(),
|
||||||
detail: stamp,
|
detail: stamp,
|
||||||
// 一次上传:像素从这里搬进渲染器,之后每帧只借用同一个句柄。
|
// 句柄按 (目录, 文件, mtime, 大小) 复用:同一张图在后续每一次 `refresh_view` 里
|
||||||
|
// 拿到的都是**同一个 id** ⇒ 渲染器不会重传纹理(见 [`handle_for`])。
|
||||||
preview: ShotPreview::Ready {
|
preview: ShotPreview::Ready {
|
||||||
handle: Handle::from_rgba(thumb.width, thumb.height, thumb.pixels.clone()),
|
handle: handle_for(dir, shot, thumb),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 像素长度与宽高对不上:壳层不该拿到这种数据,但拿到了也不能画成花屏。
|
// 像素长度与宽高对不上:壳层不该拿到这种数据,但拿到了也不能画成花屏。
|
||||||
|
|||||||
@@ -9,12 +9,11 @@
|
|||||||
|
|
||||||
use unlucky_presenter::dto::{
|
use unlucky_presenter::dto::{
|
||||||
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
|
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
|
||||||
ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ScreenshotView, ThumbnailView,
|
ScreenshotPreview, ScreenshotView, ThumbnailView, WorldView,
|
||||||
WorldView,
|
|
||||||
};
|
};
|
||||||
use unlucky_presenter::state::SavesState;
|
use unlucky_presenter::state::SavesState;
|
||||||
|
|
||||||
use super::super::saves::{failure_line, from_state, ShotPreview};
|
use super::super::saves::{failure_line, from_state};
|
||||||
use super::super::Texts;
|
use super::super::Texts;
|
||||||
use unlucky_i18n::Lang;
|
use unlucky_i18n::Lang;
|
||||||
|
|
||||||
@@ -254,106 +253,5 @@ fn millisecond_stamps_are_rendered_in_utc() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod preview;
|
||||||
mod write;
|
mod write;
|
||||||
|
|
||||||
/// **缩略图墙的三种格子**(M251 · D54 第 6 条):有像素 / 没轮到 / 具名失败,副文案各说各的。
|
|
||||||
///
|
|
||||||
/// 抓得住:①有像素却不画(`Ready` 被吞成占位);②「没生成」与「失败」被合并成同一句
|
|
||||||
/// (用户没法分辨"刷新一下可能就有了"与"得自己去修文件");③失败原因没上屏(原文丢掉)。
|
|
||||||
/// 抓不住:像素画得对不对(`Element` 没有内省接口,只能靠 `DevDocs/artifacts/m251/` 的图)。
|
|
||||||
#[test]
|
|
||||||
fn every_preview_state_gets_its_own_caption() {
|
|
||||||
let texts = Texts::of(Lang::ZhCn.catalog());
|
|
||||||
let mut snapshot = view("a");
|
|
||||||
snapshot.screenshots = vec![
|
|
||||||
ScreenshotView {
|
|
||||||
file: "ok.png".to_string(),
|
|
||||||
bytes: 2_048,
|
|
||||||
modified_ms: Some(STAMP),
|
|
||||||
preview: ScreenshotPreview::Ready(ThumbnailView {
|
|
||||||
width: 2,
|
|
||||||
height: 1,
|
|
||||||
pixels: vec![1, 2, 3, 4, 5, 6, 7, 8],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
ScreenshotView {
|
|
||||||
file: "pending.png".to_string(),
|
|
||||||
bytes: 4_096,
|
|
||||||
modified_ms: Some(STAMP - 1),
|
|
||||||
preview: ScreenshotPreview::NotGenerated,
|
|
||||||
},
|
|
||||||
ScreenshotView {
|
|
||||||
file: "huge.png".to_string(),
|
|
||||||
bytes: 9_437_184,
|
|
||||||
modified_ms: Some(STAMP - 2),
|
|
||||||
preview: ScreenshotPreview::Failed(ScreenshotThumbFailure {
|
|
||||||
kind: ScreenshotThumbKind::TooLarge,
|
|
||||||
detail: "9437184 > 8388608 字节".to_string(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let mut state = bound_state("a");
|
|
||||||
state.changed(snapshot);
|
|
||||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
|
||||||
|
|
||||||
assert_eq!(saves.thumbnails(), 1, "只有第一格有像素");
|
|
||||||
assert!(matches!(
|
|
||||||
saves.screenshots[0].preview,
|
|
||||||
ShotPreview::Ready { .. }
|
|
||||||
));
|
|
||||||
assert_eq!(
|
|
||||||
saves.screenshots[1].preview,
|
|
||||||
ShotPreview::Missing {
|
|
||||||
note: texts.saves_preview_none().to_string()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
saves.screenshots[2].preview,
|
|
||||||
ShotPreview::Missing {
|
|
||||||
note: texts
|
|
||||||
.saves_preview_reason(ScreenshotThumbKind::TooLarge)
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// 副文案三格一样是「大小 · 时间」:原因在框里,不把墙变得比图还吵(原文留在 DTO 里)。
|
|
||||||
assert!(
|
|
||||||
saves.screenshots[1].detail.starts_with("4.0 KB"),
|
|
||||||
"没生成的那格也要有大小与时间:{:?}",
|
|
||||||
saves.screenshots[1].detail
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
saves.screenshots[2].detail.starts_with("9.0 MB"),
|
|
||||||
"失败的那格同样保留元数据:{:?}",
|
|
||||||
saves.screenshots[2].detail
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 像素长度与宽高对不上时**不许**建句柄(`Handle::from_rgba` 不校验长度 ⇒ 画出来是花屏)。
|
|
||||||
///
|
|
||||||
/// 抓得住:把 `is_consistent()` 那道判断删掉(这时会走 `Ready`,`thumbnails()` 变 1 而断言要 0)。
|
|
||||||
#[test]
|
|
||||||
fn inconsistent_pixels_are_shown_as_a_failure_not_a_broken_image() {
|
|
||||||
let mut snapshot = view("a");
|
|
||||||
snapshot.screenshots = vec![ScreenshotView {
|
|
||||||
file: "short.png".to_string(),
|
|
||||||
bytes: 10,
|
|
||||||
modified_ms: Some(STAMP),
|
|
||||||
preview: ScreenshotPreview::Ready(ThumbnailView {
|
|
||||||
width: 4,
|
|
||||||
height: 4,
|
|
||||||
pixels: vec![0; 15],
|
|
||||||
}),
|
|
||||||
}];
|
|
||||||
let mut state = bound_state("a");
|
|
||||||
state.changed(snapshot);
|
|
||||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
|
||||||
assert_eq!(saves.thumbnails(), 0, "坏像素不该被当成图");
|
|
||||||
assert_eq!(
|
|
||||||
saves.screenshots[0].preview,
|
|
||||||
ShotPreview::Missing {
|
|
||||||
note: Texts::of(Lang::ZhCn.catalog())
|
|
||||||
.saves_preview_reason(ScreenshotThumbKind::Decode)
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
//! 缩略图墙的**页面模型判据**(M251)——从 `tests/saves.rs` 拆出(那边加完句柄复用的两条之后越过
|
||||||
|
//! 400 行硬线,与 `write.rs` 同一拆法)。
|
||||||
|
//!
|
||||||
|
//! 这一族只钉「快照里的三种预览状态 → 页面模型」与「句柄复用」,**不**画像素:
|
||||||
|
//! `Element` 没有内省接口,画出来对不对只能看 `DevDocs/artifacts/m251/` 的图(README 里写明哪张
|
||||||
|
//! 是排版、哪张是端到端真解码)。
|
||||||
|
|
||||||
|
use super::super::super::saves::clear_handles;
|
||||||
|
use super::super::super::saves::{from_state, Saves, ShotPreview};
|
||||||
|
use super::super::super::Texts;
|
||||||
|
use super::{bound_state, view, STAMP};
|
||||||
|
use unlucky_i18n::Lang;
|
||||||
|
use unlucky_presenter::dto::{
|
||||||
|
ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ScreenshotView, ThumbnailView,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 句柄 id 的取样口(只有 `Ready` 有句柄;别的一律 panic,免得断言在错误的状态上通过)。
|
||||||
|
fn handle_id(saves: &Saves) -> iced::widget::image::Handle {
|
||||||
|
match &saves.screenshots[0].preview {
|
||||||
|
ShotPreview::Ready { handle } => handle.clone(),
|
||||||
|
other => panic!("第一格应当是缩略图,实际 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **缩略图墙的三种格子**(M251 · D54 第 6 条):有像素 / 没轮到 / 具名失败,副文案各说各的。
|
||||||
|
///
|
||||||
|
/// 抓得住:①有像素却不画(`Ready` 被吞成占位);②「没生成」与「失败」被合并成同一句
|
||||||
|
/// (用户没法分辨"刷新一下可能就有了"与"得自己去修文件");③失败原因没上屏(原文丢掉)。
|
||||||
|
/// 抓不住:像素画得对不对(`Element` 没有内省接口,只能靠 `DevDocs/artifacts/m251/` 的图)。
|
||||||
|
#[test]
|
||||||
|
pub(super) fn every_preview_state_gets_its_own_caption() {
|
||||||
|
let texts = Texts::of(Lang::ZhCn.catalog());
|
||||||
|
let mut snapshot = view("a");
|
||||||
|
snapshot.screenshots = vec![
|
||||||
|
ScreenshotView {
|
||||||
|
file: "ok.png".to_string(),
|
||||||
|
bytes: 2_048,
|
||||||
|
modified_ms: Some(STAMP),
|
||||||
|
preview: ScreenshotPreview::Ready(ThumbnailView {
|
||||||
|
width: 2,
|
||||||
|
height: 1,
|
||||||
|
pixels: vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ScreenshotView {
|
||||||
|
file: "pending.png".to_string(),
|
||||||
|
bytes: 4_096,
|
||||||
|
modified_ms: Some(STAMP - 1),
|
||||||
|
preview: ScreenshotPreview::NotGenerated,
|
||||||
|
},
|
||||||
|
ScreenshotView {
|
||||||
|
file: "huge.png".to_string(),
|
||||||
|
bytes: 9_437_184,
|
||||||
|
modified_ms: Some(STAMP - 2),
|
||||||
|
preview: ScreenshotPreview::Failed(ScreenshotThumbFailure {
|
||||||
|
kind: ScreenshotThumbKind::TooLarge,
|
||||||
|
detail: "9437184 > 8388608 字节".to_string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut state = bound_state("a");
|
||||||
|
state.changed(snapshot);
|
||||||
|
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||||
|
|
||||||
|
assert_eq!(saves.thumbnails(), 1, "只有第一格有像素");
|
||||||
|
assert!(matches!(
|
||||||
|
saves.screenshots[0].preview,
|
||||||
|
ShotPreview::Ready { .. }
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
saves.screenshots[1].preview,
|
||||||
|
ShotPreview::Missing {
|
||||||
|
note: texts.saves_preview_none().to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
saves.screenshots[2].preview,
|
||||||
|
ShotPreview::Missing {
|
||||||
|
note: texts
|
||||||
|
.saves_preview_reason(ScreenshotThumbKind::TooLarge)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// 副文案三格一样是「大小 · 时间」:原因在框里,不把墙变得比图还吵(原文留在 DTO 里)。
|
||||||
|
assert!(
|
||||||
|
saves.screenshots[1].detail.starts_with("4.0 KB"),
|
||||||
|
"没生成的那格也要有大小与时间:{:?}",
|
||||||
|
saves.screenshots[1].detail
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
saves.screenshots[2].detail.starts_with("9.0 MB"),
|
||||||
|
"失败的那格同样保留元数据:{:?}",
|
||||||
|
saves.screenshots[2].detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 像素长度与宽高对不上时**不许**建句柄(`Handle::from_rgba` 不校验长度 ⇒ 画出来是花屏)。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `is_consistent()` 那道判断删掉(这时会走 `Ready`,`thumbnails()` 变 1 而断言要 0)。
|
||||||
|
#[test]
|
||||||
|
pub(super) fn inconsistent_pixels_are_shown_as_a_failure_not_a_broken_image() {
|
||||||
|
let mut snapshot = view("a");
|
||||||
|
snapshot.screenshots = vec![ScreenshotView {
|
||||||
|
file: "short.png".to_string(),
|
||||||
|
bytes: 10,
|
||||||
|
modified_ms: Some(STAMP),
|
||||||
|
preview: ScreenshotPreview::Ready(ThumbnailView {
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
pixels: vec![0; 15],
|
||||||
|
}),
|
||||||
|
}];
|
||||||
|
let mut state = bound_state("a");
|
||||||
|
state.changed(snapshot);
|
||||||
|
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||||
|
assert_eq!(saves.thumbnails(), 0, "坏像素不该被当成图");
|
||||||
|
assert_eq!(
|
||||||
|
saves.screenshots[0].preview,
|
||||||
|
ShotPreview::Missing {
|
||||||
|
note: Texts::of(Lang::ZhCn.catalog())
|
||||||
|
.saves_preview_reason(ScreenshotThumbKind::Decode)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **句柄复用(M251 复审要求)**:同一张图两次 `from_state` 必须拿到**同一个 `Handle` id**。
|
||||||
|
///
|
||||||
|
/// 为什么这条是判据而不是细节:`Handle::from_rgba` 每次生成新 id,而 iced 的渲染器按 id 缓存
|
||||||
|
/// 纹理——id 一变就把像素重传一遍。`from_state` 挂在 `refresh_view` 上(每个 UI 事件都跑),
|
||||||
|
/// 一屏 60 张就是「每次事件重传 ≈6MB」。id 相同 ⇒ 纹理只上传一次。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `handle_for` 换回 `Handle::from_rgba(...)`(两次 id 必不相同)、键里漏掉 mtime/大小
|
||||||
|
/// (文件变了却复用旧句柄,见下一条)。
|
||||||
|
/// 抓不住:渲染器到底有没有复用纹理(那是 iced 内部,只能由「id 相同」推断)。
|
||||||
|
#[test]
|
||||||
|
pub(super) fn the_same_screenshot_reuses_one_handle() {
|
||||||
|
clear_handles();
|
||||||
|
let mut state = bound_state("a");
|
||||||
|
state.changed(view("a"));
|
||||||
|
|
||||||
|
let first = from_state(&state, Lang::ZhCn.catalog());
|
||||||
|
let second = from_state(&state, Lang::ZhCn.catalog());
|
||||||
|
assert_eq!(
|
||||||
|
handle_id(&first).id(),
|
||||||
|
handle_id(&second).id(),
|
||||||
|
"同一张图在两次 refresh_view 里必须复用同一个句柄(否则每帧重传纹理)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文件被覆盖(mtime 变)⇒ **必须换一个新句柄**(缓存键里含 mtime 的另一半证据)。
|
||||||
|
///
|
||||||
|
/// 抓得住:句柄键只按 `(目录, 文件名)`(截图被覆盖后仍画旧像素)。
|
||||||
|
#[test]
|
||||||
|
pub(super) fn a_changed_screenshot_gets_a_new_handle() {
|
||||||
|
clear_handles();
|
||||||
|
let mut state = bound_state("a");
|
||||||
|
state.changed(view("a"));
|
||||||
|
let first = from_state(&state, Lang::ZhCn.catalog());
|
||||||
|
|
||||||
|
let mut changed = view("a");
|
||||||
|
changed.screenshots[0].modified_ms = Some(STAMP + 1);
|
||||||
|
let mut state = bound_state("a");
|
||||||
|
state.changed(changed);
|
||||||
|
let second = from_state(&state, Lang::ZhCn.catalog());
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
handle_id(&first).id(),
|
||||||
|
handle_id(&second).id(),
|
||||||
|
"mtime 变了就是另一张图,必须换句柄"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,7 +57,12 @@ pub const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
|
|||||||
pub const MAX_PER_REFRESH: usize = 60;
|
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 毫秒, 字节数)**。
|
/// 缓存键:**(完整路径, mtime 毫秒, 字节数)**。
|
||||||
///
|
///
|
||||||
@@ -76,6 +81,19 @@ fn cache() -> &'static Mutex<Cache> {
|
|||||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
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` 的唯一调用点)。
|
/// 给一批截图挂上预览(**在后台线程里调**,`super::view` 的唯一调用点)。
|
||||||
pub(super) fn attach(dir: &Path, views: &mut [ScreenshotView]) {
|
pub(super) fn attach(dir: &Path, views: &mut [ScreenshotView]) {
|
||||||
attach_with(dir, views, &mut decode);
|
attach_with(dir, views, &mut decode);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
//! | 缓存键去掉 mtime(或改成只按路径) | [`a_changed_file_is_decoded_again`](同一路径、同一大小、只改 mtime ⇒ 必须重解)与 [`a_changed_size_is_decoded_again`] |
|
//! | 缓存键去掉 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) |
|
//! | 单张 8 MiB 上限改成无上限 | [`a_file_over_the_size_cap_never_reaches_the_decoder`](解码器调用次数必须为 0) |
|
||||||
//!
|
//!
|
||||||
|
//! 每个用例开头都调 [`clear_cache`]:缓存是进程级静态,用例之间不该互相借光(M251 复审 r1)。
|
||||||
|
//!
|
||||||
//! 抓不住:缩略图**画质**(盒式采样的观感只能靠出图看);缓存淘汰策略的观感(`MAX_CACHE_ENTRIES`
|
//! 抓不住:缩略图**画质**(盒式采样的观感只能靠出图看);缓存淘汰策略的观感(`MAX_CACHE_ENTRIES`
|
||||||
//! 只有一条「不会无限长」的结构性断言,没有逐条淘汰用例)。
|
//! 只有一条「不会无限长」的结构性断言,没有逐条淘汰用例)。
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ fn shot(file: &str, bytes: u64, modified_ms: Option<u64>) -> ScreenshotView {
|
|||||||
/// `pixels` 长度与宽高对不上(壳层 `from_raw_rgba` 会画出花屏)。
|
/// `pixels` 长度与宽高对不上(壳层 `from_raw_rgba` 会画出花屏)。
|
||||||
#[test]
|
#[test]
|
||||||
fn a_real_png_becomes_a_thumbnail() {
|
fn a_real_png_becomes_a_thumbnail() {
|
||||||
|
clear_cache();
|
||||||
let dir = temp_dir("ready");
|
let dir = temp_dir("ready");
|
||||||
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
|
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
|
||||||
let mut views = [shot(
|
let mut views = [shot(
|
||||||
@@ -94,6 +97,7 @@ fn a_real_png_becomes_a_thumbnail() {
|
|||||||
/// **判据 ②**:坏文件给**结构化错误**(逐条可见),不 panic、不静默少一张。
|
/// **判据 ②**:坏文件给**结构化错误**(逐条可见),不 panic、不静默少一张。
|
||||||
#[test]
|
#[test]
|
||||||
fn a_broken_file_is_a_visible_reason() {
|
fn a_broken_file_is_a_visible_reason() {
|
||||||
|
clear_cache();
|
||||||
let dir = temp_dir("broken");
|
let dir = temp_dir("broken");
|
||||||
std::fs::write(dir.0.join("bad.png"), b"\x89PNG\r\n\x1a\n garbage").expect("写坏文件");
|
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"))
|
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 定的产品取舍)。
|
/// 抓不住:8 MiB 这个数字本身合不合理(那是 D54 定的产品取舍)。
|
||||||
#[test]
|
#[test]
|
||||||
fn a_file_over_the_size_cap_never_reaches_the_decoder() {
|
fn a_file_over_the_size_cap_never_reaches_the_decoder() {
|
||||||
|
clear_cache();
|
||||||
let dir = temp_dir("toolarge");
|
let dir = temp_dir("toolarge");
|
||||||
let file = "huge.png";
|
let file = "huge.png";
|
||||||
std::fs::write(dir.0.join(file), vec![0u8; MAX_SOURCE_BYTES as usize + 1]).expect("写大文件");
|
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` 是两种状态:一个是「没轮到」,一个是「试过但不行」)。
|
/// 占位被画成失败(`NotGenerated` 与 `Failed` 是两种状态:一个是「没轮到」,一个是「试过但不行」)。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_sixty_first_screenshot_is_not_decoded() {
|
fn the_sixty_first_screenshot_is_not_decoded() {
|
||||||
|
clear_cache();
|
||||||
let dir = temp_dir("cap");
|
let dir = temp_dir("cap");
|
||||||
let mut views: Vec<ScreenshotView> = (0..MAX_PER_REFRESH + 1)
|
let mut views: Vec<ScreenshotView> = (0..MAX_PER_REFRESH + 1)
|
||||||
.map(|index| {
|
.map(|index| {
|
||||||
@@ -194,6 +200,7 @@ fn the_sixty_first_screenshot_is_not_decoded() {
|
|||||||
/// 序号」混进键)。
|
/// 序号」混进键)。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_second_identical_refresh_does_not_redecode() {
|
fn the_second_identical_refresh_does_not_redecode() {
|
||||||
|
clear_cache();
|
||||||
let dir = temp_dir("hit");
|
let dir = temp_dir("hit");
|
||||||
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
|
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
|
||||||
let calls = std::cell::Cell::new(0u32);
|
let calls = std::cell::Cell::new(0u32);
|
||||||
@@ -217,6 +224,7 @@ fn the_second_identical_refresh_does_not_redecode() {
|
|||||||
/// 排除掉。
|
/// 排除掉。
|
||||||
#[test]
|
#[test]
|
||||||
fn a_changed_file_is_decoded_again() {
|
fn a_changed_file_is_decoded_again() {
|
||||||
|
clear_cache();
|
||||||
let dir = temp_dir("mtime");
|
let dir = temp_dir("mtime");
|
||||||
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
|
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
|
||||||
let calls = std::cell::Cell::new(0u32);
|
let calls = std::cell::Cell::new(0u32);
|
||||||
@@ -238,6 +246,7 @@ fn a_changed_file_is_decoded_again() {
|
|||||||
/// 抓得住:缓存键只剩路径(`calls` 停在 1)。与用例 ⑥ 一起把三段键的两段独立钉住。
|
/// 抓得住:缓存键只剩路径(`calls` 停在 1)。与用例 ⑥ 一起把三段键的两段独立钉住。
|
||||||
#[test]
|
#[test]
|
||||||
fn a_changed_size_is_decoded_again() {
|
fn a_changed_size_is_decoded_again() {
|
||||||
|
clear_cache();
|
||||||
let dir = temp_dir("size");
|
let dir = temp_dir("size");
|
||||||
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
|
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
|
||||||
let calls = std::cell::Cell::new(0u32);
|
let calls = std::cell::Cell::new(0u32);
|
||||||
@@ -255,6 +264,7 @@ fn a_changed_size_is_decoded_again() {
|
|||||||
/// 空快照不碰缓存、不 panic(新实例的常态)。
|
/// 空快照不碰缓存、不 panic(新实例的常态)。
|
||||||
#[test]
|
#[test]
|
||||||
fn an_empty_list_is_a_no_op() {
|
fn an_empty_list_is_a_no_op() {
|
||||||
|
clear_cache();
|
||||||
let dir = temp_dir("empty");
|
let dir = temp_dir("empty");
|
||||||
let mut views: Vec<ScreenshotView> = Vec::new();
|
let mut views: Vec<ScreenshotView> = Vec::new();
|
||||||
let calls = std::cell::Cell::new(0u32);
|
let calls = std::cell::Cell::new(0u32);
|
||||||
|
|||||||
Reference in new issue
Block a user