diff --git a/crates/app-iced/src/app/view/instances.rs b/crates/app-iced/src/app/view/instances.rs index b18f6cf..fd5c16b 100644 --- a/crates/app-iced/src/app/view/instances.rs +++ b/crates/app-iced/src/app/view/instances.rs @@ -20,8 +20,8 @@ use unlucky_i18n::{Catalog, Key, Lang}; use unlucky_presenter::dto::{ - InstanceRuntime, InstanceSummary, LaunchFailureStage, LaunchFinished, LaunchStage, LoaderKind, - LogOutcome, + InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished, + LaunchStage, LoaderKind, LogOutcome, }; use crate::pages::{Detail, LaunchFacts, Meta}; @@ -74,19 +74,47 @@ pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt InstanceRuntime::Running { pid } => { Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())])) } - // `detail` 是 core/io 给的原文(**数据**,不翻译);「哪一步」与前半句是界面文案。 - // M161 之前这里只有 `reason`,而 reason 里已经是「启动失败:…」,于是英文界面会出现 - // 「Launch failed · 启动失败:找不到 Java」——半句英文半句中文。 + // 失败有**两种形状**,各说各的话(复审 P1-1): + // - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息); + // - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码), + // 绝不说成「启动游戏进程失败」。 // // 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`] // (单行、不许折行,见 D-M79-5)。 - InstanceRuntime::Failed { stage, detail } => Some(catalog.render( + InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, true)), + } +} + +/// 一条失败状态行。 +/// +/// `with_detail = false` 是**列表行**那一档:`Stage` 只留「哪一步」,不带原文 +/// (原文可能是一长串路径,单行 overline 会把它裁成半截字);结局类的两种形状本来就没有原文。 +fn failure_line(cause: &LaunchFailureCause, catalog: Catalog, with_detail: bool) -> String { + match cause { + LaunchFailureCause::Stage { stage, detail } if with_detail => catalog.render( Key::LaunchStageFailed, &[ ("stage", failure_stage_label(*stage, catalog)), ("detail", detail.as_str()), ], - )), + ), + LaunchFailureCause::Stage { stage, .. } => catalog.render( + Key::InstancesFailed, + &[("reason", failure_stage_label(*stage, catalog))], + ), + LaunchFailureCause::Crashed { exit_code } => { + let reason = match exit_code { + Some(code) => catalog.render(Key::InstancesCrashed, &[("code", &code.to_string())]), + // 平台没给退出码:只说「崩溃」,不编一个 0 出来。 + None => catalog.text(Key::LaunchOutcomeCrash).to_string(), + }; + catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())]) + } + LaunchFailureCause::SilentFailure { .. } => { + // 静默失败的**关键信息是「exit 0 却没起来」**,不是退出码本身(复审 P1-1 第 ③ 条)。 + let reason = catalog.text(Key::InstancesSilentFailed).to_string(); + catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())]) + } } } @@ -96,10 +124,7 @@ pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt /// 路径),放进 60/76 高的单行 overline 会被裁成半截字。全文在**详情页**([`runtime_status`])。 pub(crate) fn runtime_status_row(runtime: &InstanceRuntime, catalog: Catalog) -> Option { match runtime { - InstanceRuntime::Failed { stage, .. } => Some(catalog.render( - Key::InstancesFailedStage, - &[("stage", failure_stage_label(*stage, catalog))], - )), + InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, false)), other => runtime_status(other, catalog), } } diff --git a/crates/app-iced/src/app/view/tests.rs b/crates/app-iced/src/app/view/tests.rs index 95be5ec..5d1a3bc 100644 --- a/crates/app-iced/src/app/view/tests.rs +++ b/crates/app-iced/src/app/view/tests.rs @@ -7,6 +7,9 @@ use unlucky_presenter::dto::{ }; use unlucky_presenter::mock_data::mock_instances; +#[path = "tests/launch.rs"] +mod launch; + fn instance(runtime: InstanceRuntime) -> InstanceSummary { InstanceSummary { id: "survival".to_string(), @@ -123,28 +126,6 @@ fn a_busy_instance_offers_stop_instead_of_play() { assert!(detail.can_stop, "启动中要能 Stop"); } -#[test] -fn failed_instances_can_be_retried() { - let failed = instance(InstanceRuntime::Failed { - stage: unlucky_presenter::dto::LaunchFailureStage::Spawn, - detail: "exit 1".to_string(), - }); - // 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。 - assert_eq!( - super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(), - Some("Launch failed · Starting the game process") - ); - // 详情页给全文(阶段 + 原文)。 - assert_eq!( - super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(), - Some("Launch failed · Starting the game process: exit 1") - ); - assert!( - detail_of(&failed, Lang::En.catalog()).can_launch, - "失败后要能重试" - ); -} - #[test] fn timestamps_are_sliced_not_guessed() { let en = Lang::En.catalog(); @@ -327,67 +308,3 @@ fn the_agent_route_has_no_page() { assert!(model.instances.detail.is_none()); assert_eq!(model.active, None, "AI 页不高亮任何导航项"); } - -/// **启动收尾的提示语跟着界面语言走**(M161 收的债)。 -/// -/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文; -/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。 -/// -/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail` -/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。 -#[test] -fn the_launch_toast_speaks_the_interface_language() { - use unlucky_presenter::dto::{ - LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome, - }; - - let finished = |outcome, failure| LaunchFinished { - id: "survival".to_string(), - session: "20261031T120000-abcd12".to_string(), - outcome, - exit_code: Some(1), - uptime_ms: 12_500, - failure, - }; - - // 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。 - let ok = finished(LogOutcome::Ok, None); - let en = super::instances::launch_toast(&ok, Lang::En); - let zh = super::instances::launch_toast(&ok, Lang::ZhCn); - assert_eq!(en, "Finished normally · 12.5s"); - assert_eq!(zh, "正常结束 · 12.5 秒"); - assert_ne!(en, zh, "切语言时提示语要变"); - - // 装配阶段失败:说清**哪一步**,并把原文原样带出来。 - let failed = finished( - LogOutcome::Crashed, - Some(LaunchFailureView::new( - LaunchFailureStage::InstanceConfig, - "permission denied (os error 5)", - )), - ); - let en = super::instances::launch_toast(&failed, Lang::En); - let zh = super::instances::launch_toast(&failed, Lang::ZhCn); - assert!( - en.starts_with("Launch failed · Reading the instance settings"), - "{en}" - ); - assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}"); - for text in [&en, &zh] { - assert!( - text.contains("permission denied (os error 5)"), - "原文(取证信息)不许丢:{text}" - ); - } - // 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。 - assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}"); - assert!( - !zh.contains("Launch failed"), - "中文提示里混了英文前缀:{zh}" - ); -} - -/// 有没有中文字符(上面那条「不许混语言」的判据)。 -fn is_cjk(ch: char) -> bool { - ('\u{4e00}'..='\u{9fff}').contains(&ch) -} diff --git a/crates/app-iced/src/app/view/tests/launch.rs b/crates/app-iced/src/app/view/tests/launch.rs new file mode 100644 index 0000000..b1e62f8 --- /dev/null +++ b/crates/app-iced/src/app/view/tests/launch.rs @@ -0,0 +1,149 @@ +//! 启动失败/收尾的**文案**用例(从 `app/view/tests.rs` 拆出:那边 400 行到线)。 +//! +//! 这里的断言都钉**具体句子**(不是「字符串非空」):磁贴与 Toast 必须说同一件事, +//! 「没起来」与「起来又崩」不许共用一句(M161 复审 P1-1)。 + +use super::*; + +/// 失败后磁贴要留「重试」,且行/详情文案按「哪一步 + 原文」给。 +#[test] +fn failed_instances_can_be_retried() { + // 进程**没起来**:说「哪一步 + 原文」。 + let failed = instance(InstanceRuntime::Failed { + cause: unlucky_presenter::dto::LaunchFailureCause::Stage { + stage: unlucky_presenter::dto::LaunchFailureStage::Spawn, + detail: "os error 5".to_string(), + }, + }); + // 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。 + assert_eq!( + super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(), + Some("Launch failed · Starting the game process") + ); + // 详情页给全文(阶段 + 原文)。 + assert_eq!( + super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(), + Some("Launch failed · Starting the game process: os error 5") + ); + assert!( + detail_of(&failed, Lang::En.catalog()).can_launch, + "失败后要能重试" + ); +} + +/// **「没起来」与「起来又崩」不许说成同一句**(复审 P1-1 的文案面)。 +/// +/// 抓得住:崩溃/静默失败被塞回 `LaunchFailureStage::Spawn`(磁贴会写「启动游戏进程失败」, +/// 而进程明明起来了);退出码漏成 `Some(1)` 这种 Debug 文本;静默失败丢掉 +/// 「exit 0 却没跑起来」这条信息。断言的是**具体句子**,不是「字符串非空」。 +#[test] +fn a_crash_after_start_is_not_worded_as_a_start_failure() { + use unlucky_presenter::dto::{LaunchFailureCause, LaunchFailureStage}; + + let line = |cause: LaunchFailureCause, lang: Lang| { + let runtime = InstanceRuntime::Failed { cause }; + let row = super::instances::runtime_status_row(&runtime, lang.catalog()).expect("行文案"); + let detail = super::instances::runtime_status(&runtime, lang.catalog()).expect("详情文案"); + (row, detail) + }; + + // ① 没起来:说阶段。 + let (row, detail) = line( + LaunchFailureCause::Stage { + stage: LaunchFailureStage::Spawn, + detail: "os error 5".to_string(), + }, + Lang::En, + ); + assert_eq!(row, "Launch failed · Starting the game process"); + assert_eq!( + detail, + "Launch failed · Starting the game process: os error 5" + ); + + // ② 起来又崩:说结局 + 数字退出码(不许出现 `Some(1)`)。 + let (row, detail) = line(LaunchFailureCause::Crashed { exit_code: Some(1) }, Lang::En); + assert_eq!(row, "Launch failed · the game crashed (exit 1)"); + assert_eq!(detail, row, "结局类没有「原文」可加,行与详情同一句"); + assert!(!detail.contains("Some("), "退出码不许漏 Debug:{detail}"); + assert!( + !detail.contains("Starting the game process"), + "进程起来了,不许说「启动游戏进程」:{detail}" + ); + + // ③ 静默失败:exit 0 却没跑起来 —— 这条信息不能丢。 + let (row, zh) = line( + LaunchFailureCause::SilentFailure { exit_code: Some(0) }, + Lang::ZhCn, + ); + assert_eq!(row, "启动失败 · 退出码 0,没起来"); + assert_eq!(zh, row); + assert!(row.contains("没起来"), "{row}"); + + // ④ 平台没给退出码:只说崩溃,不编一个 0 出来。 + let (row, _) = line(LaunchFailureCause::Crashed { exit_code: None }, Lang::En); + assert_eq!(row, "Launch failed · The game crashed"); +} + +/// +/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文; +/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。 +/// +/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail` +/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。 +#[test] +fn the_launch_toast_speaks_the_interface_language() { + use unlucky_presenter::dto::{ + LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome, + }; + + let finished = |outcome, failure| LaunchFinished { + id: "survival".to_string(), + session: "20261031T120000-abcd12".to_string(), + outcome, + exit_code: Some(1), + uptime_ms: 12_500, + failure, + }; + + // 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。 + let ok = finished(LogOutcome::Ok, None); + let en = super::instances::launch_toast(&ok, Lang::En); + let zh = super::instances::launch_toast(&ok, Lang::ZhCn); + assert_eq!(en, "Finished normally · 12.5s"); + assert_eq!(zh, "正常结束 · 12.5 秒"); + assert_ne!(en, zh, "切语言时提示语要变"); + + // 装配阶段失败:说清**哪一步**,并把原文原样带出来。 + let failed = finished( + LogOutcome::Crashed, + Some(LaunchFailureView::new( + LaunchFailureStage::InstanceConfig, + "permission denied (os error 5)", + )), + ); + let en = super::instances::launch_toast(&failed, Lang::En); + let zh = super::instances::launch_toast(&failed, Lang::ZhCn); + assert!( + en.starts_with("Launch failed · Reading the instance settings"), + "{en}" + ); + assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}"); + for text in [&en, &zh] { + assert!( + text.contains("permission denied (os error 5)"), + "原文(取证信息)不许丢:{text}" + ); + } + // 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。 + assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}"); + assert!( + !zh.contains("Launch failed"), + "中文提示里混了英文前缀:{zh}" + ); +} + +/// 有没有中文字符(上面那条「不许混语言」的判据)。 +fn is_cjk(ch: char) -> bool { + ('\u{4e00}'..='\u{9fff}').contains(&ch) +} diff --git a/crates/app-iced/src/pages/content/layout.rs b/crates/app-iced/src/pages/content/layout.rs index 2bf3933..ae72915 100644 --- a/crates/app-iced/src/pages/content/layout.rs +++ b/crates/app-iced/src/pages/content/layout.rs @@ -270,18 +270,70 @@ mod body_mode_tests { assert!(!BodyMode::of(300.0).list_scrolls()); } - /// 窗口最小尺寸(880×560)落在**矮**档:这是本条债的真实场景,别让它悄悄变回 `Split`。 + /// 最小窗口(880×560)落在**矮**档,验收到 760 不换版面——**正文可用高按真实外壳算**。 + /// + /// 复审 P2-2:早先这里是 `560 − 56 − 88 − 32` 反推的(三个数全错:真实是 titlebar 48 / + /// 两行页头 84 / Expanded 页边距 2×24 = chrome 180),结论碰巧对、但抓不住 metrics 漂移。 + /// 现在直接调 [`crate::pages::frame::body_height`],chrome 从 token 现取。 #[test] fn the_minimum_window_size_uses_the_scrolling_body() { - // 560 窗口 − 顶栏 56 − 页头 88(两行标题 + 留白)− 上下页边距 2×16 ≈ 384 - let body = 560.0 - 56.0 - 88.0 - 32.0; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::typography::Platform; + + use crate::pages::frame; + use crate::pages::layout::{frame_metrics, PaneKit}; + use crate::theme::{Families, Ui}; + + let ui = Ui::with_platform( + SchemeFlavor::Light, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ); + // 880 宽 = 窗口最小尺寸(`app::MIN_SIZE.width`),属 Expanded。 + let metrics = frame_metrics(PaneKit::Single, 880.0); + let chrome = + ui.metrics().titlebar_height + ui.metrics().header_height(true) + 2.0 * metrics.gutter; + + // 真值:48 + 84 + 48 = 180。这条会随 token 改而红——那时请重看一眼两档版面。 + assert_eq!(chrome, 180.0, "外壳 chrome 与 design token 不一致"); + + // 最小窗口 560 高:正文只剩 380 < 460 ⇒ 整列滚动(本条债的真实场景)。 + let short = frame::body_height(&ui, metrics, 560.0, true); + assert_eq!(short, 560.0 - chrome); assert_eq!( - BodyMode::of(body), + BodyMode::of(short), BodyMode::ScrollWhole, - "最小窗口(560 高)必须走整列滚动:正文只剩 {body}" + "正文只剩 {short}" + ); + + // 验收尺寸 760 高:580 ≥ 460 ⇒ 列表自己滚(不换版面)。 + let tall = frame::body_height(&ui, metrics, 760.0, true); + assert_eq!(BodyMode::of(tall), BodyMode::Split); + + // 把门槛和**真实 chrome** 绑死:窗口高 = chrome + MIN 时必须还是 Split, + // 少 1px 就翻成整列滚动。chrome 涨到 100/页边距 40 这类漂移会让这条立刻红 + // (而不是像旧用例那样拿反推数字自说自话)。 + assert_eq!( + BodyMode::of(frame::body_height( + &ui, + metrics, + chrome + MIN_BODY_HEIGHT, + true + )), + BodyMode::Split + ); + assert_eq!( + BodyMode::of(frame::body_height( + &ui, + metrics, + chrome + MIN_BODY_HEIGHT - 1.0, + true + )), + BodyMode::ScrollWhole ); - // 验收尺寸 760 高 → 约 584,仍走「列表自己滚」。 - let body = 760.0 - 56.0 - 88.0 - 32.0; - assert_eq!(BodyMode::of(body), BodyMode::Split, "验收尺寸不该换版面"); } } diff --git a/crates/app-iced/src/pages/frame/tests.rs b/crates/app-iced/src/pages/frame/tests.rs index 4f9689e..c44de73 100644 --- a/crates/app-iced/src/pages/frame/tests.rs +++ b/crates/app-iced/src/pages/frame/tests.rs @@ -99,3 +99,49 @@ fn two_pane_body_is_narrower_than_the_available_width() { assert!(wide.column < wide.available); assert!(wide.secondary.is_some()); } + +/// **`body_height` 用真的 `Ui::metrics` + `FrameMetrics` 算**(M161 复审 P2-2:这条函数此前 +/// 一条测试都没有,而④的「最小窗口」用例是拿 56/88/32 反推的——三个数全错)。 +/// +/// 抓得住:①`titlebar_height` / `header_height` / `gutter` 任何一个被改(chrome 变了, +/// 下面的等式立刻红);②把两行页头当单行算(`has_subtitle` 被忽略);③把页边距只减一遍。 +#[test] +fn body_height_subtracts_the_real_shell_chrome() { + let ui = ui(); + let metrics = frame_metrics(PaneKit::Single, 880.0); + let metrics_wide = frame_metrics(PaneKit::Single, 1180.0); + + let chrome = |two_line: bool| { + ui.metrics().titlebar_height + ui.metrics().header_height(two_line) + 2.0 * metrics.gutter + }; + + // 真值来自 token:自绘标题栏 48 + 两行页头 84 + 上下页边距 2×24 = 180。 + assert_eq!( + chrome(true), + 48.0 + 84.0 + 2.0 * 24.0, + "chrome 与 design token 不一致(titlebar_height / page_header_tall_height / Expanded gutter)" + ); + assert_eq!(body_height(&ui, metrics, 560.0, true), 560.0 - chrome(true)); + // 单行页头少 28(84 − 56 = `page_header_tall_height` − `page_header_height`)。 + assert_eq!( + body_height(&ui, metrics, 560.0, false), + 560.0 - chrome(false) + ); + assert!( + body_height(&ui, metrics, 560.0, true) < body_height(&ui, metrics, 560.0, false), + "带副标题的页头更高,正文可用高必须更小" + ); + // 窄窗(Compact < 600)页边距更小 ⇒ 同样窗口高下正文更高。 + let compact = frame_metrics(PaneKit::Single, 560.0); + assert_eq!(compact.class, crate::pages::layout::WidthClass::Compact); + assert!( + compact.gutter < metrics_wide.gutter, + "Compact 档的页边距应小于 Expanded(16 < 24)" + ); + assert!( + body_height(&ui, compact, 560.0, true) > body_height(&ui, metrics_wide, 560.0, true), + "页边距变小 ⇒ 正文可用高变大" + ); + // 窗口比外壳还矮时不返回负数(不许把版面算成 NaN/负值)。 + assert_eq!(body_height(&ui, metrics, 100.0, true), 0.0); +} diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs index 1d53be4..8af92b5 100644 --- a/crates/app-iced/src/pages/instances/tests/i18n.rs +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -61,15 +61,18 @@ const PAGE_SOURCES: [(&str, &str); 6] = [ /// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | /// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | /// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | -const ALLOWED_LITERALS: [&str; 22] = [ +const ALLOWED_LITERALS: [&str; 24] = [ "{date} {time} UTC", "isolated", "shared", "pid", - // M161:`InstancesFailed` 拆成「阶段 + 原文」之后,失败行改由 `LaunchStageFailed` 渲染, - // 它的两个占位符名是 `{stage}` / `{detail}`(占位符名不是文案)。 + // M161:失败行由 `LaunchStageFailed` / `InstancesFailed` / `InstancesCrashed` 渲染, + // 占位符名是 `{stage}` / `{detail}` / `{reason}` / `{code}`(占位符名不是文案); + // `{outcome}` / `{seconds}` 是收尾 Toast 的。 "stage", "detail", + "reason", + "code", "outcome", "seconds", "Instances", diff --git a/crates/app-iced/src/pages/settings/tests/update_section.rs b/crates/app-iced/src/pages/settings/tests/update_section.rs index f90e8dd..5cc288a 100644 --- a/crates/app-iced/src/pages/settings/tests/update_section.rs +++ b/crates/app-iced/src/pages/settings/tests/update_section.rs @@ -172,6 +172,17 @@ fn the_buttons_are_disabled_exactly_while_something_is_running() { assert!(applying.busy()); assert!(!applying.can_apply(), "装的时候安装键置灰,不是消失"); assert!(!applying.can_check(), "装的时候也不许再来一条检查"); + // 状态行在这一档也必须说「正在安装…」(复审 P2 附注:此前只有截图兜着)。 + assert_eq!( + super::super::update::status_text(catalog, &applying), + (catalog.text(Key::SettingsUpdateApplying).to_string(), false), + "applying 档的状态行必须是「正在安装…」且不是错误色" + ); + // 同一档的两句不许撞(「正在安装」≠「正在检查」)。 + assert_ne!( + catalog.text(Key::SettingsUpdateApplying), + catalog.text(Key::SettingsUpdateChecking) + ); // 结论回来(这里是最坏的结论:失败)⇒ 恢回可点。 let failed = Update { diff --git a/crates/app-iced/src/pages/settings/update.rs b/crates/app-iced/src/pages/settings/update.rs index 8b65c2c..ae984c8 100644 --- a/crates/app-iced/src/pages/settings/update.rs +++ b/crates/app-iced/src/pages/settings/update.rs @@ -166,11 +166,7 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element // 下载/校验/替换期间先说这一句:设置页的按钮在这一刻也点不动(见下面 `applying` 分支), // 用户得知道「它正在干活」而不是「点了没反应」。 - let (text, error) = if update.applying { - (catalog.text(Key::SettingsUpdateApplying).to_string(), false) - } else { - status_line(catalog, update) - }; + let (text, error) = status_text(catalog, update); let color = ui.color(if error { Role::Error } else { @@ -242,6 +238,18 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element block.into() } +/// 状态行此刻那句话(**唯一**产地:`applying` 与四态都在这里分派)。 +/// +/// 拆成函数是为了可断言:`applying` 这一档此前只有截图兜着(复审 P2 附注), +/// `Element` 没有内省接口,而上屏文本只能从**取词的那一个入口**取。 +pub(super) fn status_text(catalog: Catalog, update: &Update) -> (String, bool) { + if update.applying { + // 「正在下载/校验/替换」不是错误色(它只是在干活)。 + return (catalog.text(Key::SettingsUpdateApplying).to_string(), false); + } + status_line(catalog, update) +} + /// 一条状态行(文案 + 是不是「错」那一类)。 /// /// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。 diff --git a/crates/cli/src/commands/pack.rs b/crates/cli/src/commands/pack.rs index b762652..a3620ee 100644 --- a/crates/cli/src/commands/pack.rs +++ b/crates/cli/src/commands/pack.rs @@ -194,26 +194,73 @@ fn outcome( if let Some(hint) = error.hint() { human.push(format!("提示:{hint}")); } - if report.rolled_back { - human.push("已整包回滚:目标目录与导入前一致".to_string()); - } else if let Some(rollback) = &report.rollback { - human.push(format!( - "回滚未完整:{} 项没回去(详见 --json 的 rollback.failed)", - rollback.failure_count() - )); - } + human.push(rollback_line(report)); } Outcome::new(Report { data, human }).with_exit_code(report.status.exit_code()) } -/// 状态的人读名。**失败要按回滚是否干净分叉**:不完整时不能说"已回滚"。 +/// 状态的人读名。**失败要按「动没动过目标目录」分叉**(见 [`rollback_line`])。 fn status_label(report: &ImportReport) -> &'static str { - match report.status { - ImportStatus::Success => "成功", - ImportStatus::Partial => "部分成功", - ImportStatus::Failed if report.rolled_back => "失败(已回滚)", - ImportStatus::Failed => "失败(回滚不完整)", + match rollback_stage(report) { + Some(RollbackStage::NotStarted) => "失败(未写入:目标目录没被动过)", + Some(RollbackStage::RolledBack) => "失败(已回滚)", + Some(RollbackStage::Incomplete) => "失败(回滚不完整)", + None => match report.status { + ImportStatus::Success => "成功", + _ => "部分成功", + }, + } +} + +/// 「这次失败到底动没动过目标目录」(三档)。 +/// +/// 与 `presenter` 的 `host::addon::pack::convert::failure_stage` **同义**——CLI 不依赖 presenter +/// 层(`unlucky-cli` 只依赖 core + agent),所以这套判据在这里各写一份。已登记 finding: +/// 更干净的做法是把三档挪进 `core::addon::import::ImportReport`(那儿有 `rollback` / +/// `rolled_back` 两个机器位),两处都改成读它。 +/// +/// 为什么要三档而不是一个 `rolled_back` 布尔:core 的 `rolled_back` 在「回滚完整完成」与 +/// **「从未写过目标目录」**两种情形下都是 `true`。只按它出词,预检失败会被说成「已整包回滚:目标 +/// 目录与导入前一致」——那句话在**一个字节都没写**时是假的(M161 复审的 P1 同族问题)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RollbackStage { + /// 预检/装配阶段就失败:目标目录一个字节都没写 + NotStarted, + /// 写过,回滚**完整**完成 + RolledBack, + /// 写过,回滚有失败项(目标目录可能与导入前不同) + Incomplete, +} + +/// 三档判据(与 presenter 那份逐条对应)。 +fn rollback_stage(report: &ImportReport) -> Option { + if report.status != ImportStatus::Failed { + return None; + } + Some(match &report.rollback { + Some(outcome) if outcome.is_complete() => RollbackStage::RolledBack, + Some(_) => RollbackStage::Incomplete, + None if report.rolled_back => RollbackStage::NotStarted, + None => RollbackStage::Incomplete, + }) +} + +/// 失败收尾那一段人读文案(**三档三句**,不许共用)。 +fn rollback_line(report: &ImportReport) -> String { + match rollback_stage(report) { + Some(RollbackStage::NotStarted) => { + "预检未通过:目标目录一个字节都没写(不需要回滚)".to_string() + } + Some(RollbackStage::RolledBack) => "已整包回滚:目标目录与导入前一致".to_string(), + Some(RollbackStage::Incomplete) => match &report.rollback { + Some(rollback) => format!( + "回滚未完整:{} 项没回去(详见 --json 的 rollback.failed)", + rollback.failure_count() + ), + None => "回滚状态不明:目标目录可能与导入前不同(详见 --json)".to_string(), + }, + None => String::new(), } } @@ -244,3 +291,86 @@ fn build_engine(ctx: &Ctx) -> DownloadEngine { Arc::new(ContentCache::open(&cache_root)), ) } + +#[cfg(test)] +mod tests { + //! `pack import` 失败收尾的三档文案(M161 复审「不计入但该记」第一条:CLI 也曾把 + //! 预检失败说成「已整包回滚」)。 + //! + //! 抓得住:三档塌成两句(预检 → 已回滚);`is_complete()` 被丢掉;`status != Failed` 也出话。 + + use std::path::PathBuf; + + use unlucky_core::addon::import::{ImportError, ImportReport, ImportStatus, RollbackOutcome}; + + use super::*; + + fn failed() -> ImportReport { + let mut report = ImportReport::empty(PathBuf::from("C:/tmp/.minecraft")); + report.fail(ImportError::integrity("sha1 mismatch")); + report + } + + /// 三档三句,**互不相同**(同族的谎就是这个 mission 要消灭的那类)。 + #[test] + fn the_three_rollback_stages_get_three_different_sentences() { + // ① 预检没过:一个字节都没写。 + let mut preflight = failed(); + preflight.rolled_back = true; + assert_eq!(rollback_stage(&preflight), Some(RollbackStage::NotStarted)); + let not_started = rollback_line(&preflight); + assert!(not_started.contains("没写"), "{not_started}"); + assert!( + !not_started.contains("回滚:目标目录"), + "预检失败不许说「已回滚」:{not_started}" + ); + assert_eq!(status_label(&preflight), "失败(未写入:目标目录没被动过)"); + + // ② 写过又完整回滚。 + let mut rolled_back = failed(); + rolled_back.rolled_back = true; + rolled_back.rollback = Some(RollbackOutcome { + attempted: 2, + failed: Vec::new(), + }); + assert_eq!( + rollback_stage(&rolled_back), + Some(RollbackStage::RolledBack) + ); + assert!(rollback_line(&rolled_back).contains("已整包回滚")); + assert_eq!(status_label(&rolled_back), "失败(已回滚)"); + + // ③ 回滚没做完。 + let mut incomplete = failed(); + incomplete.rolled_back = false; + incomplete.rollback = Some(RollbackOutcome { + attempted: 2, + failed: vec!["mods/a.jar".to_string()], + }); + assert_eq!(rollback_stage(&incomplete), Some(RollbackStage::Incomplete)); + assert!(rollback_line(&incomplete).contains("1 项没回去")); + assert_eq!(status_label(&incomplete), "失败(回滚不完整)"); + + // 三句必须互不相同(把任何两档并成一句,这里就红)。 + let lines = [ + rollback_line(&preflight), + rollback_line(&rolled_back), + rollback_line(&incomplete), + ]; + assert_eq!( + lines.iter().collect::>().len(), + 3, + "{lines:?}" + ); + } + + /// 不是致命失败 ⇒ 没有回滚那一段话(成功/部分成功不该出现「已回滚」)。 + #[test] + fn a_non_fatal_report_says_nothing_about_rollback() { + let mut report = ImportReport::empty(PathBuf::from("C:/tmp")); + report.status = ImportStatus::Partial; + assert_eq!(rollback_stage(&report), None); + assert_eq!(rollback_line(&report), ""); + assert_eq!(status_label(&report), "部分成功"); + } +} diff --git a/crates/core/tests/setting/update_keys.rs b/crates/core/tests/setting/update_keys.rs index 16ee764..646d89b 100644 --- a/crates/core/tests/setting/update_keys.rs +++ b/crates/core/tests/setting/update_keys.rs @@ -226,6 +226,109 @@ fn an_empty_legacy_file_still_means_the_default() { assert!(update_prefs::auto_check(store.doc()), "缺字段取出厂值 true"); } +/// **源指纹闸门**:旧文件还在原地(改名失败 / 用户从备份捞回来)但内容没变 ⇒ 一次都不再迁。 +/// +/// 复审 P2-3:把 `update_prefs.rs` 里那道闸门整段删掉,113 条 setting 用例全绿——因为 +/// `running_twice_is_idempotent` 的第二遍之所以空操作,是第一遍已经把文件改名成 `.migrated`, +/// **根本没走到闸门**。这条用例是唯一真正撞它的:先按旧文件字节写好收据,再让 `prefs.json` +/// 原地留着调 `migrate`。 +/// +/// 抓得住:①闸门被删(会走「新值胜出」那一支并**重写收据**,`applied_at_ms` 变);②指纹比对 +/// 写成「有收据就跳过」而不是「指纹相同才跳过」(下面第二条反证);③闸门放行时忘了退役旧文件。 +#[test] +fn the_receipt_gate_skips_a_replayed_source_byte_for_byte() { + let temp = TempDir::new("m161-gate-replay"); + let legacy = temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#); + let receipt_path = temp.child(&format!("config/{RECEIPT_FILE}")); + + // 先按真实字节写一张收据(模拟「上一轮迁过、但旧文件又回到了原地」)。 + let bytes = std::fs::read(&legacy).expect("读旧文件"); + let receipt = unlucky_core::platform::receipt::MigrationReceipt::legacy_config( + legacy.clone(), + temp.child("config/launcher.json"), + unlucky_core::platform::receipt::fingerprint_bytes(&bytes), + 1_600_000_000_000, + ); + unlucky_core::platform::receipt::write(&receipt_path, &receipt).expect("写收据"); + let receipt_before = temp.read(&format!("config/{RECEIPT_FILE}")); + + let outcome = run(&temp); + assert_eq!( + outcome.as_str(), + "nothing", + "源指纹相同 ⇒ 必须走闸门(Nothing),实际 {outcome:?}" + ); + // 关键断言:**收据没被重写**(重写 = 走了迁移分支,闸门失效)。 + assert_eq!( + temp.read(&format!("config/{RECEIPT_FILE}")), + receipt_before, + "收据被重写了 ⇒ 闸门没拦住重放" + ); + // 主配置一个字都没写(闸门那一支只退役旧文件)。 + assert!(!temp.exists("config/launcher.json"), "闸门放行时不该建配置"); + // 旧文件仍然退役(它是真相源之外的东西,留着只会下次再撞一次)。 + assert!(!temp.exists("cache/update/prefs.json")); + + // 反证:源文件**变了**(false → true)⇒ 指纹不同 ⇒ 允许重放,收据被更新。 + temp.write("cache/update/prefs.json", r#"{"auto_check": true}"#); + let outcome = run(&temp); + assert_eq!( + outcome.as_str(), + "migrated", + "源内容变了就该重放,实际 {outcome:?}" + ); + assert_ne!( + temp.read(&format!("config/{RECEIPT_FILE}")), + receipt_before, + "重放要写新收据(指纹变了)" + ); +} + +/// **`Refused`:主配置不可写时不动旧文件**(唯一有用户数据风险的分支)。 +/// +/// 造法:主配置的 `$schema` 写成**更高的 minor**(1.7.0 > 1.6.0)⇒ `SchemaCompat` 判 +/// `ReadOnlyPreserveSchema` ⇒ `FileAccess::ReadOnly` ⇒ `can_save()` false。 +/// 抓得住:把「写不进去」当成「迁完了」(旧文件被改名,用户的值两边都没有了)。 +#[test] +fn a_read_only_launcher_config_never_retires_the_legacy_file() { + let temp = TempDir::new("m161-refused"); + temp.write( + "config/launcher.json", + &json!({ + "$schema": "https://schemas.unlucky-launcher.dev/ull/launcher/1.7.0", + "schemaVersion": 1 + }) + .to_string(), + ); + temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#); + + let outcome = run(&temp); + let Migration::Refused { reason } = &outcome else { + panic!("只读主配置必须走 Refused,实际 {outcome:?}"); + }; + // 拒绝的理由必须是「先看能不能写」这一条(而不是写到一半才发现 `set` 被拒): + // 抓得住把 `can_save()` 守卫删掉这种变异——那条路径的 reason 是「写 update.auto_check 被拒」。 + assert!( + reason.contains("不可写"), + "应当在动手前就判定主配置不可写,实际理由:{reason}" + ); + // 旧文件原样留着(下一次还能再试)。 + assert!( + temp.exists("cache/update/prefs.json"), + "写不进去时**不许**退役旧文件——用户的值不能两边都没有" + ); + assert!( + !temp.exists("cache/update/prefs.json.migrated"), + "不许留下「已迁移」的痕迹" + ); + // 不写收据(没迁成就不该有收据)。 + assert!(!temp.exists(&format!("config/{RECEIPT_FILE}"))); + // 主配置一个字都没改(还是那份 1.7.0)。 + let text = temp.read("config/launcher.json"); + assert!(text.contains("1.7.0"), "{text}"); + assert!(!text.contains("auto_check"), "只读文件不许被写:{text}"); +} + /// 真 `$schema`(加载不降级才看得到显式值)。 fn schema_url() -> String { use unlucky_core::setting::keys::launcher_version; diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 602e29f..166d720 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -35,7 +35,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("InstancesIsolationIsolated", "Isolated", "独立目录"), ("InstancesIsolationShared", "Shared", "共用目录"), ("InstancesRunning", "Running · pid {pid}", "运行中 · pid {pid}"), - ("InstancesFailedStage", "Launch failed · {stage}", "启动失败 · {stage}"), + ("InstancesFailed", "Launch failed · {reason}", "启动失败 · {reason}"), + ("InstancesCrashed", "the game crashed (exit {code})", "游戏崩溃(退出码 {code})"), + ("InstancesSilentFailed", "exit 0, but never started", "退出码 0,没起来"), ("InstancesStageDownload", "Downloading", "下载中"), ("InstancesStageVerify", "Verifying", "校验中"), ("InstancesStageLaunch", "Starting", "启动中"), diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 77ff703..3fbd0f9 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -73,7 +73,13 @@ macro_rules! instances_entries { InstancesIsolationShared => ("Shared", "共用目录"), // ── 运行状态与启动阶段(M120;数字与分隔符不随语言变形) InstancesRunning => ("Running · pid {pid}", "运行中 · pid {pid}"), - InstancesFailedStage => ("Launch failed · {stage}", "启动失败 · {stage}"), + // 失败状态行的**框架**:`{reason}` 是「哪一步」或「结局」的短句 + // (M161 复审 P1-1:「起来了又崩」与「没起来」必须能说出不同的话)。 + InstancesFailed => ("Launch failed · {reason}", "启动失败 · {reason}"), + InstancesCrashed => ("the game crashed (exit {code})", "游戏崩溃(退出码 {code})"), + // 单行列表行也要放得下(`Launch failed · ` + 这一句 ≤ 行宽), + // 「exit 0 却没起来」这一条信息必须留(复审 P1-1 第 ③ 条)。 + InstancesSilentFailed => ("exit 0, but never started", "退出码 0,没起来"), InstancesStageDownload => ("Downloading", "下载中"), InstancesStageVerify => ("Verifying", "校验中"), InstancesStageLaunch => ("Starting", "启动中"), diff --git a/crates/presenter/src/dto/instances.rs b/crates/presenter/src/dto/instances.rs index 93bce4d..3c65ad6 100644 --- a/crates/presenter/src/dto/instances.rs +++ b/crates/presenter/src/dto/instances.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -pub use super::launch_failure::{LaunchFailureStage, LaunchFailureView}; +pub use super::launch_failure::{LaunchFailureCause, LaunchFailureStage, LaunchFailureView}; use std::path::PathBuf; @@ -212,13 +212,12 @@ pub enum InstanceRuntime { /// 上次启动失败(磁贴显示「重试」) /// /// M161 之前这里只有一个 `reason: String`,而且里面已经写着「启动失败:…」—— - /// 界面再包一层 `InstancesFailed`(`Launch failed · {reason}`)就成了「英文前缀 + 中文句子」, - /// 中文界面则是「启动失败 · 启动失败:…」。现在拆成「阶段(可本地化)+ 原文(不翻译)」。 + /// 界面再包一层就成了「英文前缀 + 中文句子」,中文界面则是「启动失败 · 启动失败:…」。 + /// 现在给的是结构化原因(见 [`LaunchFailureCause`]):**没起来**与**起来又崩**是两种形状, + /// 界面各说各的话(复审 P1-1:早先崩溃被说成「启动游戏进程失败」)。 Failed { - /// 失败阶段 - stage: LaunchFailureStage, - /// 原始错误文本(不翻译) - detail: String, + /// 这一场为什么是失败态 + cause: LaunchFailureCause, }, } diff --git a/crates/presenter/src/dto/launch_failure.rs b/crates/presenter/src/dto/launch_failure.rs index 964e552..acf3c3a 100644 --- a/crates/presenter/src/dto/launch_failure.rs +++ b/crates/presenter/src/dto/launch_failure.rs @@ -72,3 +72,81 @@ impl LaunchFailureView { } } } + +/// 磁贴/详情上「这一场为什么是失败态」的**完整原因**。 +/// +/// 两种**互斥**的形状,混成一种就会说假话(M161 复审 P1-1 抓的就是这个): +/// +/// | 形状 | 意思 | 界面该说什么 | +/// |---|---|---| +/// | [`Self::Stage`] | 进程**没起来**(装配/启动阶段失败) | 「启动失败 · <哪一步>:<原文>」 | +/// | [`Self::Crashed`] | 进程起来过,**崩了** | 「启动失败 · 游戏崩溃(退出码 N)」 | +/// | [`Self::SilentFailure`] | 进程起来过,但 exit 0 却没真正跑起来 | 「启动失败 · 退出码 0,但游戏没起来」 | +/// +/// 为什么把结局也放在这里:磁贴的状态行与 Toast 必须说同一件事。早先「起来了又崩」被 +/// 硬塞进 [`LaunchFailureStage::Spawn`](因为 `LaunchFinished.failure` 在这次是 `None`), +/// 于是磁贴写「启动游戏进程失败」而 Toast 写「游戏崩溃」——同一时刻两句话打架,而且 +/// `detail` 还漏出了 `exit Some(1)` 这种 Rust Debug 文本。 +/// +/// `detail` 只在 [`Self::Stage`] 里有:那是 core / io 给的原文(路径、版本 id、os error), +/// **故意不翻译**。结局类没有什么「原文」可给,退出码是数字,措辞由界面按语言出。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum LaunchFailureCause { + /// 装配/启动阶段失败:进程**没起来** + Stage { + /// 失败在上面的哪一步 + stage: LaunchFailureStage, + /// 那一步的原始错误文本(不翻译) + detail: String, + }, + /// 进程起来过,结局是崩溃 + Crashed { + /// 退出码(`None` = 平台没给) + exit_code: Option, + }, + /// 进程起来过,但疑似静默失败(exit 0 却没真正跑起来) + SilentFailure { + /// 退出码(通常是 0) + exit_code: Option, + }, +} + +impl LaunchFailureCause { + /// 稳定 slug(日志 / 测试用;**不要改名**)。 + #[must_use] + pub const fn slug(&self) -> &'static str { + match self { + Self::Stage { .. } => "stage", + Self::Crashed { .. } => "crashed", + Self::SilentFailure { .. } => "silent_failure", + } + } + + /// 进程到底有没有起来过。 + #[must_use] + pub const fn started(&self) -> bool { + !matches!(self, Self::Stage { .. }) + } + + /// 由一次收尾组装原因。 + /// + /// `failure` 有值 = 装配/启动阶段失败(进程没起来);`None` = 跑起来了,此时**只能** + /// 看 `outcome`(`Ok` 不该走到这里,兜底按崩溃处理)。 + #[must_use] + pub fn of( + outcome: crate::dto::LogOutcome, + failure: Option<&LaunchFailureView>, + exit_code: Option, + ) -> Self { + match failure { + Some(failure) => Self::Stage { + stage: failure.stage, + detail: failure.detail.clone(), + }, + None if outcome == crate::dto::LogOutcome::SilentFail => { + Self::SilentFailure { exit_code } + } + None => Self::Crashed { exit_code }, + } + } +} diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 85b5118..2f91cac 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -46,8 +46,8 @@ pub use content::{ pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch}; pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage}; pub use instances::{ - CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureStage, - LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress, + CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause, + LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress, }; pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView}; pub use launch_args::{LaunchArgsPatch, LaunchArgsView}; diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index 0faadc5..4d167e9 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -375,3 +375,7 @@ pub(super) fn slug(text: &str) -> String { trimmed } } + +#[cfg(test)] +#[path = "convert/stage_tests.rs"] +mod stage_tests; diff --git a/crates/presenter/src/host/addon/pack/convert/stage_tests.rs b/crates/presenter/src/host/addon/pack/convert/stage_tests.rs new file mode 100644 index 0000000..eae6751 --- /dev/null +++ b/crates/presenter/src/host/addon/pack/convert/stage_tests.rs @@ -0,0 +1,108 @@ +//! M161 复审 P2-1:`failure_stage` 是⑥三档的**唯一**判定点,此前零覆盖(把它改成恒 +//! `RolledBack`,presenter 254 条全绿)。这里直接喂 core 的机器位,断言的是**判定**, +//! 不是渲染(渲染在 app-iced 的 `packui/model/tests/stage.rs`)。 +//! +//! 抓得住:`None if report.rolled_back` 被写反(预检失败 → 「已回滚」,正是⑥要消灭的谎); +//! `is_complete()` 被丢掉(回滚没做完也说成零残留);`status != Failed` 也发阶段。 + +use std::path::PathBuf; + +use unlucky_core::addon::import::{ImportError, ImportReport, ImportStatus, RollbackOutcome}; + +use super::*; + +/// 一份「失败」的空报告(还没决定回滚结果)。 +fn failed() -> ImportReport { + let mut report = ImportReport::empty(PathBuf::from("C:/tmp/.minecraft")); + report.fail(ImportError::integrity("sha1 mismatch")); + report +} + +/// 预检就失败(**从未写过目标目录**)⇒ `NotStarted`。 +/// +/// core 的 `abandoned()` 就是这一形:`rollback == None` + `rolled_back == true`。 +#[test] +fn preflight_failure_is_not_started() { + let mut report = failed(); + report.rolled_back = true; + assert_eq!( + failure_stage(&report), + Some(ImportFailureStageView::NotStarted), + "没写过目标目录就是「根本没开始」,不许说成「已回滚」" + ); + assert_eq!(failure_stage(&report).unwrap().slug(), "not_started"); + // 渲染层同一份判据(`failed_report_with` 走的是同一条)。 + assert_eq!( + failed_report_with( + PathBuf::from("C:/tmp"), + std::path::Path::new("Better MC.mrpack"), + ImportErrorView { + code: "pack_traversal".to_string(), + label: "Path traversal".to_string(), + message: "../etc/passwd".to_string(), + hint: None, + }, + ) + .failure_stage, + Some(ImportFailureStageView::NotStarted), + "宿主侧失败(目标实例不可用)也是「没开始写」,不是「已回滚」" + ); +} + +/// 跑过回滚且完整 ⇒ `RolledBack`。 +#[test] +fn a_complete_rollback_is_rolled_back() { + let mut report = failed(); + report.rolled_back = true; + report.rollback = Some(RollbackOutcome { + attempted: 3, + failed: Vec::new(), + }); + assert_eq!( + failure_stage(&report), + Some(ImportFailureStageView::RolledBack) + ); + assert_eq!(failure_stage(&report).unwrap().slug(), "rolled_back"); +} + +/// 跑过回滚但有失败项 ⇒ `RollbackIncomplete`(目标目录可能与导入前不同)。 +#[test] +fn a_partial_rollback_is_incomplete() { + let mut report = failed(); + report.rolled_back = false; + report.rollback = Some(RollbackOutcome { + attempted: 3, + failed: vec!["mods/a.jar: 占用中".to_string()], + }); + assert_eq!( + failure_stage(&report), + Some(ImportFailureStageView::RollbackIncomplete) + ); + assert_eq!( + failure_stage(&report).unwrap().slug(), + "rollback_incomplete" + ); +} + +/// 既没回滚也没宣称干净(core 的 `failed()` 单独一支)⇒ 只能如实说「可能不一致」。 +#[test] +fn a_failure_without_any_claim_is_incomplete_not_not_started() { + let mut report = failed(); + report.rolled_back = false; + report.rollback = None; + assert_eq!( + failure_stage(&report), + Some(ImportFailureStageView::RollbackIncomplete), + "没跑回滚也不宣称干净:不许说「没开始写」" + ); +} + +/// 不是致命失败 ⇒ 没有「失败阶段」可言(成功/部分成功都不该发这一段)。 +#[test] +fn a_non_fatal_report_has_no_stage() { + let mut report = ImportReport::empty(PathBuf::from("C:/tmp")); + report.rolled_back = false; + assert_eq!(failure_stage(&report), None, "Success 不该有失败阶段"); + report.status = ImportStatus::Partial; + assert_eq!(failure_stage(&report), None, "Partial 不该有失败阶段"); +} diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index 98309d9..e25de91 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -2,10 +2,7 @@ //! //! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。 -use crate::dto::{ - AppEvent, CoreEvent, InstanceRuntime, LaunchFailureStage, LaunchFailureView, LogOutcome, - PackEvent, -}; +use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LaunchFailureCause, LogOutcome, PackEvent}; use crate::toast::ToastKind; use super::{addon, logs, CoreHost, HostTask, LiveLaunch}; @@ -163,20 +160,19 @@ impl CoreHost { self.failed.remove(&finished.id); InstanceRuntime::Idle } else { - // 记住失败(阶段 + 原文):随后的 refresh_instances 会重建快照, + // 记住失败:随后的 refresh_instances 会重建快照, // 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。 - let failure = finished.failure.clone().unwrap_or_else(|| { - // 跑起来再崩:没有「装配阶段」可言,用结局本身当阶段外的说明。 - LaunchFailureView::new( - LaunchFailureStage::Spawn, - format!("exit {:?}", finished.exit_code), - ) - }); - self.failed.insert(finished.id.clone(), failure.clone()); - InstanceRuntime::Failed { - stage: failure.stage, - detail: failure.detail, - } + // + // 原因**按形状**组装(复审 P1-1):`failure` 有值 = 进程没起来(阶段失败); + // 没值 = 跑起来了,那就必须看 `outcome`(崩溃 / 疑似静默失败), + // 不许再拿「启动游戏进程失败」去顶——那句话在进程明明起来时说不过去。 + let cause = LaunchFailureCause::of( + finished.outcome, + finished.failure.as_ref(), + finished.exit_code, + ); + self.failed.insert(finished.id.clone(), cause.clone()); + InstanceRuntime::Failed { cause } }; self.send(CoreEvent::InstanceRuntimeChanged { id: finished.id.clone(), diff --git a/crates/presenter/src/host/launch_state_tests.rs b/crates/presenter/src/host/launch_state_tests.rs index f313fbd..6bff8e3 100644 --- a/crates/presenter/src/host/launch_state_tests.rs +++ b/crates/presenter/src/host/launch_state_tests.rs @@ -213,13 +213,15 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() { .into_iter() .find(|instance| instance.id == id) .expect("实例存在"); + // 跑起来又崩:原因是**结局**,不是「进程起不来」(复审 P1-1)。 + // 抓得住:把崩溃塞回 `LaunchFailureStage::Spawn`(磁贴会说「启动游戏进程失败」,假话), + // 或者把退出码漏成 `Some(1)` 这种 Debug 文本(这里只认数字)。 assert_eq!( failed.runtime, InstanceRuntime::Failed { - stage: crate::dto::LaunchFailureStage::Spawn, - detail: "exit Some(1)".to_string() + cause: crate::dto::LaunchFailureCause::Crashed { exit_code: Some(1) } }, - "失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)" + "崩溃要如实报成「起来了又崩」,且经得住一次刷新" ); } @@ -258,13 +260,101 @@ fn launch_end_before_launch_started_still_releases_the_instance() { assert_eq!( failed.runtime, InstanceRuntime::Failed { - stage: crate::dto::LaunchFailureStage::Identity, - detail: "找不到账户 `steve`".to_string() + cause: crate::dto::LaunchFailureCause::Stage { + stage: crate::dto::LaunchFailureStage::Identity, + detail: "找不到账户 `steve`".to_string() + } }, - "早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉" + "早失败(进程没起来)要留在阶段那一支,不能被 Idle 盖掉" ); } +/// **三种失败形状各归各的**(复审 P1-1 的核心判据)。 +/// +/// | 收尾事件 | 期望原因 | +/// |---|---| +/// | `failure: Some(..)` | `Stage{..}`(进程**没起来**)| +/// | `failure: None` + `Crashed` | `Crashed{exit_code}`(起来又崩)| +/// | `failure: None` + `SilentFail` | `SilentFailure{exit_code}` | +/// +/// 抓得住:把「起来了又崩」塞进 `LaunchFailureStage::Spawn`(磁贴会说「启动游戏进程失败」, +/// 而进程明明起来了);或者把静默失败也塌成崩溃(丢掉「退出码 0 却没跑起来」这条信息)。 +/// 断言的是**枚举变体**,不是字符串里有没有某个词。 +#[test] +fn a_launch_end_without_a_stage_reports_the_outcome_not_a_start_failure() { + use crate::dto::{LaunchFailureCause, LaunchFailureStage, LaunchFailureView}; + + /// 一行用例:`(标签, 结局, 阶段失败, 退出码, 期望原因)`。 + type Case = ( + &'static str, + LogOutcome, + Option, + Option, + LaunchFailureCause, + ); + + let cases: Vec = vec![ + ( + "spawn-failed", + LogOutcome::Crashed, + Some(LaunchFailureView::new( + LaunchFailureStage::Spawn, + "os error 5", + )), + None, + LaunchFailureCause::Stage { + stage: LaunchFailureStage::Spawn, + detail: "os error 5".to_string(), + }, + ), + ( + "crashed-after-start", + LogOutcome::Crashed, + None, + Some(1), + LaunchFailureCause::Crashed { exit_code: Some(1) }, + ), + ( + "silent-after-start", + LogOutcome::SilentFail, + None, + Some(0), + LaunchFailureCause::SilentFailure { exit_code: Some(0) }, + ), + ]; + + for (label, outcome, failure, exit_code, expected) in cases { + let (mut host, id, _root) = host_with_instance(label); + host.mark_pending(&id); + host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished { + id: id.clone(), + session: format!("20261031T120000-{label}"), + outcome, + exit_code, + uptime_ms: 1_000, + failure, + }))); + let failed = instances_from_events({ + host.refresh_instances(); + host.drain_events() + }) + .into_iter() + .find(|instance| instance.id == id) + .expect("实例存在"); + match (&failed.runtime, &expected) { + (InstanceRuntime::Failed { cause }, want) => { + assert_eq!(cause, want, "{label}:失败原因说错了"); + assert_eq!( + cause.slug(), + want.slug(), + "{label}:slug 与变体不一致(日志/事件流按 slug 判)" + ); + } + (other, want) => panic!("{label}:期望 Failed{{cause:{want:?}}},实际 {other:?}"), + } + } +} + /// 从事件队列里取最后一次 `InstancesChanged`。 fn instances_from_events(events: Vec) -> Vec { events diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index c503aa1..c830ebd 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -41,7 +41,7 @@ use std::time::Duration; use crate::dto::{ AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView, - InstanceRuntime, JavaChainStep, LaunchFailureView, NoticeView, SkinImage, + InstanceRuntime, JavaChainStep, LaunchFailureCause, NoticeView, SkinImage, }; use crate::mock::MockBackend; use crate::toast::{Toast, ToastKind}; @@ -93,7 +93,7 @@ pub struct CoreHost { pending: BTreeSet, /// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观) /// 上次启动失败(阶段 + 原文;磁贴的「失败/重试」外观靠它重建) - failed: BTreeMap, + failed: BTreeMap, /// 实时日志尾随器 tail: logs::Tail, /// AI 对话的临时后端(agent GUI host 落地前的占位) @@ -229,10 +229,9 @@ impl CoreHost { stage: crate::dto::LaunchStage::Launch, progress: crate::dto::Progress { done: 0, total: 1 }, }; - } else if let Some(failure) = self.failed.get(&instance.id) { + } else if let Some(cause) = self.failed.get(&instance.id) { instance.runtime = InstanceRuntime::Failed { - stage: failure.stage, - detail: failure.detail.clone(), + cause: cause.clone(), }; } } diff --git a/crates/presenter/src/mock_data.rs b/crates/presenter/src/mock_data.rs index 1b8f586..b985977 100644 --- a/crates/presenter/src/mock_data.rs +++ b/crates/presenter/src/mock_data.rs @@ -68,9 +68,9 @@ pub fn mock_instances() -> Vec { version: "21.1.72".to_string(), }, last_played: Some("2026-10-27T12:40:00Z".to_string()), + // 静默失败 = **起来过**但 exit 0 没真正跑起来(不是「进程起不来」,见复审 P1-1)。 runtime: InstanceRuntime::Failed { - stage: crate::dto::LaunchFailureStage::Spawn, - detail: "exit 0 但游戏没起来(静默失败)".to_string(), + cause: crate::dto::LaunchFailureCause::SilentFailure { exit_code: Some(0) }, }, icon: None, isolation: "isolated".to_string(),