test(launch): M298 用例搬到 dialog/tests/m298.rs(守 400 行单文件门禁)

This commit is contained in:
AskaEth committed 2026-11-13 13:21:24 +08:00
1 parent 97768b7e3f
commit aa25583806
2 files changed
+196 -185

No files matched your search

+4 -185
View File
@@ -14,13 +14,11 @@
use std::path::PathBuf; use std::path::PathBuf;
use unlucky_core::launch::crash_report::{ use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
self, ArtifactFact, CrashReport, ReportContext, SessionAttribution,
};
use unlucky_i18n::catalog::Key; use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang; use unlucky_i18n::Lang;
use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource}; use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource};
use unlucky_presenter::dto::{LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome}; use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use super::{current, path_rows, CrashCard, DialogRequestView}; use super::{current, path_rows, CrashCard, DialogRequestView};
@@ -43,19 +41,6 @@ fn slot_guard() -> std::sync::MutexGuard<'static, ()> {
LOCK.lock().unwrap_or_else(|error| error.into_inner()) LOCK.lock().unwrap_or_else(|error| error.into_inner())
} }
/// 一次启动的现场上下文(与 presenter 里那份同形:实例 / 运行目录 / 日志目录)。
fn context() -> ReportContext {
ReportContext::new(
"survival",
"生存",
"1.20.4",
std::path::Path::new("/instances/survival"),
std::path::Path::new("/logs"),
std::time::SystemTime::now(),
"os=windows",
)
}
fn headless() -> App { fn headless() -> App {
use unlucky_presenter::mock::MockBackend; use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge; use unlucky_presenter::settings_bridge::SettingsBridge;
@@ -368,171 +353,5 @@ fn boot_facts_are_recorded_into_the_launcher_log() {
#[path = "tests/export.rs"] #[path = "tests/export.rs"]
mod export; mod export;
#[path = "tests/m298.rs"]
// ------------------------------------------------------------------ M298:收尾失败必须弹 mod m298;
/// **真收尾失败必须弹出**(M298 修的那条静默漏弹)。
///
/// 出口形状取自 `crates/presenter/src/host/launch.rs` 的 `finish()` 失败分支,逐字段对齐:
/// 进程**起来过**(有会话)、`finish()` 报错 ⇒ `outcome = SilentFail` + `failure = Some(Spawn)`。
/// 现场由 core 的 `CrashReport::before_start` 登记,会话归因 = 收尾事件里那个会话。
///
/// ⚠️ 这一条出口**拿不到退出码**:`finish()` 只在「等进程出错」或「看门狗放弃等待」时失败,
/// 两种都没有 `ExitStatus`(`pending_ms` 那条路径进程还在跑)。所以「exit_code 有值」在本出口上
/// 不成立,判据改用「报告带会话 + 进程起来过」;带退出码的那条出口见下一条用例。
///
/// **变异证明**:把 `CrashReport::before_start` 的 `session` 改回写死空串(本 mission 之前的
/// 写法),`open_failure_dialog` 的并发守卫成立并 `return` ⇒ 本用例第一段立刻红。
#[test]
fn a_finish_failure_pops_the_dialog_with_its_own_session() {
let _slot = slot_guard();
let mut app = headless();
let log = PathBuf::from("/sessions/game/9f2c.jsonl");
crash_report::publish(CrashReport::before_start(
&context(),
"spawn",
"会话写入器不可用(锁中毒)",
"spawn",
Some(SessionAttribution {
id: "9f2c",
log: Some(log.as_path()),
}),
));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::SilentFail,
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(
LaunchFailureStage::Spawn,
"会话写入器不可用(锁中毒)",
)),
};
app.open_failure_dialog(&finished);
assert!(
app.state.dialogs.current().is_some(),
"真收尾失败必须弹框——用户原话是「找半天没找到崩溃日志」"
);
let card = app.dialog_card.as_ref().expect("卡片");
assert_eq!(
card.report.session, finished.session,
"弹的是**这一场**的现场"
);
// 现场带着会话 ⇒ 事件流路径必须出现在可复制的路径行里(不是「进程没起来」那一套)。
let modal = app.dialog_modal().expect("模型");
let paths = modal.card.as_ref().expect("卡片视图").paths.clone();
assert!(
paths.iter().any(|(_, path)| path.ends_with("9f2c.jsonl")),
"会话 JSONL 必须可复制:{paths:?}"
);
crash_report::clear();
}
/// 带**退出码**的那条出口(真崩溃)照旧要弹:报告的 `session` 与收尾事件同源。
///
/// **变异证明**:把 `CrashReport::collect` 里的 `session: session.session_id.clone()` 改成空串,
/// 本用例红(并发守卫把"自己这一场"判成别人的)。
#[test]
fn a_crashed_launch_with_an_exit_code_pops_with_its_evidence() {
let _slot = slot_guard();
let mut app = headless();
crash_report::publish(report(true, vec!["Exception in thread main".to_string()]));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 1_000,
failure: None,
};
app.open_failure_dialog(&finished);
assert!(app.state.dialogs.current().is_some(), "真崩溃必须弹框");
let modal = app.dialog_modal().expect("模型");
assert!(
modal.body_text().contains("退出码 1"),
"正文必须带退出码:{}",
modal.body_text()
);
crash_report::clear();
}
/// **并发守卫的原意不许被松掉**:登记的是另一场 ⇒ 不许拿它解释这一场。
///
/// 这条与上一条是一对:修「自己这一场被误判」不能靠删守卫(删了这条就红)。
#[test]
fn a_report_from_another_session_is_never_reused() {
let _slot = slot_guard();
let mut app = headless();
crash_report::publish(CrashReport::before_start(
&context(),
"spawn",
"另一场的现场",
"spawn",
Some(SessionAttribution {
id: "aaaa",
log: None,
}),
));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::SilentFail,
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(LaunchFailureStage::Spawn, "x")),
};
app.open_failure_dialog(&finished);
assert!(
app.state.dialogs.current().is_none(),
"A 场的现场不许拿来解释 B 场"
);
crash_report::clear();
}
/// 没有会话的两个出口(装配失败 / spawn 失败)**照旧要弹**:那里空串是事实,守卫不该拦它。
///
/// 抓得住:为修「收尾失败被误判」而把守卫整条删掉 / 或把空串也编一个 id 出来(后者会让
/// 装配失败的现场指向一个不存在的会话)。
#[test]
fn a_session_less_failure_still_pops() {
let _slot = slot_guard();
for (stage, detail) in [
(LaunchFailureStage::VersionManifest, "缺 1.20.4.json"),
(LaunchFailureStage::Spawn, "javaw.exe 不存在"),
] {
let mut app = headless();
crash_report::publish(CrashReport::before_start(
&context(),
stage.slug(),
detail,
stage.slug(),
None,
));
let finished = LaunchFinished {
id: "survival".to_string(),
session: String::new(),
outcome: LogOutcome::Crashed,
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(stage, detail)),
};
app.open_failure_dialog(&finished);
assert!(
app.state.dialogs.current().is_some(),
"{stage:?} 出口必须弹框(这条出口的 session 为空是事实)"
);
let card = app.dialog_card.as_ref().expect("卡片");
assert!(card.report.session.is_empty(), "{stage:?}");
assert_eq!(
card.report.exit_code, None,
"{stage:?}: 没有退出码就是 None"
);
assert_eq!(
card.report.stage.as_deref(),
Some(stage.slug()),
"{stage:?}"
);
crash_report::clear();
}
}
@@ -0,0 +1,192 @@
//! M298 的收尾失败 / 并发守卫用例(从 `dialog/tests.rs` 拆出:那个文件贴 400 行硬线)。
//!
//! 共用父模块的夹具:`headless()`(注入式 `App`)、`report()`(一份现场)、`slot_guard()`
//! (`crash_report` 的现场是进程级单槽,凡 publish 的用例都要拿它)。
use std::path::PathBuf;
use unlucky_core::launch::crash_report::{self, CrashReport, ReportContext, SessionAttribution};
use unlucky_presenter::dto::{LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome};
use super::{headless, report, slot_guard};
/// 一次启动的现场上下文(与 presenter 里那份同形:实例 / 运行目录 / 日志目录)。
fn context() -> ReportContext {
ReportContext::new(
"survival",
"生存",
"1.20.4",
std::path::Path::new("/instances/survival"),
std::path::Path::new("/logs"),
std::time::SystemTime::now(),
"os=windows",
)
}
// ------------------------------------------------------------------ M298:收尾失败必须弹
/// **真收尾失败必须弹出**(M298 修的那条静默漏弹)。
///
/// 出口形状取自 `crates/presenter/src/host/launch.rs` 的 `finish()` 失败分支,逐字段对齐:
/// 进程**起来过**(有会话)、`finish()` 报错 ⇒ `outcome = SilentFail` + `failure = Some(Spawn)`。
/// 现场由 core 的 `CrashReport::before_start` 登记,会话归因 = 收尾事件里那个会话。
///
/// ⚠️ 这一条出口**拿不到退出码**:`finish()` 只在「等进程出错」或「看门狗放弃等待」时失败,
/// 两种都没有 `ExitStatus`(`pending_ms` 那条路径进程还在跑)。所以「exit_code 有值」在本出口上
/// 不成立,判据改用「报告带会话 + 进程起来过」;带退出码的那条出口见下一条用例。
///
/// **变异证明**:把 `CrashReport::before_start` 的 `session` 改回写死空串(本 mission 之前的
/// 写法),`open_failure_dialog` 的并发守卫成立并 `return` ⇒ 本用例第一段立刻红。
#[test]
fn a_finish_failure_pops_the_dialog_with_its_own_session() {
let _slot = slot_guard();
let mut app = headless();
let log = PathBuf::from("/sessions/game/9f2c.jsonl");
crash_report::publish(CrashReport::before_start(
&context(),
"spawn",
"会话写入器不可用(锁中毒)",
"spawn",
Some(SessionAttribution {
id: "9f2c",
log: Some(log.as_path()),
}),
));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::SilentFail,
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(
LaunchFailureStage::Spawn,
"会话写入器不可用(锁中毒)",
)),
};
app.open_failure_dialog(&finished);
assert!(
app.state.dialogs.current().is_some(),
"真收尾失败必须弹框——用户原话是「找半天没找到崩溃日志」"
);
let card = app.dialog_card.as_ref().expect("卡片");
assert_eq!(
card.report.session, finished.session,
"弹的是**这一场**的现场"
);
// 现场带着会话 ⇒ 事件流路径必须出现在可复制的路径行里(不是「进程没起来」那一套)。
let modal = app.dialog_modal().expect("模型");
let paths = modal.card.as_ref().expect("卡片视图").paths.clone();
assert!(
paths.iter().any(|(_, path)| path.ends_with("9f2c.jsonl")),
"会话 JSONL 必须可复制:{paths:?}"
);
crash_report::clear();
}
/// 带**退出码**的那条出口(真崩溃)照旧要弹:报告的 `session` 与收尾事件同源。
///
/// **变异证明**:把 `CrashReport::collect` 里的 `session: session.session_id.clone()` 改成空串,
/// 本用例红(并发守卫把"自己这一场"判成别人的)。
#[test]
fn a_crashed_launch_with_an_exit_code_pops_with_its_evidence() {
let _slot = slot_guard();
let mut app = headless();
crash_report::publish(report(true, vec!["Exception in thread main".to_string()]));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 1_000,
failure: None,
};
app.open_failure_dialog(&finished);
assert!(app.state.dialogs.current().is_some(), "真崩溃必须弹框");
let modal = app.dialog_modal().expect("模型");
assert!(
modal.body_text().contains("退出码 1"),
"正文必须带退出码:{}",
modal.body_text()
);
crash_report::clear();
}
/// **并发守卫的原意不许被松掉**:登记的是另一场 ⇒ 不许拿它解释这一场。
///
/// 这条与上一条是一对:修「自己这一场被误判」不能靠删守卫(删了这条就红)。
#[test]
fn a_report_from_another_session_is_never_reused() {
let _slot = slot_guard();
let mut app = headless();
crash_report::publish(CrashReport::before_start(
&context(),
"spawn",
"另一场的现场",
"spawn",
Some(SessionAttribution {
id: "aaaa",
log: None,
}),
));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::SilentFail,
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(LaunchFailureStage::Spawn, "x")),
};
app.open_failure_dialog(&finished);
assert!(
app.state.dialogs.current().is_none(),
"A 场的现场不许拿来解释 B 场"
);
crash_report::clear();
}
/// 没有会话的两个出口(装配失败 / spawn 失败)**照旧要弹**:那里空串是事实,守卫不该拦它。
///
/// 抓得住:为修「收尾失败被误判」而把守卫整条删掉 / 或把空串也编一个 id 出来(后者会让
/// 装配失败的现场指向一个不存在的会话)。
#[test]
fn a_session_less_failure_still_pops() {
let _slot = slot_guard();
for (stage, detail) in [
(LaunchFailureStage::VersionManifest, "缺 1.20.4.json"),
(LaunchFailureStage::Spawn, "javaw.exe 不存在"),
] {
let mut app = headless();
crash_report::publish(CrashReport::before_start(
&context(),
stage.slug(),
detail,
stage.slug(),
None,
));
let finished = LaunchFinished {
id: "survival".to_string(),
session: String::new(),
outcome: LogOutcome::Crashed,
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(stage, detail)),
};
app.open_failure_dialog(&finished);
assert!(
app.state.dialogs.current().is_some(),
"{stage:?} 出口必须弹框(这条出口的 session 为空是事实)"
);
let card = app.dialog_card.as_ref().expect("卡片");
assert!(card.report.session.is_empty(), "{stage:?}");
assert_eq!(
card.report.exit_code, None,
"{stage:?}: 没有退出码就是 None"
);
assert_eq!(
card.report.stage.as_deref(),
Some(stage.slug()),
"{stage:?}"
);
crash_report::clear();
}
}