Files
Unlucky-Launcher/crates/presenter/src/host/update_tests.rs
T
AskaEth afe169751f fix(m165): forget 之后让自更新也换引擎(复审 P2-1)+ 卡口计数与端到端守卫
复审 P2-1(采「修法 1」,不是只改措辞):`shared::forget` 只让登记处不再持有
那一份,换不掉自更新 `Factory` 里常驻的 Arc —— 于是「重新检测地区」对更新路径
不生效,直到重启。现在设置动作会把 Factory 一起重建。

- presenter/update_state: `Runtime::rebuild_factory` + `CoreHost::refresh_update_factory`;
  只换 factory,不动 view/inflight(在飞的 Job 派发时已克隆走装配,重建不打断它)
- presenter/dispatch: `SetSetting` 成功且键是「重新检测地区」时调 refresh
- presenter/engine: `DETECT_REGION_ACTION` 常量(设置页行 id / set 分派 / dispatch
  判断共用);放这里而不是 settings.rs 是因为后者已到 400 行红线
- core/shared: 文档改准 —— forget 的范围是「登记处不再持有」,长命持有者必须自己重建;
  记账从 `engine_for` 移到 `DownloadEngine::new` 这个卡口(分两把锁,避免自锁),
  于是 `constructions` 连绕过登记处另造引擎的调用点也统得住

守卫(都做了变异实证):
- update_tests::detect_region_rebuilds_the_update_factory_engine:走真实命令路径
  `AppCommand::SetSetting` → 断言 Factory 换了引擎且指向登记处当前实例;变异(删掉
  dispatch 里那句 refresh)→ 红在 Arc::ptr_eq
- update_tests::rebuilding_the_factory_does_not_touch_a_running_job:重建不清
  inflight、不改快照,在飞 Job 仍能跑完
- addon/http::tests::three_host_request_paths_share_one_engine:连走三条真实宿主路径
  (Modrinth 元数据 GET / CF 带 header GET / Java 页渲染)后 constructions == 1;
  变异(让 get_via_engine 自己造引擎)→ 红,left:2 right:1

顺带修:settings.rs 在上一提交被推到 403 行、engine.rs 401 行,两者都超
guards.yml 的 file-size 红线(>400 直接失败)。现在 settings.rs 400 / engine.rs 399,
全仓 `*.rs` 复跑「>400」扫描无输出。

验证:core lib 608 过、5 个 download 集成 target 42 过、presenter lib 270 过、
clippy --all-targets 干净、fmt 干净。真站点仍未打(见报告「没验的部分」)。
2026-11-08 09:30:05 +08:00

