From 70c6aec6ddc30783713fed6c2e6a1b8ae423fe37 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 19:21:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(presenter):=20=E5=86=99=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=82=A3=E9=81=93=E9=97=A8=E6=8C=89(=E6=A0=B9,=E7=89=88?= =?UTF-8?q?=E6=9C=AC)=E5=88=A4=E7=BB=91=E5=AE=9A=20+=20=E5=8F=8D=E6=9F=A5?= =?UTF-8?q?=E5=9B=9E=E8=90=BD=E7=95=99=E6=97=A5=E5=BF=97=20+=20=E6=97=97?= =?UTF-8?q?=E6=A0=87=E6=94=B9=E7=9C=9F=E5=90=8D=EF=BC=88M274=20r2=20P1-1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/presenter/src/backend.rs | 10 +- crates/presenter/src/host/env.rs | 12 +- crates/presenter/src/host/game.rs | 21 +-- crates/presenter/src/host/game/discovered.rs | 115 +++++++----- .../src/host/game/tests_multiroot.rs | 5 +- .../src/host/game/tests_multiroot3.rs | 163 ++++++++++++++++++ 6 files changed, 260 insertions(+), 66 deletions(-) create mode 100644 crates/presenter/src/host/game/tests_multiroot3.rs diff --git a/crates/presenter/src/backend.rs b/crates/presenter/src/backend.rs index a29772b..3672d2c 100644 --- a/crates/presenter/src/backend.rs +++ b/crates/presenter/src/backend.rs @@ -7,7 +7,7 @@ use crate::host::CoreHost; use crate::host::HostEnv; use crate::mock::MockBackend; -/// 启动选项(`--data-root` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。 +/// 启动选项(`--data-dir` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。 #[derive(Debug, Clone, Default)] pub struct AppOptions { /// 钉死数据根(`/.ULL` + `/.minecraft`);None = 按便携配置解析。 @@ -26,7 +26,7 @@ pub fn config_path(root: &Path) -> PathBuf { /// 钉了数据根时用哪个环境(`None` = 没钉,走解析链)。 /// -/// **单独一格只为可测**:`--data-root` 是产品通道,钉了数据根照样要看见官方启动器里的实例 +/// **单独一格只为可测**:`--data-dir` 是产品通道,钉了数据根照样要看见官方启动器里的实例 /// (M274 r1 复审 P2-2)。退回 [`HostEnv::at_root`] 会让用户**静默**丢一半实例,而这层 /// `Box` 在用例里没有 env 取回口(`Backend` 没有访问器),所以判据就落在这个 /// 纯函数上(`backend::tests::data_root_keeps_discovering_the_official_launcher`)。 @@ -52,11 +52,11 @@ pub fn build_backend(options: &AppOptions) -> Box { mod tests { use super::*; - /// `--data-root`(产品通道)**保留**多根发现;`at_root`(截图/演示/用例)不保留。 + /// `--data-dir`(产品通道)**保留**多根发现;`at_root`(截图/演示/用例)不保留。 /// /// 抓得住:`host_env_for` 被改回 `HostEnv::at_root`——用户钉一个数据根就永久静默看不到 /// 官方启动器里的实例(M274 r1 复审 P2-2)。 - /// 抓不住:`--data-root` 的命令行解析(app 侧);mounted 的数据根真的解析得出来(那是 resolver)。 + /// 抓不住:`--data-dir` 的命令行解析(app 侧);mounted 的数据根真的解析得出来(那是 resolver)。 #[test] fn data_root_keeps_discovering_the_official_launcher() { let root = std::env::temp_dir().join(format!("ull-backend-m274-{}", std::process::id())); @@ -76,7 +76,7 @@ mod tests { unlucky_core::platform::paths::Platform::current(), &unlucky_core::platform::paths::ProcessEnv, ), - "`--data-root` 是产品通道:不许顺手关掉官方根发现" + "`--data-dir` 是产品通道:不许顺手关掉官方根发现" ); // 没钉数据根时这一格不参与(走解析链),`mock` 在前一步就分走了。 assert!(host_env_for(&AppOptions::default()).is_none()); diff --git a/crates/presenter/src/host/env.rs b/crates/presenter/src/host/env.rs index 401299b..2a419f6 100644 --- a/crates/presenter/src/host/env.rs +++ b/crates/presenter/src/host/env.rs @@ -54,10 +54,10 @@ impl HostEnv { Some(Self::from_data(data)) } - /// **钉根但照常做多根发现**:`--data-root`(生产通道)与 [`HostEnv::at_root`] 同一个落点, + /// **钉根但照常做多根发现**:`--data-dir`(生产通道)与 [`HostEnv::at_root`] 同一个落点, /// 差别只有一格——`standard_game` 保留(用户钉的是数据根,不是「别看官方启动器」)。 /// - /// 为什么两个入口必须分开(M274 r1 复审 P2-2):`--data-root` 是**产品**开关,之前它走 + /// 为什么两个入口必须分开(M274 r1 复审 P2-2):`--data-dir` 是**产品**开关,之前它走 /// [`HostEnv::at_root`],于是用户一钉数据根就永久静默看不到官方启动器里的实例。而 /// [`HostEnv::at_root`] 还被截图 / 演示 / 七十多处用例用着,那些地方**必须**确定性 /// (不能因为开发机上装了官方启动器就让用例结果变),所以它继续不填 `standard_game`。 @@ -68,7 +68,7 @@ impl HostEnv { /// 直接钉一个根:`/.ULL` + `/.minecraft`。 /// /// **给谁用**:截图 / 演示 / 单元测试——凡是「结果必须与这台机器上装没装官方启动器无关」的地方。 - /// **生产路径不要用这个**:产品里钉数据根(`--data-root`)走 [`HostEnv::at_data_root`], + /// **生产路径不要用这个**:产品里钉数据根(`--data-dir`)走 [`HostEnv::at_data_root`], /// 那条会照常发现官方启动器里的实例;用错这里,用户会**静默**看不到官方实例(M274 r1 复审 P2-2)。 /// /// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**: @@ -210,11 +210,11 @@ mod tests { /// /// | 入口 | 谁用 | `standard_game` | /// |---|---|---| - /// | [`HostEnv::at_data_root`] | 产品 `--data-root`(`backend.rs`) | `Some(官方位置)`:钉数据根 ≠ 别看官方实例 | + /// | [`HostEnv::at_data_root`] | 产品 `--data-dir`(`backend.rs`) | `Some(官方位置)`:钉数据根 ≠ 别看官方实例 | /// | [`HostEnv::at_root`] | 截图 / 演示 / 七十多处用例 | `None`:同一份用例在任何机器上结果一样 | /// | [`HostEnv::from_data`] | 生产解析链(便携 / 标准 / 自定义) | `Some(官方位置)` | /// - /// 抓得住:`--data-root` 退回 `at_root`(用户钉数据根后永久看不到官方实例,且没有任何报错)、 + /// 抓得住:`--data-dir` 退回 `at_root`(用户钉数据根后永久看不到官方实例,且没有任何报错)、 /// 或 `at_root` 顺手开始发现(用例在装了官方启动器的机器上变红/变绿)。 /// 抓不住:`paths::standard_game_home` 自己算错(那是 core 的用例)。 /// @@ -240,7 +240,7 @@ mod tests { assert_eq!( HostEnv::at_data_root(&root).standard_game, expected, - "`--data-root` 是产品通道:钉数据根不等于别看官方实例" + "`--data-dir` 是产品通道:钉数据根不等于别看官方实例" ); // 两条入口除了 `standard_game` 之外必须**完全一致**(落点、模式)。 let pinned = HostEnv::at_root(&root); diff --git a/crates/presenter/src/host/game.rs b/crates/presenter/src/host/game.rs index bde4165..b038218 100644 --- a/crates/presenter/src/host/game.rs +++ b/crates/presenter/src/host/game.rs @@ -171,20 +171,12 @@ pub fn instances(env: &HostEnv) -> Vec { let mut homes: Vec> = Vec::with_capacity(profiles.profiles.len()); let mut bound: Vec<&str> = Vec::new(); for profile in &profiles.profiles { - match found.find(profile.id.as_str()) { - Some((root, _)) => { - // 属于**这个根**的档案(懒创建的 `ext-` 档案可能属于官方根):磁贴按那个根描述自己。 - if root.configured() { - bound.push(profile.version_id.as_str()); - } - homes.push(Some(root.game().clone())); - } - None => { - // 反查不到 ⇒ 按旧口径算配置根的(M273 之前合成的 `ext-` id / 那批根已不在盘上)。 - bound.push(profile.version_id.as_str()); - homes.push(None); - } + // 两件事用**同一份**判据(`binds_configured_root` 与写盘那道门共用一个函数): + // ① 这条档案属于哪个根(磁贴按它描述自己);② 它是否占住配置根的同名版本。 + if discovered::binds_configured_root(&found, profile) { + bound.push(profile.version_id.as_str()); } + homes.push(found.game_of(profile.id.as_str()).cloned()); } let mut snapshot: Vec = profiles .profiles @@ -349,3 +341,6 @@ mod tests_multiroot; #[cfg(test)] mod tests_multiroot2; + +#[cfg(test)] +mod tests_multiroot3; diff --git a/crates/presenter/src/host/game/discovered.rs b/crates/presenter/src/host/game/discovered.rs index ecf5046..962fe0a 100644 --- a/crates/presenter/src/host/game/discovered.rs +++ b/crates/presenter/src/host/game/discovered.rs @@ -1,11 +1,16 @@ //! 自动发现的实例(M273 · 多根 M274):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。 //! -//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事: +//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件三件事: //! -//! 1. **哪些盘上的版本算实例**:`versions//.json` 能解析(判据来自 `core::game::adopt::scan`, -//! 本文件只做「哪个根」与「有没有档案」这两步); +//! 1. **哪些盘上的版本算实例**:`versions//.json` 能解析(判据来自 `core::game::adopt::scan`; +//! 本文件只负责「每个根扫一遍」并把结果装进 [`Found`]); //! 2. **它们从哪个目录跑**:[`base_mode`]——`versions//` 里有痕迹(`mods` / `saves` / -//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**)。 +//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**); +//! 3. **身份与判据**:[`external_instance_id`](id 里带根)、[`game_for`](读盘走哪个根)、 +//! [`binds_configured_root`](这条档案占住的是**配置根**里那个版本吗——列表侧与写盘那道门共用)。 +//! +//! 「哪个版本已经被档案绑定」这一步**不在本文件**:它属于 [`super::instances`] 的并集规则, +//! 而写盘那道门([`external_version_of`])用的是同一个 [`binds_configured_root`]。 //! //! # 多根(M274):身份 = (根, 版本) //! @@ -13,9 +18,10 @@ //! [`external_instance_id`](把根的规范化路径喂进哈希)。于是「同一个根 + 同一个版本目录」跨重启 //! 拿到同一个 id,而两个根里的同名版本拿到两个不同 id——列表自然出两条。 //! -//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`locate`] 枚举全部根、每个根扫一遍 -//! `versions/`,逐个比 id。因此它只在「用户动了一次某个实例」时被调用(存档 / 模组 / 启动 / 改设置), -//! 不在 `instances()` 的循环里。 +//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`Found::scan`] 枚举全部根、每个根扫一遍 +//! `versions/`,逐个比 id。**一拍只扫一遍**:`instances()` 建一份 [`Found`] 给整轮用(扫描次数 = +//! #根,r1 复审实测过旧形状是 `#根 × (1 + #ext 档案)`),单实例动作(存档 / 模组 / 启动 / 改设置) +//! 各建一份(同样是 #根 次)。 //! //! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节, //! 更不往官方启动器的根里写任何东西。 @@ -23,7 +29,7 @@ use std::collections::BTreeMap; use unlucky_core::game::isolation::IsolationMode; -use unlucky_core::game::{GameLayout, ProfileList}; +use unlucky_core::game::{GameLayout, InstanceProfile, ProfileList}; use crate::dto::{InstanceRuntime, InstanceSummary}; @@ -160,24 +166,6 @@ pub(in crate::host) fn external_summary( } } -/// 视图 id → (所属根, 版本 id):**唯一的反查点**(哈希不可逆,只能重算比对)。 -/// -/// 与 [`external_versions`] 同一份判据(可登记 ⇒ 会出现在列表里),但**不**看档案绑定: -/// 一个自动发现的实例被懒创建成档案之后,id 仍然是 `ext-…`,而它的根必须还认得出来 -/// (否则存档 / 模组 / 启动会退回配置根,那正是 M274 要修的错)。 -/// -/// 不是 `ext-` 前缀的 id 直接返回 `None`(档案实例属于配置根,不需要反查)。 -/// -/// **代价**(M274 r1 复审实测过):这是一次「每根扫一遍」的全树扫描。单实例动作(存档 / 模组 / -/// 启动 / 改设置)走这里没问题,**但绝不能在 `instances()` 的循环里调**——那里用 [`Found`] -/// 一拍只扫一遍(见它的文档)。 -#[must_use] -pub(in crate::host) fn locate(env: &HostEnv, id: &str) -> Option<(GameRoot, String)> { - Found::scan(env) - .find(id) - .map(|(root, version_id)| (root.clone(), version_id.to_string())) -} - /// **一拍之内**的发现快照:每个根只扫一遍 `versions/`,之后所有「这个 id 属于哪个根」都是内存查表。 /// /// 为什么要有它(M274 r1 复审 P2-1 的实测):把反查直接写进 `instances()` 的循环里,扫描次数是 @@ -233,31 +221,76 @@ impl Found { /// /// 档案实例、以及「反查不到」的 id 一律退回配置根 `env.game`——**没有第二个答案**: /// 宁可读配置根(旧行为),也不把实例指向一个猜出来的目录。 +/// +/// # 退回的后果(M274 r2 复审 P2-1,任务⑥点名要写) +/// +/// `is_discovered_instance(id)` 却反查不到,只有两种情形: +/// 1. **老档案**(M273 时代的 `ext-` id,哈希里没有根)——退回配置根是对的; +/// 2. **那个版本此刻不可登记**:官方启动器正在更新(清单半写 / 被锁 / 目录被移走)⇒ +/// `adopt::scan` 判 `adoptable() == false` ⇒ 这里查不到。 +/// +/// 第 2 种情形下退回配置根是有代价的:**若配置根里恰好有同名版本**,装配会去读它、 +/// `gameDir` 也落在配置根 ⇒ 可能**启动/读档到另一个根**。所以这里留一行启动器日志 +/// (`logs/launcher.log`,分类 `multiroot`),让这条回落可观测,而不是让调用方无感。 pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout { - Found::scan(env) - .game_of(id) - .cloned() - .unwrap_or_else(|| env.game.clone()) + let found = Found::scan(env); + if let Some(game) = found.game_of(id) { + return game.clone(); + } + if crate::dto::is_discovered_instance(id) { + unlucky_core::launch::launcher_log::record( + "multiroot", + &format!( + "实例 {id} 反查不到所属根(版本此刻不可登记:清单半写/被锁/目录已不在)⇒ 按配置根 {} 解析", + env.game.root().display() + ), + ); + } + env.game.clone() } -/// 视图 id → 版本 id 的反查(只认「可登记、且**还没有档案绑定**」的版本)。 +/// 这条档案**占住的是配置根里的那个版本吗**(`instances()` 的 `bound` 与写盘那道门共用同一份判据)。 /// -/// 与 `instances()` 同一份判据:合成的视图 id 只在那个集合里有意义,于是「界面上出现过的外部实例」 -/// 与「能懒创建的外部实例」永远是同一批(有了档案就走档案,不再重复列一条)。 +/// - 反查到某个根 ⇒ 看那个根是不是配置根; +/// - 反查不到 ⇒ 按配置根口径算(老 M273 的 `ext-` id、用户/向导建的自建档案、那批根已不在盘上)。 +/// +/// 为什么必须是**一个**函数(M274 r2 复审 P1-1):这两处曾经各写一份,列表侧对、写盘侧只比 +/// `version_id`——于是官方根那个 `A` 被懒创建成档案后,**配置根**里同名那个 `A` 被判成 +/// 「已被绑定」,`ensure_profile` 返回 `找不到实例 ext-…`,而磁贴还好端端列着。 +/// 判据分叉的代价是「有时能改有时不能改」,最难排的那一种。 +pub(in crate::host) fn binds_configured_root(found: &Found, profile: &InstanceProfile) -> bool { + found + .find(profile.id.as_str()) + .is_none_or(|(root, _)| root.configured()) +} + +/// 视图 id → (所属根, 版本 id) 的反查(只认「可登记、且**还没有档案绑定**」的版本)。 +/// +/// 与 `instances()` 同一份判据(含 [`binds_configured_root`]):合成的视图 id 只在那个集合里有意义, +/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批(有了档案就走档案, +/// 不再重复列一条)。 #[must_use] pub(in crate::host) fn external_version_of( env: &HostEnv, profiles: &ProfileList, id: &str, ) -> Option<(GameRoot, String)> { - let (root, version_id) = locate(env, id)?; - let bound = profiles - .profiles - .iter() - .any(|profile| profile.version_id == version_id); - // 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,同一个 id 也不会撞)。 - if root.configured() && bound { - return None; + // 一拍只扫一次:下面「查自己」与「查绑定者」都用这一份。 + let found = Found::scan(env); + let (root, version_id) = found + .find(id) + .map(|(root, version_id)| (root.clone(), version_id.to_string()))?; + // 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,id 不会撞)。 + // 而「绑定者」是不是配置根里的那一条,也按它**自己的根**判——不许只比 `version_id`: + // 官方根的 `A` 与配置根的 `A` 是两个实例(r2 复审 P1-1 的现场)。 + if root.configured() { + let taken = profiles.profiles.iter().any(|profile| { + profile.version_id == version_id + && (profile.id.as_str() == id || binds_configured_root(&found, profile)) + }); + if taken { + return None; + } } Some((root, version_id)) } diff --git a/crates/presenter/src/host/game/tests_multiroot.rs b/crates/presenter/src/host/game/tests_multiroot.rs index 99c8e42..0a7008c 100644 --- a/crates/presenter/src/host/game/tests_multiroot.rs +++ b/crates/presenter/src/host/game/tests_multiroot.rs @@ -1,4 +1,7 @@ -//! M274:**多根发现**(只读那条用例在 `tests_multiroot2.rs`:升级成内容级快照,M274 r1 P2-3)(配置根 + 官方启动器的标准位置)的判据用例。 +//! M274:**多根发现**(配置根 + 官方启动器的标准位置)的判据用例。 +//! +//! 只读那条用例搬到了 `tests_multiroot2.rs` 并升级成内容级快照(M274 r1 复审 P2-3); +//! 写设置那道门的两条方向用例在 `tests_multiroot3.rs`(M274 r2 复审 P1-1)。 //! //! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是 //! `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `/.minecraft`——两个不同的目录。 diff --git a/crates/presenter/src/host/game/tests_multiroot3.rs b/crates/presenter/src/host/game/tests_multiroot3.rs new file mode 100644 index 0000000..250c6ba --- /dev/null +++ b/crates/presenter/src/host/game/tests_multiroot3.rs @@ -0,0 +1,163 @@ +//! M274 **r2 复审 P1-1**:写设置那道门(`ensure_profile` → `external_version_of`)必须按 +//! **(根, 版本)** 判「这条档案是否占住配置根的同名版本」,不许只比 `version_id`。 +//! +//! 现场(复审探针,夹具就是 mission 判据⑤那份「两根各一个同名 `A`」): +//! +//! ```text +//! 官方那条 set_isolation => Ok(()) +//! 配置根那条 set_isolation => Err("找不到实例 `ext-cf5883f9d893ad24`") ← 而磁贴还好好列着 +//! ``` +//! +//! 方向还是不对称的(先改配置根那条再改官方那条是通的)——同一台机器上「有时能改有时不能改」, +//! 最难排的那种。根因是列表侧(`instances()` 的 `bound`)按根判、写侧(`external_version_of`) +//! 只比 `version_id`;现在两处共用 [`super::discovered::binds_configured_root`]。 +//! +//! | 用例 | 抓得住什么 | 抓不住什么 | +//! |---|---|---| +//! | [`both_same_named_instances_take_settings_in_either_order`] | 两个根的**同名**实例都改得动设置,两个方向都行,且写进去的值读得回来 | 图形界面点按钮那一路(`Element` 无内省;这里走的是同一条 host 函数) | + +use std::path::PathBuf; + +use unlucky_core::game::install::instance::{read_instance_config, Isolation}; +use unlucky_core::game::GameLayout; + +use super::*; + +/// 临时根(每个用例一个目录;断言 panic 时也会删)。 +mod temp { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + pub struct Root(pub PathBuf); + + impl Root { + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + } + + impl Drop for Root { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(Path::new(&self.0)); + } + } +} + +/// 造两个根:配置根 = `/.minecraft`,官方位置 = `/official/.minecraft`。 +fn two_roots(tag: &str) -> (temp::Root, HostEnv) { + let root = temp::Root::new(tag); + let mut env = HostEnv::at_root(&root.0); + let official = root.0.join("official").join(".minecraft"); + std::fs::create_dir_all(&official).expect("官方根目录"); + env.standard_game = Some(official); + (root, env) +} + +fn official(env: &HostEnv) -> PathBuf { + env.standard_game.clone().expect("用例里一定钉了官方根") +} + +fn write_version(game: &GameLayout, id: &str) { + let path = game.version_json(id); + std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录"); + std::fs::write(&path, format!(r#"{{"id":"{id}"}}"#)).expect("写版本 JSON"); +} + +/// 列表里满足 `hit` 的那一条的 id(两条磁贴都含 `A`,所以判据写在调用处)。 +fn tile_id(env: &HostEnv, hit: impl Fn(&crate::dto::InstanceSummary) -> bool) -> String { + instances(env) + .into_iter() + .find(|tile| hit(tile)) + .unwrap_or_else(|| panic!("列表里没有那一条:{:?}", instances(env))) + .id +} + +/// 两个根的**同名**实例都改得动设置:正序、反序各跑一遍,每条都断言「写进去读得回来」。 +/// +/// 变异证明(复审给的形状):把 `external_version_of` 的 `bound` 改回 +/// 「只比 `version_id`」⇒ 正序那条(先官方、后配置根)立刻红,报 +/// `找不到实例 ext-…`,而两个方向的第二条都还是绿——这正是它被抓到的现场形状。 +#[test] +fn both_same_named_instances_take_settings_in_either_order() { + for official_first in [true, false] { + let (_root, env) = two_roots("ull-host-multiroot3-order"); + write_version(&env.game, "A"); + write_version(&GameLayout::new(official(&env)), "A"); + + // 官方根那条的名字带根路径;配置根那条保持版本目录名(见 `discovered::display_name`)。 + let official_id = tile_id(&env, |tile| tile.name.contains("official")); + let configured_id = tile_id(&env, |tile| tile.name == "A"); + assert_ne!(official_id, configured_id, "两个根的同名版本必须是两条实例"); + + let order: [(&str, &str); 2] = if official_first { + [(&official_id, "official"), (&configured_id, "configured")] + } else { + [(&configured_id, "configured"), (&official_id, "official")] + }; + + for (index, (id, which)) in order.iter().enumerate() { + // 两条生产入口铺开用:一条走 `set_isolation`,另一条走 `apply_launch_args`。 + if index == 0 { + set_isolation(&env, id, "isolated").expect("改隔离档不该报「找不到实例」"); + let config = read_instance_config(&env.layout, id) + .expect("读实例设置") + .expect("实例设置文件应当已写盘"); + assert_eq!( + config.isolation, + Some(Isolation::Isolated), + "{which} 那条写进去的隔离档读不回来" + ); + } else { + let patch = crate::dto::LaunchArgsPatch { + jvm_args: Some("-XX:+UseG1GC".to_string()), + ..crate::dto::LaunchArgsPatch::default() + }; + let mut host = super::super::CoreHost::headless(env.clone()); + host.apply_launch_args(id, &patch) + .expect("写启动参数不该报「找不到实例」"); + let config = read_instance_config(&env.layout, id) + .expect("读实例设置") + .expect("实例设置文件应当已写盘"); + assert_eq!( + config.launch.jvm_args.as_deref(), + Some("-XX:+UseG1GC"), + "{which} 那条写进去的启动参数读不回来" + ); + } + // 写完一次之后,另一条**还在列表里**、而且还能继续改(不许被吞掉)。 + let snapshot = instances(&env); + assert_eq!( + snapshot.len(), + 2, + "写了 {which} 那条之后列表变了:{snapshot:?}" + ); + assert!( + snapshot.iter().any(|tile| tile.id == official_id), + "官方根那条消失了:{snapshot:?}" + ); + assert!( + snapshot.iter().any(|tile| tile.id == configured_id), + "配置根那条消失了:{snapshot:?}" + ); + } + + // 两条都写过之后:id → 根的反查不许漂(写盘与读盘同一份身份)。 + assert_eq!( + game_for(&env, &official_id).root(), + official(&env), + "官方根那条写完设置之后反查退回了配置根" + ); + assert_eq!( + game_for(&env, &configured_id).root(), + env.game.root(), + "配置根那条反查错了根" + ); + let profiles = read_profiles(&env.layout).expect("读档案"); + assert_eq!(profiles.profiles.len(), 2, "两条实例各一份档案,不多不少"); + } +}