feat(m168): 中文字体 CDN 下载真接线(D28 后半)

M167 调研的结论是「中文字体 CDN 下载至今未实装」:`i18n::font` 只有 `Fetcher` trait 与
假实现,`needs-download` 分支什么也不做。这条把它接成一条真链路。

判据 → 下载 → 注册(一条链,四个 crate 各管一段):

* `unlucky_i18n::font`(零依赖):`Source::Pinned { urls, sha256, bytes }` 把「下什么」
  钉死——google/fonts 的 `NotoSansSC[wght].ttf` 钉到 commit `2894aab…`(不是 `@main`),
  17 772 300 B / sha256 `a3041811…`。可变字体一份覆盖 wght 100–900,省掉三份静态子集的 25 MiB。
  `FetchError` 补第三档 `Disk`(网络 / 校验 / 磁盘,UI 按类别选词)。
* `unlucky_core::download::font`(新):缓存命中 → 共享引擎 `download_bytes` → 复验(sha256 +
  sfnt 魔数 + 32 MiB 上限)→ 原子落盘。**复验在写盘之前**,所以「哈希不符」时盘上什么都没有;
  缓存按 `<sha256 前 16 位>` 命名,pin 一换自动换名。
* `unlucky_app_iced::fonts::{cdn,install}`(新):`CdnFetcher` 把 pin 接进 core(`ui.font_cdn`
  可覆盖 URL,哈希不换);`install` 是状态机 + 后台线程,只有 `NeedsDownload` 才起线程
  (英文 / 系统已有 / 探测不出一律零网络)。注册走 `iced_graphics::text::FontSystem::load_font`,
  族名从字体库读回来核对。失败只降级(继续用系统字体),不阻塞、不崩。
* 接入两条路径:`fonts::resolve_with`(启动)与 `fonts::cjk_for`(设置页切语言)都调
  `install::after_probe`;设置页 General 组多一行状态(下载中 / 完成 / 失败 / 无需下载 + 三类失败
  原因),文案进 catalog 金表(链尾追加,既有顺序不动);`App::advance` 每秒比对状态快照后重建
  视图模型,所以那一行会自己刷新。

配置:新增 `ui.font_cdn`(`keys/font.rs`,schema 1.7.0),空 = 内置 jsDelivr 镜像(含备用边缘)。

守卫(三条都能变红,变异证明见报告):core 侧 8 条(缓存不联网 / 哈希不符拒落盘 / HTML 不当字体
/ 坏缓存自愈 / 上限 / pin 校验),app 侧 6 条(三种闲置策略零调用、失败不注册、类别不串、幂等、
截图钉子、单行日志),页面侧 1 条(四态 × 两语言无缺译、不写假百分比)。

