diff --git a/crates/app-iced/src/app/update/instances/scan_phase.rs b/crates/app-iced/src/app/update/instances/scan_phase.rs index 3b5b303..5ea0fcd 100644 --- a/crates/app-iced/src/app/update/instances/scan_phase.rs +++ b/crates/app-iced/src/app/update/instances/scan_phase.rs @@ -8,6 +8,7 @@ //! | [`an_empty_snapshot_still_counts_as_ready`] | 空快照被当成「未就绪」(用户永远看着「正在扫描」) | 空快照从哪来(presenter 的 `refresh_tests` 用真空盘钉) | //! | [`a_stalled_scan_goes_back_to_scanning_on_retry`] | 超时之后没有出路(`Stalled` 不会被新一轮扫描收回 `Scanning`) | 15 s 的阈值本身(`host::game::SCAN_STALL`,单测不等真实时间) | //! | [`retry_scan_dispatches_a_scan_only_refresh`] | 重试按钮派错命令 / 被「必须有选中实例」的守卫挡住(冷启动时根本没有选中项) | 命令真的到达 host(`host::dispatch` 的 `RefreshInstances` 分支由 presenter 的穷尽 match 守) | +//! | [`a_real_host_flips_the_detail_button_in_the_same_beat`] | 真 host + 真 app:点启动后**同一拍**(`advance` 之前)没进运行态(把 `Busy` 拖到回程才发) | 像素;受理之后那条后台线程的成败(不在本条判据里) | //! M302 的**首屏三态接线**:presenter 的 `InstancePhase` → 页面三态,以及「重新扫描」的落点。 //! @@ -261,3 +262,105 @@ fn a_refresh_after_ready_keeps_the_list_and_the_tree() { ); assert!(!app.view.instances.rows.is_empty(), "列表也不许被清空"); } + +/// **M304 的端到端判据**:真 `CoreHost` + 真数据根 + 真 app —— 点启动 ⇒ **同一拍**(`advance` 之前) +/// 详情页的按钮已经翻成「可停止」。 +/// +/// 判据落在**状态**上:`app.dispatch(LaunchInstance)` 只做「派发 + 抽干**当下**已有的事件」,之后 +/// 一次 `advance`(= `backend.tick()`)都不调。所以这条绿的意思是「`Busy` 在 host 的 `dispatch` +/// 返回前就发出来了」,而不是「等某条后台线程回报」。 +/// +/// 抓得住:把 host 那条 `InstanceRuntimeChanged{Busy}` 挪进启动线程(= 只有回程才发)——那时抽干 +/// 到的是空队列,`can_stop` 仍是 false,这条红(presenter 侧的同款判据在 +/// `host::launch::target_tests::clicking_launch_shows_busy_in_the_same_beat`)。 +/// 抓不住:真鼠标点击(从 `AppCommand` 起跑);受理之后装配/启动的成败(这一条只判「同一拍」)。 +#[test] +fn a_real_host_flips_the_detail_button_in_the_same_beat() { + use std::time::Duration; + + use unlucky_core::game::install::instance::write_profiles; + use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList}; + use unlucky_presenter::host::{CoreHost, HostEnv}; + use unlucky_presenter::router::Route; + + let dir = std::env::temp_dir().join(format!("ull-m304-launch-beat-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录"); + + let env = HostEnv::at_root(&dir); + // 一条实例档案。**不造版本本体**:装配会失败——但那是**受理之后**的事,本条判的是受理那一拍。 + let profile = InstanceProfile { + id: ProfileId::generate(), + name: "demo".to_string(), + version_id: "1.20.1".to_string(), + game_dir: None, + running_dir: None, + java_path: None, + created_at: None, + last_played_at: None, + extra: std::collections::BTreeMap::new(), + }; + let id = profile.id.as_str().to_string(); + write_profiles( + &env.layout, + &ProfileList { + profiles: vec![profile], + ..ProfileList::default() + }, + ) + .expect("写 profiles.json"); + + let mut app = App::with_presenter( + Options { + data_dir: Some(dir.clone()), + ..Options::default() + }, + FAMILIES, + SchemeFlavor::Light, + Box::new(CoreHost::new(env)), + SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)), + ); + app.dispatch(AppCommand::RefreshAll); + // 先 `advance` 到**第一份快照落地**(M302 之后扫盘在后台线程,这一拍必须等) + for _ in 0..200 { + if app.state.tiles().iter().any(|cell| cell.instance.id == id) { + break; + } + app.advance(Duration::from_millis(33)); + } + assert!( + app.state.tiles().iter().any(|cell| cell.instance.id == id), + "前提:快照里必须已经有这条实例(等了 200 拍)" + ); + app.state.navigate(Route::Detail(id.clone())); + app.refresh_view(); + let before = app.view.instances.detail.clone().expect("有选中项"); + assert!( + before.can_launch && !before.can_stop, + "前提:空闲态可启动不可停止({before:?})" + ); + + // 点 Play:只派发 + 抽干**当下**的事件,不 tick、不 advance + app.dispatch(AppCommand::LaunchInstance(id.clone())); + + let after = app.view.instances.detail.clone().expect("有选中项"); + assert!( + after.can_stop && !after.can_launch, + "点下去必须**同一拍**进运行态(advance 之前):{after:?}" + ); + + // 收尾:让那条后台线程把失败交回来(不造版本本体 ⇒ 装配失败),再把临时根删干净 + for _ in 0..100 { + app.advance(Duration::from_millis(33)); + if app + .view + .instances + .detail + .as_ref() + .is_some_and(|detail| !detail.can_stop) + { + break; + } + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/presenter/src/host/game/discovered.rs b/crates/presenter/src/host/game/discovered.rs index b028202..434169b 100644 --- a/crates/presenter/src/host/game/discovered.rs +++ b/crates/presenter/src/host/game/discovered.rs @@ -103,6 +103,31 @@ pub(in crate::host) fn external_versions(env: &HostEnv, game: &GameLayout) -> Ve .collect() } +/// **M273 时代的 `ext-` id**:只哈希版本目录名(不含根)。M274 之后这种 id 永远反查不到根。 +/// +/// 存在的唯一理由:`host/game/refresh/live.rs` 判「反查落空的 `ext-` id」时要用它把两种情形分开—— +/// 老档案(这条 ⇒ 放行,按配置根解析)与**根不见了**(不是这条 ⇒ 拒绝,否则会起错副本)。 +pub(in crate::host) fn legacy_external_instance_id(version_id: &str) -> String { + instance_id_from(&[version_id]) +} + +/// FNV-1a 64 的多段哈希:段之间插一个 `0` 分隔符(路径里不可能有 NUL,所以 `("ab","c")` 与 +/// `("a","bc")` 不会撞)。 +fn instance_id_from(parts: &[&str]) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for (index, part) in parts.iter().enumerate() { + if index > 0 { + // 分隔符那一个 `0` 字节的 `hash ^= 0` 是恒等,留下的痕迹只有这一次乘法 + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + for byte in part.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + } + format!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX) +} + /// 外部实例的视图 id(= 懒创建时写进 `profiles.json` 的档案 id)。 /// /// **不能直接拿版本 id 当实例 id**:`core::platform::layout::is_valid_instance_id` 只收 @@ -112,21 +137,10 @@ pub(in crate::host) fn external_versions(env: &HostEnv, game: &GameLayout) -> Ve /// /// 前缀 + 稳定哈希 ⇒ 同一个 (根, 版本) 每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。 /// -/// **M274**:`root_key` 也进哈希(`fnv(root || 0 || version)`)——两个根里的 `1.20.4` 因此是两个 -/// 实例。分隔符用 `0`:路径里不可能有 NUL,所以 `("ab","c")` 与 `("a","bc")` 不会撞。 +/// **M274**:`root_key` 也进哈希(`fnv(root || 0 || version)`,见 [`instance_id_from`])——两个根里 +/// 的 `1.20.4` 因此是两个实例。 pub(in crate::host) fn external_instance_id(root_key: &str, version_id: &str) -> String { - let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - let bytes = root_key - .as_bytes() - .iter() - .copied() - .chain(std::iter::once(0)) - .chain(version_id.as_bytes().iter().copied()); - for byte in bytes { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x0000_0100_0000_01b3); - } - format!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX) + instance_id_from(&[root_key, version_id]) } /// 磁贴的显示名:配置根里的版本就是版本目录名;**别的根**里的版本带上根路径。 diff --git a/crates/presenter/src/host/game/refresh/live.rs b/crates/presenter/src/host/game/refresh/live.rs index 1e464b6..9086e90 100644 --- a/crates/presenter/src/host/game/refresh/live.rs +++ b/crates/presenter/src/host/game/refresh/live.rs @@ -19,10 +19,21 @@ //! | 档案实例(id 不是 `ext-`) | `profiles.json` 里还有这条 id,且 `version_id` 没被改过 | 一次小文件读 | //! | 自动发现的(`ext-`) | 逐个根重算 id(哈希)定位到 (根, 版本),再用 core 的 `adopt::inspect` 判这一个版本 | #根 次哈希 + 一次单版本检查 | //! -//! 反查不到根的 `ext-` id(M273 时代的老档案,哈希里没有根)**不在这里判死**:那种 id 的语义 -//! 本来就是「按配置根解析」,真没版本时 `build_request` 会给出具名阶段失败(`VersionManifest` + -//! 路径),一个进程都没起来。改前那条同步扫盘也不会拦它——`version_present: false` 的磁贴在 -//! 旧实现里照样能点、照样走到同一条具名失败。 +//! ## 反查落空的两种情形,必须分开(r1 复审 P2-1) +//! +//! `roots::all` 对非配置根是 `if !extra.is_dir() { continue; }`(只有配置根无条件入列)。于是 +//! 「官方根被删 / 改名 / 盘没挂上 / `canonicalize` 变了」时,那个 `ext-` id 一个根都对不上—— +//! 若照旧放行,`build_request` → `game_for`(`discovered::game_for` 的回落)会退回**配置根**, +//! 配置根里恰好有同名 `version_id` 就把**另一份**拉起来(gameDir / 存档都在另一边)。这是相对 +//! 改前(同步扫盘 ⇒ 扫不出来 ⇒ 拒绝「找不到实例」)的回归,所以必须分开判: +//! +//! - **M273 老档案**(id 只哈希版本目录名 ⇒ `legacy_external_instance_id` 命中):**放行**—— +//! 那种 id 的语义本来就是「按配置根解析」,真没版本时 `build_request` 给具名阶段失败; +//! - **所属根不见了**(删 / 改名 / 盘没挂上 / `canonicalize` 变了):**拒绝** + 具名原因,并由 +//! 调用方立刻重扫(`Rejection::stale`)。 +//! +//! 放行老档案不是偷懒:改前那条同步扫盘也不会拦它——`version_present: false` 的磁贴在旧实现里 +//! 照样能点、照样走到同一条具名失败(`VersionManifest` + 路径),进程一个都没起来。 //! //! ## 为什么挂在 [`super::Scan`] 上 //! @@ -37,7 +48,7 @@ use unlucky_core::game::install::instance::read_profiles; use crate::dto::InstanceSummary; use super::super::super::HostEnv; -use super::super::discovered::external_instance_id; +use super::super::discovered::{external_instance_id, legacy_external_instance_id}; use super::super::roots; impl super::Scan { @@ -105,5 +116,13 @@ fn discovered(env: &HostEnv, instance: &InstanceSummary) -> Result<(), String> { ) }); } - Ok(()) + // 反查落空:老档案放行,**根不见了就拒绝**(理由见模块文档——放行会让 `game_for` 退回配置根, + // 把另一个根里同名的那个版本拉起来)。 + if legacy_external_instance_id(&instance.version_id) == instance.id { + return Ok(()); + } + Err(format!( + "实例 `{}` 所属的游戏目录已不在(被移走 / 改名 / 盘没挂上),列表已过期", + instance.name + )) } diff --git a/crates/presenter/src/host/game/tests.rs b/crates/presenter/src/host/game/tests.rs index 80d0641..ed0436d 100644 --- a/crates/presenter/src/host/game/tests.rs +++ b/crates/presenter/src/host/game/tests.rs @@ -196,3 +196,30 @@ fn isolation_write_then_read_round_trips() { assert!(set_isolation(&env, &id, "nope").is_err()); std::fs::remove_dir_all(&dir).ok(); } + +/// **实例 id 的哈希公式钉死**(M304 r1:把「老档案放行」那条判据挂在这两个函数上,公式一漂就会 +/// 误杀所有存量实例)。 +/// +/// 两个字面量是 FNV-1a 64 的独立复算结果(`ext-` 前缀 + 16 位小写十六进制): +/// +/// | 输入 | 期望 | +/// |---|---| +/// | `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`] 里的一个多段函数,这条就是那次重构的回归网。 +#[test] +fn instance_id_hashes_are_the_released_ones() { + assert_eq!( + super::discovered::legacy_external_instance_id("1.20.4"), + "ext-52d6191436663b4e", + "M273 老档案的 id 公式变了(存量实例会被判成「根不见了」)" + ); + assert_eq!( + super::discovered::external_instance_id("c:/mc/.minecraft", "1.20.4"), + "ext-1e6c24c50b4a95c2", + "M274 的 id 公式变了(存量 ext- 档案一律反查不到)" + ); +} diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 8c7f23b..d7cb8cb 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -235,6 +235,12 @@ pub fn isolation_label(mode: IsolationMode) -> &'static str { mode.as_str() } +#[cfg(test)] +mod fixtures; + +#[cfg(test)] +mod revalidate_tests; + #[cfg(test)] mod target_tests; diff --git a/crates/presenter/src/host/launch/fixtures.rs b/crates/presenter/src/host/launch/fixtures.rs new file mode 100644 index 0000000..91c8fed --- /dev/null +++ b/crates/presenter/src/host/launch/fixtures.rs @@ -0,0 +1,137 @@ +//! M304 用例的**共用夹具**(临时数据根 / 实例档案 / 首份快照落地 / 事件读取)。 +//! +//! 拆出来的理由同仓里其它 `tests*` 的拆分:单文件 400 行硬线。判据本身在 +//! [`super::target_tests`](快照解析 / 同一拍 / 同名不猜 / 停止读 pid)与 +//! [`super::revalidate_tests`](所属根不见了 / 运行时态落进快照)。 + +use std::time::Duration; + +use unlucky_core::game::install::instance::write_profiles; +use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList}; + +use crate::dto::{AppEvent, Backend, CoreEvent}; +use crate::toast::ToastKind; + +use super::super::{CoreHost, HostEnv}; + +/// 临时数据根守卫:用例结束(含断言 panic)时删掉,不留空壳。 +pub(super) struct TempRoot(pub(super) std::path::PathBuf); + +impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// 一条实例档案(自建实例:id 与名字由用例给)。 +pub(super) fn profile(name: &str, version: &str) -> InstanceProfile { + InstanceProfile { + id: ProfileId::generate(), + name: name.to_string(), + version_id: version.to_string(), + game_dir: None, + running_dir: None, + java_path: None, + created_at: None, + last_played_at: None, + extra: std::collections::BTreeMap::new(), + } +} + +/// 把实例清单**写进盘**(`profiles.json` 是「盘上有没有这个实例」的档案侧真相)。 +/// +/// 增删实例都走它:用例改的就是 host 看不见的那份盘面,快照因此真的会过期。 +pub(super) fn write(env: &HostEnv, profiles: &[InstanceProfile]) { + let list = ProfileList { + profiles: profiles.to_vec(), + ..ProfileList::default() + }; + write_profiles(&env.layout, &list).expect("写 profiles.json"); +} + +/// 建一个只有这几条档案的数据根,返回 `(host, 守卫)`。 +pub(super) fn host(label: &str, profiles: &[InstanceProfile]) -> (CoreHost, TempRoot) { + let dir = temp_dir(label); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.config()).expect("config 目录"); + write(&env, profiles); + (CoreHost::headless(env), TempRoot(dir)) +} + +/// **两个根**的数据根:配置根里几条档案 + 官方根(`/official/.minecraft`)里一个能登记的版本。 +/// +/// `HostEnv::at_root` 默认**不做多根发现**(见 `env.rs::at_root` 的文档:截图/用例要确定性),所以 +/// 这里在交给 host 之前显式把 `standard_game` 指过去——用例要的正是「实例属于**另一个**根」。 +pub(super) fn host_with_official( + label: &str, + profiles: &[InstanceProfile], + version: &str, +) -> (CoreHost, TempRoot, std::path::PathBuf) { + let dir = temp_dir(label); + let mut env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.config()).expect("config 目录"); + write(&env, profiles); + let official = dir.join("official").join(".minecraft"); + let version_dir = official.join("versions").join(version); + std::fs::create_dir_all(&version_dir).expect("官方根的版本目录"); + std::fs::write( + version_dir.join(format!("{version}.json")), + format!(r#"{{"id":"{version}","mainClass":"net.minecraft.client.main.Main"}}"#), + ) + .expect("写版本 JSON"); + env.standard_game = Some(official.clone()); + (CoreHost::headless(env), TempRoot(dir), official) +} + +/// 一次性临时目录(`line!()` 让同一个文件里的两条用例不会撞)。 +fn temp_dir(label: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-m304-{label}-{}-{}", + std::process::id(), + line!() + )); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +/// 让第一份快照落地(M302 的后台扫盘),并把这一批事件读走。 +pub(super) fn land_snapshot(host: &mut CoreHost) { + host.refresh_instances(); + host.pump_instances(); + let _ = host.drain_events(); +} + +/// 有没有发过「运行时变了」(受理成功 = 一条 `Busy`)。 +pub(super) fn runtime_event_emitted(events: &[AppEvent]) -> bool { + events.iter().any(|event| { + matches!( + event, + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. }) + ) + }) +} + +/// 事件里唯一那条错误 Toast 的原文(拒绝必须是**具名**的,所以这里要求它存在)。 +pub(super) fn error_toast(events: &[AppEvent]) -> String { + events + .iter() + .find_map(|event| match event { + AppEvent::Toast(toast) if toast.kind == ToastKind::Error => Some(toast.text.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("拒绝受理必须给一条错误提示(具名原因),实际事件:{events:?}")) +} + +/// 等启动后台线程把结局交回来(夹具里的实例都没有版本本体 ⇒ 装配必然失败)。 +/// +/// 不只是收尾:它顺手证明**受理不依赖这条线程**——Busy 的断言都在它之前做完了。 +pub(super) fn settle(host: &mut CoreHost) { + for _ in 0..2_000 { + if host.pending.is_empty() { + return; + } + host.tick(Duration::from_millis(1)); + std::thread::sleep(Duration::from_millis(1)); + } + panic!("启动线程没在 2 s 内收尾(pending 还剩 {:?})", host.pending); +} diff --git a/crates/presenter/src/host/launch/revalidate_tests.rs b/crates/presenter/src/host/launch/revalidate_tests.rs new file mode 100644 index 0000000..e954e5b --- /dev/null +++ b/crates/presenter/src/host/launch/revalidate_tests.rs @@ -0,0 +1,63 @@ +//! M304 r1 复审的两条判据:**所属根不见了必须拒绝**、**运行时态事件要落进已发布快照**。 +//! +//! 前半条是真风险(复审 P2-1):`revalidate` 对 `ext-` 实例是「逐个根重算 id 定位」,而 +//! `roots::all` 对非配置根是 `if !extra.is_dir() { continue; }`。根被删 / 改名 / 盘没挂上 ⇒ 一个都 +//! 对不上 ⇒ 若照旧放行,`build_request` → `game_for` 退回**配置根**,配置根里恰好有同名版本就把 +//! **另一份**拉起来(gameDir / 存档都在另一边)。改前的同步扫盘扫不出来 ⇒ 拒绝,所以放行是回归。 +//! +//! 夹具见 [`super::fixtures`],其余判据见 [`super::target_tests`]。 + +use crate::dto::{AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime}; + +use super::super::CoreHost; +use super::fixtures::{error_toast, host_with_official, land_snapshot, profile}; + +/// 从事件里挑出一条「运行时变成 Busy」的 id(受理成功的证据)。 +fn busy_id(events: &[AppEvent]) -> Option { + events.iter().find_map(|event| match event { + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + id, + runtime: InstanceRuntime::Busy { .. }, + }) => Some(id.clone()), + _ => None, + }) +} + +/// **P2-1**:实例属于另一个根,快照落地之后那个根不见了 ⇒ 点它必须**拒绝并具名**。 +/// +/// 抓得住:`revalidate` 在反查落空时直接 `Ok(())`(= 复审的现场,这条立即红)。 +/// 抓不住:M273 老档案那条**放行**分支——它由 presenter 的 `game::tests_multiroot3`(老 id 的 +/// 反查回落)与 `host::game::refresh` 的 `legacy_external_instance_id` 共用同一份哈希守。 +#[test] +fn an_instance_whose_root_disappeared_is_refused_with_a_named_reason() { + let demo = profile("demo", "1.20.1"); + let (mut host, _root, official) = + host_with_official("root-gone", std::slice::from_ref(&demo), "1.20.9"); + land_snapshot(&mut host); + let tile = tile_of_version(&host, "1.20.9"); + + // 快照之后根没了(盘掉线 / 被移走 / 改名) + std::fs::remove_dir_all(&official).expect("删掉官方根"); + + host.dispatch(AppCommand::LaunchInstance(tile)); + let events = host.drain_events(); + assert!( + busy_id(&events).is_none(), + "所属根不见了不许受理(放行会让 game_for 退回配置根,把另一份拉起来):{events:?}" + ); + assert!(host.pending.is_empty(), "拒绝受理 ⇒ 不进 pending"); + let message = error_toast(&events); + assert!( + message.contains("游戏目录已不在") && message.contains("1.20.9"), + "具名原因要说出是哪个实例、为什么(现在是:{message})" + ); +} + +/// 快照里那个版本的磁贴 id(`ext-` 前缀 = 自动发现的实例)。 +fn tile_of_version(host: &CoreHost, version: &str) -> String { + host.published + .iter() + .find(|instance| instance.version_id == version) + .map(|instance| instance.id.clone()) + .unwrap_or_else(|| panic!("快照里没有版本 `{version}` 的磁贴:{:?}", host.published)) +} diff --git a/crates/presenter/src/host/launch/target_tests.rs b/crates/presenter/src/host/launch/target_tests.rs index c022ea5..1f42d9d 100644 --- a/crates/presenter/src/host/launch/target_tests.rs +++ b/crates/presenter/src/host/launch/target_tests.rs @@ -1,118 +1,26 @@ //! M304 的判据:启动/停止的实例解析读**已发布快照**,而不是在调用方线程扫一遍盘。 //! -//! 三条判据各打一个具体的谎: -//! //! | 判据 | 抓得住什么 | //! |---|---| //! | ① 点下去**同一拍**进 Busy(无 tick、无后台回程) | 把受理做成「等解析回来才发 Busy」 | //! | ② 快照落地后**在盘上新增**一条实例 ⇒ 点它必须被拒绝,不是被扫出来 | 解析改回**现场扫盘**(`game::instances`)| //! | ③ 快照落地后**删掉**这个实例 ⇒ 点它必须拒绝并给具名原因 | 去掉受理那一刻的单实例复核 | +//! | ④ 两个同名实例 ⇒ 按名字点必须拒绝(不猜) | 名字兜底取第一个 | +//! | ⑤ 停止读快照里的 pid | `running_pid` 兜底改回 `game::instances()` | //! //! ②与③是一对:只做②(纯快照、不复核)会把已删实例拉起来;只做③(只复核、读盘)会退回扫盘。 //! 两条都靠**盘面在快照之后变了**来判,不靠计时——计时在 CI 上只会假绿。 //! -//! 另外两条小的:同名不猜(两个实例同名时拒绝而不是取第一个)、停止读快照里的 pid(不为此扫盘)。 +//! 另外两条在 [`super::revalidate_tests`]:所属根不见了必须拒绝(r1 P2-1)、运行时态事件也要落进 +//! 已发布快照(r1 P2-2)。夹具在 [`super::fixtures`]。 -use std::time::Duration; +use crate::dto::{AppCommand, Backend, InstanceRuntime}; -use unlucky_core::game::install::instance::write_profiles; -use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList}; - -use crate::dto::{AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime}; -use crate::toast::ToastKind; - -use super::super::{CoreHost, HostEnv}; - -/// 临时数据根守卫:用例结束(含断言 panic)时删掉,不留空壳。 -struct TempRoot(std::path::PathBuf); - -impl Drop for TempRoot { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -/// 一条实例档案(自建实例:id 与名字由用例给)。 -fn profile(name: &str, version: &str) -> InstanceProfile { - InstanceProfile { - id: ProfileId::generate(), - name: name.to_string(), - version_id: version.to_string(), - game_dir: None, - running_dir: None, - java_path: None, - created_at: None, - last_played_at: None, - extra: std::collections::BTreeMap::new(), - } -} - -/// 把实例清单**写进盘**(`profiles.json` 是「盘上有没有这个实例」的档案侧真相)。 -/// -/// 增删实例都走它:用例改的就是 host 看不见的那份盘面,快照因此真的会过期。 -fn write(env: &HostEnv, profiles: &[InstanceProfile]) { - let list = ProfileList { - profiles: profiles.to_vec(), - ..ProfileList::default() - }; - write_profiles(&env.layout, &list).expect("写 profiles.json"); -} - -/// 建一个只有这几条档案的数据根,返回 `(host, 守卫)`。 -fn host(label: &str, profiles: &[InstanceProfile]) -> (CoreHost, TempRoot) { - let dir = std::env::temp_dir().join(format!( - "ull-m304-{label}-{}-{}", - std::process::id(), - line!() - )); - let _ = std::fs::remove_dir_all(&dir); - let env = HostEnv::at_root(&dir); - std::fs::create_dir_all(env.layout.config()).expect("config 目录"); - write(&env, profiles); - (CoreHost::headless(env), TempRoot(dir)) -} - -/// 让第一份快照落地(M302 的后台扫盘),并把这一批事件读走。 -fn land_snapshot(host: &mut CoreHost) { - host.refresh_instances(); - host.pump_instances(); - let _ = host.drain_events(); -} - -/// 有没有发过「运行时变了」(受理成功 = 一条 `Busy`)。 -fn runtime_event_emitted(events: &[AppEvent]) -> bool { - events.iter().any(|event| { - matches!( - event, - AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. }) - ) - }) -} - -/// 事件里唯一那条错误 Toast 的原文(拒绝必须是**具名**的,所以这里要求它存在)。 -fn error_toast(events: &[AppEvent]) -> String { - events - .iter() - .find_map(|event| match event { - AppEvent::Toast(toast) if toast.kind == ToastKind::Error => Some(toast.text.clone()), - _ => None, - }) - .unwrap_or_else(|| panic!("拒绝受理必须给一条错误提示(具名原因),实际事件:{events:?}")) -} - -/// 等启动后台线程把结局交回来(本文件的实例都没有版本本体 ⇒ 装配必然失败)。 -/// -/// 不只是收尾:它顺手证明**受理不依赖这条线程**——Busy 的断言都在它之前做完了。 -fn settle(host: &mut CoreHost) { - for _ in 0..2_000 { - if host.pending.is_empty() { - return; - } - host.tick(Duration::from_millis(1)); - std::thread::sleep(Duration::from_millis(1)); - } - panic!("启动线程没在 2 s 内收尾(pending 还剩 {:?})", host.pending); -} +use super::super::job::HostTask; +use super::super::CoreHost; +use super::fixtures::{ + error_toast, host, land_snapshot, profile, runtime_event_emitted, settle, write, +}; /// **判据①**:点启动 ⇒ `dispatch` 返回时 Busy 已经在事件队列里(不需要 `tick`、不需要后台回程)。 /// @@ -145,7 +53,7 @@ fn clicking_launch_shows_busy_in_the_same_beat() { /// **判据②(变异:解析改回现场扫盘 ⇒ 这条红)**:快照落地之后盘上**多了**一条实例。 /// /// 点它必须被拒绝(快照里没有),而不是被「现场扫一遍盘」扫出来直接拉起来。用户看见的列表里 -/// 根本没有这一条——把不在屏幕上的实例启动起来,正是「缓存/盘面两套真相」最坏的形状。 +/// 根本没有这一条——把不在屏幕上的实例启动起来,正是「两套真相」最坏的形状。 /// /// 拒绝之后 host 会自己重扫(`Rejection::stale`)⇒ 重扫完成再点,就必须受理。 #[test] @@ -278,3 +186,42 @@ fn stop_reads_the_pid_from_the_published_snapshot_not_the_disk() { "停止要读快照里的 pid,不要为此再扫一次盘" ); } + +/// **判据⑥(r1 复审 P2-2)**:运行时态事件(`InstanceRuntimeChanged`)也必须落进已发布快照。 +/// +/// app 侧 `apply` 把它**就地**写进自己的实例列表(只认扫描回程就会分叉),而 `running_pid` 的 +/// 兜底读的正是快照里那一格 —— 今天不炸只因 `launches` 先命中。 +/// +/// 抓得住:`send()` 只认 `InstancesChanged`(快照停在 Idle,这条红)。 +#[test] +fn runtime_events_land_in_the_published_snapshot() { + let demo = profile("demo", "1.20.1"); + let id = demo.id.as_str().to_string(); + let (mut host, _root) = host("published-runtime", std::slice::from_ref(&demo)); + land_snapshot(&mut host); + let runtime_of = |host: &CoreHost| { + host.published + .iter() + .find(|instance| instance.id == id) + .map(|instance| instance.runtime.clone()) + }; + assert_eq!( + runtime_of(&host), + Some(InstanceRuntime::Idle), + "前提:快照里是 Idle" + ); + + // 唯一真实写入路径:后台线程回报「起来了」 + host.handle(HostTask::LaunchStarted { + id: id.clone(), + session_id: "20261113T120000-m304a".to_string(), + pid: 4_242, + }); + + assert_eq!( + runtime_of(&host), + Some(InstanceRuntime::Running { pid: 4_242 }), + "运行时态事件必须同时落进已发布快照(否则界面那份比快照新)" + ); + assert_eq!(host.running_pid(&id), Some(4_242)); +} diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index f5fdecd..862e6d6 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -207,9 +207,16 @@ impl CoreHost { fn send(&mut self, event: CoreEvent) { // M304:**已发布快照**在这里留一份(启动/停止的实例解析读它,不再扫盘)。 - // 选这里而不是扫描回程:这里发出去的就是界面上的那一份,两者不可能分叉。 - if let CoreEvent::InstancesChanged(instances) = &event { - self.published.clone_from(instances); + // 选这里而不是扫描回程:这里发出去的就是界面上的那一份,两者不可能分叉——所以运行时态事件 + // 也必须同时落进快照(app 侧 `apply` 会把它**就地**写进自己的列表,只认扫描回程就会分叉)。 + match &event { + CoreEvent::InstancesChanged(instances) => self.published.clone_from(instances), + CoreEvent::InstanceRuntimeChanged { id, runtime } => { + if let Some(instance) = self.published.iter_mut().find(|entry| &entry.id == id) { + instance.runtime.clone_from(runtime); + } + } + _ => {} } self.events.push_back(AppEvent::Core(event)); }