P1(阻断):遮罩从 Role::OnSurface@32% 换成组件层的不透明预合成 (feedback::dialog::scrim,黑@0.32 叠画布)。深色档此前是「近白@32%」把整窗提亮, 实测 (9,21,28)→(129,138,145);现在 (6,14,19)(= 组件文档预测值),且两后端逐字节相同。 顺带按纪律改用 Component::Dialog + DialogGeometry(半径 28 / 宽 560 / 内距 24), 两后端弹窗图差异从 66% 降到 2.65%(浅)/ 2.76%(深),余量全是文字抗锯齿。 P2-1:卡片那层的命中区改回卡片本身(opaque 只包卡片)。此前 mouse_area 包的是整窗居中 容器 ⇒ 点卡片外先被 Swallow 捕获,遮罩永远收不到,「点遮罩关闭」只在注释里成立。 守卫:断言卡片层的 size_hint 任一维都不是 Fill。 P2-2:条目名与清单**同源**(BundleSpec::entry_names),会话 JSONL 钉成 session/session.jsonl。 此前条目名用原始文件名(会话 id 原样进包)而 manifest 过了 redactor ⇒ 清单指向的条目不存在。 (上一轮报告把这条写反了,本轮以 unzip -l 实输出为准。) P2-3:截断改「头 + 尾」(MAX_ENTRY_BYTES 一半一半,中间插 <truncated N bytes>), 并在弹窗补一句「有 N 个文件太大,已截断」。此前只留头部 ⇒ 长会话 JSONL 的 exit 事件丢失。 P2-4:app 启动那一拍(主题/语言/路由/窗口/实例数/msa 行)真的写进 launcher_log; 目录仍由 presenter 在首次启动时登记,所以这些行先进 core 缓冲、登记时一次刷出。 P3-1/P3-2:关闭与 Esc 立即重画(否则空闲 1Hz 下弹窗再留 1 秒);导出成败按 write_bundle 真实结果判(不再看 launcher_logs 有没有值)。 用例:新增 5 条(深色遮罩方向、卡片层命中区、条目名与清单一致、截断保尾、启动日志落点), 每条都给了变异证明(只改实现侧,均已还原)。
373 lines
15 KiB
Rust
373 lines
15 KiB
Rust
//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。
|
||
//!
|
||
//! ## 这些用例抓得住什么 / 抓不住什么(说在明处)
|
||
//!
|
||
//! | 用例 | 抓得住 | 抓不住 |
|
||
//! |---|---|---|
|
||
//! | [`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 done = super::export::export(&spec_report, Lang::ZhCn);
|
||
let text = done.text.clone();
|
||
assert!(done.ok, "{text}");
|
||
assert_eq!(done.truncated, 0);
|
||
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}"
|
||
);
|
||
}
|
||
|
||
/// P2-1:**卡片那一层只能是卡片大**。
|
||
///
|
||
/// 抓得住什么:r1 复审实测的那个 bug —— `mouse_area(整窗居中容器)` 的命中区是整窗,
|
||
/// 卡片外任何一次点击都先被它捕获,遮罩永远收不到(「点遮罩关闭」形同虚设)。
|
||
/// `Widget::size_hint()` 不需要渲染器就能问,所以这条能在单测里跑:卡片层若声明
|
||
/// `Fill`/`Fill`,就等于「命中区 = 整窗」。
|
||
///
|
||
/// **抓不住什么**:端到端指针派发(本仓没有事件 harness,`Element` 的 `layout`/`update`
|
||
/// 需要真渲染器)。所以这条守的是**结构属性**,不是「点一下真的关了」。
|
||
#[test]
|
||
fn the_card_layer_is_card_sized_so_the_backdrop_can_be_clicked() {
|
||
use iced::Length;
|
||
|
||
let mut app = headless();
|
||
app.state.dialogs.push(
|
||
DialogSource::UserAction,
|
||
"t",
|
||
"b",
|
||
DialogKind::Confirm {
|
||
primary: "p".to_string(),
|
||
secondary: "s".to_string(),
|
||
},
|
||
);
|
||
let modal = {
|
||
let request = app.state.dialogs.current().expect("当前条");
|
||
let view = DialogRequestView::of(request);
|
||
super::current(app.lang(), &view, None)
|
||
};
|
||
let plane = super::view::card_plane(app.ui(), &modal);
|
||
let hint = iced::advanced::Widget::size_hint(plane.as_widget());
|
||
// 任一维是 `Fill` 就意味着「跟随父级 = 整窗」(外层是 Fill 的居中容器),
|
||
// 命中区于是变成整窗 —— 那正是 r1 抓到的原实现。
|
||
assert_ne!(
|
||
hint.width,
|
||
Length::Fill,
|
||
"卡片层宽度不能 Fill(会撑成整窗)"
|
||
);
|
||
assert_ne!(
|
||
hint.height,
|
||
Length::Fill,
|
||
"卡片层高度不能 Fill(会撑成整窗)"
|
||
);
|
||
}
|
||
|
||
/// P1-1:遮罩必须是**不透明预合成**,而且两档都**压暗**(深色档尤其)。
|
||
///
|
||
/// 抓得住 r1 实测的那个 p1:`Role::OnSurface @ 32%` 在深色档是「近白 @32%」⇒ 遮罩把整窗
|
||
/// 提亮(实测 (9,21,28) → (129,138,145))。这里用亮度直接判方向:遮罩必须比画布暗。
|
||
///
|
||
/// 抓不住:具体色值对不对(那是令牌层的事)。
|
||
#[test]
|
||
fn the_backdrop_is_an_opaque_precomposite_and_darkens_both_flavors() {
|
||
use unlucky_design::color::{Role, SchemeFlavor};
|
||
use unlucky_design::elevation::MaterialLayer;
|
||
use unlucky_design::typography::Platform;
|
||
|
||
let luma = |color: iced::Color| 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
|
||
|
||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||
let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES);
|
||
// 断言**视图真正用的那一份**(不是组件层的默认值):改回「OnSurface @32%」这里就红。
|
||
let backdrop = super::view::backdrop_color(&ui);
|
||
assert_eq!(
|
||
backdrop.a, 1.0,
|
||
"{flavor:?}:遮罩必须是不透明预合成(D27:CPU 档零半透明)"
|
||
);
|
||
|
||
let canvas = crate::theme::to_iced(
|
||
unlucky_design::color::Scheme::baseline(flavor)
|
||
.get(MaterialLayer::Canvas.surface_role()),
|
||
);
|
||
assert!(
|
||
luma(backdrop) < luma(canvas),
|
||
"{flavor:?}:遮罩必须比画布暗(当前 lane {:.3} vs 画布 {:.3})—— 用 Role::OnSurface 当遮罩会在深色档把整窗提亮",
|
||
luma(backdrop),
|
||
luma(canvas)
|
||
);
|
||
assert_eq!(Role::Surface, MaterialLayer::Canvas.surface_role());
|
||
}
|
||
}
|
||
|
||
/// P2-4:启动那一拍的**事实**确实进了启动器日志(登记目录后一次刷出)。
|
||
///
|
||
/// 抓得住:记录函数被拆掉/改空(缓冲里就什么都没有)。
|
||
/// 抓不住:`on_boot` 有没有调它 —— 那靠这条用例旁边的调用点与 `host/launch.rs` 的 `set_dir`,
|
||
/// 真机第一次崩溃才是最终验收闸门(复审与我的一致口径)。
|
||
#[test]
|
||
fn boot_facts_are_recorded_into_the_launcher_log() {
|
||
let dir = std::env::temp_dir().join(format!("ull-m187-bootlog-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).expect("建目录");
|
||
unlucky_core::launch::launcher_log::set_dir(&dir);
|
||
|
||
let app = headless();
|
||
crate::app::update::boot_env::record_boot_log(&app);
|
||
|
||
let path = dir.join(unlucky_core::launch::launcher_log::FILE_NAME);
|
||
let text = std::fs::read_to_string(&path).expect("launcher.log");
|
||
assert!(text.contains("[boot] app boot"), "{text}");
|
||
assert!(text.contains("theme="), "{text}");
|
||
assert!(text.contains("window="), "{text}");
|
||
}
|