测试:`cargo test -p unlucky-i18n --lib` 31 绿;`-p unlucky-core --lib` 622 绿;
`-p unlucky-app-iced --lib` 684 绿;`cargo fmt --check` 干净。截图三张见报告。
This commit is contained in:
AskaEth committed 2026-11-08 14:38:23 +08:00
1 parent 2a004a5754
commit 449ded2cb7
22 files changed
+2191 -184

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
//! `i18n::font` 的单测(拆出来只为守 400 行硬线)。
use std::cell::Cell;
use super::*;
fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool {
move |family| installed.contains(&family)
}
/// 假下载器:记录调用次数,避免「悄悄联网」这种事在单测里被放过。
struct FakeFetcher {
calls: Cell<usize>,
result: Result<Vec<u8>, FetchError>,
seen: Cell<Option<&'static str>>,
}
impl FakeFetcher {
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
FakeFetcher {
calls: Cell::new(0),
result,
seen: Cell::new(None),
}
}
}
impl Fetcher for FakeFetcher {
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
self.calls.set(self.calls.get() + 1);
self.seen.set(Some(font.family));
self.result.clone()
}
}
#[test]
fn english_never_wants_a_cjk_font() {
// 抓得住:D28 的核心承诺——英文环境不加载/不下载中文字体(D28 说「零额外体积/零网络」)。
// 抓不住:程序真的没去读那个字体文件(那是加载器的行为,本 crate 只管判据)。
assert_eq!(decide(Lang::En, &only(&[])), Strategy::NotNeeded);
assert_eq!(
decide(Lang::En, &only(&["Microsoft YaHei"])),
Strategy::NotNeeded,
"装了中文字体也不该进入中文分支"
);
}
#[test]
fn chinese_prefers_the_first_installed_candidate() {
// 抓得住:候选顺序(YaHei UI > YaHei > SimSun…)被调换,或命中判断写成了「装了任意字面就 ok」。
// 抓不住:命中的族是否真有简体字形覆盖(族名存在 ≠ cmap 覆盖,见模块文档)。
assert_eq!(
decide(
Lang::ZhCn,
&only(&["Segoe UI", "Microsoft YaHei", "SimSun"])
),
Strategy::System {
family: "Microsoft YaHei"
}
);
assert_eq!(
decide(Lang::ZhCn, &only(&["PingFang SC"])),
Strategy::System {
family: "PingFang SC"
}
);
assert_eq!(
decide(Lang::ZhCn, &only(&["Noto Sans CJK SC", "Noto Sans SC"])),
Strategy::System {
family: "Noto Sans CJK SC"
}
);
}
#[test]
fn chinese_without_cjk_fonts_emits_the_download_signal() {
// 抓得住:中文环境 + 系统无 CJK 时**给信号**(而不是静默回英文、也不是自己联网)。
// 抓不住:信号之后 UI 有没有把提示画出来(页面侧 / 截图验收)。
let strategy = decide(Lang::ZhCn, &only(&["Segoe UI", "Roboto", "Cascadia Mono"]));
let Strategy::NeedsDownload(font) = strategy else {
panic!("应当要求下载:{strategy:?}");
};
assert_eq!(font.family, "Noto Sans SC");
assert_eq!(font.license, "OFL-1.1");
assert_eq!(strategy.describe(), "cjk=needs-download(Noto Sans SC)");
}
/// pin 的形态守卫(M168):URL 走 https、哈希是 64 位小写十六进制、字节数在大小上限内。
///
/// 抓得住:①有人把 URL 改成 `@main` 这类会漂移的引用(不是不行,但那样哈希必须跟着变,
/// 这条断言至少逼他看见自己在动什么);②手抄哈希抄错一位(下一个人只会在真机上看到
/// 「校验失败」然后降级);③把三个静态子集塞进来却没改字节数。
/// 抓不住:CDN 上今天还发不发得出这份字节(要真联网,报告里写明未验)。
#[test]
fn the_pinned_source_is_well_formed() {
let Source::Pinned {
urls,
sha256,
bytes,
} = NOTO_SANS_SC.source;
assert!(!urls.is_empty(), "至少要有一个 URL");
for url in urls {
assert!(url.starts_with("https://"), "字体必须走 https:{url}");
assert!(
!url.contains("@main"),
"钉 commit 而不是会漂移的分支:{url}"
);
}
assert_eq!(sha256.len(), 64, "{sha256}");
assert!(
sha256.bytes().all(|byte| byte.is_ascii_hexdigit()),
"{sha256}"
);
assert_eq!(sha256, sha256.to_ascii_lowercase(), "哈希用小写:{sha256}");
assert!(bytes > 0 && bytes < 32 * 1024 * 1024, "{bytes}");
}
#[test]
fn download_fonts_cover_the_required_weights() {
// 抓得住:将来换了字体却没覆盖 400/500/700(M3 Expressive 的 emphasized 档要 700)。
// 抓不住:字重是可变轴还是静态子集(那要看下载 mission 选的文件,本 crate 只有元数据)。
for weight in REQUIRED_WEIGHTS {
assert!(
NOTO_SANS_SC.weights.contains(weight),
"缺 {weight} 档:{:?}",
NOTO_SANS_SC.weights
);
}
}
#[test]
fn ensure_only_touches_the_fetcher_when_download_is_needed() {
// 抓得住:英文环境/系统已有字体时**仍然调了下载器**(= 偷跑网络请求);
// 也抓得住下载失败被吞掉(错误必须原样透出)。
// 抓不住:真实现的网络行为(本 crate 没有网络实现,这正是不联网的证据)。
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
assert_eq!(ensure(Strategy::NotNeeded, &idle), Ok(Fetched::NothingToDo));
assert_eq!(
ensure(Strategy::System { family: "SimSun" }, &idle),
Ok(Fetched::AlreadySystem { family: "SimSun" })
);
assert_eq!(ensure(Strategy::Unprobeable, &idle), Ok(Fetched::Unknown));
assert_eq!(idle.calls.get(), 0, "这三种情况都不许联网");
let ok = FakeFetcher::new(Ok(vec![9; 4]));
assert_eq!(
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &ok),
Ok(Fetched::Downloaded(vec![9; 4]))
);
assert_eq!(ok.calls.get(), 1);
assert_eq!(ok.seen.get(), Some("Noto Sans SC"));
let broken = FakeFetcher::new(Err(FetchError::Network("超时".to_owned())));
assert_eq!(
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken),
Err(FetchError::Network("超时".to_owned()))
);
}
#[test]
fn strategy_describe_is_one_line_for_every_case() {
// 抓得住:日志片段里混进换行(启动日志是按行解析的)。
// 抓不住:这些片段是否真的被打印(app-iced 的启动路径在那边测)。
let cases = [
Strategy::NotNeeded,
Strategy::System { family: "SimSun" },
Strategy::NeedsDownload(&NOTO_SANS_SC),
Strategy::Unprobeable,
];
for case in cases {
let line = case.describe();
assert_eq!(line.lines().count(), 1, "{line}");
assert!(line.starts_with("cjk="), "{line}");
}
assert_eq!(Strategy::NotNeeded.describe(), "cjk=not-needed");
assert_eq!(Strategy::Unprobeable.describe(), "cjk=unprobeable");
}