复审 P2-1:进场腿自己续帧所以能跑,但进场结束 view_dirty 回 false,
3.6s 后 remaining_ms 掉进 150ms 退场窗口时没有任何代码重新置脏 ⇒ 那一段
收缩从不发生,expire 一抹胶囊整块消失(复审探针:120 拍后 progress 仍 1.0)。
- update/clock.rs:expire 之后用无副作用的 ToastTracks::needs_frame 置脏,
落到既有的 if view_dirty { refresh_view() } 上采样;refresh_view 仍是门控,
没有变成每帧无条件调用(静止态判据不变)。
- state/toast.rs:进出两条腿改成**同一个时钟**(寿命 = 首次看见时的
remaining_ms,已活 = 寿命 - remaining),去掉 Instant 与节拍的二时钟错位;
新增 needs_frame;新增两条真节拍用例(退场腿必被抽到 / 到期后不再续帧)。
- N1:装配失败与 spawn 失败也用 publish_before_start 的返回值填 session。
- N2:m298 用例注释改准(它抓不住删守卫,那条由 a_report_from_another_session 守)。
- N3:before_start 文档不再把两种来源都说成「进程没起来」。
- N4:refresh_view 末尾的死赋值 view_dirty = false 删掉。
红灯原文(摘掉那 3 行触发点,其余为最终代码):
退场窗口内必须真的开始收(remaining=Some(40)ms, progress=1)
196 lines
7.6 KiB
Rust
196 lines
7.6 KiB
Rust
//! 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 出来(那会让装配失败的现场指向一个不存在的会话);
|
||
/// 也抓得住这条出口被新判据误伤(例如把「session 为空」当成「必须不弹」)。
|
||
///
|
||
/// **抓不住「把并发守卫整条删掉」**——复审的变异 C 实测:删掉守卫后本用例照样绿(它本来就
|
||
/// 走 session 为空的路径)。那条守卫由 `a_report_from_another_session_is_never_reused` 守。
|
||
#[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();
|
||
}
|
||
}
|