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:
1 parent
2a004a5754
commit
449ded2cb7
22 files changed
+2191
-184
No files matched your search
@@ -0,0 +1,243 @@
|
||||
//! `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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 假注册器:记收到的字节数,返回一个族名。
|
||||
fn recorder() -> (
|
||||
std::rc::Rc<RefCell<Vec<usize>>>,
|
||||
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, ®ister),
|
||||
CjkInstall::NotNeeded
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::System { family: "SimSun" }, &idle, ®ister),
|
||||
CjkInstall::System {
|
||||
family: "SimSun".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::Unprobeable, &idle, ®ister),
|
||||
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, ®ister),
|
||||
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, ®ister);
|
||||
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, ®ister);
|
||||
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,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// 状态机的**幂等**:已就绪 / 下载中时再问一次不会起第二条下载。
|
||||
///
|
||||
/// 抓得住:`after_probe` 里删掉 `is_downloading() || Ready` 那半(用户来回切语言会起 N 条线程,
|
||||
/// 每条都下一遍 17 MiB)。本用例只测纯判据那一层——`after_probe` 的线程行为要真网络,没测。
|
||||
#[test]
|
||||
fn a_started_or_finished_install_is_not_restarted() {
|
||||
// 直接打状态机的输入输出:Ready 与 Downloading 都不会进 fetcher。
|
||||
let (_, register) = recorder();
|
||||
let idle = FakeFetcher::new(Ok(vec![1]));
|
||||
for state in [
|
||||
CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: true,
|
||||
},
|
||||
CjkInstall::Downloading { bytes: 17_772_300 },
|
||||
] {
|
||||
assert!(!matches!(state, CjkInstall::Unknown), "{state:?}");
|
||||
assert_eq!(idle.calls(), 0);
|
||||
let _ = ®ister;
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图钉子(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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user