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:
1 parent
fc8f5c6794
commit
97768b7e3f
19 files changed
+1699
-553
No files matched your search
@@ -18,6 +18,9 @@
|
||||
//!
|
||||
//! 交互:主按钮 / 次按钮 / 点遮罩 / `Esc` 都关当前条(`DialogQueue::close_current`)。
|
||||
//!
|
||||
//! M298 把**视图模型**那一段(`Modal` / `CardView` / `current` 与四个纯函数)搬到 [`model`],
|
||||
//! `pub use` 保路径:本文件只留「卡片事实 + 消息枚举 + 与 `App` 的接线」。
|
||||
//!
|
||||
//! ## 崩溃卡片额外带什么
|
||||
//!
|
||||
//! `DialogRequest`(presenter 的 DTO)只有标题/正文/按钮,而这次用户要的是**证据**:
|
||||
@@ -29,18 +32,22 @@
|
||||
//! 事实来源是 `unlucky_core::launch::crash_report`(启动线程收尾时登记的现场)。这里只读它,
|
||||
//! 不重算:**界面不读盘、不猜原因**。
|
||||
|
||||
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
|
||||
use unlucky_core::launch::crash_report::CrashReport;
|
||||
use unlucky_i18n::catalog::{Catalog, Key};
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dialogs::{DialogId, DialogKind, DialogSource};
|
||||
|
||||
use crate::app::state::App;
|
||||
|
||||
pub(super) mod export;
|
||||
mod model;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(super) mod view;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) use model::path_rows;
|
||||
pub(super) use model::{current, DialogRequestView, Modal};
|
||||
|
||||
/// 崩溃卡片:一次失败的**事实**(不含任何结论)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CrashCard {
|
||||
@@ -77,225 +84,6 @@ pub enum Message {
|
||||
CopyPath(usize),
|
||||
}
|
||||
|
||||
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(super) struct Modal {
|
||||
/// 标题。
|
||||
pub title: String,
|
||||
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
|
||||
pub body: Vec<String>,
|
||||
/// 主按钮文字。
|
||||
pub primary: String,
|
||||
/// 次按钮文字(`None` = 不画)。
|
||||
pub secondary: Option<String>,
|
||||
/// 是否有次按钮(`Progress` 没有)。
|
||||
pub blocking_progress: bool,
|
||||
/// 崩溃卡片(普通弹窗为 `None`)。
|
||||
pub card: Option<CardView>,
|
||||
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
/// 崩溃卡片的展示内容。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(super) struct CardView {
|
||||
/// 展开 / 收起按钮的文字。
|
||||
pub toggle: String,
|
||||
/// 当前的展开态。
|
||||
pub expanded: bool,
|
||||
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
|
||||
pub paths: Vec<(String, String)>,
|
||||
/// 详细说明的小标题(`None` = 没有可展开的内容)。
|
||||
pub detail_title: Option<String>,
|
||||
/// 详细说明的正文行。
|
||||
pub detail_lines: Vec<String>,
|
||||
/// 三个按钮的文字。
|
||||
pub actions: [String; 3],
|
||||
}
|
||||
|
||||
impl Modal {
|
||||
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
pub fn body_text(&self) -> String {
|
||||
self.body.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
|
||||
#[must_use]
|
||||
pub(super) fn current(
|
||||
lang: Lang,
|
||||
request: &DialogRequestView<'_>,
|
||||
card: Option<&CrashCard>,
|
||||
) -> Modal {
|
||||
let catalog = Catalog::of(lang);
|
||||
let mut modal = Modal {
|
||||
title: request.title.to_string(),
|
||||
body: vec![request.body.to_string()],
|
||||
primary: request.primary.to_string(),
|
||||
secondary: request.secondary.map(str::to_string),
|
||||
blocking_progress: request.blocking_progress,
|
||||
card: None,
|
||||
notice: None,
|
||||
};
|
||||
if let Some(card) = card {
|
||||
let detail = detail_of(&catalog, card);
|
||||
modal.body = failure_body(&catalog, &card.report);
|
||||
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
|
||||
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
|
||||
modal.card = Some(CardView {
|
||||
toggle: catalog
|
||||
.text(if card.expanded {
|
||||
Key::CrashDialogHideDetail
|
||||
} else {
|
||||
Key::CrashDialogShowDetail
|
||||
})
|
||||
.to_string(),
|
||||
expanded: card.expanded,
|
||||
paths: path_rows(&catalog, &card.report),
|
||||
detail_title: detail.0,
|
||||
detail_lines: detail.1,
|
||||
actions: [
|
||||
catalog.text(Key::CrashDialogExport).to_string(),
|
||||
catalog.text(Key::CrashDialogOpenFolder).to_string(),
|
||||
catalog.text(Key::CrashDialogCopyPaths).to_string(),
|
||||
],
|
||||
});
|
||||
modal.notice = card.outcome.clone();
|
||||
}
|
||||
modal
|
||||
}
|
||||
|
||||
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
|
||||
pub(super) struct DialogRequestView<'a> {
|
||||
/// 标题。
|
||||
pub title: &'a str,
|
||||
/// 正文。
|
||||
pub body: &'a str,
|
||||
/// 主按钮。
|
||||
pub primary: &'a str,
|
||||
/// 次按钮。
|
||||
pub secondary: Option<&'a str>,
|
||||
/// 进度框不可点主按钮。
|
||||
pub blocking_progress: bool,
|
||||
}
|
||||
|
||||
impl<'a> DialogRequestView<'a> {
|
||||
/// 从 presenter 的请求取视图。
|
||||
pub(super) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
|
||||
Self {
|
||||
title: &request.title,
|
||||
body: &request.body,
|
||||
primary: request.kind.primary_label(),
|
||||
secondary: request.kind.secondary_label(),
|
||||
blocking_progress: request.kind.is_blocking_progress(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
|
||||
///
|
||||
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
|
||||
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
|
||||
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
|
||||
let mut body = Vec::new();
|
||||
if let Some(code) = report.exit_code {
|
||||
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
|
||||
}
|
||||
if let Some(stage) = &report.stage {
|
||||
let label = stage_label(catalog, stage);
|
||||
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
|
||||
} else if report.exit_code == Some(0) {
|
||||
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
|
||||
} else {
|
||||
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
|
||||
}
|
||||
body.push(
|
||||
match report.evidence() {
|
||||
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
|
||||
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
|
||||
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
body
|
||||
}
|
||||
|
||||
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
|
||||
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
|
||||
let key = match slug {
|
||||
"version_manifest" => Key::LaunchStageVersionManifest,
|
||||
"inheritance" => Key::LaunchStageInheritance,
|
||||
"instance_config" => Key::LaunchStageInstanceConfig,
|
||||
"isolation" => Key::LaunchStageIsolation,
|
||||
"identity" => Key::LaunchStageIdentity,
|
||||
_ => Key::LaunchStageSpawn,
|
||||
};
|
||||
catalog.text(key)
|
||||
}
|
||||
|
||||
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
|
||||
fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
|
||||
let mut rows = Vec::new();
|
||||
if let Some(log) = &report.session_log {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogSessionLog).to_string(),
|
||||
log.display().to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(dir) = &report.launcher_logs {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
|
||||
dir.display().to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(path) = report.latest_log_path() {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogGameLog).to_string(),
|
||||
path.display().to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(dir) = report.crash_reports_dir() {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogCrashReports).to_string(),
|
||||
dir.display().to_string(),
|
||||
));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
|
||||
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
|
||||
let report = &card.report;
|
||||
if let Some(artifact) = report
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.source == "crash_report")
|
||||
{
|
||||
let mut lines = Vec::new();
|
||||
if let Some(excerpt) = &artifact.excerpt {
|
||||
lines.extend(excerpt.lines().map(str::to_string));
|
||||
} else {
|
||||
lines.push(artifact.path.display().to_string());
|
||||
}
|
||||
let title = format!(
|
||||
"{} {}",
|
||||
catalog.text(Key::LogsDiagnosisTitle),
|
||||
artifact.path.display()
|
||||
);
|
||||
return (Some(title), lines);
|
||||
}
|
||||
if report.stderr_tail.is_empty() {
|
||||
return (None, Vec::new());
|
||||
}
|
||||
let title = catalog.render(
|
||||
Key::CrashDialogCapturedTail,
|
||||
&[("count", &report.stderr_tail.len().to_string())],
|
||||
);
|
||||
(Some(title), report.stderr_tail.clone())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 与 App 的接线
|
||||
|
||||
impl App {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! 弹窗的**视图模型**:队列当前条 + 崩溃卡片 → 「这一屏该出现什么」(M298 从 `dialog.rs` 拆出)。
|
||||
//!
|
||||
//! 纯函数、不读盘、不看 `App`:测试与渲染共用同一份(`crate::app::dialog::{current, Modal}`)。
|
||||
//! 拆分的直接原因只有行数(`dialog.rs` 当时 390 行、贴着 400 行硬线),**没有行为变化**:
|
||||
//! 每个函数体逐行搬移,符号经父模块 `pub use` 保路径。
|
||||
|
||||
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
|
||||
use unlucky_i18n::catalog::{Catalog, Key};
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use super::CrashCard;
|
||||
|
||||
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(crate) struct Modal {
|
||||
/// 标题。
|
||||
pub title: String,
|
||||
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
|
||||
pub body: Vec<String>,
|
||||
/// 主按钮文字。
|
||||
pub primary: String,
|
||||
/// 次按钮文字(`None` = 不画)。
|
||||
pub secondary: Option<String>,
|
||||
/// 是否有次按钮(`Progress` 没有)。
|
||||
pub blocking_progress: bool,
|
||||
/// 崩溃卡片(普通弹窗为 `None`)。
|
||||
pub card: Option<CardView>,
|
||||
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
/// 崩溃卡片的展示内容。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(crate) struct CardView {
|
||||
/// 展开 / 收起按钮的文字。
|
||||
pub toggle: String,
|
||||
/// 当前的展开态。
|
||||
pub expanded: bool,
|
||||
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
|
||||
pub paths: Vec<(String, String)>,
|
||||
/// 详细说明的小标题(`None` = 没有可展开的内容)。
|
||||
pub detail_title: Option<String>,
|
||||
/// 详细说明的正文行。
|
||||
pub detail_lines: Vec<String>,
|
||||
/// 三个按钮的文字。
|
||||
pub actions: [String; 3],
|
||||
}
|
||||
|
||||
impl Modal {
|
||||
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
pub fn body_text(&self) -> String {
|
||||
self.body.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
|
||||
#[must_use]
|
||||
pub(crate) fn current(
|
||||
lang: Lang,
|
||||
request: &DialogRequestView<'_>,
|
||||
card: Option<&CrashCard>,
|
||||
) -> Modal {
|
||||
let catalog = Catalog::of(lang);
|
||||
let mut modal = Modal {
|
||||
title: request.title.to_string(),
|
||||
body: vec![request.body.to_string()],
|
||||
primary: request.primary.to_string(),
|
||||
secondary: request.secondary.map(str::to_string),
|
||||
blocking_progress: request.blocking_progress,
|
||||
card: None,
|
||||
notice: None,
|
||||
};
|
||||
if let Some(card) = card {
|
||||
let detail = detail_of(&catalog, card);
|
||||
modal.body = failure_body(&catalog, &card.report);
|
||||
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
|
||||
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
|
||||
modal.card = Some(CardView {
|
||||
toggle: catalog
|
||||
.text(if card.expanded {
|
||||
Key::CrashDialogHideDetail
|
||||
} else {
|
||||
Key::CrashDialogShowDetail
|
||||
})
|
||||
.to_string(),
|
||||
expanded: card.expanded,
|
||||
paths: path_rows(&catalog, &card.report),
|
||||
detail_title: detail.0,
|
||||
detail_lines: detail.1,
|
||||
actions: [
|
||||
catalog.text(Key::CrashDialogExport).to_string(),
|
||||
catalog.text(Key::CrashDialogOpenFolder).to_string(),
|
||||
catalog.text(Key::CrashDialogCopyPaths).to_string(),
|
||||
],
|
||||
});
|
||||
modal.notice = card.outcome.clone();
|
||||
}
|
||||
modal
|
||||
}
|
||||
|
||||
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
|
||||
pub(crate) struct DialogRequestView<'a> {
|
||||
/// 标题。
|
||||
pub title: &'a str,
|
||||
/// 正文。
|
||||
pub body: &'a str,
|
||||
/// 主按钮。
|
||||
pub primary: &'a str,
|
||||
/// 次按钮。
|
||||
pub secondary: Option<&'a str>,
|
||||
/// 进度框不可点主按钮。
|
||||
pub blocking_progress: bool,
|
||||
}
|
||||
|
||||
impl<'a> DialogRequestView<'a> {
|
||||
/// 从 presenter 的请求取视图。
|
||||
pub(crate) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
|
||||
Self {
|
||||
title: &request.title,
|
||||
body: &request.body,
|
||||
primary: request.kind.primary_label(),
|
||||
secondary: request.kind.secondary_label(),
|
||||
blocking_progress: request.kind.is_blocking_progress(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
|
||||
///
|
||||
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
|
||||
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
|
||||
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
|
||||
let mut body = Vec::new();
|
||||
if let Some(code) = report.exit_code {
|
||||
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
|
||||
}
|
||||
if let Some(stage) = &report.stage {
|
||||
let label = stage_label(catalog, stage);
|
||||
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
|
||||
} else if report.exit_code == Some(0) {
|
||||
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
|
||||
} else {
|
||||
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
|
||||
}
|
||||
body.push(
|
||||
match report.evidence() {
|
||||
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
|
||||
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
|
||||
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
body
|
||||
}
|
||||
|
||||
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
|
||||
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
|
||||
let key = match slug {
|
||||
"version_manifest" => Key::LaunchStageVersionManifest,
|
||||
"inheritance" => Key::LaunchStageInheritance,
|
||||
"instance_config" => Key::LaunchStageInstanceConfig,
|
||||
"isolation" => Key::LaunchStageIsolation,
|
||||
"identity" => Key::LaunchStageIdentity,
|
||||
_ => Key::LaunchStageSpawn,
|
||||
};
|
||||
catalog.text(key)
|
||||
}
|
||||
|
||||
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
|
||||
pub(crate) fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
|
||||
let mut rows = Vec::new();
|
||||
if let Some(log) = &report.session_log {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogSessionLog).to_string(),
|
||||
log.display().to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(dir) = &report.launcher_logs {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
|
||||
dir.display().to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(path) = report.latest_log_path() {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogGameLog).to_string(),
|
||||
path.display().to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(dir) = report.crash_reports_dir() {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogCrashReports).to_string(),
|
||||
dir.display().to_string(),
|
||||
));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
|
||||
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
|
||||
let report = &card.report;
|
||||
if let Some(artifact) = report
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.source == "crash_report")
|
||||
{
|
||||
let mut lines = Vec::new();
|
||||
if let Some(excerpt) = &artifact.excerpt {
|
||||
lines.extend(excerpt.lines().map(str::to_string));
|
||||
} else {
|
||||
lines.push(artifact.path.display().to_string());
|
||||
}
|
||||
let title = format!(
|
||||
"{} {}",
|
||||
catalog.text(Key::LogsDiagnosisTitle),
|
||||
artifact.path.display()
|
||||
);
|
||||
return (Some(title), lines);
|
||||
}
|
||||
if report.stderr_tail.is_empty() {
|
||||
return (None, Vec::new());
|
||||
}
|
||||
let title = catalog.render(
|
||||
Key::CrashDialogCapturedTail,
|
||||
&[("count", &report.stderr_tail.len().to_string())],
|
||||
);
|
||||
(Some(title), report.stderr_tail.clone())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,10 @@ mod boot_probe;
|
||||
mod construct;
|
||||
mod mods_shot;
|
||||
mod refresh;
|
||||
// M298: toast 的动效时间轴(进/出进度;`view` 只采样)
|
||||
pub(super) mod toast;
|
||||
// M298: 出图取证用的 Toast 夹具(`ULL_TOAST_FIXTURE`)
|
||||
mod toast_shot;
|
||||
// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)
|
||||
mod saves_shot;
|
||||
mod theme;
|
||||
@@ -229,6 +233,11 @@ pub struct App {
|
||||
/// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就
|
||||
/// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。
|
||||
pub(super) dialog_model: Option<super::dialog::Modal>,
|
||||
/// Toast 的动效时间轴(M298):每条提示的出生时刻 + 这一拍的进/出进度。
|
||||
///
|
||||
/// 为什么住在 `App` 而不是 presenter:它是**动效**(这一帧该缩放多少),不是业务事实。
|
||||
/// 事实(文案 / 语义色 / 剩余寿命)全在 `state.toasts`,这边只记时间。
|
||||
pub(super) toast_tracks: toast::ToastTracks,
|
||||
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
|
||||
pub(super) avatars: pages::account::avatar::Cache,
|
||||
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
|
||||
|
||||
@@ -155,6 +155,7 @@ impl App {
|
||||
mods_failed_seen: None,
|
||||
dialog_card: None,
|
||||
dialog_model: None,
|
||||
toast_tracks: super::toast::ToastTracks::default(),
|
||||
content: pages::content::PageState::default(),
|
||||
account_form: pages::account::Form::new(),
|
||||
account: pages::account::Page::build(
|
||||
@@ -182,6 +183,8 @@ impl App {
|
||||
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
||||
app.lang.log();
|
||||
app.lang.log_cjk();
|
||||
// M298:出图取证用的 Toast 夹具(`ULL_TOAST_FIXTURE`;产品路径读不到这个变量)。
|
||||
super::toast_shot::seed(&mut app);
|
||||
app
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,18 @@ impl App {
|
||||
}
|
||||
self.view_dirty = false;
|
||||
self.refreshes += 1;
|
||||
// M298:toast 的进/出场也要「每帧一次」——而 `refresh_view` 只在 `view_dirty` 时跑。
|
||||
// 于是这里把「还有提示在动」翻译成**再续一帧**(`view_dirty` 重新置位);静止在位之后
|
||||
// 它回到 `false`,与改造前一样不白重建(M192 的「空闲不烧帧」口径不变)。
|
||||
self.view_dirty = self.sync_toasts();
|
||||
}
|
||||
|
||||
/// toast 的动效时间轴采样(**update 侧唯一入口**,见 `state/toast.rs` 的模块文档)。
|
||||
///
|
||||
/// 返回 `true` = 还有提示在进/出场,这一帧要续。
|
||||
fn sync_toasts(&mut self) -> bool {
|
||||
let toasts = self.state.toasts.items();
|
||||
self.toast_tracks.observe(toasts, std::time::Instant::now())
|
||||
}
|
||||
|
||||
/// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Toast 的**动效时间轴**(M298):一条提示的进场 / 退场进度从哪来。
|
||||
//!
|
||||
//! ## 为什么不新造一条轨道
|
||||
//!
|
||||
//! `components::motion` 里的帧泵只认三类轨道(页面 / 模态 / 右栗折叠),而 toasts 的寿命不归
|
||||
//! 它管:`ToastCenter` 已经按 33 ms 一拍递减 `remaining_ms`(`app/update/clock.rs`),到期自己
|
||||
//! 消失。再往帧泵里塞一条「toast 轨道」等于把**同一件事的时钟记两遍**,两边一旦不同步就是
|
||||
//! 「提示还在、动画已经结束」。
|
||||
//!
|
||||
//! 所以这里只做一件帧泵不做的事:记住**每一条是什么时候出现的**(`born`),然后每拍按 toast
|
||||
//! 自己的两个已知量采样进度——`born` 到现在的时长(进场)与 `remaining_ms`(退场)。
|
||||
//! 时长档与缓动档**复用模态那一对腿**([`motion::modal_spec`]):toast 与对话框同属 M3 的
|
||||
//! 「用户刚做了一件事的回应」短档,没有第二条曲线可言。
|
||||
//!
|
||||
//! ## 为什么采样发生在 `update` 侧
|
||||
//!
|
||||
//! 与帧泵同一条纪律(`components::motion` 模块文档的五条契约):`view` 只采样、不起跑。这里
|
||||
//! 的 [`App::sync_toasts`](super::App) 在 [`App::refresh_view`](super::App::refresh_view) 里跑,
|
||||
//! 结果是「这一帧每条该缩放多少」——`view` 拿到的只是一份只读快照。
|
||||
//!
|
||||
//! ## 关掉动效 = 一帧都不动
|
||||
//!
|
||||
//! `MotionScale::NONE`(截图通道 / CPU 档 / 系统「减少动效」)时进度**恒为 1.0**,且
|
||||
//! [`ToastTracks::observe`] 返回 `false` ⇒ 不会为了动画续帧。出图因此可复现(D30)。
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use unlucky_presenter::toast::{Toast, ToastId};
|
||||
|
||||
use crate::app::components::motion;
|
||||
|
||||
/// 一条 toast 的动效记录。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Entry {
|
||||
/// toast id(`ToastCenter` 重新编号,进程内唯一)。
|
||||
id: ToastId,
|
||||
/// 第一次被看见的时刻。
|
||||
born: Instant,
|
||||
/// 这一拍算出来的进度(`0` = 刚起手 / 正在退场尾声,`1` = 完全在位)。
|
||||
progress: f32,
|
||||
}
|
||||
|
||||
/// toast 的动效时间轴(挂在 [`App`](super::App) 上,`Default` = 空)。
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ToastTracks {
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl ToastTracks {
|
||||
/// 这一拍动没动过(`false` = 全都静止在位,不必续帧)。
|
||||
///
|
||||
/// **update 侧每拍调一次**:登记新出现的、忘掉已经消失的、重算每条进度。
|
||||
pub(crate) fn observe(&mut self, toasts: &[Toast], now: Instant) -> bool {
|
||||
self.entries
|
||||
.retain(|entry| toasts.iter().any(|toast| toast.id == entry.id));
|
||||
let spec = motion::modal_spec();
|
||||
let scale = motion::scale();
|
||||
let mut moving = false;
|
||||
for toast in toasts {
|
||||
let born = match self.entries.iter().find(|entry| entry.id == toast.id) {
|
||||
Some(entry) => entry.born,
|
||||
None => {
|
||||
self.entries.push(Entry {
|
||||
id: toast.id,
|
||||
born: now,
|
||||
progress: 1.0,
|
||||
});
|
||||
now
|
||||
}
|
||||
};
|
||||
// 关着动效:进度就是终值,且**不**算「在动」(不续帧、不出图差异)。
|
||||
let progress = if scale.is_off() {
|
||||
1.0
|
||||
} else {
|
||||
let (progress, in_flight) = sample(toast, born, now, &spec, scale);
|
||||
moving |= in_flight;
|
||||
progress
|
||||
};
|
||||
if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == toast.id) {
|
||||
entry.progress = progress;
|
||||
}
|
||||
}
|
||||
moving
|
||||
}
|
||||
|
||||
/// 这一条当前该缩放成什么样(`0..=1`;没登记过 = 在位,宁可不动也不许卡在中间)。
|
||||
pub(crate) fn progress(&self, id: ToastId) -> f32 {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|entry| entry.id == id)
|
||||
.map_or(1.0, |entry| entry.progress)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条 toast 的进 / 出采样(纯函数,测试直接打这里)。
|
||||
///
|
||||
/// 进场:`born` 之后走完 `enter` 腿;退场:`remaining_ms` 掉进 `exit` 腿的时长里之后走到 0。
|
||||
/// 两条腿同时跑(进场还没完就到期的极端情况取**小的那个**,于是它一开始就在退场,不会闪一下
|
||||
/// 全尺寸再消失)。
|
||||
fn sample(
|
||||
toast: &Toast,
|
||||
born: Instant,
|
||||
now: Instant,
|
||||
spec: &motion::ModalSpec,
|
||||
scale: unlucky_design::motion::MotionScale,
|
||||
) -> (f32, bool) {
|
||||
let enter_ms = scale.ms(spec.enter.duration).max(1.0);
|
||||
let exit_ms = scale.ms(spec.exit.duration).max(1.0);
|
||||
let lived_ms = now.saturating_duration_since(born).as_secs_f32() * 1000.0;
|
||||
let enter_t = (lived_ms / enter_ms).clamp(0.0, 1.0);
|
||||
let exit_t = (1.0 - toast.remaining_ms as f32 / exit_ms).clamp(0.0, 1.0);
|
||||
let progress = spec
|
||||
.enter
|
||||
.easing
|
||||
.sample(enter_t)
|
||||
.min(1.0 - spec.exit.easing.sample(exit_t))
|
||||
.clamp(0.0, 1.0);
|
||||
(progress, enter_t < 1.0 || exit_t > 0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! **进/出两条腿的数值取证**:起点、终点、单调、以及「不动就一帧都不续」。
|
||||
//!
|
||||
//! 抓得住:进场方向做反(`progress` 从 1 掉到 0 即红)、退场没接上(`remaining_ms` 掉了
|
||||
//! 进度却还是 1)、关掉动效仍然报「在动」(`NONE` 下 `observe` 必须返回 `false`)。
|
||||
//! 抓不住:**观感**(截图见 `DevDocs/artifacts/m298/`)。
|
||||
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
use unlucky_design::motion::MotionScale;
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
fn toast(id: ToastId, remaining_ms: u64) -> Toast {
|
||||
Toast {
|
||||
id,
|
||||
kind: ToastKind::Info,
|
||||
text: "已取消安装".to_string(),
|
||||
remaining_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn tracks(scale: MotionScale) -> ToastTracks {
|
||||
motion::reset();
|
||||
motion::configure_for_test(scale, None);
|
||||
ToastTracks::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_toast_enters_from_zero_and_stays_at_one() {
|
||||
let mut tracks = tracks(MotionScale::STANDARD);
|
||||
let now = Instant::now();
|
||||
let items = [toast(1, 4_000)];
|
||||
// 第一拍:刚登记 = 进场起点。
|
||||
assert!(tracks.observe(&items, now), "进场这一段必须在动");
|
||||
assert!(
|
||||
tracks.progress(1) < 0.05,
|
||||
"刚出现必须是进场起点,实测 {}",
|
||||
tracks.progress(1)
|
||||
);
|
||||
// 进场腿走完之后:停在 1.0,且不再报「在动」。
|
||||
let later = now + Duration::from_millis(400);
|
||||
assert!(!tracks.observe(&items, later), "进场走完就不该再续帧");
|
||||
assert_eq!(tracks.progress(1), 1.0, "在位就必须是满进度");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_moments_play_the_exit_leg_down_to_zero() {
|
||||
let mut tracks = tracks(MotionScale::STANDARD);
|
||||
let now = Instant::now();
|
||||
tracks.observe(&[toast(7, 4_000)], now);
|
||||
let settled = now + Duration::from_millis(400);
|
||||
tracks.observe(&[toast(7, 4_000)], settled);
|
||||
assert_eq!(tracks.progress(7), 1.0);
|
||||
|
||||
// 寿命只剩 60 ms(小于退场腿 150 ms)⇒ 进度必须已经掉下来且仍在动。
|
||||
assert!(
|
||||
tracks.observe(&[toast(7, 60)], settled),
|
||||
"退场这一段必须在动"
|
||||
);
|
||||
let mid = tracks.progress(7);
|
||||
assert!(
|
||||
mid < 1.0 && mid > 0.0,
|
||||
"退场途中的进度应在 (0,1),实测 {mid}"
|
||||
);
|
||||
|
||||
// 到期当拍(`remaining_ms` 归零):进度必须是 0 —— 它就在这一刻被 `expire` 抹掉,
|
||||
// 中间不许留一帧「还满着」。
|
||||
tracks.observe(&[toast(7, 0)], settled);
|
||||
assert!(tracks.progress(7) < 0.05, "到期当拍必须归零");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_off_is_bit_exact_still_and_never_asks_for_frames() {
|
||||
let mut tracks = tracks(MotionScale::NONE);
|
||||
let now = Instant::now();
|
||||
assert!(
|
||||
!tracks.observe(&[toast(1, 4_000)], now),
|
||||
"关掉动效时一帧都不该续(截图 / CPU 档的可复现前提)"
|
||||
);
|
||||
assert_eq!(tracks.progress(1), 1.0, "NONE 下进度恒为终值");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vanished_toast_is_forgotten() {
|
||||
let mut tracks = tracks(MotionScale::STANDARD);
|
||||
let now = Instant::now();
|
||||
tracks.observe(&[toast(1, 4_000)], now);
|
||||
assert!(tracks.observe(&[toast(2, 4_000)], now), "新的那条在进场");
|
||||
assert_eq!(tracks.progress(1), 1.0, "已消失的按在位处理(不留半截)");
|
||||
assert!(
|
||||
!tracks.entries.iter().any(|entry| entry.id == 1),
|
||||
"消失的条目必须被清掉(否则长时间常驻会线性增长)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! 出图取证用的 Toast 夹具(`ULL_TOAST_FIXTURE`;产品路径零影响)。
|
||||
//!
|
||||
//! # 它解决什么
|
||||
//!
|
||||
//! M298 的交付图要证明「提示真的画在窗口底部」,而产品路径的 toast 由事件推出来
|
||||
//! (安装取消 / 启动收尾 / 复制成功),截图通道在第 3 帧就出图:等不到那条事件。
|
||||
//! 处理方式与 `mods_shot` / `saves_shot` 一致——**状态注入**,注在「快照 → 页面模型」之前,
|
||||
//! 于是覆盖层、胶囊几何、i18n 文案走的都是产品代码。
|
||||
//!
|
||||
//! # 边界(不要把它读成产品能力)
|
||||
//!
|
||||
//! - 只在环境变量存在时生效(`ULL_TOAST_FIXTURE` 非空且不为 `0`);
|
||||
//! - 推的是**一条真实形状的提示**:文案由产品函数 `launch_toast` 按**生效语言**现求,
|
||||
//! 不是夹具里写死的一句中文;
|
||||
//! - 剩余寿命走 `ToastCenter` 的默认 TTL(4 s),过期仍由 `app/update/clock.rs` 的 `expire` 管。
|
||||
|
||||
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use super::App;
|
||||
|
||||
/// 夹具开关是否打开(`ULL_TOAST_FIXTURE` 非空且不为 `0`)。
|
||||
fn enabled() -> bool {
|
||||
std::env::var("ULL_TOAST_FIXTURE")
|
||||
.map(|value| {
|
||||
let value = value.trim();
|
||||
!value.is_empty() && value != "0"
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 推一条「游戏崩溃」的收尾提示(就是用户在真机上看到的那一句)。
|
||||
pub(super) fn seed(app: &mut App) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
// 形状取自真实收尾事件:崩溃 + 退出码 1 + 跑了 12 秒。
|
||||
let finished = LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: String::new(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 12_000,
|
||||
failure: None,
|
||||
};
|
||||
let text = crate::app::view::instances::launch_toast(&finished, app.lang());
|
||||
app.state.toasts.push(ToastKind::Error, text);
|
||||
}
|
||||
@@ -123,6 +123,9 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
||||
.catalog()
|
||||
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
||||
);
|
||||
// M298:提示有了渲染点之后,「推了不置脏」= 要等下一次事件才出现(拷贝成功尤其
|
||||
// 刺眼:用户盯着屏幕等一句"已复制")。两条拷贝路径都在这里补上。
|
||||
app.view_dirty = true;
|
||||
iced::clipboard::write(export::paths_text(&card.report))
|
||||
}
|
||||
DialogMessage::CopyPath(index) => {
|
||||
@@ -138,6 +141,8 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
||||
.catalog()
|
||||
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
||||
);
|
||||
// M298:同 `CopyPaths`(推了必须置脏,否则这一条要等下一次事件)。
|
||||
app.view_dirty = true;
|
||||
iced::clipboard::write(path.display().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ mod account_page;
|
||||
pub(super) mod instances;
|
||||
mod logs;
|
||||
mod rail;
|
||||
mod toast;
|
||||
mod update;
|
||||
|
||||
use instances::{detail_of, runtime_status_row, supporting_line};
|
||||
@@ -186,6 +187,15 @@ impl App {
|
||||
self.lang(),
|
||||
),
|
||||
};
|
||||
// M298:Toast 覆盖层。**接在页面树之上、模态之下**:一条提示不该盖住对话框
|
||||
// (启动失败时两者会同时出现——Toast 说结局,对话框给证据),但必须盖住页面内容。
|
||||
// 队列为空时 `layer` 原样返回,所以没有提示的那些帧树形与改造前逐节点相同。
|
||||
let tree = toast::layer(
|
||||
&self.ui,
|
||||
tree,
|
||||
self.state.toasts.items(),
|
||||
&self.toast_tracks,
|
||||
);
|
||||
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
|
||||
let tree = match self.dialog_model.as_ref() {
|
||||
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Toast 覆盖层(M298):`state.toasts` 的**唯一渲染点**。
|
||||
//!
|
||||
//! ## 缺口有多真
|
||||
//!
|
||||
//! M297 盘查:`state.toasts` 全仓只被 **push**(安装取消 / 启动收尾 / mod 启停失败 / 复制成功)
|
||||
//! 与 **expire**(`app/update/clock.rs`),`view.rs` 与 `pages/**` 对 `toasts` 零命中 ⇒ 提示一条
|
||||
//! 都看不见。与「收尾失败静默不弹框」叠在一起,启动失败可以**完全无反馈**。
|
||||
//!
|
||||
//! ## 这一层画什么
|
||||
//!
|
||||
//! MD3E 胶囊:`InverseSurface` 底、`InverseOnSurface` 字、**`ShapeScale::Full`**(高的一半 ⇒
|
||||
//! 真胶囊)、贴**底部居中**、多条自下而上叠(最新的那条在最下面,与推送顺序一致)。
|
||||
//! 几何取组件层 [`SnackbarGeometry`](高 52 / 横内距 16 / 竖 6 / 间距 8 / 外边距 12 / 上限宽 600),
|
||||
//! 不在这里写第二份数字。**不画阴影**:D27(CPU 档禁模糊)+ 组件层 D-4 的同一口径。
|
||||
//!
|
||||
//! ## 进 / 出过渡:复用模态那一对腿,不新造弹簧
|
||||
//!
|
||||
//! 进度由 [`ToastTracks`](super::super::state::toast::ToastTracks) 在 **update 侧**采样
|
||||
//! (toast 自己的出生时刻 + `remaining_ms`),这里只把进度换成缩放:`motion::modal_scale`
|
||||
//! 与 [`motion::scaled`]。档位、缓动、缩放起点全是既有令牌(D31:适配层不新造数)。
|
||||
//! 关掉动效时进度恒 `1.0` ⇒ 恒等变换、出图逐像素不变。
|
||||
//!
|
||||
//! ## 为什么这一层不吃点击
|
||||
//!
|
||||
//! 它是一层铺满窗口的 `container`(`Fill`/`Fill`):iced 的 `Stack` 只在子控件
|
||||
//! **捕获**事件时才停止向下派发,而普通容器不捕获 ⇒ 点到胶囊以外(以及胶囊本身)的按下照样
|
||||
//! 落到下面的页面树。所以这一层不提供「点掉」交互:`ToastCenter::dismiss` 今天没有调用点,
|
||||
//! 到期由 `clock.rs` 的 `expire` 统一处理(任务要求:过期仍走既有那条)。
|
||||
|
||||
use iced::advanced::layout::{self, Layout};
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::widget::{tree, Tree};
|
||||
use iced::advanced::{mouse, Clipboard, Shell, Widget};
|
||||
use iced::widget::{container, row, text, Space};
|
||||
use iced::{
|
||||
Alignment, Background, Border, Element, Event, Length, Padding, Rectangle, Size, Vector,
|
||||
};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_presenter::toast::{Toast, ToastKind};
|
||||
|
||||
use crate::app::components::feedback::panel::SnackbarGeometry;
|
||||
use crate::app::components::motion;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::message::Message;
|
||||
use super::super::state::toast::ToastTracks;
|
||||
|
||||
/// 把 toast 覆盖层叠在 `base` 上(队列为空时原样返回 `base`)。
|
||||
pub(super) fn layer<'a>(
|
||||
ui: &'a Ui,
|
||||
base: Element<'a, Message>,
|
||||
toasts: &'a [Toast],
|
||||
tracks: &'a ToastTracks,
|
||||
) -> Element<'a, Message> {
|
||||
if toasts.is_empty() {
|
||||
return base;
|
||||
}
|
||||
let geometry = SnackbarGeometry::of(ui);
|
||||
// 推送顺序 = 出现顺序:最早的在上,最新的贴底(MD3E 的堆叠方向)。
|
||||
let mut column = iced::widget::Column::new()
|
||||
.spacing(geometry.gap)
|
||||
.align_x(Alignment::Center);
|
||||
for toast in toasts {
|
||||
column = column.push(capsule(ui, &geometry, toast, tracks.progress(toast.id)));
|
||||
}
|
||||
let plane = container(column)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding(Padding::from(geometry.margin))
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::End);
|
||||
let texts = toasts
|
||||
.iter()
|
||||
.map(|toast| toast.text.clone())
|
||||
.collect::<Vec<_>>();
|
||||
iced::widget::stack![base, tagged(plane.into(), texts)].into()
|
||||
}
|
||||
|
||||
/// 一条胶囊:语义色的点 + 一行字(高度固定,字按词折行)。
|
||||
fn capsule<'a>(
|
||||
ui: &'a Ui,
|
||||
geometry: &SnackbarGeometry,
|
||||
toast: &'a Toast,
|
||||
progress: f32,
|
||||
) -> Element<'a, Message> {
|
||||
let height = geometry.height_for(1);
|
||||
let style = ui.style(TypeRole::BodyMedium);
|
||||
let surface = ui.color(Role::InverseSurface);
|
||||
// 语义色**只**落在前面那个点上:底与字必须保持 inverse 那一对(否则深色档对比度会掉)。
|
||||
let accent = match toast.kind {
|
||||
ToastKind::Info | ToastKind::Success => Role::InversePrimary,
|
||||
ToastKind::Warn | ToastKind::Error => Role::Error,
|
||||
};
|
||||
// 点的直径取组件层的 **间距档**(8),圆角 `Full` ⇒ 正圆;不另写一个点径数字。
|
||||
let dot_side = geometry.gap;
|
||||
let dot: Element<'a, Message> = container(
|
||||
Space::new()
|
||||
.width(Length::Fixed(dot_side))
|
||||
.height(Length::Fixed(dot_side)),
|
||||
)
|
||||
.style(move |_theme: &iced::Theme| container::Style {
|
||||
background: Some(Background::Color(ui.color(accent))),
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, dot_side),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into();
|
||||
|
||||
let body = row![
|
||||
dot,
|
||||
text(toast.text.as_str())
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::InverseOnSurface))
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(geometry.gap);
|
||||
|
||||
let bar: Element<'a, Message> = container(body)
|
||||
.width(Length::Fill)
|
||||
.max_width(geometry.max_width)
|
||||
.height(Length::Fixed(height))
|
||||
.padding(Padding {
|
||||
top: geometry.pad_v,
|
||||
right: geometry.pad_h,
|
||||
bottom: geometry.pad_v,
|
||||
left: geometry.pad_h,
|
||||
})
|
||||
.style(move |_theme: &iced::Theme| container::Style {
|
||||
background: Some(Background::Color(surface)),
|
||||
border: Border {
|
||||
radius: capsule_radius(ui, height),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into();
|
||||
motion::scaled(bar, motion::modal_scale(progress))
|
||||
}
|
||||
|
||||
/// 胶囊圆角:`ShapeScale::Full` 对高求值(= 高的一半 ⇒ 真胶囊)。
|
||||
///
|
||||
/// 与组件层 snackbar 的 **16** 不是同一个口径(那是规格卡的矩形提示条);MD3E 的 toast 是
|
||||
/// 胶囊,任务点名要 `ShapeScale::Full`,所以这里按**高的一半**算,且只在这一处求值。
|
||||
fn capsule_radius(ui: &Ui, height: f32) -> iced::border::Radius {
|
||||
ui.radius(ShapeScale::Full, height)
|
||||
}
|
||||
|
||||
/// 覆盖层的**自证状态**:树里带着它 = 这些提示真的进了控件树。
|
||||
///
|
||||
/// 为什么需要它:iced 0.14 没有「把整棵树遍历成文本」的入口(`Element` 不透出内容、
|
||||
/// `Widget::children()` 只给 [`Tree`],而 `Tree` 不带文字)。所以这一层把自己的**文案清单**
|
||||
/// 作为 [`Widget::state`] 交出去——用例顺着 `Tree::state` 就能读到「画出来的到底是哪几句」,
|
||||
/// 而不是只看 `state.toasts.len()`(那证明不了覆盖层被接上)。
|
||||
#[derive(Debug)]
|
||||
pub(super) struct CapsuleState {
|
||||
/// 这一帧真正画出去的文案(顺序 = 自下而上的堆叠顺序)。
|
||||
///
|
||||
/// 只有用例读它(产品路径把状态交给 iced 就完事),所以非测试构建里显式放过 dead_code。
|
||||
#[allow(dead_code)]
|
||||
pub texts: Vec<String>,
|
||||
}
|
||||
|
||||
/// 给覆盖层挂上自证状态(`tag` 用状态类型,见 iced 的 `container` / `text` 同一手法)。
|
||||
pub(super) fn tagged<'a>(
|
||||
content: Element<'a, Message>,
|
||||
texts: Vec<String>,
|
||||
) -> Element<'a, Message> {
|
||||
Element::new(Tagging { content, texts })
|
||||
}
|
||||
|
||||
struct Tagging<'a, Theme, Renderer> {
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
texts: Vec<String>,
|
||||
}
|
||||
|
||||
impl<Theme, Renderer> Widget<Message, Theme, Renderer> for Tagging<'_, Theme, Renderer>
|
||||
where
|
||||
Renderer: iced::advanced::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<CapsuleState>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(CapsuleState {
|
||||
texts: self.texts.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
self.content.as_widget().children()
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.state = self.state();
|
||||
self.content.as_widget().diff(tree);
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content.as_widget_mut().layout(tree, renderer, limits)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(tree, renderer, theme, style, layout, cursor, viewport);
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn iced::advanced::widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(tree, layout, cursor, viewport, renderer)
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<iced::overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(tree, layout, renderer, viewport, translation)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 覆盖层的守门用例(M298)。
|
||||
//!
|
||||
//! ## 这一组抓得住什么 / 抓不住什么(说在明处)
|
||||
//!
|
||||
//! | 用例 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`a_pushed_toast_really_reaches_the_widget_tree`] | 覆盖层**接在 `view()` 上**,且真的把那句话交给了控件树(把 `view.rs` 里那行 `toast::layer(..)` 删掉即红) | 像素:胶囊长什么样(`DevDocs/artifacts/m298/toast-capsule.png` 守) |
|
||||
//! | [`the_capsule_is_a_full_radius_pill`] | 圆角是 `ShapeScale::Full`(高的一半 = 26),不是组件层的 16 | 观感对不对 |
|
||||
//! | [`an_empty_queue_adds_no_layer_at_all`] | 没有提示时树形与改造前**逐节点相同**(覆盖层不是常驻空壳) | —— |
|
||||
//!
|
||||
//! ## 为什么不是「只断 `state.toasts.len()`」
|
||||
//!
|
||||
//! 长度能证明队列里有东西,证明不了**有人画它**(M297 的缺口正是这样活下来的:队列里一直有
|
||||
//! 数据,渲染点一处都没有)。所以这里顺着 `Widget::children()` 走真控件树,靠
|
||||
//! [`CapsuleState`] 把「画出去的文案」读回来。
|
||||
|
||||
use iced::advanced::widget::Tree;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::typography::Platform;
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::message::Message;
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::{capsule_radius, CapsuleState};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn headless() -> App {
|
||||
App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 顺着真控件树把所有 `CapsuleState` 里的文案收集出来(`Tree::state` 是 iced 给控件挂
|
||||
/// 自证状态的官方位置,`container` / `text` 用它存自己的缓存)。
|
||||
fn painted_texts(element: &iced::Element<'_, Message>) -> Vec<String> {
|
||||
fn walk(tree: &Tree, out: &mut Vec<String>) {
|
||||
if tree.tag == iced::advanced::widget::tree::Tag::of::<CapsuleState>() {
|
||||
let state = tree.state.downcast_ref::<CapsuleState>();
|
||||
out.extend(state.texts.iter().cloned());
|
||||
}
|
||||
for child in &tree.children {
|
||||
walk(child, out);
|
||||
}
|
||||
}
|
||||
let tree = Tree::new(element.as_widget());
|
||||
let mut out = Vec::new();
|
||||
walk(&tree, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// 守卫①:**push 一条 toast ⇒ 视图树里真的出现那句话**。
|
||||
///
|
||||
/// **变异证明**:把 `app/view.rs` 里 `let tree = toast::layer(...)` 那一行删掉 / 改成直接返回
|
||||
/// `tree`,本用例第二段立刻红(`painted_texts` 拿到空表)。
|
||||
#[test]
|
||||
fn a_pushed_toast_really_reaches_the_widget_tree() {
|
||||
let mut app = headless();
|
||||
app.refresh_view();
|
||||
assert!(
|
||||
painted_texts(&app.view()).is_empty(),
|
||||
"出厂态不该有覆盖层节点(那时队列是空的)"
|
||||
);
|
||||
|
||||
app.state.toasts.push(ToastKind::Info, "已取消安装");
|
||||
app.refresh_view();
|
||||
let texts = painted_texts(&app.view());
|
||||
assert!(
|
||||
texts.iter().any(|text| text == "已取消安装"),
|
||||
"push 之后控件树里必须有这句话——实际拿到 {texts:?}"
|
||||
);
|
||||
// 队列里那一条仍然是**事实来源**,覆盖层只是它的投影(不是第二份真相)。
|
||||
assert_eq!(app.state.toasts.len(), 1);
|
||||
}
|
||||
|
||||
/// 守卫②:胶囊是真胶囊(`ShapeScale::Full` ⇒ 高的一半)。
|
||||
///
|
||||
/// **变异证明**:把 `capsule` 里那一行改成 `geometry.radius`(组件层的 16),本用例立刻红。
|
||||
#[test]
|
||||
fn the_capsule_is_a_full_radius_pill() {
|
||||
let ui = crate::theme::Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES);
|
||||
let height = super::SnackbarGeometry::of(&ui).height_for(1);
|
||||
let radius = capsule_radius(&ui, height);
|
||||
assert_eq!(
|
||||
radius,
|
||||
iced::border::Radius::from(height / 2.0),
|
||||
"MD3E 胶囊的圆角必须等于高的一半(ShapeScale::Full),实测高 {height}"
|
||||
);
|
||||
// 同一件事的第二种说法:就是 `ShapeScale::Full` 在这一档上的求值。
|
||||
assert_eq!(radius, ui.radius(ShapeScale::Full, height));
|
||||
// 反向守卫:组件层 snackbar 的 16 不是胶囊(改回 `geometry.radius` 时上面两条立刻红)。
|
||||
assert_ne!(radius, iced::border::Radius::from(16.0));
|
||||
}
|
||||
|
||||
/// 守卫③:空队列 ⇒ **不叠任何东西**(没有提示的那些帧树形与改造前一致)。
|
||||
///
|
||||
/// **变异证明**:把 `layer` 开头的 `if toasts.is_empty() { return base; }` 删掉,本用例立刻红
|
||||
/// ——那时每帧都会多一层空容器(白白多一棵子树、也让「有没有提示」在树上不可辨)。
|
||||
#[test]
|
||||
fn an_empty_queue_adds_no_layer_at_all() {
|
||||
fn nodes(element: &iced::Element<'_, Message>) -> usize {
|
||||
fn walk(tree: &Tree) -> usize {
|
||||
1 + tree.children.iter().map(walk).sum::<usize>()
|
||||
}
|
||||
walk(&Tree::new(element.as_widget()))
|
||||
}
|
||||
let mut app = headless();
|
||||
app.refresh_view();
|
||||
let empty = nodes(&app.view());
|
||||
app.state.toasts.push(ToastKind::Info, "已取消安装");
|
||||
app.refresh_view();
|
||||
let with_toast = nodes(&app.view());
|
||||
assert!(
|
||||
with_toast > empty,
|
||||
"有提示时树上必须多出覆盖层(空队列 {empty} 个节点,有提示 {with_toast} 个)"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user