fix(launch): M304 r1 —— 根不见了必须拒绝 + 运行时态落进快照 + 真 host 端到端同拍判据

三条 r1 复审 P2,全部按代码修(不是只改注释)。

**P2-1(唯一实质问题)**:`ext-` 实例「所属根消失 / 改名」被静默放行 ⇒ 可能起错副本。
`live.rs::discovered` 反查落空时原来一律 `Ok(())`;而 `roots::all` 对非配置根是
`if !extra.is_dir() { continue; }`,于是官方根被删 / 改名 / 盘没挂上时反查必然落空 ——
放行之后 `build_request` → `game_for` 会退回配置根,配置根里恰好有同名 `version_id`
就把**另一份**拉起来(gameDir / 存档都在另一边),相对改前(同步扫盘 ⇒ 扫不出来 ⇒ 拒绝)
是回归。改法:把 M273 的「只哈希版本目录名」搬进 `discovered.rs`
(`legacy_external_instance_id`,与 `external_instance_id` 共用同一个多段 FNV),反查落空时
**老档案放行、其余一律拒绝**并给具名原因(+ `stale` 触发重扫)。
另加 `game::tests::instance_id_hashes_are_the_released_ones`:两个哈希公式的字面量钉死
——这次把哈希抽成公共函数,公式一漂就会误杀所有存量 `ext-` 档案。

**P2-2**:`send()` 只认 `InstancesChanged`,而 app 侧 `state.rs:274` 把
`InstanceRuntimeChanged` **就地**写进自己的列表 ⇒ `runtime` 这一位必定分叉,而
`accept::running_pid` 的兜底读的正是它。改:`send()` 同时把运行时态落到 `published`
对应条目,并加用例 `runtime_events_land_in_the_published_snapshot`。

**P2-3**:补真 `CoreHost` + 真 app 的端到端判据(`app/update/instances/scan_phase.rs`):
临时数据根 + 真 host,`advance` 到首份快照落地后点启动,**`advance` 之前**详情页必须已
`can_stop`。变异(把 Busy 挪进启动线程、只有回程才发)实测必红。

判据(新增 3 条 + 1 条钉公式):`revalidate_tests`(根不见了 ⇒ 拒绝)、
`target_tests`(运行时态落快照)、`scan_phase`(真 host 同一拍)、`game::tests`(哈希公式)。
用例夹具按 400 行硬线拆到 `launch/fixtures.rs`。

门禁:`cargo test -p unlucky-presenter -p unlucky-app-iced --all-targets` 全绿
(426 + 1076)· `clippy --workspace --all-targets -- -D warnings` 0 ·
`fmt --all --check` 0 · 行数全 ≤400(最大 366;未碰的 `app/update/instances.rs` 399)。
This commit is contained in:
AskaEth committed 2026-11-13 20:46:00 +08:00
1 parent 583a6cf24b
commit 2fb61d8006
9 files changed
+449 -126

No files matched your search

@@ -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);
}