diff --git a/crates/presenter/src/host/game/refresh/live.rs b/crates/presenter/src/host/game/refresh/live.rs index 9086e90..324caef 100644 --- a/crates/presenter/src/host/game/refresh/live.rs +++ b/crates/presenter/src/host/game/refresh/live.rs @@ -102,6 +102,19 @@ fn profile(env: &HostEnv, instance: &InstanceSummary) -> Result<(), String> { /// /// 反查不到根 ⇒ 放行(M273 老档案,理由见模块文档):不猜、也不在这里改成「拒绝」, /// 否则所有老用户的 `ext-` 磁贴会集体点不动。 +/// +/// **别把末尾那三行当死代码删掉**(M306 两次复审的账,别只记一半): +/// +/// - 删掉它,`cargo test` **仍然全绿**(M304 r2 实测:整棵 presenter 426 passed / 0 failed); +/// - 但**不是无声的**:`unused_imports` 会点名 `legacy_external_instance_id`, +/// `clippy -- -D warnings` 当场红(M306 r1 复审实测 EXIT=101)。`#![allow(dead_code)]` 只盖 +/// 「函数变死码」,盖不住「use 行变空」——那是 `unused` 组里**另一条** lint; +/// - **这道保险可以被绕过**:把 import 一起删掉、或在那行加 `#[allow(unused_imports)]`,编译器 +/// 就真的不吭声了。所以还有两道网,删之前先跑: +/// `host::game::tests::a_m273_legacy_ext_id_is_allowed`(判**行为**)与 +/// `the_legacy_pass_through_is_still_in_the_source`(直接扫本文件的**源码**)。 +/// +/// 拒绝那一侧由 `an_ext_id_from_no_visible_root_is_refused` 守着。 fn discovered(env: &HostEnv, instance: &InstanceSummary) -> Result<(), String> { for root in roots::all(env) { if external_instance_id(root.key(), &instance.version_id) != instance.id { diff --git a/crates/presenter/src/host/game/tests.rs b/crates/presenter/src/host/game/tests.rs index ed0436d..e0b4d82 100644 --- a/crates/presenter/src/host/game/tests.rs +++ b/crates/presenter/src/host/game/tests.rs @@ -207,9 +207,11 @@ fn isolation_write_then_read_round_trips() { /// | `legacy_external_instance_id("1.20.4")` | `ext-52d6191436663b4e`(= M273 的「只哈希版本目录名」)| /// | `external_instance_id("c:/mc/.minecraft", "1.20.4")` | `ext-1e6c24c50b4a95c2`(= root + `0` + version)| /// -/// 抓得住:把分隔符那一次乘法漏掉 / 把 `0` 字节也过一遍 `xor`(两种都改哈希)——那时**所有**存量 -/// `ext-` id 都会换一个值(`profiles.json` 里旧的 `ext-` 档案一律反查不到)。M304 把哈希抽成 -/// [`super::discovered`] 里的一个多段函数,这条就是那次重构的回归网。 +/// 抓得住:漏掉段间那次乘法、或改动迭代顺序——那时**所有**存量 `ext-` id 都会换一个值 +/// (`profiles.json` 里旧的 `ext-` 档案一律反查不到)。段间那个分隔符**本身**不单独进哈希:它 +/// 等价于多乘一次素数(`hash ^= 0` 是恒等),痕迹只在这一次乘法上——所以「分隔符」与「那一次 +/// 乘法」是同一件事,不是两处可分别改动的输入。M304 把哈希抽成 [`super::discovered`] 里的一个 +/// 多段函数,这条就是那次重构的回归网。 #[test] fn instance_id_hashes_are_the_released_ones() { assert_eq!( @@ -223,3 +225,123 @@ fn instance_id_hashes_are_the_released_ones() { "M274 的 id 公式变了(存量 ext- 档案一律反查不到)" ); } + +/// 一条 `ext-` 磁贴,版本本体**不在盘上**(这个临时根里没有 `versions/`)。 +/// +/// 走生产的构造函数([`super::discovered::external_summary`],id = M274 的 `fnv(根 ‖ 0 ‖ 版本)`), +/// 调用方只把 id 换成要判的那一种写法——这样「磁贴的形状」与界面上的永远同源。`roots[0]` 恒为 +/// 配置根([`super::roots::all`] 的不变式:配置根永远第一位)。 +fn ext_tile(env: &HostEnv, version_id: &str) -> InstanceSummary { + let roots = super::roots::all(env); + super::discovered::external_summary(env, &roots[0], version_id, &BTreeMap::new()) +} + +/// 建一个只有 `config/` 的临时根(用例自己的,`pid` + 标签让并行的两条不撞)。 +fn bare_root(label: &str) -> (HostEnv, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("ull-host-game-{label}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.config()).expect("config 目录"); + (env, dir) +} + +/// **M273 老档案必须放行**(M306:给 `refresh/live.rs::discovered` 最后那三行挂网)。 +/// +/// 判据:`ext-` id 只哈希版本目录名(M274 之前的公式)⇒ `roots::all` 的每个根都反查不到它。 +/// 这种 id 的语义本来就是「按配置根解析」,真没版本时启动链给具名阶段失败(进程一个都没起来) +/// ——所以这里必须 `Ok(())`。拒绝它 = 所有存量 M273 用户的 `ext-` 磁贴集体点不动,还配一句错的 +/// 原因(「所属的游戏目录已不在」)。 +/// +/// 抓得住:**只**删掉 `live.rs::discovered` 里 +/// `if legacy_external_instance_id(&instance.version_id) == instance.id { return Ok(()); }` 这三行 +/// ——本条立即红(M306 实测:`Err("实例 `1.20.4` 所属的游戏目录已不在…")`)。 +/// +/// 但**别把编译器当第二道网**(M306 r1 复审实测,本文件早先那句「编译器一声不吭」是错的): +/// `#![allow(dead_code)]` 只盖「函数变死码」,盖不住「use 行变空」——`unused_imports` 属于 +/// `unused` 组里另一条 lint,删掉那三行时它会点名 `legacy_external_instance_id`,CI 的 +/// `-D warnings` 当场红。可**这道保险也能绕过**:连 import 一起删、或在那行加 +/// `#[allow(unused_imports)]`,编译器就真的不吭声了(那时 `cargo test` 依旧全绿)。 +/// 所以源码侧由下面的 `the_legacy_pass_through_is_still_in_the_source` 兜底。 +/// 抓不住:反查落空后「该拒绝」那一侧(下一条用例管)。 +#[test] +fn a_m273_legacy_ext_id_is_allowed() { + let (env, dir) = bare_root("legacy-ok"); + let mut tile = ext_tile(&env, "1.20.4"); + tile.id = super::discovered::legacy_external_instance_id("1.20.4"); + + // 先清临时根再断言:断言失败也不在 /tmp 留空壳。 + let verdict = Scan::revalidate(&env, &tile); + std::fs::remove_dir_all(&dir).ok(); + assert!( + verdict.is_ok(), + "M273 老档案(id 只哈希版本目录名)必须放行,否则存量 `ext-` 磁贴全部点不动:{verdict:?}" + ); +} + +/// **换个根的 `ext-` id、反查落空,必须拒绝并具名**(同一条分叉的另一侧)。 +/// +/// 与上一条**同一个版本**,只把 id 换成 `external_instance_id(别的根, 版本)`:盘上根本没有那个根 +/// ⇒ 一个根都对不上,而它也不是老档案 ⇒ 必须 `Err`。放行会让 `game_for` 退回配置根,配置根里 +/// 恰好有同名版本就把**另一份**拉起来(gameDir / 存档都在另一边)。 +/// +/// 抓得住:把那条拒绝改写成 `Ok(())`(复审现场的另一半)。它与上一条各钉分叉的一侧,所以 +/// 只删老档案那三行时本条**不会**假红——红色只会出现在上一条。 +#[test] +fn an_ext_id_from_no_visible_root_is_refused() { + let (env, dir) = bare_root("legacy-refused"); + let mut tile = ext_tile(&env, "1.20.4"); + // 盘上不存在的根:`roots::all` 只给配置根 ⇒ 这个 id 一个根都对不上,且它不是老档案的公式。 + tile.id = super::discovered::external_instance_id("d:/m306-no-such-root/.minecraft", "1.20.4"); + + let verdict = Scan::revalidate(&env, &tile); + std::fs::remove_dir_all(&dir).ok(); + let reason = verdict.expect_err( + "反查落空又不是老档案 ⇒ 必须拒绝(放行会让 game_for 退回配置根,把同名版本拉起来)", + ); + assert!( + reason.contains("游戏目录已不在") && reason.contains("1.20.4"), + "具名原因要说清是哪条实例、为什么(现在是:{reason})" + ); +} + +/// `live.rs` 的**代码**:每行剥掉 `//` 之后的部分(`//` / `///` / `//!` 都从此处走)。 +/// +/// 必须剥:`live.rs` 的文档里写着那条 `if` 的原文(就是给下一个「清死代码」的人看的),注释里 +/// 出现不算数——否则下面的守卫会**自证**地永远绿(M306 r1 复审要的正是这道网,别让它空转)。 +fn live_code() -> String { + include_str!("refresh/live.rs") + .lines() + .map(|line| line.split("//").next().unwrap_or_default()) + .collect::>() + .join("\n") +} + +/// **源码级守卫**:`live.rs` 里那条「M273 老档案放行」必须**原样还在**,且没人把编译器的提醒关掉。 +/// +/// 为什么行为用例不够(M306 r1 复审证明):删掉那三行时编译器**会**给信号(`unused_imports` +/// 点名 `legacy_external_instance_id`,`-D warnings` 判红)——**但这道保险可绕过**:把 import +/// 一起删掉、或加 `#[allow(unused_imports)]`,编译期**一条 warning 都不打**(M306 r1 实测: +/// `grep -c '^warning'` = 0),`cargo test` 也依旧全绿。 +/// 那时只有扫源码能说话:`a_m273_legacy_ext_id_is_allowed` 判**行为**,本条判**判据还在不在**。 +/// +/// 抓得住:那三行被删(注释里残留的原文不算)/import 里的名字被删/有人加 `unused_imports` 豁免。 +/// 抓不住:把判据换个写法搬去别处——那时本条会红,请顺手把下面的字面量改成新形状(别把守卫删了)。 +#[test] +fn the_legacy_pass_through_is_still_in_the_source() { + let code = live_code(); + let branch = "if legacy_external_instance_id(&instance.version_id) == instance.id {"; + let import = + "use super::super::discovered::{external_instance_id, legacy_external_instance_id};"; + assert!( + code.contains(branch) && code.contains(import), + "live.rs::discovered 里那条「M273 老档案放行」不见了(分支在 = {},import 在 = {}):\ + 删掉它会让所有存量 M273 用户的 `ext-` 磁贴点不动,还配一句错的「所属的游戏目录已不在」", + code.contains(branch), + code.contains(import) + ); + assert!( + !code.contains("unused_imports"), + "live.rs 里出现了 `unused_imports` 豁免:它会把「删掉放行分支后 use 行变空」这条唯一的\ + 编译期提醒关掉——那是本条守卫存在的理由,不许豁免" + ); +} diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index 7421dae..70e443c 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -25,7 +25,6 @@ use crate::dto::{ LogSession, NoticeView, PackState, - Progress, SettingGroupView, UpdateView, VersionSummary, @@ -355,8 +354,10 @@ impl AppState { .collect() } - /// 覆盖某实例的启动进度(mock / 真实后端共用同一入口)。 - pub fn set_progress(&mut self, id: &str, progress: Progress) { + /// 覆盖某实例的启动进度(**只给单测用**:生产路径没有调用方,它就地改 `instances` 而不动 + /// `published` ⇒ 两份会分叉;生产推进度走 `CoreEvent::InstanceRuntimeChanged` → `apply`)。 + #[cfg(test)] + pub fn set_progress(&mut self, id: &str, progress: crate::dto::Progress) { if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) { if let InstanceRuntime::Busy { progress: current, ..