Files
Unlucky-Launcher/crates/presenter/src/host/game/refresh_tests.rs
T
AskaEth f03411d58b fix(instances): M302 r1 P2 —— 冷启动首帧三态(占位 / 真空态 / 有实例)+ N1~N4
复审 P2-1:扫盘搬后台之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、
不 `tick`),**首帧必然**在快照之前。改前那一帧画的是「计数 0 + 还没有实例 + 实心导入 CTA」
——一句假话,而且 CTA 能点。改后三态分开:

- 真源放 presenter:`dto::InstancePhase { Scanning, Stalled, Ready }`,`AppState::instances_phase`
  在 `CoreEvent::InstancesChanged` 落地时置 `Ready`;**不从 `instances.is_empty()` 推**(那就是 bug 形态)。
- `Scanning` = 首个快照未到:详情列画「正在扫描实例…」占位(新 key `InstancesScanning`,golden 两语),
  列头**不写计数**(藏起来,不写 0 也不写「—」),**没有**空态 CTA。
- `Stalled` = 有界兜底:`host::game::SCAN_STALL = 15 s`(= 仓库内最坏实测 1.35 s 的约 11 倍,
  理由在常量文档里)。超时只**报**一次、不掐线程;迟到的结果照样落地(代次没变)。
  那一屏给一枚「重新扫描」→ `InstanceMessage::RetryScan` → 新命令 `AppCommand::RefreshInstances`
  (只重扫实例目录,不重拉版本清单)。那一支在 `selected_instance_id()` 守卫**之前**——冷启动没有选中项。
- `Ready` = 收到过快照(空列表也算):这时才轮到 M273 的两种空态(没选中 / 一个都没有)。
- 已就绪时**不发** `Scanning`:否则每次刷新列表都退回占位(把卡顿换成闪屏)。

判据(都能红,变异原文见报告):
- `pages/instances/tests/cold_boot.rs`:真控件树(未就绪 7 枚按钮 = 有实例那一屏,零实例 = 8 枚,
  多的那枚就是 CTA;每行 2 条文字节点的差分数行数)+ 源码结构(`Scanning`/`Stalled` 两支不碰
  `detail_empty` 与空态文案、超时那支接 `RetryScan`、三个 key 真的被读)。
- `app/update/instances/scan_phase.rs`:首帧 = `Scanning`、空快照 = `Ready`、`Stalled` 经重试回到
  `Scanning`、重试只派 `RefreshInstances`。夹具 `ULL_INSTANCE_PHASE` 只给出图(与 `ULL_SAVES_FIXTURE` 同一条纪律)。

顺手收 N1~N4(全是措辞 / 观测口径,不动行为):
- N1 两处 docstring 不再声称「`admit` 写成 `>=` 会红」(实测等价),改成「去掉或恒真才红」;
- N2 收尾用例那条 Idle 断言的失败消息改准(它不区分新旧快照,区分靠条数/代次断言);
- N3 boot 双刷新必然丢 1 份 ⇒ 丢弃日志分成「此前没有快照落地(启动期,非交错)」与「已有落地(真交错)」,
  `Scan::dropped` 的文档登记这条已知噪音;
- N4 app 用例 docstring 收窄成「一拍无事件不清表、不钉时钟」(它绕过 `App::dispatch`,抓不住那 5 行)。

拆文件守 400 行:`detail/scan.rs`(分派 + 占位)、`refresh_stall_tests.rs`(空根 / 超时)、
`app/update/instances/scan_phase.rs`(app 级三态用例);`i18n` 的 `PAGE_SOURCES` 同步加上新页面文件。
2026-11-13 19:00:30 +08:00

