Files
Unlucky-Launcher/crates/app-iced/src/fonts/install/tests.rs
T
AskaEth 460da9330d fix(m168): 复审 r3 四项 —— 说准 panic 档、给两条修复补守卫、时限算术补洞
复审 r3(p2-4items / fix-then-merge)四项 + 顺手五条:

1. **P2-1 `catch_unwind` 在发布档是死代码,三处文档说了不成立的话**:
   `Cargo.toml` 的 `[profile.release]` 是 `panic = "abort"`(只有 `fast` 是 `unwind`),发布走
   `--release`。分两半写清楚:①**闸前移仍安全**——release 档 panic 是进程退出,不存在永久停在
   `Downloading`;`dev`/`test`/`fast` 档 `catch_unwind` 真会写 `Failed`;②但「下载失败不崩」在
   panic 这一支上对发布版**是反的**:注册/`load_font` panic ⇒ 启动器整体 abort,不是降级。
   `install.rs::plan_with_offline` 的文档(新增两档对照表)、`worker.rs` 模块文档与 `run` 注释全部
   按这个说法重写,不再用 `catch_unwind` 给「不崩」背书。

2. **P2-2 「启动不发起下载」这条修复没有守卫**:拆出纯函数
   `fonts::resolve_with_faces(setting, faces)`(`resolve_with` 只是 `system_faces()` 的包装),
   新增 `fonts::tests::resolving_fonts_never_starts_a_download`:给一张只有拉丁族的脸表
   (一定判 `NeedsDownload`),断言调用后 `install::status()` **不是** `Downloading`。
   变异(实跑):把 `install::after_probe(cjk_report.strategy)` 加回 `resolve_with_faces` ⇒
   `求值字体**不许**发起下载(状态从 Unknown 变成了 Downloading { bytes: 17772300 })`,红。

3. **P2-3 时限算术有洞 + `is_last` 没守卫**:改 `ATTEMPT_DEADLINE=60 s` / `TOTAL_DEADLINE=420 s`
   (原来 120/360 时,第一个候选吃掉 120 s 后最后一个只剩 240 s ⇒ 门槛 72.3 KiB/s,r1 实测
   67 KiB/s 需要 259 s,仍然必失败)。文档按新数值重算成一张表:第一个候选瞬间失败 ⇒ 420 s ⇒
   42.3 KiB/s;爬满 60 s 被切 ⇒ 360 s ⇒ 49.4 KiB/s,两者都低于实测最慢的 67 KiB/s。
   守卫:把同解的旧用例换成 **单候选 + attempt 300 ms / total 5 s**——有 `is_last` 时跑到总时限
   (≈5 s),没有时 300 ms 就切,两种实现可分辨。变异(实跑):删掉 `is_last` ⇒
   `the_last_candidate_gets_the_whole_remaining_budget` 红在
   `最后一个候选该拿整个剩余预算(≈5 s)…452.6035ms`。

4. **P2-4(b)(c)**:①`install::tests` 里三处判据用例改用 `plan_with_offline(..., false)`,把进程级
   `OFFLINE` 从判据里摘出去(否则同进程里 `headless(screenshot_options())` 会把它置 true 且从不
   还原 ⇒ 结果依赖执行顺序);②`fonts.rs` 注释改成真实机制——出图 harness 不经
   `App::with_presenter`、压根没有 `set_offline`,它零流量靠的是「`resolve_with` 不触发」。

顺手:①`sha256_mismatch…` 的 doc 改成「两层校验互备,本用例抓的是两层同时拆」(原来说
「拆任意一层就红」是假的);②`font.rs` 文档「把 90 s 压成 300 ms」→ 60 s/420 s;③`install.rs` 与
`catalog/font_install.rs` 的「≈17 MB」→ 16.9;④`worker::start` 的裸 `thread::spawn` 换成
`Builder::spawn`,起不来就落 `Failed`(发布档 panic = abort,不能让它 panic);线程体抽成 `run()`。

