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:
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);
|
||||
}
|
||||
Reference in new issue
Block a user