fix(launch): M298 收尾失败不再静默、Toast 接上渲染点

三条提示通道各漏一半,用户才会「启动失败但找不到崩溃日志」:

1) 收尾失败静默不弹:finish() 报错那条出口把 LaunchFinished.session 发成真实
   会话,而它登记的现场走 before_start 写死空串 ⇒ dialog.rs 的并发守卫
   (!finished.session.is_empty() && report.session != finished.session)成立后
   return。改成**会话归属单一来源**:before_start 新增可选 SessionAttribution,
   presenter 的 publish_before_start 把它写进报告后**返回同一个值**给 DTO;
   两处不可能再各写一遍。守卫原样保留(A 场的现场不许解释 B 场,有用例钉住)。

2) Toast 只 push 不渲染:新增 app/view/toast.rs 覆盖层(MD3E 胶囊 =
   ShapeScale::Full,底部居中,InverseSurface 底),接在页面树之上、模态之下;
   过期仍走 clock.rs 的 expire;进/出过渡复用模态那一对腿(motion::modal_spec /
   modal_scale / scaled),时间轴在 update 侧由 state/toast.rs 采样,关掉动效时
   恒为终值(出图可复现)。两个拷贝路径补上 view_dirty(推了不置脏要等下一次事件)。

3) 三个贴线文件先拆再改(纯搬移 + pub use 保路径,机器核对:搬出的 14 个函数体
   逐行相同):crash_report.rs 394→318(+context.rs / slot.rs)、
   host/launch.rs 399→233(+build.rs)、app/dialog.rs 390→178(+model.rs)。

证据:DevDocs/artifacts/m298/toast-capsule.png(--screenshot + ULL_TOAST_FIXTURE,
图上可见「游戏崩溃 · 12.0 秒」胶囊)。
This commit is contained in:
AskaEth committed 2026-11-13 13:18:11 +08:00
1 parent fc8f5c6794
commit 97768b7e3f
19 files changed
+1699 -553

No files matched your search

+196 -2
View File
@@ -14,11 +14,13 @@
use std::path::PathBuf;
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
use unlucky_core::launch::crash_report::{
self, ArtifactFact, CrashReport, ReportContext, SessionAttribution,
};
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource};
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use unlucky_presenter::dto::{LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome};
use super::{current, path_rows, CrashCard, DialogRequestView};
@@ -32,6 +34,28 @@ const FAMILIES: Families = Families {
mono: "Consolas",
};
/// 串行闸:`crash_report` 的现场是**进程级单槽**,本文件里凡是 publish 的用例都要拿它。
///
/// 不拿会怎样:A 用例 publish 自己的现场之后、读之前,B 用例插进来 publish 一份 —— A 拿到的是
/// B 的会话,断言随机红(这条守卫是 M298 加重并发用例之后补的)。
fn slot_guard() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
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 {
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
@@ -197,6 +221,7 @@ fn two_failure_shapes_say_two_different_things() {
/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。
#[test]
fn closing_the_dialog_clears_the_card() {
let _slot = slot_guard();
let mut app = headless();
crash_report::publish(report(false, vec!["line".to_string()]));
let finished = LaunchFinished {
@@ -219,6 +244,7 @@ fn closing_the_dialog_clears_the_card() {
/// 成功收尾不打扰用户。
#[test]
fn a_successful_launch_does_not_pop_anything() {
let _slot = slot_guard();
let mut app = headless();
crash_report::publish(report(false, Vec::new()));
let finished = LaunchFinished {
@@ -342,3 +368,171 @@ fn boot_facts_are_recorded_into_the_launcher_log() {
#[path = "tests/export.rs"]
mod export;
// ------------------------------------------------------------------ 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();
}
}