399 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! M302 的判据:**扫盘搬后台线程之后,谁的结果说了算**。
//!
//! ## 为什么这组用例必须在 `game/` 下
//!
//! 本 mission 的 scope 只开到 `host/mod.rs`、`host/game.rs`、`host/game/**`、`host/job.rs`、
//! `host/dispatch.rs`、`host/handle.rs`(外加 `launch_state_tests.rs`),所以「host 级刷新编排」
//! 的判据落在 `game/refresh.rs` 的子模块里——判据对象是 host,不是 `game::instances` 的盘面判据
//! (后者由 `game/tests*` 那几份守着,本条**一个字节都没动**)。
//!
//! ## 三条交错(任务原文)
//!
//! | 交错 | 用例 | 抓得住 |
//! |---|---|---|
//! | 并发两次刷新 | [`two_refreshes_in_flight_publish_only_the_newest`] | 两份结果里只有最新那份能被采纳 |
//! | 盘面在两轮之间变了 | [`an_older_scan_cannot_resurrect_what_a_newer_one_saw_deleted`] | 「删掉的实例又回来了」 |
//! | 启动收尾触发的第三次刷新 | [`a_launch_end_refresh_beats_the_two_that_were_in_flight`] | 收尾后列表不许被扫描期间的旧结果盖回「运行中」 |
//!
//! 三份都是**真构造**:真临时数据根、真 `profiles.json` 写入、真后台线程跑真 `game::instances`。
//! 只有**交付顺序**是人为摆的(把 `rx` 里的任务按住、按想验的顺序喂给 host)——那正是要验的「先发后到」。
//!
//! 每条用例的「抓不住」写在各自文档里;共同的一条:**不量真实耗时**(进程内可能只有几百微秒),
//! 也不证明用户能感知的快慢——那要真盘上的 236 ms / 1.35 s 场景(M297 的实测口径)。
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, InstanceRuntime, InstanceSummary, LaunchFinished, LogOutcome,
};
use crate::host::{CoreHost, HostEnv, HostTask, LiveLaunch};
use super::Scan;
/// 取任务的等待上限(真线程 + 临时根,毫秒级就回来;超时说明真的坏了)
const WAIT: Duration = Duration::from_secs(5);
// ------------------------------------------------------------------ 夹具
/// 临时数据根守卫:测试结束(含断言 panic)时删掉目录。
pub(super) struct TempRoot(std::path::PathBuf);
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 建一个空数据根(`HostEnv::at_root` ⇒ 不做多根发现,盘面只有我们写的东西)。
fn root(label: &str) -> (HostEnv, TempRoot) {
let dir = std::env::temp_dir().join(format!(
"ull-m302-{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 目录");
(env, TempRoot(dir))
}
/// 把盘上的档案**写成** `ids` 这几条(真读写的唯一真相源是 `profiles.json`),返回它们。
fn seed(env: &HostEnv, ids: &[String]) {
let profiles: Vec<InstanceProfile> = ids
.iter()
.enumerate()
.map(|(index, id)| InstanceProfile {
id: ProfileId::new(id),
name: format!("demo-{index}"),
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(),
})
.collect();
write_profiles(
&env.layout,
&ProfileList {
profiles,
..ProfileList::default()
},
)
.expect("写 profiles.json");
}
/// `count` 条新档案的 id(落盘用 [`seed`])。
fn ids(count: usize) -> Vec<String> {
(0..count)
.map(|_| ProfileId::generate().as_str().to_string())
.collect()
}
/// 建好 `count` 条档案的 host(headless:不跑 `refresh_all`,不起版本清单网络线程)。
pub(super) fn host_with(label: &str, count: usize) -> (CoreHost, HostEnv, Vec<String>, TempRoot) {
let (env, guard) = root(label);
let ids = ids(count);
seed(&env, &ids);
(CoreHost::headless(env.clone()), env, ids, guard)
}
// ------------------------------------------------------------------ 取件
/// 从通道里等下一份扫盘任务(**不经过 `tick`**:这里要自己按住顺序)。
pub(super) fn next_scan(host: &CoreHost) -> HostTask {
host.rx.recv_timeout(WAIT).expect("扫盘任务要在 5 s 内回来")
}
/// 任务的代次。
fn epoch_of(task: &HostTask) -> u64 {
match task {
HostTask::Instances { epoch, .. } => *epoch,
other => panic!("这不是实例扫描任务:{other:?}"),
}
}
/// 一份任务里的实例 id(排好序,方便比集合)。
fn ids_of(task: &HostTask) -> Vec<String> {
match task {
HostTask::Instances { instances, .. } => {
let mut ids: Vec<String> = instances.iter().map(|item| item.id.clone()).collect();
ids.sort();
ids
}
other => panic!("这不是实例扫描任务:{other:?}"),
}
}
/// 事件队列里所有 `InstancesChanged` 快照(按先后)。
pub(super) fn snapshots(events: Vec<AppEvent>) -> Vec<Vec<InstanceSummary>> {
events
.into_iter()
.filter_map(|event| match event {
AppEvent::Core(CoreEvent::InstancesChanged(list)) => Some(list),
_ => None,
})
.collect()
}
/// 快照里的实例 id(排好序)。
fn sorted_ids(snapshot: &[InstanceSummary]) -> Vec<String> {
let mut ids: Vec<String> = snapshot.iter().map(|item| item.id.clone()).collect();
ids.sort();
ids
}
// ------------------------------------------------------------------ 判据 ①:真的不在 UI 线程
/// **把 `job::spawn` 改回 inline 就红**:`refresh_instances()` 返回时,队列里不该已经有快照。
///
/// 抓得住:谁把 `refresh_instances` 改回「同步扫 + 同步发事件」(M302 之前的样子),
/// 这一条立刻红——那时 `drain_events()` 会拿到快照;也抓得住「干脆不扫了」(后半段要求它照样会到)。
/// 抓不住:**具体**跑在哪条线程(没有可移植的查询口);这里断言的是「返回即完成」这件事不成立,
/// 而那正是「扫盘跑在调用方线程(= UI 线程)」的唯一可观测指纹。
#[test]
fn a_refresh_returns_before_the_scan_is_done() {
let (mut host, _env, _ids, _guard) = host_with("inline", 1);
let _ = host.drain_events();
host.refresh_instances();
assert!(
snapshots(host.drain_events()).is_empty(),
"刷新返回时快照已经到了 ⇒ 扫盘又跑回调用方线程了(M302 要的就是它不在那儿)"
);
// 结果照样会到(不是把功能删掉换成「什么都不发」)
host.pump_instances();
assert_eq!(
snapshots(host.drain_events()).len(),
1,
"等一代之后必须有且只有一份快照"
);
}
// ------------------------------------------------------------------ 判据 ②-1:并发两次刷新
/// **并发两次刷新**:两份结果里只有**最新代次**那份能被采纳,先发的那份后到也不许盖。
///
/// 抓得住:去掉代次判据(`admit` 恒真)时这条红——旧代次的那份会照发出第二条快照,
/// 而 `CoreEvent::InstancesChanged` 是**整体替换**,界面就被过期盘面盖掉了。
/// 抓不住:两次扫描之间盘面若完全一样,两份内容无法区分——所以这条另配
/// [`an_older_scan_cannot_resurrect_what_a_newer_one_saw_deleted`] 用内容判别。
#[test]
fn two_refreshes_in_flight_publish_only_the_newest() {
let (mut host, _env, _ids, _guard) = host_with("concurrent", 2);
let _ = host.drain_events();
host.refresh_instances();
let first_epoch = host.instances_scan.epoch();
host.refresh_instances();
let second_epoch = host.instances_scan.epoch();
assert!(second_epoch > first_epoch, "刷新必须换代次");
// 两条真线程都真扫完了
let a = next_scan(&host);
let b = next_scan(&host);
// 交付顺序摆成「旧代次后到」——这就是要防的那种交错
let (stale, fresh) = if epoch_of(&a) < epoch_of(&b) {
(a, b)
} else {
(b, a)
};
assert_eq!(epoch_of(&stale), first_epoch);
assert_eq!(epoch_of(&fresh), second_epoch);
host.handle(fresh);
host.handle(stale);
let published = snapshots(host.drain_events());
assert_eq!(
published.len(),
1,
"两份结果只许落地一条快照(实际 {} 条):多了说明旧代次那份也发出去了",
published.len()
);
assert_eq!(published[0].len(), 2, "盘上就是两条");
assert_eq!(
host.instances_scan.adopted().map(|adopted| adopted.epoch),
Some(second_epoch),
"落地的那份必须是当代次"
);
assert_eq!(
host.instances_scan.dropped(),
1,
"旧代次那份要被记成丢弃(可观测口径:> 0 就证明「先发后到」真发生了)"
);
}
// ------------------------------------------------------------------ 判据 ②-2:扫描途中删实例
/// **扫描途中有一个实例被删**:后到的那份旧扫描里还有它,绝不许把它复活。
///
/// 现场就是最坏的一种:刷新 #1 扫到 3 条 → 用户(或别的启动器)删掉第 3 条 → 刷新 #2 扫到 2 条
/// → #2 先到、#1 后到。没有代次判据时,`InstancesChanged` 的整体替换会把被删的磁贴**放回来**
/// (点进去必然报错,用户以为删失败了)。
///
/// 抓得住:把 `admit` 去掉(或改成恒真)——这条红,因为旧代次那份会被采纳、把被删的磁贴放回来;
/// 把丢掉旧代次那一步做成「发一条空快照」也红(那正是任务里禁止的「清空再重填」)。
/// **不**抓:把 `admit` 的判据从 `==` 改成 `>=` —— 代次只增不减、在飞任务不可能带着**未来**代次,
/// 两种写法行为等价(复审 r1 实测变异③全绿,这条 docstring 原先声称会红,是错的)。
/// 抓不住:刷新 #1 与删除动作**同时**发生的窄窗口——本用例把两份扫描都等回来了再摆交付顺序,
/// 所以它验的是「顺序」,不是「磁盘并发」。
#[test]
fn an_older_scan_cannot_resurrect_what_a_newer_one_saw_deleted() {
let (mut host, env, ids, _guard) = host_with("delete", 3);
let _ = host.drain_events();
let (keep, doomed) = ids.split_at(2);
let doomed = doomed[0].clone();
// #1:盘上 3 条
host.refresh_instances();
let older = next_scan(&host);
assert_eq!(ids_of(&older).len(), 3, "前提:#1 扫到了 3 条");
// 扫描途中删掉一条(真写 profiles.json,与用户删实例是同一条落盘路径)
seed(&env, keep);
// #2:盘上 2 条
host.refresh_instances();
let newer = next_scan(&host);
assert_eq!(ids_of(&newer).len(), 2, "前提:#2 扫到了 2 条");
// 新快照先到
host.handle(newer);
let published = snapshots(host.drain_events());
assert_eq!(published.len(), 1);
assert_eq!(
sorted_ids(&published[0]),
{
let mut expected = keep.to_vec();
expected.sort();
expected
},
"新扫描的盘面就是剩下两条"
);
// 旧的那份后到:**一条事件都不许出**(不然被删的磁贴又回来了)
host.handle(older);
assert!(
host.drain_events().is_empty(),
"旧代次的快照不许落地,也不许发空快照(那只是把卡顿换成白屏)"
);
assert_eq!(host.instances_scan.dropped(), 1);
assert!(!doomed.is_empty(), "被删的实例 id 不该是空串");
}
// ------------------------------------------------------------------ 判据 ②-3:收尾触发的第三次刷新
/// **启动收尾触发的第三次刷新**要压住前两次在飞的扫描。
///
/// 现场:连点两次「刷新」→ 扫描在飞 → 游戏退出(`LaunchEnd`)又触发一次刷新。前两次扫的时候
/// 那个实例还在跑,它们的结果若后到,盖回运行时态虽然仍是 `Idle`(盖的时候读的是 host 当前
/// 状态),但**盘面/最后游玩时间**是旧的——而且 `InstancesChanged` 整体替换,界面会被过期快照
/// 顶一下。
///
/// 抓得住:去掉代次判据时这条会落地三条快照(实际断言 1 条);也抓得住「收尾那次刷新根本没发」
/// (`adopted().epoch` 不是第三次)。
/// 抓不住:真实游戏进程的收尾时序——这里喂的是 `HostTask::LaunchEnd`(host 的唯一收尾入口,
/// 与 `launch` 线程回报的形状一致),进程真的起来/退出由 `launch_state_tests` 与真机覆盖。
#[test]
fn a_launch_end_refresh_beats_the_two_that_were_in_flight() {
let (mut host, _env, ids, _guard) = host_with("launch-end", 2);
let id = ids[0].clone();
let _ = host.drain_events();
// 两份在飞的扫描(当时这个实例还在跑)
host.launches.push(LiveLaunch {
instance: id.clone(),
session: "20261113T120000-m302a".to_string(),
pid: 4_242,
});
host.refresh_instances();
let flying_1 = next_scan(&host);
host.refresh_instances();
let flying_2 = next_scan(&host);
// 收尾:清启动表 + 触发第三次刷新
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
id: id.clone(),
session: "20261113T120000-m302a".to_string(),
outcome: LogOutcome::Ok,
exit_code: Some(0),
uptime_ms: 2_000,
failure: None,
})));
let third_epoch = host.instances_scan.epoch();
let third = next_scan(&host);
assert_eq!(epoch_of(&third), third_epoch, "收尾必须触发一次新刷新");
// 先交付收尾那份,再把两份旧的补上
host.handle(third);
host.handle(flying_1);
host.handle(flying_2);
let published = snapshots(host.drain_events());
assert_eq!(
published.len(),
1,
"只许收尾那次落地(实际 {} 条):多出来的是扫描期间就已经过期的盘面",
published.len()
);
let tile = published[0]
.iter()
.find(|item| item.id == id)
.expect("实例还在盘上");
assert_eq!(
tile.runtime,
InstanceRuntime::Idle,
"收尾之后磁贴必须是 Idle:运行态是**落地那刻**按 host 当前表盖的,收尾已清表,\
所以哪怕是过期快照落地也会是 Idle —— 这条断言只验「盖的结果对」,\
新旧之分靠上面的条数与代次断言(`published.len() == 1` / `adopted.epoch` / `dropped`)"
);
assert_eq!(
host.instances_scan.adopted().map(|adopted| adopted.epoch),
Some(third_epoch)
);
assert_eq!(host.instances_scan.dropped(), 2, "前两份都要记成丢弃");
assert!(!host.in_flight(&id), "收尾之后该实例不该还在飞行态");
}
// ------------------------------------------------------------------ 代次账(纯单元)
/// `Scan` 的账目:换代 → 只认当代次 → 旧代次计数 → 记账。
///
/// 抓得住:把 `admit` 去掉(或改成恒真)、把丢弃计数漏掉、把 `adopt` 记到别的代次上。
/// **不**抓:把 `==` 写成 `>=`(代次只增不减 ⇒ 两种写法等价,复审 r1 变异③实测全绿)。
/// 抓不住:真实交错的时序(那是上面三条用例的事)。
#[test]
fn the_ledger_only_admits_the_current_epoch() {
let mut scan = Scan::default();
assert_eq!(scan.epoch(), 0, "初值 = 还没刷新过");
assert!(scan.adopted().is_none());
let first = scan.begin();
assert_eq!(first, 1);
assert!(scan.admit(first), "当代次必须放行");
scan.adopt(first, 3, 236);
let adopted = scan.adopted().expect("刚记的账");
assert_eq!(
(adopted.epoch, adopted.count, adopted.elapsed_ms),
(1, 3, 236)
);
let second = scan.begin();
assert_eq!(second, 2);
assert!(!scan.admit(first), "旧代次必须丢");
assert_eq!(scan.dropped(), 1);
assert_eq!(
scan.adopted().map(|adopted| adopted.epoch),
Some(1),
"丢掉的旧结果不该改写账本"
);
assert!(scan.admit(second));
}