336 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.
//! 自更新宿主通道的单测(M159)。
//!
//! 全部离线:清单与产物都在临时目录里造([`Source::Dir`]),**没有一个用例发公网请求**。
//! 覆盖 mission 的验收条目:
//!
//! | 用例 | 守什么 |
//! |---|---|
//! | [`version_matrix_decides_who_gets_prompted`] | 版本更高才提示;同版本/更低一律不提示;`v0.2.3` 这类真实 tag 形态 |
//! | [`a_broken_manifest_degrades_to_failure_not_up_to_date`] | 清单坏 JSON → 「检查失败」,**不是**「已是最新」 |
//! | [`a_higher_version_without_our_platform_is_not_up_to_date`] | 有新版但没本平台产物 → 单独的变体 |
//! | [`sha256_mismatch_refuses_the_apply_and_leaves_no_temp_file`] | 摘要不符拒绝应用、暂存文件不残留、目标没被换掉 |
//! | [`a_successful_apply_leaves_the_new_file_and_a_backup`] | 换上去的是新字节、旧字节留在备份里、回报是「就位」 |
//! | [`skipping_a_version_does_not_silence_the_next_one`] | 忽略的语义是**那一个版本**:同版本不再弹、更高版本照弹;手动检查清掉忽略 |
//! | [`auto_check_off_means_no_request_on_start`] | 关掉开关后启动不派发检查(结构性 + 状态两层) |
//! | [`the_gui_and_the_cli_share_one_kernel_path`] | GUI/CLI 走同一个 `Job`(源码锚点,不是口号) |
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use unlucky_core::download::region::{RegionMode, RegionProfile};
use unlucky_core::download::verify::digest_bytes;
use unlucky_core::download::HashAlgo;
use unlucky_core::update::{Applied, Channel, Target, Version};
use crate::dto::{UpdateBanner, UpdateOutcome};
use super::super::env::HostEnv;
use super::super::update::{outcome_of, ApplyReport, Factory, Report};
use super::Runtime;
/// 一次性临时目录。
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("ull-m159-{}-{tag}-{unique}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
fn join(&self, name: &str) -> PathBuf {
self.path.join(name)
}
fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
let path = self.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建父目录");
}
std::fs::write(&path, bytes).expect("写文件");
path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn sha256(bytes: &[u8]) -> String {
digest_bytes(HashAlgo::Sha256, bytes)
}
/// 本平台在清单里的 `target` 写法。
fn here() -> String {
Target::current().as_str().to_string()
}
/// 一份「本平台有产物」的清单(摘要按 `payload` 真实值算)。
fn manifest_for(version: &str, payload: &[u8], notes: &str) -> String {
format!(
r#"{{"channel":"stable","releases":[{{"version":"{version}","channel":"stable","notes":"{notes}",
"artifacts":[{{"target":"{}","format":"portable-exe","url":"asset.exe","size":{},"sha256":"{}"}}]}}]}}"#,
here(),
payload.len(),
sha256(payload)
)
}
/// 造一个本地更新源目录(`dist/stable.json` + `dist/asset.exe`)。
fn dir_source(tmp: &TempDir, manifest: &str, payload: &[u8]) {
tmp.write("dist/stable.json", manifest.as_bytes());
tmp.write("dist/asset.exe", payload);
}
/// 造一份装配:本地源 + 临时目标 + 临时暂存。
fn factory(tmp: &TempDir) -> Factory {
Factory::for_env(&HostEnv::at_root(tmp.join("root")))
.with_dir(tmp.join("dist"))
.with_dest(tmp.join("bin/unlucky-launcher.exe"))
.with_staging(tmp.join("staging"))
}
/// 一份 job(走同一份装配)。
fn job(tmp: &TempDir) -> unlucky_core::update::Job {
factory(tmp).job()
}
fn version(text: &str) -> Version {
Version::parse(text).expect("合法版本")
}
// ------------------------------------------------------------------ 版本矩阵
/// 版本更高才提示;同版本 / 更低一律不提示;真实 tag 形态(`v0.2.3`)照常解析。
///
/// 这条是 mission 第二条任务的核心判据,**判定本身在 core**(`UpdateChecker`),这里守的是
/// 「host 把它翻成 DTO 之后仍然如此」——两处任何一处把 `<=` 写成 `<` 都会红。
#[test]
fn version_matrix_decides_who_gets_prompted() {
// (当前版本, 清单里的版本, 期望「有更新」)
let cases = [
("0.2.2", "0.2.3", true),
("0.2.2", "v0.2.3", true),
("0.2.2", "0.2.2", false),
("0.2.2", "v0.2.2", false),
("0.2.2", "0.2.1", false),
("0.2.2", "0.10.0", true),
("0.2.2", "0.2.10", true),
("0.2.2", "0.2.2-rc.1", false),
("0.2.2-rc.1", "0.2.2", true),
];
for (current, listed, expected) in cases {
let tmp = TempDir::new("matrix");
let payload = b"new";
dir_source(&tmp, &manifest_for(listed, payload, ""), payload);
let outcome = outcome_of(job(&tmp).check(&version(current), Channel::Stable));
assert_eq!(
outcome.candidate().is_some(),
expected,
"当前 {current} vs 清单 {listed}:期望 available={expected},实际 {outcome:?}"
);
if !expected {
assert!(
matches!(outcome, UpdateOutcome::UpToDate { .. }),
"不提示的时候必须是「已是最新」,实际 {outcome:?}"
);
}
}
}
// ------------------------------------------------------------- 清单契约降级
/// 坏 JSON / 缺字段 ⇒ **检查失败**,绝不是「已是最新」。
///
/// 两个变体的文案与后果完全不同:说成「已是最新」会让用户以为网络通、版本新,实际一个字节都没取到。
#[test]
fn a_broken_manifest_degrades_to_failure_not_up_to_date() {
for (label, manifest) in [
("not json", "{ this is not json".to_string()),
(
"wrong field type",
r#"{"channel":"stable","releases":42}"#.to_string(),
),
(
"bad digest",
format!(
r#"{{"channel":"stable","releases":[{{"version":"0.9.0","channel":"stable","artifacts":[{{"target":"{}","format":"portable-exe","url":"a.exe","size":3,"sha256":"zz"}}]}}]}}"#,
here()
),
),
(
"missing sha256",
format!(
r#"{{"channel":"stable","releases":[{{"version":"0.9.0","channel":"stable","artifacts":[{{"target":"{}","format":"portable-exe","url":"asset.exe","size":3}}]}}]}}"#,
here()
),
),
("bad version", manifest_for("not-a-version", b"abc", "")),
] {
let tmp = TempDir::new("badmanifest");
tmp.write("dist/stable.json", manifest.as_bytes());
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
assert!(
matches!(outcome, UpdateOutcome::Failed(_)),
"{label}:坏清单必须降级成「检查失败」,实际 {outcome:?}"
);
let failure = outcome.failure().expect("失败带原因");
assert!(
!failure.message.trim().is_empty(),
"{label}:失败要有人话原因"
);
}
}
/// 清单里**一条发布都没有**(`{"channel":"stable"}` / 空 `releases`)⇒ 已是最新,不是失败。
///
/// 这是 core 的既定口径(「该渠道还没发过版」不是错误),与上面那条「坏清单」要分得清清楚楚:
/// 少一个字段是坏文件,空列表是「还没发」。两者都被报成同一种状态的话,发布脚本第一次上线
/// 之前所有人都会看到红字。
#[test]
fn an_empty_manifest_means_nothing_published_yet() {
for manifest in [
r#"{"channel":"stable"}"#.to_string(),
r#"{"channel":"stable","releases":[]}"#.to_string(),
] {
let tmp = TempDir::new("empty-manifest");
tmp.write("dist/stable.json", manifest.as_bytes());
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
assert!(
matches!(&outcome, UpdateOutcome::UpToDate { latest: None }),
"空清单 = 还没发过版,实际 {outcome:?}"
);
}
}
/// 有更高的版本、但里面没有本平台的产物 ⇒ 单独的变体(不是「已是最新」,也不是「有更新」)。
#[test]
fn a_higher_version_without_our_platform_is_not_up_to_date() {
let tmp = TempDir::new("missing-artifact");
tmp.write(
"dist/stable.json",
format!(
r#"{{"channel":"stable","releases":[{{"version":"0.9.0","channel":"stable","artifacts":[{{"target":"{}","format":"portable-exe","url":"asset.exe","size":3,"sha256":"{}"}}]}}]}}"#,
// 故意给一个不可能匹配本机的目标(`unknown` 架构永远对不上)
"linux-x86",
sha256(b"abc")
)
.as_bytes(),
);
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
assert!(
matches!(&outcome, UpdateOutcome::MissingArtifact { version } if version == "0.9.0"),
"缺本平台产物要单独报出来,实际 {outcome:?}"
);
assert_eq!(outcome.as_str(), "missing_artifact");
}
// 两个子模块各守一段(拆开只为守 400 行硬线;夹具在本模块,子模块 `use super::*` 共用,
// 不重复抄一份)。
#[path = "update_apply_tests.rs"]
mod apply;
#[path = "update_session_tests.rs"]
mod session;
// ---------------------------------------------------------------- M165 复审 P2-1
//
// 这两条走的是**真实的命令路径**(`AppCommand::SetSetting` → `dispatch` → `settings::set`),
// 不是直接调内部函数:要守的正是「动作路径上有没有把该做的事做全」。
/// 「重新检测地区」之后,**自更新手里的引擎也要换**。
///
/// 自更新的 `Factory` 从 `CoreHost::build` 起就把引擎存在字段里(常驻到进程结束);
/// `download::shared::forget` 只让登记处不再持有那一份,**换不掉**它。于是只 forget 的话,
/// 自更新会继续按**旧地区档案**展开候选,直到重启——M165 复审 P2-1 的那个洞。
///
/// 抓得住的变异:删掉 `host/dispatch.rs` 的 `SetSetting` 成功分支里那句
/// `self.refresh_update_factory()` ⇒ 本用例红在 `Arc::ptr_eq`(Factory 还是淘汰前那一份)。
#[test]
fn detect_region_rebuilds_the_update_factory_engine() {
use std::sync::Arc;
use unlucky_core::download::shared;
let tmp = TempDir::new("region-refresh");
let env = HostEnv::at_root(tmp.path());
std::fs::create_dir_all(env.layout.cache()).expect("建缓存目录");
// 钉一份确定的地区档案:第一次构造不依赖本机时区/语言,断言才有确定的起点。
let region_path = env.layout.cache().join("region.json");
unlucky_core::download::region::store_cached(
&region_path,
&RegionProfile::pinned(RegionMode::Direct, unlucky_core::download::cache::now_ms()),
)
.expect("写 region.json");
let mut host = crate::host::CoreHost::headless(env.clone());
let cache_root = env.layout.cache();
assert_eq!(
shared::constructions(&cache_root),
1,
"装配时只该造一个引擎"
);
let before = host
.update
.factory_engine()
.expect("生产装配(Default)应有引擎")
.clone();
assert_eq!(before.region().mode, RegionMode::Direct);
host.dispatch_command(crate::dto::AppCommand::SetSetting {
key: "download.action.detect_region".to_string(),
value: String::new(),
});
let after = host
.update
.factory_engine()
.expect("重建后仍该有引擎")
.clone();
assert!(
!Arc::ptr_eq(&before, &after),
"「重新检测地区」必须换掉自更新用的引擎(否则它按旧地区档案跑到进程结束)"
);
assert!(
Arc::ptr_eq(&after, &crate::host::engine::engine(&env)),
"自更新必须指向登记处的当前引擎,而不是被淘汰的那一份"
);
assert_eq!(
shared::constructions(&cache_root),
2,
"淘汰一次 = 多构造一次(既不多也不少)"
);
}
/// 重建 `Factory` **不打断**在飞的检查/应用:在跑的那条 `Job` 派发时就已经把装配克隆走了。
#[test]
fn rebuilding_the_factory_does_not_touch_a_running_job() {
let tmp = TempDir::new("factory-rebuild");
dir_source(&tmp, &manifest_for("0.9.0", b"new", "notes"), b"new");
let env = HostEnv::at_root(tmp.path());
let mut runtime = Runtime::with_factory(&env, factory(&tmp));
// 派发一条(模拟「正在查」),再重建装配:
let job = runtime.begin_check(true).expect("应能派发");
assert!(runtime.inflight());
runtime.rebuild_factory(&env);
assert!(runtime.inflight(), "重建装配不该清掉在飞标记");
assert!(
matches!(runtime.view().outcome, UpdateOutcome::Checking),
"重建装配不该改状态快照"
);
// 在飞那条 Job 仍然可用(它拿着自己那份装配),能正常跑完并回报。
let outcome = outcome_of(job.check(&version("0.2.2"), Channel::Stable));
assert_eq!(outcome.as_str(), "available");
}