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

+5
View File
@@ -118,6 +118,7 @@ mod content;
mod core_text;
mod download;
mod font;
mod font_install;
mod general;
mod instances;
mod launch;
@@ -186,6 +187,7 @@ use content::content_entries;
use core_text::core_text_entries;
use download::{download_entries, version_entries};
use font::font_entries;
use font_install::font_install_entries;
use general::general_entries;
use instances::instances_entries;
use launch::launch_entries;
@@ -228,6 +230,9 @@ nav_entries! {
account_entries,
// M159:自更新(设置页「更新」分组 + 顶部提示条)。同样只追加在链尾之后。
update_entries,
// M168:中文字体下载状态行(设置页 General 组)。同样只追加在链尾之后——
// `catalog/font.rs` 那四条老文案在链中间,往那里追加会平移其后所有 key(金表整片红)。
font_install_entries,
finish,
{}
}
+67
View File
@@ -0,0 +1,67 @@
//! 中文字体**下载状态行**的文案(M168):设置页 General 组那一行的四态文字 + 三类失败原因。
//!
//! ## 为什么单开一个域文件(而不是塞进 `font.rs`)
//!
//! `catalog/font.rs` 那四条(`FontCjkNeedsDownload` / `FontCjkDownloading` / `FontCjkDownloadFailed`
//! / `FontCjkReady`)是 M92「只发信号」时代的文案,位置在装配链**中间**。往那个域后面追加会把
//! 其后所有 key 在 `Key::ALL` 里整体平移一位 ⇒ 金表(逐 index 逐字节)整片红。
//!
//! 本域挂在**链尾**(`update_entries` 之后),于是 `Key::ALL` 的既有顺序一个不动,金表只需在
//! 末尾追加(M104 / M106 / M107 / M109 / M114 / M159 都是这条做法)。
//!
//! ## 四条状态各一句话(tower 定死的口径)
//!
//! | 状态 | 文案说什么 |
//! |---|---|
//! | 下载中 | 「正在获取中文字体…(≈17 MB)」——**没有**假进度条,有真实量(字节数)才写 |
//! | 完成 | 「中文字体已就绪:⟨族名⟩」(复用 `FontCjkReady`) |
//! | 失败 | 「获取失败(⟨三类原因之一⟩)。界面照常可用,用的是系统字体;再切一次语言可以重试」 |
//! | 无需下载 | 系统已有字体 / 当前语言不需要,两条分开说(对用户是两件事) |
//!
//! 「探测不出」也要有话说:那是「不知道」,既不是「没有」也不是「不需要」——写清楚「未下载」,
//! 免得用户以为我们偷偷下过一份。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! font_install_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 中文字体下载状态行(M168:设置页 General 组)
FontCjkStatusLabel => ("Chinese font", "中文字体"),
FontCjkStatusNotNeeded => (
"Not needed for this interface language",
"当前界面语言不需要中文字体"
),
FontCjkStatusSystem => ("Ready (system font: {family})", "已就绪(系统字体:{family})"),
FontCjkStatusDownloading => (
"Fetching a Chinese font… ({size})",
"正在获取中文字体…({size})"
),
FontCjkStatusFailed => (
"Download failed ({reason}). The interface keeps working with system fonts; switch the language again to retry.",
"获取失败({reason})。界面照常可用,用的是系统字体;再切一次语言可以重试。"
),
FontCjkReasonNetwork => ("network unreachable", "网络不通"),
FontCjkReasonIntegrity => (
"the mirror sent content that failed verification",
"镜像内容校验不通过"
),
FontCjkReasonDisk => ("cannot write to the data directory", "写不进数据目录"),
FontCjkStatusUnknown => (
"Not determined yet; nothing was downloaded",
"尚未判定;没有下载任何字体"
),
FontCjkStatusUnprobeable => (
"System fonts cannot be probed, so nothing was downloaded",
"无法探测系统字体,因此没有下载"
),
FontCjkStatusHint => (
"Downloaded once into the data directory and cached; the next start does not download it again.",
"只下这一次,缓存在数据目录里,下次启动不重下。"
),
} }
};
}
pub(crate) use font_install_entries;
+12
View File
@@ -209,4 +209,16 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("UpdateBannerDismiss", "Skip this version", "忽略这个版本"),
("UpdateBannerRestartNow", "Restart now", "立即重启"),
("UpdateBannerRetry", "Try again", "重试"),
// M168:中文字体下载状态行(链尾追加,既有顺序不动)
("FontCjkStatusLabel", "Chinese font", "中文字体"),
("FontCjkStatusNotNeeded", "Not needed for this interface language", "当前界面语言不需要中文字体"),
("FontCjkStatusSystem", "Ready (system font: {family})", "已就绪(系统字体:{family})"),
("FontCjkStatusDownloading", "Fetching a Chinese font… ({size})", "正在获取中文字体…({size})"),
("FontCjkStatusFailed", "Download failed ({reason}). The interface keeps working with system fonts; switch the language again to retry.", "获取失败({reason})。界面照常可用,用的是系统字体;再切一次语言可以重试。"),
("FontCjkReasonNetwork", "network unreachable", "网络不通"),
("FontCjkReasonIntegrity", "the mirror sent content that failed verification", "镜像内容校验不通过"),
("FontCjkReasonDisk", "cannot write to the data directory", "写不进数据目录"),
("FontCjkStatusUnknown", "Not determined yet; nothing was downloaded", "尚未判定;没有下载任何字体"),
("FontCjkStatusUnprobeable", "System fonts cannot be probed, so nothing was downloaded", "无法探测系统字体,因此没有下载"),
("FontCjkStatusHint", "Downloaded once into the data directory and cached; the next start does not download it again.", "只下这一次,缓存在数据目录里,下次启动不重下。"),
];
+94 -167
View File
@@ -1,4 +1,4 @@
//! 中文字体策略(D28 后半):**只做判据,不联网**。
//! 中文字体策略(D28 后半):**只做判据与「该下载什么」,不联网**。
//!
//! ## 三句话
//!
@@ -6,22 +6,28 @@
//! D28 原话是「纯英文环境不用管(零额外体积/零网络)」,这条是本模块最重要的断言。
//! 2. **中文环境先扫系统**:候选族名单见 [`CJK_FAMILIES`],命中即用([`Strategy::System`])。
//! 3. **扫不到才说要下载**:[`Strategy::NeedsDownload`] 是一个**信号**,不是动作——
//! 真正下载由注入的 [`Fetcher`] 实现,本 mission 不写网络实现(D28 的 CDN 下载是后续 mission)。
//! 真正下载由注入的 [`Fetcher`] 实现(M168 起的真实现是
//! `crates/app-iced/src/fonts/cdn.rs`,底层走 `unlucky_core::download::font`)。本 crate 保持
//! **零依赖**:它只携带「下什么」([`Source::Pinned`]:URL 候选 + sha256 + 字节数),不碰网络。
//!
//! ## 扫描为什么在 UI 壳层而不是这里
//! ## 谁在什么时刻问这里
//!
//! 真扫系统字体要 FontConfig / DirectWrite / iced 的 `font_system()`——任何一条都会把 iced 或平台
//! SDK 拉进本 crate,破掉零依赖纪律。所以本模块只收一个 `installed: &dyn Fn(&str) -> bool`
//! (iced 版实现在 `crates/app-iced/src/fonts/cjk.rs`),单测用假闭包,脱机可跑。
//! | 调用点 | 时机 | 后果 |
//! |---|---|---|
//! | `crates/app-iced/src/fonts.rs::resolve` | 启动(按系统 locale) | 打一行 `cjk=…` 日志 |
//! | `crates/app-iced/src/fonts.rs::cjk_for` | 设置页切语言 | 复算 + 需要时**触发下载** |
//!
//! 两条路都**只**在 [`Strategy::NeedsDownload`] 时才碰 [`Fetcher`]([`ensure`] 的判据),
//! 英文环境与「系统已有字体」两条路一次网络调用都没有(守卫见本文件的单测与
//! `crates/app-iced/src/fonts/install/tests.rs`)。
//!
//! ## 诚实边界(抓得住什么 / 抓不住什么)
//!
//! - **抓得住**:语言 → 是否需要中文 → 系统里有没有候选族 → 要不要下载 这条链路的判定;
//! - **抓不住**:①**族名存在 ≠ 字形覆盖**——`fontdb` 里挂着一个叫 `SimSun` 的族也可能是个残缺子集,
//! 真判据要用 `ttf-parser` 查 cmap(M60 Q3 的 spike 做过,本 mission 不引依赖);
//! ②下载下来的字节怎么注册进 iced 的字体系统——iced 0.14 有 `iced::font::load(bytes) -> Task`
//! 可在运行时注册(已核对上游源码),但「什么时候调、失败怎么回退、和启动日志怎么对账」
//! 属于下载 mission,本 crate 不碰。
//! 真判据要用 `ttf-parser` 查 cmap(M60 Q3 的 spike 做过,本 crate 不引依赖);
//! ②下载下来的字节能不能在**这台机器**的渲染器上正常出字(注册与回退在 app-iced;macOS/Linux
//! 没有真机验证,见 M168 报告)。
use crate::lang::Lang;
@@ -56,13 +62,26 @@ pub const CJK_FAMILIES: &[&str] = &[
/// 而 M3 Expressive 的 emphasized 档要 700 ⇒ 「400 / 500 / 700 至少三档」)。
pub const REQUIRED_WEIGHTS: &[u16] = &[400, 500, 700];
/// 可下载字体的来源。本 mission **不 pin URL/哈希**——见 [`Source::PendingPin`]。
/// 可下载字体的来源:**已钉死**的 URL 候选 + `sha256` + 字节数。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
/// 待下载 mission 钉死:确切 URL + `sha256` + 子集范围(简体常用字 + 标点),
/// 并按 [`REQUIRED_WEIGHTS`] 覆盖 400/500/700。**故意不写一个随手想出来的 URL**:
/// 猜错的 CDN 路径会被后面所有人当事实用。
PendingPin,
/// 钉死的一套下载参数(M168)。
///
/// 为什么是「常量 + 多重校验」而不是「让下载器自己挑」:CDN 上的字节会变(上游改字体、
/// 镜像换了实现),而**字体是注册进渲染器的可执行内容**。所以 URL 钉到 **git commit**
/// (不是 `@main`)、字节数与 `sha256` 一起钉,任何一项对不上就拒绝落盘(见
/// `unlucky_core::download::font`)。
///
/// `urls` 是**候选**:首选挂了才试下一个(两个都是同一份字节的镜像,哈希只认一份——
/// 镜像若给了别的内容,校验那关会当场拒掉,而不是把别的东西注册成字体)。
Pinned {
/// URL 候选(按顺序尝试)。
urls: &'static [&'static str],
/// 期望 `sha256`(小写十六进制,64 位)。
sha256: &'static str,
/// 期望字节数(进度预算 + 大小上限判据)。
bytes: u64,
},
}
/// 一套可下载的 CJK 字体描述(不含 URL / 哈希,原因见 [`Source`])。
@@ -78,12 +97,37 @@ pub struct CjkFont {
pub source: Source,
}
/// 缺字体时的默认方案:Noto Sans SC(OFL-1.1)。
/// 缺字体时的默认方案:Noto Sans SC(OFL-1.1)**可变字体**(wght 100–900,整个简体字符集)。
///
/// 钉死的东西(M168 实测抓的,不是猜的):
///
/// | 项 | 值 |
/// |---|---|
/// | 上游 | `google/fonts` 仓库的 `ofl/notosanssc/NotoSansSC[wght].ttf` |
/// | 版本 | commit `2894aab31764f10f29c421bdfd2340d3b382d384`(该文件的最后一次改动,2022-12-09) |
/// | 字节数 | 17 772 300(≈17.0 MiB) |
/// | sha256 | `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da` |
///
/// 为什么钉 commit 而不是 `@main`:`@main` 会随上游改动漂移,哈希一漂就成了「每次中文环境都下
/// 一份、然后校验失败、然后降级」——比不下更糟。commit 是不变内容,哈希因此是可复现的判据。
///
/// 为什么是**一个**可变字体而不是三个静态子集(400/500/700 各一份):三份静态子集合计 ≈25 MiB,
/// 而可变字体一份 17 MiB 就覆盖 100–900(cosmic-text 0.15 会把请求的字重写进 `wght` 轴,见
/// `crates/core/src/download/font.rs` 模块文档与 M168 报告里的「抓不住」一段)。
pub const NOTO_SANS_SC: CjkFont = CjkFont {
family: "Noto Sans SC",
license: "OFL-1.1",
weights: &[300, 400, 500, 700, 900],
source: Source::PendingPin,
weights: &[100, 200, 300, 400, 500, 600, 700, 800, 900],
source: Source::Pinned {
urls: &[
// 首选:jsDelivr(国内多数线路可达;`@<commit>` 保证内容不变)
"https://cdn.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf",
// 备用:同一份内容的另一个 jsDelivr 边缘(首选被墙/被限速时才用)
"https://fastly.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf",
],
sha256: "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
bytes: 17_772_300,
},
};
/// 字体策略结论。
@@ -134,22 +178,50 @@ pub fn decide(lang: Lang, installed: &dyn Fn(&str) -> bool) -> Strategy {
})
}
/// 字体下载器接口(**注入式**;本 mission 没有网络实现)。
/// 字体下载器接口(**注入式**:i18n 不碰网络,真实现由 app 层提供)。
pub trait Fetcher {
/// 取一套字体(返回原始字节,装进字体系统是调用方的事)。
///
/// 契约:**同一个进程里第二次调用应当零网络**(缓存命中),且返回的字节已通过校验
/// (实现方的责任,见 `unlucky_core::download::font`)。
///
/// # Errors
/// 网络失败 / 校验失败等人话错误。
/// 网络失败 / 校验失败 / 磁盘失败三类人话错误。
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError>;
}
/// 下载失败的分类(给 UI 决定要不要重试;分类少是故意的)。
///
/// 三类对应三句不同的话:网络类「过会儿再试」、校验类「这个源给的东西不对」、磁盘类
/// 「腾点空间」。UI 文案按 [`FetchError::class`] 选词,**不要**按错误串猜。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FetchError {
/// 网络层失败(超时 / 连不上 / 非 2xx)。
Network(String),
/// 校验失败(哈希 / 长度不符,或不是字体文件)。
Integrity(String),
/// 本地磁盘失败(建目录 / 写文件 / 换名)。
Disk(String),
}
impl FetchError {
/// 稳定类别名(`network` / `integrity` / `disk`;日志与 i18n 金表按它选词)。
#[must_use]
pub const fn class(&self) -> &'static str {
match self {
Self::Network(_) => "network",
Self::Integrity(_) => "integrity",
Self::Disk(_) => "disk",
}
}
/// 人类可读描述。
#[must_use]
pub fn message(&self) -> &str {
match self {
Self::Network(message) | Self::Integrity(message) | Self::Disk(message) => message,
}
}
}
/// [`ensure`] 的结局。
@@ -182,150 +254,5 @@ pub fn ensure(strategy: Strategy, fetcher: &dyn Fetcher) -> Result<Fetched, Fetc
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
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!(font.source, Source::PendingPin, "URL/哈希留给下载 mission");
assert_eq!(strategy.describe(), "cjk=needs-download(Noto Sans SC)");
}
#[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");
}
}
#[path = "font/tests.rs"]
mod tests;
+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");
}