feat(m187): 启动失败弹窗 + 诊断包导出,并接通 DialogQueue
用户实测「MC 启动失败找不到崩溃日志」的直接修法:失败时弹一个框,把退出码、 一句人话原因、证据形态、四条真实路径与三个按钮摆在同一个地方。 - app-iced:最小一层模态(遮罩 + 卡片 + 主/次按钮 + Esc/点遮罩关闭), 消费 presenter 的 DialogQueue(此前全仓零引用,所有弹窗都画不出来); 队列管弹窗,CrashCard 管这次失败的事实,按 DialogId 绑定。 - 弹窗区分两种失败:游戏自己写了 crash-reports(贴 Description + 栈片段)/ 游戏没来得及写(明说「这次没有生成崩溃报告」并贴出捕获的最后 30 行)/ 什么都没抓到(给最可能原因分类)。绝不对第二种说「找不到日志」。 - 导出诊断包:core 侧新 crash_bundle 写 zip(清单 + 启动器日志 + 会话 JSONL + crash-reports/* + hs_err + latest.log + 版本 JSON),落盘即脱敏(家目录 / token / 会话 id),单文件 4 MiB、总量 48 MiB 上限并在清单记截断。 - 启动器自身日志落盘(core::launch::launcher_log → <.ULL>/logs/launcher.log, 登记目录前先缓冲),补上「双击启动时诊断行直接蒸发」的缺口。 - i18n:25 条中英文案进 catalog/logs.rs + 金表。 - 取证:ULL_DIALOG_FIXTURE=crash-report|no-logs|export 在临时目录造真文件出图, wgpu 与 tiny-skia 各一套,见 DevDocs/artifacts/m187/。 未复现 M190 的「owned 文字在 wgpu 下丢字」:同一串中文两条文字路径、两种后端逐字相同 (ab-wgpu.png / ab-tinyskia.png),因此本轮未改任何渲染路径。
This commit is contained in:
1 parent
29da7e2e47
commit
414f6ff886
23 files changed
+2818
-10
No files matched your search
@@ -0,0 +1,266 @@
|
||||
//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。
|
||||
//!
|
||||
//! ## 这些用例抓得住什么 / 抓不住什么(说在明处)
|
||||
//!
|
||||
//! | 用例 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`queue_drives_the_modal_through_refresh_view`] | 队列里有请求 ⇒ `refresh_view` 之后 `view()` 拿到的模型里就有它(把消费点删掉即红) | 像素:`view()` 是否真的把控件塞进 iced 树(由 `DevDocs/artifacts/m187/` 的截图守) |
|
||||
//! | [`runtime_chinese_reaches_the_body`] | 运行时拼出来的中文(`render` / `format!` 的 owned 串)确实在正文里 | 字形是否栅格化出来(同上,wgpu / tiny-skia 并排图) |
|
||||
//! | [`two_failure_shapes_say_two_different_things`] | 没有 crash-report 时**不会**说成「有崩溃报告」,而且必须贴出捕获行 | 那句话读起来顺不顺(review) |
|
||||
//!
|
||||
//! 「owned 文字在 wgpu 下丢字」这条 M190 的怀疑在本 branch 上**没有复现**(A/B 图见
|
||||
//! `DevDocs/artifacts/m187/ab-wgpu.png` 与 `ab-tinyskia.png`):同一串中文走 `text_role` 与
|
||||
//! `text_role_owned` 在两种后端下逐字相同。所以这里不编一条「为了变绿而改渲染路径」的断言。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource};
|
||||
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
|
||||
|
||||
use super::{current, path_rows, CrashCard, DialogRequestView};
|
||||
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn headless() -> App {
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
unlucky_design::color::SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
fn report(with_crash_report: bool, tail: Vec<String>) -> CrashReport {
|
||||
let run_dir = PathBuf::from("/instances/survival");
|
||||
let artifacts = if with_crash_report {
|
||||
vec![ArtifactFact {
|
||||
source: "crash_report".to_string(),
|
||||
path: run_dir.join("crash-reports/crash-2026-11-08.txt"),
|
||||
excerpt: Some("Description: Initializing game".to_string()),
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
CrashReport {
|
||||
instance: "survival".to_string(),
|
||||
name: "生存".to_string(),
|
||||
version: "1.20.4-fabric".to_string(),
|
||||
session: "9f2c".to_string(),
|
||||
session_log: Some(PathBuf::from("/sessions/game/9f2c.jsonl")),
|
||||
version_json: Some(PathBuf::from("/game/versions/1.20.4/1.20.4.json")),
|
||||
launcher_logs: Some(PathBuf::from("/logs")),
|
||||
run_dir: Some(run_dir),
|
||||
artifacts,
|
||||
exit_code: Some(1),
|
||||
exit_type: "application_error".to_string(),
|
||||
uptime_ms: 1_000,
|
||||
command: "javaw -Xmx4096m".to_string(),
|
||||
java: "javaw.exe (21)".to_string(),
|
||||
system: "os=windows".to_string(),
|
||||
stage: None,
|
||||
stage_detail: None,
|
||||
stderr_tail: tail,
|
||||
}
|
||||
}
|
||||
|
||||
/// 守卫①:队列消费。请求进队列 ⇒ `refresh_view` 之后模型里有它;空队列 ⇒ 没有。
|
||||
///
|
||||
/// **变异证明**:把 `refresh_view` 末尾那行 `self.dialog_model = self.dialog_modal();` 删掉,
|
||||
/// 本用例第二段立刻红(`dialog_model` 恒 `None`)。
|
||||
#[test]
|
||||
fn queue_drives_the_modal_through_refresh_view() {
|
||||
let mut app = headless();
|
||||
app.refresh_view();
|
||||
assert!(
|
||||
app.dialog_model.is_none(),
|
||||
"空队列不该画任何弹窗(现状:队列零引用,弹窗从不渲染)"
|
||||
);
|
||||
|
||||
let title = app.lang().catalog().text(Key::CrashDialogTitleFailed);
|
||||
app.state.dialogs.push(
|
||||
DialogSource::UserAction,
|
||||
title,
|
||||
"正文一行",
|
||||
DialogKind::Confirm {
|
||||
primary: "主要".to_string(),
|
||||
secondary: "次要".to_string(),
|
||||
},
|
||||
);
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
|
||||
let modal = app.dialog_model.as_ref().expect("队列里有请求就必须有模型");
|
||||
assert_eq!(modal.title, title);
|
||||
assert_eq!(modal.primary, "主要");
|
||||
assert_eq!(modal.secondary.as_deref(), Some("次要"));
|
||||
}
|
||||
|
||||
/// 守卫②:**运行时拼出来的中文**必须真的进正文(owned 串走 `Catalog::render` / `format!`)。
|
||||
///
|
||||
/// **变异证明**:把 `failure_body` 里的 `catalog.render(Key::CrashDialogExitCode, …)` 换成固定
|
||||
/// 英文,本用例第一处断言立刻红。
|
||||
#[test]
|
||||
fn runtime_chinese_reaches_the_body() {
|
||||
let lang = Lang::ZhCn;
|
||||
let card = CrashCard {
|
||||
report: report(false, vec!["java.lang.RuntimeException: boom".to_string()]),
|
||||
expanded: true,
|
||||
dialog: 1,
|
||||
outcome: None,
|
||||
};
|
||||
let mut queue = DialogQueue::new();
|
||||
queue.push(
|
||||
DialogSource::UserAction,
|
||||
"游戏崩溃",
|
||||
"",
|
||||
DialogKind::Confirm {
|
||||
primary: String::new(),
|
||||
secondary: String::new(),
|
||||
},
|
||||
);
|
||||
let request = queue.current().expect("当前条");
|
||||
let view = DialogRequestView::of(request);
|
||||
let modal = current(lang, &view, Some(&card));
|
||||
let body = modal.body_text();
|
||||
|
||||
// 退出码那句是 `render`(带参数)拼出来的:必须带中文与数字。
|
||||
assert!(body.contains("退出码 1"), "{body}");
|
||||
assert!(body.contains("游戏进程起来了"), "{body}");
|
||||
// 没有 crash-report 时必须说「没生成」+「下面是捕获的行」。
|
||||
assert!(body.contains("没有生成崩溃报告"), "{body}");
|
||||
assert!(body.contains("捕获"), "{body}");
|
||||
// 英文界面里同样的三句不许出现中文(反向守卫:i18n 不是只往一个方向写)。
|
||||
let en = current(Lang::En, &view, Some(&card));
|
||||
assert!(!en.body_text().contains('崩'), "{}", en.body_text());
|
||||
}
|
||||
|
||||
/// 守卫③:两种失败**说两句不同的话**,而且都可以导出(事实齐全)。
|
||||
#[test]
|
||||
fn two_failure_shapes_say_two_different_things() {
|
||||
let lang = Lang::ZhCn;
|
||||
let with = report(true, vec!["stack line".to_string()]);
|
||||
let without = report(false, vec!["Error: could not find java.dll".to_string()]);
|
||||
|
||||
assert_ne!(with.evidence(), without.evidence());
|
||||
let with_card = CrashCard {
|
||||
report: with.clone(),
|
||||
expanded: true,
|
||||
dialog: 1,
|
||||
outcome: None,
|
||||
};
|
||||
let without_card = CrashCard {
|
||||
report: without.clone(),
|
||||
expanded: true,
|
||||
dialog: 1,
|
||||
outcome: None,
|
||||
};
|
||||
let mut queue = DialogQueue::new();
|
||||
queue.push(
|
||||
DialogSource::UserAction,
|
||||
"t",
|
||||
"",
|
||||
DialogKind::Error {
|
||||
primary: "p".to_string(),
|
||||
hint: None,
|
||||
},
|
||||
);
|
||||
let request = queue.current().unwrap();
|
||||
let view = DialogRequestView::of(request);
|
||||
|
||||
let crash = current(lang, &view, Some(&with_card));
|
||||
let no_log = current(lang, &view, Some(&without_card));
|
||||
assert_ne!(crash.body_text(), no_log.body_text());
|
||||
assert!(crash.body_text().contains("写出了崩溃报告"));
|
||||
assert!(!no_log.body_text().contains("写出了崩溃报告"));
|
||||
// 没有 crash-report 的那一态仍然有可展开的细节(贴的是捕获行)与四条路径。
|
||||
assert!(no_log.card.as_ref().expect("卡片").detail_title.is_some());
|
||||
assert_eq!(path_rows(&lang.catalog(), &without).len(), 4);
|
||||
assert!(path_rows(&lang.catalog(), &without)
|
||||
.iter()
|
||||
.any(|(_, path)| path.ends_with("latest.log")));
|
||||
}
|
||||
|
||||
/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。
|
||||
#[test]
|
||||
fn closing_the_dialog_clears_the_card() {
|
||||
let mut app = headless();
|
||||
crash_report::publish(report(false, vec!["line".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(), "失败必须弹框");
|
||||
assert!(app.dialog_card.is_some());
|
||||
|
||||
app.close_dialog();
|
||||
assert!(app.state.dialogs.current().is_none());
|
||||
assert!(app.dialog_card.is_none());
|
||||
}
|
||||
|
||||
/// 成功收尾不打扰用户。
|
||||
#[test]
|
||||
fn a_successful_launch_does_not_pop_anything() {
|
||||
let mut app = headless();
|
||||
crash_report::publish(report(false, Vec::new()));
|
||||
let finished = LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: "9f2c".to_string(),
|
||||
outcome: LogOutcome::Ok,
|
||||
exit_code: Some(0),
|
||||
uptime_ms: 90_000,
|
||||
failure: None,
|
||||
};
|
||||
app.open_failure_dialog(&finished);
|
||||
assert!(app.state.dialogs.current().is_none());
|
||||
}
|
||||
|
||||
/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。
|
||||
#[test]
|
||||
fn export_writes_a_bundle_even_without_a_crash_report() {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("logs")).expect("建目录");
|
||||
std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志");
|
||||
|
||||
let mut spec_report = report(false, vec!["Error: boom".to_string()]);
|
||||
spec_report.launcher_logs = Some(root.join("logs"));
|
||||
spec_report.session_log = None;
|
||||
spec_report.run_dir = None;
|
||||
spec_report.version_json = None;
|
||||
|
||||
let text = super::export::export(&spec_report, Lang::ZhCn);
|
||||
assert!(text.contains("已导出"), "{text}");
|
||||
assert!(text.contains("字节"), "{text}");
|
||||
|
||||
let exports = root.join("logs").join("exports");
|
||||
let files: Vec<_> = std::fs::read_dir(&exports)
|
||||
.expect("导出目录")
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
assert_eq!(files.len(), 1, "一次导出只该落一个包");
|
||||
let size = files[0].metadata().expect("元数据").len();
|
||||
assert!(size > 0, "包不该是空文件");
|
||||
assert!(
|
||||
text.contains(&size.to_string()),
|
||||
"报出来的字节数要与磁盘一致:{text} / {size}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user