fix(m168): 复审 r1 五项 —— 修红的 integration target、独立失败域、整体时限

复审 r1(p1-1/fix-then-merge)五项全改:

1. **p1-1 CI 会红的 4 条 integration test**(`crates/core/tests/setting/`,我上一轮只跑了 `--lib`,
   scope 漏掉了覆盖 schema 版本号的目录式 target):
   - `java_install_keys` / `isolation_keys` / `appearance_keys`:版本断言 1.6.0 → 1.7.0(M168 的
     `ui.font_cdn`),并写明「每加一批键推一次 minor 是纪律」。
   - `update_keys::a_read_only_launcher_config_never_retires_the_legacy_file`:夹具从**写死
     1.7.0** 改成 `launcher_version().minor + 1` 经 `SchemaRef::url()` 生成。写死数字的版本一推
     这条守卫就变成「同版本」⇒ 文件可写 ⇒ 迁移真的执行 ⇒ 旧文件被退役,而断言还绿着(用户数据
     守卫静默失效)。相对推导不会过期。
2. **p2-1 备用镜像不备用**:`cdn` 与 `fastly` 同属 Fastly(都 CNAME 到 jsdelivr.map.fastly.net),
   换掉。现在首选 `gcore.jsdelivr.net`(Cloudflare,独立失败域),二选 `cdn.jsdelivr.net`
   (Fastly)。注释里放了实测表(本机 + 复审机两份数据、字节数、sha256、IPv6 慢路径),
   并写清排序依据(Fastly 侧 IPv6 实测 67 KB/s ⇒ 丢掉 4 分钟;Cloudflare 侧两次都完整下完)。
3. **p2-2 整体时限**:`core::download::font` 新增 `ATTEMPT_DEADLINE = 90 s` /
   `TOTAL_DEADLINE = 180 s`,落在本模块自己的候选循环里(不动引擎全体语义):每次尝试
   `min(单次上限, 剩余总预算)`,超时归 `Network` 档。新增守卫
   `a_trickling_connection_is_cut_by_the_deadline`(回环服务端每 100 ms 挤 1 字节、预算 300 ms)。
   顺带修掉一个真 bug:`tokio::time::timeout` 在运行时外构造会 panic(5 条既有用例当场抓到),
   现在整段包进 `block_on(async { … })`。UI 文案改成「网络不通或太慢(超时)」,不校验失败混。
4. **p2-3 说 `Source::PendingPin` 的两处**:`fonts/cjk.rs` 的模块文档已改;`ui/lang.rs::log_cjk`
   那句「D28 的下载通道还是 PendingPin」(在下载已发起之后打印,等于当面否定本功能)改成
   「已按 D28 发起下载,见 [font] cjk-download … 与设置页那一行」。全仓再扫,只剩注释里的历史说明。
5. **p2-4 假守卫**:判据抽成纯函数 `install::plan(strategy, current) -> Step`(`Skip` / `Apply` /
   `Start`),`after_probe` 只执行。旧的空转用例换成打真判据的打表用例;变异证明:删掉
   `Skip` 那一臂 ⇒ `a_started_or_finished_install_is_not_restarted` 红(已实跑)。

次要项也一并处理:`from_cache` 改成**本次是否真的命中缓存**(`CdnFetcher::was_cached()`,
`has_cached()` 降级为诊断字段 `cache_present=`);`FontSpec` 的 URL 口径收紧为「非回环必须
https」(`is_acceptable_url` + 守卫);`ui.font_cdn` 写进状态行 hint;文档里的 17.0 MB → 16.9 MB。
`download/font.rs` 拆出 `font/spec.rs` 守 400 行。

验证(这台机器上单个 800 MB 测试 exe 链接要 10 分钟,链接期间文件被 Defender 扫描,故耗时很长):
- `cargo test -p unlucky-core --all-targets` → **1188 passed / 0 failed**(16 个 target,含
  `setting` 那个 115 条、上一轮红 4 条的 integration target)
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed
- `cargo test -p unlucky-app-iced --lib` → 685 passed / 0 failed
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → 干净
- `cargo fmt --all -- --check` → 干净;全仓受检文件行数最大 388(<400)
This commit is contained in:
AskaEth committed 2026-11-08 14:38:23 +08:00
1 parent 449ded2cb7
commit 875b792206
17 files changed
+512 -170

No files matched your search

+67 -20
View File
@@ -38,11 +38,11 @@ impl Fetcher for FakeFetcher {
}
}
/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。
type Registered = std::rc::Rc<RefCell<Vec<usize>>>;
/// 假注册器:记收到的字节数,返回一个族名。
fn recorder() -> (
std::rc::Rc<RefCell<Vec<usize>>>,
impl Fn(Vec<u8>) -> Result<String, String>,
) {
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>| {
@@ -156,26 +156,73 @@ fn every_failure_class_survives_the_trip() {
));
}
/// 状态机的**幂等**:已就绪 / 下载中时再问一次不会起第二条下载。
/// 幂等闸(M168 复审 P2-4:这条以前是**假守卫**,现在打真判据)。
///
/// 抓得住:`after_probe` 里删掉 `is_downloading() || Ready` 那半(用户来回切语言会起 N 条线程,
/// 每条都下一遍 17 MiB)。本用例只测纯判据那一层——`after_probe` 的线程行为要真网络,没测。
/// 复审的原话:上一版的循环体只断言「一个从没被传进任何函数的假下载器没被调用」,把
/// `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() {
// 直接打状态机的输入输出: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 _ = &register;
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(Strategy::NeedsDownload(&NOTO_SANS_SC), &current),
Step::Skip,
"{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做"
);
}
// 反方向(否则上面两条可能是「永远 Skip」的假绿):其余三种当前状态都要真的发起。
for current in [
CjkInstall::Unknown,
CjkInstall::NotNeeded,
CjkInstall::Failed {
class: FetchClass::Network,
message: "超时".to_owned(),
},
] {
assert_eq!(
plan(Strategy::NeedsDownload(&NOTO_SANS_SC), &current),
Step::Start(&NOTO_SANS_SC),
"{current:?}:需要下载且还没成就该发起(失败后的重试也只能走这条)"
);
}
}
/// 三种「不需要下载」的策略在 [`plan`] 里只产生 `Apply`(**零网络**),且状态文案对得上。
///
/// 抓得住:谁把 `NotNeeded` / `System` / `Unprobeable` 的臂改成 `Start`(那就会替英文用户偷偷
/// 下一次 17 MiB);也抓得住「系统已有字体」被记成「需要下载」这种状态映射错。
/// 抓不住:真线程行为(`after_probe` 的执行部分)。
#[test]
fn the_idle_strategies_only_apply_state() {
assert_eq!(
plan(Strategy::NotNeeded, &CjkInstall::Unknown),
Step::Apply(CjkInstall::NotNeeded)
);
assert_eq!(
plan(Strategy::System { family: "SimSun" }, &CjkInstall::Unknown),
Step::Apply(CjkInstall::System {
family: "SimSun".to_owned()
})
);
assert_eq!(
plan(Strategy::Unprobeable, &CjkInstall::Downloading { bytes: 1 }),
Step::Apply(CjkInstall::Unprobeable),
"探测不出也要把状态改回来(否则界面会停在「正在下载」)"
);
}
/// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。