验证(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-168`,取退出码、不截断):
- `cargo test -p unlucky-app-iced --lib` → **688 passed / 0 failed / 2 ignored / exit 0**
- `cargo test -p unlucky-core --all-targets` → **16 个 target / 1189 passed / 0 failed / exit 0**
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed / exit 0
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → exit 0
- `cargo fmt --all -- --check` 干净;全仓无文件 >400;本轮改动文件最大 378(`app-iced/src/fonts.rs`)
2026-11-08 14:38:25 +08:00

370 lines
14 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.
//! `fonts::install` 的守卫(M168):**三条能变红的断言**,每条都写明「抓得住什么」。
//!
//! 这些用例全部脱机:下载器是假的(记调用次数),注册器也是假的(记收到的字节)。真下载、真注册
//! 进 iced 字体系统这两件事**不在**本文件的能力范围内(真注册要 iced 的全局字体系统,见报告)。
use std::cell::RefCell;
use unlucky_i18n::font::{CjkFont, FetchError, Fetcher, Strategy, NOTO_SANS_SC};
use super::*;
/// 假下载器:记调用次数与拿到的字体族名,返回预置结果。
struct FakeFetcher {
calls: RefCell<usize>,
seen: RefCell<Option<&'static str>>,
result: Result<Vec<u8>, FetchError>,
}
impl FakeFetcher {
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
Self {
calls: RefCell::new(0),
seen: RefCell::new(None),
result,
}
}
fn calls(&self) -> usize {
*self.calls.borrow()
}
}
impl Fetcher for FakeFetcher {
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
*self.calls.borrow_mut() += 1;
*self.seen.borrow_mut() = Some(font.family);
self.result.clone()
}
}
/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。
type Registered = std::rc::Rc<RefCell<Vec<usize>>>;
/// 假注册器:记收到的字节数,返回一个族名。
fn recorder() -> (Registered, impl Fn(Vec<u8>) -> Result<String, String>) {
let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
let recorder = std::rc::Rc::clone(&seen);
let register = move |bytes: Vec<u8>| {
recorder.borrow_mut().push(bytes.len());
Ok("Noto Sans SC".to_owned())
};
(seen, register)
}
/// 守卫 ①:**英文环境 / 系统已有字体 / 探测不出 ⇒ 一次都不联网**。
///
/// 抓得住:把 `ensure_for` 的判据换成「先试着下一下看看」、或 `after_probe` 里对这三种策略也走
/// `start()`——本用例在 `fetcher.calls() == 0` 处当场红(这是新路径,不是 i18n 那条既有用例)。
/// 抓不住:`after_probe` 起线程之后的真实网络行为(那由 `core::download::font` 的守卫与
/// `CdnFetcher` 的缓存判定承担)。
#[test]
fn the_three_idle_strategies_never_touch_the_fetcher() {
let (registered, register) = recorder();
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
assert_eq!(
ensure_for(Strategy::NotNeeded, &idle, &register),
CjkInstall::NotNeeded
);
assert_eq!(
ensure_for(Strategy::System { family: "SimSun" }, &idle, &register),
CjkInstall::System {
family: "SimSun".to_owned()
}
);
assert_eq!(
ensure_for(Strategy::Unprobeable, &idle, &register),
CjkInstall::Unprobeable
);
assert_eq!(
idle.calls(),
0,
"英文 / 系统已有 / 探测不出:一次都不许调下载器"
);
assert!(registered.borrow().is_empty(), "这三路不许注册任何字体");
// 反方向:真需要时才调一次(否则上面那三个 0 可能是「永远不调」的假绿)。
let working = FakeFetcher::new(Ok(vec![9; 4096]));
assert_eq!(
ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &working, &register),
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: false
}
);
assert_eq!(working.calls(), 1);
assert_eq!(working.seen.borrow().as_deref(), Some("Noto Sans SC"));
assert_eq!(*registered.borrow(), vec![4096]);
}
/// 守卫 ②:**校验失败 ⇒ 状态 `Failed(integrity)`,且一个字节都不注册**。
///
/// 抓得住:把 `ensure_for` 的错误分支写成「失败也注册回落的字节」、或把类别压成 `network`
/// (设置页就会说「网络不通」,而真相是镜像给了不对的东西)。
/// 抓不住:sha256 到底比得对不对(那是 `core::download::font` 的守卫)。
#[test]
fn an_integrity_failure_registers_nothing_and_keeps_the_class() {
let (registered, register) = recorder();
let broken = FakeFetcher::new(Err(FetchError::Integrity("sha256 不符".to_owned())));
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken, &register);
assert_eq!(
state,
CjkInstall::Failed {
class: FetchClass::Integrity,
message: "sha256 不符".to_owned()
}
);
assert!(registered.borrow().is_empty(), "校验失败不许注册");
assert!(!state.is_downloading(), "失败是终态:不再转圈、不阻塞界面");
}
/// 三类失败各归各的类别(设置页按类别选词,串了就等于对用户说错话)。
#[test]
fn every_failure_class_survives_the_trip() {
let (_, register) = recorder();
for (error, class) in [
(FetchError::Network("超时".to_owned()), FetchClass::Network),
(
FetchError::Integrity("哈希不符".to_owned()),
FetchClass::Integrity,
),
(FetchError::Disk("磁盘满".to_owned()), FetchClass::Disk),
] {
let fetcher = FakeFetcher::new(Err(error));
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &fetcher, &register);
match state {
CjkInstall::Failed { class: got, .. } => assert_eq!(got, class),
other => panic!("应当是 Failed:{other:?}"),
}
}
// 注册本身失败(字体系统锁毒掉 / 字体库不认)也要落到失败的磁盘档,而不是 Ok。
let fetcher = FakeFetcher::new(Ok(vec![7; 8]));
let state = ensure_for(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&fetcher,
&|_bytes| Err("字体系统锁被毒掉".to_owned()),
);
assert!(matches!(
state,
CjkInstall::Failed {
class: FetchClass::Disk,
..
}
));
}
/// 幂等闸(M168 复审 P2-4:这条以前是**假守卫**,现在打真判据)。
///
/// 复审的原话:上一版的循环体只断言「一个从没被传进任何函数的假下载器没被调用」,把
/// `after_probe` 里的 `is_downloading() || Ready` 整段删掉它**依然全绿**。
///
/// 现在直接打 [`plan`]:删掉 `Skip` 那一臂(或把它改成 `Start`),本用例在
/// `assert_eq!(plan(...), Step::Skip)` 处当场红。为什么这条闸不是可有可无:去掉之后每切一次
/// 语言就 spawn 一条线程,线程会命中缓存(不联网)但会**再注册一遍 17 MiB 字体**
/// (`load_font(Cow::Owned)` 不做去重),内存随切换次数累积。
///
/// 抓得住:幂等判据被删/被改反(下载中、已就绪两种状态各自一行)。
/// 抓不住:`after_probe` 真的 spawn 几条线程(那要真线程 + 真网络,见报告「未验」)。
#[test]
fn a_started_or_finished_install_is_not_restarted() {
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
let ready = CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
};
for current in [downloading, ready] {
assert_eq!(
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), &current, false),
Step::Skip,
"{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做"
);
}
// 反方向(否则上面两条可能是「永远 Skip」的假绿):其余三种当前状态都要真的发起。
for current in [
CjkInstall::Unknown,
CjkInstall::NotNeeded,
CjkInstall::Failed {
class: FetchClass::Network,
message: "超时".to_owned(),
},
] {
assert_eq!(
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), &current, false),
Step::Start(&NOTO_SANS_SC),
"{current:?}:需要下载且还没成就该发起(失败后的重试也只能走这条)"
);
}
}
/// 三种「不需要下载」的策略在判据表里只产生 `Apply`(**零网络**),且状态文案对得上。
///
/// 这里显式给 `offline = false`(`plan_with_offline`)而不是用 `plan`:`plan` 会读进程级的
/// `set_offline` 开关,而同进程里 `headless(screenshot_options())` 那 20+ 处会把它置 true 且
/// 从不还原(复审 r3 P2-4 ①)——用 `plan` 的话这三条的结果取决于执行顺序。
///
/// 抓得住:谁把 `NotNeeded` / `System` / `Unprobeable` 的臂改成 `Start`(那就会替英文用户偷偷
/// 下一次 17 MiB);也抓得住「系统已有字体」被记成「需要下载」这种状态映射错。
/// 抓不住:真线程行为(`after_probe` 的执行部分)。
#[test]
fn the_idle_strategies_only_apply_state() {
assert_eq!(
plan_with_offline(Strategy::NotNeeded, &CjkInstall::Unknown, false),
Step::Apply(CjkInstall::NotNeeded)
);
assert_eq!(
plan_with_offline(
Strategy::System { family: "SimSun" },
&CjkInstall::Unknown,
false
),
Step::Apply(CjkInstall::System {
family: "SimSun".to_owned()
})
);
// 复审 r2 P2-2 ② 改判:**下载中一律 Skip**,即使策略说「探测不出」。
// 上一版在这里断言 `Apply(Unprobeable)`,理由是「否则界面会停在正在下载」——那个理由只对
// 僵死成立(线程 panic),而在飞是常见情形:线程还在下,状态却被改成「无法探测」,下完又
// 翻回「已就绪」,状态行在几十秒里来回翻。「僵死」那一半按构建档分两说(worker 模块文档):
// unwind 档(dev/test/fast)由 `catch_unwind` 兜底;发布档 `panic = "abort"` 是进程退出,
// 不存在停在 Downloading 的状态。
assert_eq!(
plan_with_offline(
Strategy::Unprobeable,
&CjkInstall::Downloading { bytes: 1 },
false
),
Step::Skip,
"在飞的下载不许被别的策略改写状态"
);
}
/// **下载中一律 `Skip`**(复审 r2 P2-2 ②):四种策略挨个打一遍。
///
/// 抓得住:把 `is_downloading()` 那道闸挪回 `NeedsDownload` 分支里(上一版就是这样)——
/// `Unprobeable` / `NotNeeded` / `System` 三行立刻红。
/// 抓不住:worker 真的永不僵死(那要制造 panic,见 `worker` 的 `catch_unwind` 与报告的「没验」)。
#[test]
fn every_strategy_skips_while_a_download_is_in_flight() {
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
for strategy in [
Strategy::NeedsDownload(&NOTO_SANS_SC),
Strategy::NotNeeded,
Strategy::System { family: "SimSun" },
Strategy::Unprobeable,
] {
assert_eq!(
plan_with_offline(strategy, &downloading, false),
Step::Skip,
"{strategy:?}:下载中不许改状态、不许起第二条线程"
);
}
}
/// 出图通道(`offline`)**永不发起下载**(D30:同一份代码出同一张图)。
///
/// 抓得住:删掉 `plan_with_offline` 里 `NeedsDownload(_) if offline => Skip` 那一臂 ⇒ 截图/演示
/// 通道会在 zh-locale 且无中文字体的机器上真下 17 MiB,用例在 `Step::Skip` 处红。
/// 抓不住:真出图时有没有产生流量(那要真机跑 `--screenshot`,见报告「没验」)。
#[test]
fn the_screenshot_channel_never_starts_a_download() {
assert_eq!(
plan_with_offline(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&CjkInstall::Unknown,
true
),
Step::Skip,
"出图通道不许联网"
);
assert_eq!(
plan_with_offline(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&CjkInstall::Unknown,
false
),
Step::Start(&NOTO_SANS_SC),
"正常通道该发起(否则上面那条可能是「永远 Skip」的假绿)"
);
// 只改状态的三种策略在两条通道里表现一致(它们本来就不联网)。
for strategy in [
Strategy::NotNeeded,
Strategy::System { family: "SimSun" },
Strategy::Unprobeable,
] {
assert_eq!(
plan_with_offline(strategy, &CjkInstall::Unknown, false),
plan_with_offline(strategy, &CjkInstall::Unknown, true),
"{strategy:?}:不联网的策略与通道无关"
);
}
}
/// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。
///
/// 抓得住:出图夹具写了一个不存在的取值却静默出「未知」状态的图(报告就会撒谎)。
#[test]
fn the_capture_switch_parses_every_state() {
assert_eq!(parse_capture("not-needed"), Some(CjkInstall::NotNeeded));
assert_eq!(parse_capture("unprobeable"), Some(CjkInstall::Unprobeable));
assert!(matches!(
parse_capture("system"),
Some(CjkInstall::System { .. })
));
assert!(matches!(
parse_capture("downloading"),
Some(CjkInstall::Downloading { .. })
));
assert!(matches!(
parse_capture("ready"),
Some(CjkInstall::Ready {
from_cache: true,
..
})
));
for (raw, class) in [
("failed", FetchClass::Network),
("failed:network", FetchClass::Network),
("failed:integrity", FetchClass::Integrity),
("failed:disk", FetchClass::Disk),
] {
match parse_capture(raw) {
Some(CjkInstall::Failed { class: got, .. }) => assert_eq!(got, class, "{raw}"),
other => panic!("{raw} 应解析成 Failed:{other:?}"),
}
}
assert_eq!(parse_capture("bogus"), None);
assert_eq!(parse_capture(""), None);
}
/// 一行日志片段:每种状态都能被 grep 到,且**不带换行**(启动日志按行解析)。
#[test]
fn every_state_describes_itself_on_one_line() {
let states = [
CjkInstall::Unknown,
CjkInstall::NotNeeded,
CjkInstall::System {
family: "SimSun".to_owned(),
},
CjkInstall::Unprobeable,
CjkInstall::Downloading { bytes: 17_772_300 },
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: false,
},
CjkInstall::Failed {
class: FetchClass::Integrity,
message: "sha256 不符".to_owned(),
},
];
for state in states {
let line = state.describe();
assert_eq!(line.lines().count(), 1, "{line}");
assert!(line.starts_with("cjk-install="), "{line}");
}
}