Files
Unlucky-Launcher/crates/app-iced/src/app/dialog/tests/m298.rs
T
AskaEth 3b4f8bb997 fix(notify): M298 r1 —— toast 退场腿接上触发点,四条 nit 一并收
复审 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)
2026-11-13 13:50:41 +08:00

196 lines
7.6 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.
//! 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();
}
}