From 063a1dba39dd102404d2af64eabec03ae3cb63d2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 19:00:08 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(app-iced):=20M95=20=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E9=A1=B5=E6=8E=A5=E7=9C=9F=E7=BB=84=E4=BB=B6=20+=20=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E9=9D=A2=E6=9D=BF=EF=BC=88=E7=AD=89=E5=AE=BD=E6=8A=98?= =?UTF-8?q?=E8=A1=8C=20/=20=E6=8C=89=E4=B8=A5=E9=87=8D=E5=BA=A6=E5=88=86?= =?UTF-8?q?=E7=BB=84=20/=20=E4=B8=80=E8=A1=8C=E6=91=98=E8=A6=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 日志页从 M84 占位换成真组件,数据全部来自 presenter 快照(页面不读盘): - 日志行列表:`pages/logs/lines.rs`,等宽(`BodySmall` 12/16 + mono 族)+ 按词或字形 折行 + 等级色条;**选折行不选横滚**的理由写在模块文档(424px × 200 字符的现实 + mono 族的 CJK 回退会毁掉横滚唯一的收益「列对齐」)。 - 诊断面板:`pages/logs/diagnostic.rs`,按严重度分组(CRITICAL → WARNING → NOTICE), 每条给规则 id / 结论 / 说明 / 建议动作 / 置信度;卡片头钉住「Copy summary」, 摘要是稳定单行的 `ULL-LOGS v1 | …`(崩溃日志换 issue 时直接粘)。 - 空态:真组件 `feedback::EmptyState`(图标块 + 标题 + 说明 + CTA 回实例页)。 - 两块都在 `pages/logs/panel.rs` 里**定高 + 内部独立滚动**(`FillPortion(3):(2)`): 第一版让诊断卡按内容撑开,三条结论就把日志卡压成 0 高度(实拍复现后改的)。 - 视图模型:`pages/logs/model.rs`(纯展示字段)+ `app/view.rs::logs_model` 做快照转换。 - 接线(只追加):`pages/mod.rs` 的 `pub use logs::…` / `page()` 那行 / `Message::CopySummary` / `demo_model` 一行;`pages::Model` 加 `logs` 与 `lang` 两个字段; `app/update.rs` 把 `CopySummary` 落到 `iced::clipboard::write`、`ClearLogs` 清 presenter 的 `log_lines`(呈现层操作,不造命令)。 - i18n:`crates/i18n/src/catalog.rs` 自己锚点下 16 个 key(中英同表);按塔裁决把 `#[cfg(test)] mod tests` 逐字外置到 `crates/i18n/src/catalog/tests.rs` (catalog.rs 415 → 257 行,tests.rs 164 行;7 个用例逐字未动、i18n 23 个用例全绿)。 验证:`fmt --check` / `clippy -p unlucky-app-iced --all-targets -- -D warnings` 干净; `test -p unlucky-app-iced` 272 + 6 全绿、`-p unlucky-i18n` 23 全绿;截图 `.screens/logs-{empty,crash,live}-{light,dark}.png`(1180×760,本地 gitignore)。 遗留(已报 finding):`dto::Diagnosis` 缺 core 的 `severity` 与规则表 `cause.key`; `Action::OpenLogs` 没有对应 `AppCommand`(点了只有占位日志)。 --- crates/app-iced/src/app/update.rs | 31 +-- .../src/app/update/download_wiring.rs | 10 +- .../app-iced/src/app/update/page_messages.rs | 91 +++++++ crates/app-iced/src/app/view.rs | 77 +++++- crates/app-iced/src/pages/logs.rs | 246 +++++++++--------- crates/app-iced/src/pages/logs/demo.rs | 153 +++++++++++ crates/app-iced/src/pages/logs/diagnostic.rs | 222 ++++++++++++++++ crates/app-iced/src/pages/logs/lines.rs | 159 +++++++++++ crates/app-iced/src/pages/logs/model.rs | 218 ++++++++++++++++ crates/app-iced/src/pages/logs/panel.rs | 63 +++++ .../app-iced/src/pages/logs/sample_crash.log | 23 ++ crates/app-iced/src/pages/logs/tests.rs | 209 +++++++++++++++ crates/app-iced/src/pages/mod.rs | 10 +- crates/app-iced/src/pages/model.rs | 11 + crates/i18n/src/catalog.rs | 22 ++ crates/i18n/src/catalog/tests.rs | 4 +- 16 files changed, 1392 insertions(+), 157 deletions(-) create mode 100644 crates/app-iced/src/app/update/page_messages.rs create mode 100644 crates/app-iced/src/pages/logs/demo.rs create mode 100644 crates/app-iced/src/pages/logs/diagnostic.rs create mode 100644 crates/app-iced/src/pages/logs/lines.rs create mode 100644 crates/app-iced/src/pages/logs/model.rs create mode 100644 crates/app-iced/src/pages/logs/panel.rs create mode 100644 crates/app-iced/src/pages/logs/sample_crash.log create mode 100644 crates/app-iced/src/pages/logs/tests.rs diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 36e0218..14f47bf 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -42,6 +42,7 @@ use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW}; use super::CAPTURE_AT_FRAME; mod download; +mod page_messages; impl App { /// 一条消息进来,改状态并返回后续任务。 @@ -96,10 +97,7 @@ impl App { } Task::none() } - Message::Page(message) => { - self.on_page(message); - Task::none() - } + Message::Page(message) => self.on_page(message), // 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转 Message::Select(nav) => { self.state.navigate(route_of(nav)); @@ -275,31 +273,6 @@ impl App { } } - /// 页面消息:导航走路由栈,动作翻成 `AppCommand`。 - fn on_page(&mut self, message: pages::Message) { - // 下载页的四支(含「刷新下载源」)在 `update/download.rs`:本文件 M93 合入后已贴 400 行 - if self.on_download(&message) { - return; - } - - match message { - pages::Message::Navigate(route) => { - self.state.navigate(route); - self.view_dirty = true; - self.refresh_view(); - } - pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), - pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), - pages::Message::Appearance(change) => self.on_appearance(change), - // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) - pages::Message::Action(action) => { - eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); - } - // 下载页四支 +「刷新下载源」已由上面的 `on_download` 消化,这里只是穷尽性兜底 - _ => {} - } - } - /// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。 pub(super) fn dispatch(&mut self, command: AppCommand) { self.backend.dispatch(command); diff --git a/crates/app-iced/src/app/update/download_wiring.rs b/crates/app-iced/src/app/update/download_wiring.rs index c5c06de..fbe6525 100644 --- a/crates/app-iced/src/app/update/download_wiring.rs +++ b/crates/app-iced/src/app/update/download_wiring.rs @@ -41,10 +41,12 @@ fn download_intents_dispatch_real_commands() { Box::new(Recorder(Rc::clone(&log))), SettingsBridge::memory_only(), ); - app.on_page(pages::Message::InstallVersion("1.21.9".to_string())); - app.on_page(pages::Message::CancelInstall); - app.on_page(pages::Message::SetCategory(pages::Category::Release)); - app.on_page(pages::Message::SelectVersion("1.21.8".to_string())); + // M95 起 `on_page` 返回 `Task`(剪贴板命令要走返回的 Task):这里只关心派发与视图模型, + // 任务本身没有需要断言的后效,显式丢弃。 + let _ = app.on_page(pages::Message::InstallVersion("1.21.9".to_string())); + let _ = app.on_page(pages::Message::CancelInstall); + let _ = app.on_page(pages::Message::SetCategory(pages::Category::Release)); + let _ = app.on_page(pages::Message::SelectVersion("1.21.8".to_string())); let expected = vec![ AppCommand::InstallVersion("1.21.9".to_string()), AppCommand::CancelInstall, diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs new file mode 100644 index 0000000..5183a65 --- /dev/null +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -0,0 +1,91 @@ +//! 页面消息的落点(`on_page`):解 M94 冲突时从 `update.rs` 拆出来。 +//! +//! 为什么拆:`main` 上 `update.rs` 已经 **397 行**(M94 自己也是因此把下载页四支拆到 +//! `update/download.rs`),日志页再往里塞「剪贴板 + 清日志」两支就顶破 400 硬线。 +//! 与 M94 同一种做法:`app/update/**` 属本 mission scope。 +//! +//! ## 穷尽性(M94 复审的 p3,本轮收掉) +//! +//! 这里是全 crate **唯一**对 [`pages::Message`] 的穷尽匹配点,所以**没有** `_ => {}` 兜底: +//! 谁往枚举里加变体却忘了接线,编译器立刻报 E0004。原来是 `_ => {}` + 注释「做穷尽性」—— +//! 方向正好写反了,它**取消**穷尽性,把「漏接线」变成「点了没反应」。 +//! +//! 代价:由前置分支(`on_download` / 本文件里的剪贴板分支)先消费的变体,在 match 里**仍要 +//! 显式列出**。那是保住穷尽性的必要代价,也是这条守卫抓得住东西的原因。 +//! +//! ## 边界(抓得住什么 / 抓不住什么) +//! +//! - 抓得住:**加一个 `pages::Message` 变体却没接线** → 编译失败(E0004),不会静默; +//! - 抓不住:加一个 `pages::Action` 变体 —— 它落进 `Action(action)` 的占位分支(打印一行 +//! `[page] 占位动作 …`),编译期不会报。这是占位期的既有设计,不是本文件能守的。 + +use super::*; + +impl App { + /// 页面消息:导航走路由栈,真业务命令派发,纯视图态改本地呈现。 + pub(super) fn on_page(&mut self, message: pages::Message) -> Task { + // 下载页的四支(含「刷新下载源」)在 `update/download.rs`:`update.rs` 早已贴 400 行 + if self.on_download(&message) { + return Task::none(); + } + + match message { + pages::Message::Navigate(route) => { + self.state.navigate(route); + self.view_dirty = true; + self.refresh_view(); + } + pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), + pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), + pages::Message::Appearance(change) => self.on_appearance(change), + // M95:摘要落剪贴板(文本由页面造,这里只搬) + pages::Message::CopySummary(text) => return iced::clipboard::write(text), + // 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档 + pages::Message::SelectVersion(_) + | pages::Message::InstallVersion(_) + | pages::Message::CancelInstall + | pages::Message::SetCategory(_) => debug_assert!(false, "应由 on_download 消费"), + // M95:清空日志是**呈现层**操作(`log_lines` 是 presenter 的公开快照字段, + // 不派业务命令、不写持久状态) + pages::Message::Action(pages::Action::ClearLogs) => { + self.state.log_lines.clear(); + self.view_dirty = true; + self.refresh_view(); + } + // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) + pages::Message::Action(action) => { + eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); + } + } + + Task::none() + } +} + +#[cfg(test)] +mod tests { + /// 抓得住:有人给 `on_page` 的 match 重新加 `_ => {}` 兜底 —— 那正是 M94 复审的 p3: + /// 兜底让「加了变体忘了接线」从**编译错误**退化成「点了没反应」,这条测试把它拽回来。 + /// 抓不住:别的兜底写法(把 match 拆成 if-else 链、给子枚举加通配之后再漏变体), + /// 也抓不住「加了变体但接错分支」——那两条只有编译器和人读得出来。 + #[test] + fn the_page_match_has_no_wildcard_fallback() { + let source = include_str!("page_messages.rs"); + let wildcards: Vec<(usize, &str)> = source + .lines() + .enumerate() + .filter(|(_, line)| { + let trimmed = line.trim(); + trimmed.starts_with("_ =>") || trimmed.starts_with("_=>") + }) + .map(|(index, line)| (index + 1, line)) + .collect(); + + assert!( + wildcards.is_empty(), + "on_page 里出现了 `_ =>` 兜底(行 {:?}):它会取消对 pages::Message 的穷尽性,\ + 新变体漏接线时编译器不再报 E0004", + wildcards + ); + } +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index b418390..b20f829 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -4,7 +4,10 @@ use iced::{Element, Theme}; -use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage}; +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{ + Diagnosis, InstanceRuntime, InstanceSummary, LaunchStage, LogLevel, LogOutcome, +}; use unlucky_presenter::router::Route; use unlucky_presenter::state::AppState; @@ -115,6 +118,9 @@ pub(super) fn model( active: route.nav(), instances: pages::Instances { rows, detail }, download: pages::Download::from_snapshot(state, category, unlucky_i18n::Lang::En.catalog()), + logs: logs_model(state), + // 语言入口还没接到 `Ui`(`theme.rs` 归 M93),产品路径先英文。 + lang: Lang::En, forced: forced.map(|state| match state { crate::cli::ForcedState::Hover => Interaction::Hovered, crate::cli::ForcedState::Pressed => Interaction::Pressed, @@ -122,6 +128,75 @@ pub(super) fn model( } } +/// 日志页视图模型(M95):presenter 快照 → 日志行 + 诊断结论。 +/// +/// **页面不读盘**:日志文本只从 `log_lines`(host 尾随 JSONL 后按批次发的事件)来;会话元信息 +/// 从 `log_sessions` 里按「运行中的那一场 → 诊断归属的那一场」找,找不到就不画。 +/// +/// 两处 DTO 侧的**信息丢失**(已报 finding,不在本 scope 修):`dto::Diagnosis` 没有 core 的 +/// `severity`(严重度只能按置信度折算,阈值是债)与 `cause.key`(`title`/`hint` 只能当数据画)。 +fn logs_model(state: &AppState) -> pages::Logs { + let report = state.diagnosis.as_ref(); + let current = state.selected_session.as_deref(); + let session = report + .and_then(|report| state.log_sessions.iter().find(|s| s.id == report.session)) + .or_else(|| { + state + .log_sessions + .iter() + .find(|s| Some(s.id.as_str()) == current) + }); + + pages::Logs { + instance: session.map(|s| s.instance.clone()), + when: session.map(|s| s.when.clone()), + outcome: session.map(|s| log_outcome(s.outcome)), + lines: state + .log_lines + .iter() + .map(|line| pages::Line { + text: line.text.clone(), + level: log_level(line.level), + }) + .collect(), + findings: report + .map(|report| diagnosis_of(&report.diagnosis)) + .into_iter() + .collect(), + } +} + +/// 日志等级:presenter 枚举 → 页面等级(**结构化出口**,不是认字符串)。 +fn log_level(level: LogLevel) -> pages::Level { + match level { + LogLevel::Debug => pages::Level::Debug, + LogLevel::Info => pages::Level::Info, + LogLevel::Warn => pages::Level::Warn, + LogLevel::Error => pages::Level::Error, + } +} + +/// 会话结局:presenter 枚举 → 页面结局(文案走 `LaunchOutcome*` key,页面不画 core 的中文串)。 +fn log_outcome(outcome: LogOutcome) -> pages::Outcome { + match outcome { + LogOutcome::Ok => pages::Outcome::Ok, + LogOutcome::Crashed => pages::Outcome::Crashed, + LogOutcome::SilentFail => pages::Outcome::SilentFail, + } +} + +/// 一条诊断结论 → 页面结论(严重度折算见 [`logs_model`] 的文档)。 +fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding { + pages::Finding { + rule: diagnosis.rule.clone(), + severity: pages::Severity::of_confidence(diagnosis.confidence), + title: diagnosis.title.clone(), + hint: diagnosis.hint.clone(), + confidence: diagnosis.confidence, + action: diagnosis.fix.clone(), + } +} + /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版」,见 `LoaderInfo::badge`)。 fn supporting_line(instance: &InstanceSummary) -> String { format!("{} · {}", instance.mc_version, instance.loader.badge()) diff --git a/crates/app-iced/src/pages/logs.rs b/crates/app-iced/src/pages/logs.rs index 32d2832..1601ba2 100644 --- a/crates/app-iced/src/pages/logs.rs +++ b/crates/app-iced/src/pages/logs.rs @@ -1,145 +1,149 @@ -//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤424)。 +//! 日志页(M95):**单列**版面的第二个用例,也是本项目卖点(崩溃日志诊断)的落地页。 //! -//! 与下载页的区别在页头:日志页的页头是**两行**(标题 + 一行运行状态), -//! 高度走 `LayoutMetrics::page_header_tall_height`(84),这是 12b §16.1 那栏在骨架期的落地。 +//! ## 一屏由三块组成 //! -//! 内容区给「日志源」一张卡(文件行)+ 空态位(当前没有输出)。真正的等宽日志视图属于 -//! 后续「日志页接事件流」的 mission,本页只负责版式。 +//! ```text +//! ┌ 页头(两行 84:标题 + 运行状态;右侧 Open folder / Clear)──────────┐ +//! ├ 诊断卡片(只在有结论时出现,**按严重度分组**)──────────────────────┤ +//! │ DIAGNOSIS 3 CRITICAL 1 / WARNING 2 │ +//! │ integrity.unsatisfied_link(规则 id,等宽) │ +//! │ 结论(规则表原文) / 说明 / Suggested fix / Confidence 85% │ +//! │ ─ One-line summary ─ (等宽可复制的一行) [Copy summary] │ +//! ├ 日志行列表(等宽 + 折行 + **独立滚动**,吃满剩余高度)──────────────┤ +//! └──────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! 数据源只有两个(页面**不读盘**,路径从不进过页面): +//! +//! | 来源 | 谁造 | 用途 | +//! |---|---|---| +//! | presenter 快照(`log_lines` / `diagnosis` / `log_sessions`) | `app/view.rs::logs_model` | 产品运行时 | +//! | [`demo`] 内嵌的崩溃日志片段 | `pages::demo_model`(`ULL_LOGS_FIXTURE=empty\|live\|crash`) | 出图 | +//! +//! ## M95 的取舍(逐条) +//! +//! | # | 取舍 | 为什么 | +//! |---|---|---| +//! | 1 | **折行**而不是横向滚动 | 见 [`lines`] 模块文档:424px × 200 字符的现实下,横滚与外壳竖向滚动打架,且等宽族的 CJK 回退会毁掉「按列对齐」这个横滚唯一的好处 | +//! | 2 | 诊断卡片在日志列表**之上** | 崩溃时用户先要结论;把结论放在 14 行日志下面等于让人先翻页 | +//! | 3 | 规则原文(中文)当**数据**直接画 | 界面文案全走 i18n key;规则表的 `cause.summary` 是诊断结论本身,翻译要等 `rules.d` 带 `cause.key` 进 DTO(已报 finding) | +//! | 4 | 严重度按**置信度折算** | `dto::Diagnosis` 没有 `severity` 字段(core 有,DTO 丢了,已报 finding),阈值写在 `Severity::of_confidence` | +//! | 5 | 页头保持**两行 84** | M84 定的版式(运行状态在副标题上);计数留在两张卡片自己的标题行,避免为了拼计数把 i18n 字符串漏进页头生命期 | +//! +//! ## 指令(只有真业务命令才 dispatch) +//! +//! | 控件 | 消息 | 落到哪 | +//! |---|---|---| +//! | Copy summary | [`Message::CopySummary`] | `app/update.rs` → `iced::clipboard::write` | +//! | Open folder | `Action::OpenLogs` | **还没有 `AppCommand`**(报 finding,现在是占位分支) | +//! | Clear | `Action::ClearLogs` | `app/update.rs` → 清空 presenter 的 `log_lines` | +//! | 空态的 CTA | [`Message::Navigate`] | 压一层路由栈回实例页(真导航,不是占位动作) | +//! +//! 截图的强弱边界写在 `logs/tests.rs` 每条断言下面。 -use iced::widget::{column, row}; -use iced::{Alignment, Element, Length, Padding}; +mod demo; +mod diagnostic; +mod lines; +mod model; +mod panel; + +#[cfg(test)] +mod tests; + +/// 诊断卡在正文里的高度份额(见 [`panel`] 的模块文档:两块必须都是定高 + 内部滚动)。 +const DIAGNOSIS_SHARE: u16 = 3; +/// 日志卡的高度份额。 +const LOGS_SHARE: u16 = 2; + +pub use demo::demo; +pub use model::{Finding, Level, Line, Logs, Outcome, Severity}; + +use iced::widget::column; +use iced::{Element, Length}; -use unlucky_design::color::Role; -use unlucky_design::component_map::Component; use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; -use unlucky_design::typography::TypeRole; +use unlucky_i18n::catalog::{Catalog, Key}; +use unlucky_i18n::Lang; +use unlucky_presenter::router::Route; +use crate::app::components::buttons::{self, Button}; +use crate::app::components::feedback::empty_state::{empty_state, EmptyState}; use crate::theme::Ui; use super::frame::{self, Header}; use super::layout::FrameMetrics; use super::{Action, Message}; -/// 演示日志源(文件名 + 大小/时间)。 -const SOURCES: [(&str, &str); 3] = [ - ("launcher.log", "128 KiB · live"), - ("launch-2026-11-01T09-14-02.log", "412 KiB · 09:14"), - ("diagnose-2026-10-31.yaml", "18 KiB · yesterday"), -]; - /// 整页。 -pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> { - let header = Header::new("Logs") - .subtitle("Launcher and game output · nothing is running right now") - .action(frame::text_action(ui, "Open folder", Action::OpenLogs)) - .action(frame::text_action( - ui, - "Copy diagnostics", - Action::CopyDiagnostics, - )) - .action(frame::text_action(ui, "Clear", Action::ClearLogs)); +pub fn view<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + logs: &'a Logs, + lang: Lang, +) -> Element<'a, Message> { + let catalog = Catalog::of(lang); - let body = frame::single_column( - metrics, - column![ - source_card(ui), - frame::empty_state( - ui, - IconKind::Logs, - "No output yet", - "Logs stream here while the launcher or the game is running. Start an instance to see \ - the first lines appear.", - vec![frame::text_action(ui, "Start an instance", Action::Play)], + let header = Header::new(catalog.text(Key::NavLogs)) + .subtitle(subtitle(&catalog, logs)) + .action(buttons::text( + ui, + Button::new( + catalog.text(Key::LogsOpenFolder), + Message::Action(Action::OpenLogs), ), + )) + .action(buttons::text( + ui, + Button::new( + catalog.text(Key::LogsClear), + Message::Action(Action::ClearLogs), + ), + )); + + let body = if logs.is_empty() { + frame::single_column(metrics, empty(ui, &catalog)) + } else if logs.findings.is_empty() { + // 没有结论:日志卡片独吞整页(这样「正常运行的一场」不会留一块空卡片)。 + frame::single_column(metrics, lines::card(ui, logs, lang, Length::Fill)) + } else { + // 有结论:两块**都定高**、各自独立滚动(诊断 3 : 日志 2)。 + // 定高不是偷懒:第一版让诊断卡按内容撑开,三条结论就把日志卡压成 0 高度(截图见报告)。 + let stack = column![ + diagnostic::card(ui, logs, lang, Length::FillPortion(DIAGNOSIS_SHARE)), + lines::card(ui, logs, lang, Length::FillPortion(LOGS_SHARE)), ] - .spacing(ui.space(Space::X4)) .width(Length::Fill) - .height(Length::Fill), - ); + .height(Length::Fill) + .spacing(ui.space(Space::X4)); + + frame::single_column(metrics, stack) + }; frame::page(ui, metrics, header, body) } -/// 日志源列表(12b 的 `ListItem` 档:行文 `BodyLarge`、副文案 `BodySmall`)。 -fn source_card<'a>(ui: &'a Ui) -> Element<'a, Message> { - let spec = Component::ListItem.spec(); - let mut rows = column![].spacing(0.0).width(Length::Fill); - - for (index, (name, meta)) in SOURCES.iter().enumerate() { - if index > 0 { - rows = rows.push(frame::divider(ui)); - } - - rows = rows.push( - row![ - frame::glyph_box( - IconKind::Logs, - ui.color(Role::OnSurfaceVariant), - frame::ICON_PX - ), - column![ - frame::text_role(ui, spec.text, false, name, ui.color(Role::OnSurface)), - frame::text_role( - ui, - spec.support.unwrap_or(TypeRole::BodySmall), - false, - meta, - ui.color(Role::OnSurfaceVariant), - ), - ] - .spacing(0.0) - .width(Length::Fill), - ] - .spacing(ui.space(Space::X3)) - .width(Length::Fill) - .align_y(Alignment::Center) - .padding(Padding::from([ui.space(Space::X2), ui.space(Space::X2)])), - ); - } - - frame::card(ui, rows) -} - -#[cfg(test)] -mod tests { - use super::super::layout::{frame_metrics, measure_cap_px, PaneKit}; - use super::*; - use unlucky_design::color::SchemeFlavor; - use unlucky_design::typography::Platform; - - use crate::theme::Families; - - #[test] - fn logs_page_is_single_column_and_capped() { - for width in [700.0, 1180.0, 1600.0] { - let metrics = frame_metrics(PaneKit::Single, width); - assert!(metrics.secondary.is_none()); - assert!(metrics.column <= measure_cap_px() + f32::EPSILON); - } - } - - #[test] - fn header_is_two_lines_because_of_the_status_subtitle() { - let ui = Ui::with_platform( - SchemeFlavor::Dark, - Platform::Windows, - Families { - text: "Segoe UI", - display: "Segoe UI", - mono: "Consolas", - }, - ); - assert_eq!(ui.metrics().header_height(true), 84.0); - assert!(ui.metrics().header_height(true) > ui.metrics().header_height(false)); - } - - #[test] - fn every_source_row_has_a_name_and_meta() { - assert_eq!(SOURCES.len(), 3); - for (name, meta) in SOURCES { - assert!(name.contains("log") || name.contains("yaml")); - assert!(!meta.is_empty()); - } +/// 页头副标题:**静态 key**(两行页头的第二行),计数留给卡片标题行。 +fn subtitle(catalog: &Catalog, logs: &Logs) -> &'static str { + if logs.is_empty() { + catalog.text(Key::LogsSubtitleIdle) + } else { + catalog.text(Key::LogsSubtitleActive) } } + +/// 空态(`feedback::EmptyState` 真组件:图标块 + 标题 + 说明 + 一个 CTA)。 +fn empty<'a>(ui: &'a Ui, catalog: &Catalog) -> Element<'a, Message> { + empty_state( + ui, + EmptyState::new( + IconKind::Logs, + catalog.text(Key::LogsEmptyTitle), + catalog.text(Key::LogsEmptyBody), + ) + .action( + catalog.text(Key::LogsEmptyAction), + Message::Navigate(Route::Instances), + ), + ) +} diff --git a/crates/app-iced/src/pages/logs/demo.rs b/crates/app-iced/src/pages/logs/demo.rs new file mode 100644 index 0000000..d22c456 --- /dev/null +++ b/crates/app-iced/src/pages/logs/demo.rs @@ -0,0 +1,153 @@ +//! 日志页演示数据:**只给 `pages_demo` 出图与单测**,产品运行时的数据来自 presenter 快照 +//! (`app/view.rs::logs_model`)。页面任何路径都不读盘:日志文本要么来自快照,要么来自 +//! 本文件 `include_str!` 内嵌的 [`SAMPLE`](编译期进二进制,运行时零 I/O)。 +//! +//! ## 为什么用环境变量挑状态 +//! +//! 三种状态(空态 / 有日志 / 有诊断)要分别出图,而 `pages_demo` 的 `--screen` 只有一个 +//! `logs`。环境变量是这个 crate 已有的 harness 口径(`ULL_GALLERY_SHOT` / `ULL_FEEDBACK_PART` +//! 都是这么挑的,见 `components/gallery/mod.rs` 的说明):既能被测试直接调纯函数覆盖, +//! 又不用跟别人的 CLI 解析抢同一个文件。 +//! +//! ```text +//! ULL_LOGS_FIXTURE=empty pages_demo --screen=logs --screenshot=logs-empty-light.png +//! ``` +//! +//! 取值:`empty`(空态)/ `live`(有日志、无诊断)/ 其它或未设 = `crash`(日志 + 三条诊断)。 +//! 默认取 `crash`:一屏就能看见页面的全部结构,reviewer 不必先猜环境变量。 +//! +//! ## 样例内容的出处 +//! +//! [`SAMPLE`] 是按 `core/diagnose/rules.d/samples/graphics/graphics_driver.log` 与 +//! `integrity/unsatisfied_link.log` 的形状写的(含中文 + 堆栈 + 200 字符以上的长行), +//! 诊断结论的 `rule` / `title` / `hint` 抄自规则表 `rules.d/integrity.yaml` 与 `graphics.yaml`。 +//! **中文在这里是「规则表的输出(数据)」,不是界面文案**:界面文案(标题 / 按钮 / 分组名 / +//! 空态)全部走 `unlucky_i18n` 的 key,见 `logs.rs`。 + +use super::model::{Finding, Level, Line, Logs, Outcome, Severity}; + +/// 挑演示状态的环境变量。 +pub const STATE_ENV: &str = "ULL_LOGS_FIXTURE"; + +/// 内嵌的崩溃日志片段(见模块文档的出处)。 +const SAMPLE: &str = include_str!("sample_crash.log"); + +/// 演示状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { + /// 空态:没有输出,也没有诊断。 + Empty, + /// 有日志、无诊断(正常跑着的一场)。 + Live, + /// 崩溃现场:日志 + 诊断结论。 + Crash, +} + +impl State { + /// 从环境变量取值解析(未设 / 不认识一律 `Crash`)。 + #[must_use] + pub fn of_env_value(raw: Option<&str>) -> Self { + match raw { + Some("empty") => Self::Empty, + Some("live") => Self::Live, + _ => Self::Crash, + } + } + + /// 造出这个状态的视图模型。 + #[must_use] + pub fn build(self) -> Logs { + match self { + Self::Empty => Logs::default(), + Self::Live => Logs { + outcome: Some(Outcome::Ok), + findings: Vec::new(), + ..crash() + }, + Self::Crash => crash(), + } + } +} + +/// harness 入口:按 [`STATE_ENV`] 挑状态。 +#[must_use] +pub fn demo() -> Logs { + State::of_env_value(std::env::var(STATE_ENV).ok().as_deref()).build() +} + +/// 崩溃现场(日志 + 三条诊断,覆盖 critical / warning 两个分组)。 +#[must_use] +pub fn crash() -> Logs { + Logs { + instance: Some("开发测试".to_owned()), + when: Some("11-02 19:40".to_owned()), + outcome: Some(Outcome::Crashed), + lines: parse(SAMPLE), + findings: findings(), + } +} + +/// 解析内嵌样例:`#` 注释与空行跳过,首字符是等级前缀。 +fn parse(text: &str) -> Vec { + text.lines() + .filter_map(|raw| { + let raw = raw.trim_end(); + if raw.is_empty() || raw.starts_with('#') { + return None; + } + + let (prefix, rest) = raw.split_at(1); + let level = match prefix { + "D" => Level::Debug, + "I" => Level::Info, + "W" => Level::Warn, + "E" => Level::Error, + _ => return None, + }; + + Some(Line { + text: rest.trim_start().to_owned(), + level, + }) + }) + .collect() +} + +/// 三条诊断(`rule` / `title` / `hint` 抄自规则表;`severity` 走 [`Severity::of_confidence`], +/// 也就是 DTO 缺 `severity` 时产品路径用的同一条折算)。 +fn findings() -> Vec { + vec![ + finding( + "integrity.unsatisfied_link", + 85, + "找不到原生库(natives):lwjgl_opengl.dll 没解压出来或被杀软删了", + "重装组件(重新解压 natives);Windows 上检查杀软隔离区", + Some("reinstall natives"), + ), + finding( + "graphics.gl_operation_failure", + 70, + "某个 mod/光影包做了非法的 OpenGL 操作(GL_INVALID_OPERATION)", + "换掉最近装的光影/资源包,或把资源包分辨率降下来", + Some("change_graphics.renderer = OpenGL 兼容模式"), + ), + finding( + "integrity.no_class_def_found", + 60, + "NoClassDefFoundError:缺少类文件(库没下全 / jar 被解压 / mod 不配套)", + "先检查 libraries 是否完整,再排查 mods 里的解压目录", + Some("reinstall libraries"), + ), + ] +} + +fn finding(rule: &str, confidence: u8, title: &str, hint: &str, action: Option<&str>) -> Finding { + Finding { + rule: rule.to_owned(), + severity: Severity::of_confidence(confidence), + title: title.to_owned(), + hint: hint.to_owned(), + confidence, + action: action.map(str::to_owned), + } +} diff --git a/crates/app-iced/src/pages/logs/diagnostic.rs b/crates/app-iced/src/pages/logs/diagnostic.rs new file mode 100644 index 0000000..cc1c9e1 --- /dev/null +++ b/crates/app-iced/src/pages/logs/diagnostic.rs @@ -0,0 +1,222 @@ +//! 诊断结果区:**按严重度分组**的结论卡片 + 可直接粘进 issue 的一行摘要。 +//! +//! ## 一条结论要能看出什么(tower 的三问) +//! +//! | 问题 | 落点 | 数据来源 | +//! |---|---|---| +//! | 哪条规则命中 | 规则 id(等宽、`LabelMedium`) | `Finding::rule` | +//! | 严重度 | 分组标题 + 分组顺序(`CRITICAL` → `WARNING` → `NOTICE`) | `Finding::severity` | +//! | 建议动作 | `Suggested fix` 一行(规则表的 `fix_action`,技术标识原样) | `Finding::action` | +//! +//! ## 用户真的要拿去用的两样东西 +//! +//! 1. **结论原文**(`title` / `hint`):规则表 `rules.d/*.yaml` 写的 `cause.summary` / `detail`。 +//! 它是**数据**(诊断结论),页面不翻译也不改写——改写会让「规则命中」与「用户读到的」 +//! 变成两套说法。界面文案(分组名 / 标签 / 按钮)仍然全部走 i18n key。 +//! 2. **一行摘要**([`Logs::summary`]):等宽显示 + 「Copy summary」按钮;按钮把字符串塞进 +//! 剪贴板(`Message::CopySummary` → `iced::app/update.rs` → `iced::clipboard::write`)。 +//! 崩溃日志换 issue 时粘这一行就够:实例 / 时间 / 结局 / 行数 / 命中的规则与置信度都在里面。 + +use iced::widget::text::{LineHeight, Wrapping}; +use iced::widget::{column, row, text}; +use iced::{Alignment, Color, Element, Length, Pixels}; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_design::typography::{TypeRole, TypeStyle}; +use unlucky_i18n::catalog::{Catalog, Key}; +use unlucky_i18n::Lang; + +use crate::app::components::buttons::{self, Button}; +use crate::theme::Ui; + +use super::super::frame; +use super::super::Message; +use super::lines::{mono, mono_owned}; +use super::model::{Finding, Logs, Severity, GROUPS}; +use super::panel; + +/// 诊断卡片(只在有结论时画)。高度由调用方定(见 [`panel`]),组与摘要在卡片内滚动。 +pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Element<'a, Message> { + let catalog = Catalog::of(lang); + + let mut group = column![].spacing(ui.space(Space::X2)).width(Length::Fill); + + for severity in GROUPS { + let findings = logs.group(severity); + if findings.is_empty() { + continue; + } + + group = group.push(frame::divider(ui)); + group = group.push(title_row( + ui, + catalog.text(key_of(severity)), + findings.len(), + )); + for finding in findings { + group = group.push(finding_block(ui, &catalog, finding)); + } + } + + group = group.push(frame::divider(ui)); + group = group.push(summary_block(ui, &catalog, logs)); + + panel::card( + ui, + height, + column![head_row(ui, &catalog, logs), panel::scroll(ui, group)].width(Length::Fill), + ) +} + +/// 卡片头部:`DIAGNOSIS` + 条数 + **始终可见的「Copy summary」**。 +/// +/// 复制按钮钉在头上而不是跟着摘要滚:三条结论就足以把摘要推到折叠线以下, +/// 而「换 issue 时粘一行」正是这页的出口,不能要用户先滚一下才发现它。 +fn head_row<'a>(ui: &'a Ui, catalog: &Catalog, logs: &Logs) -> Element<'a, Message> { + row![ + frame::text_role( + ui, + TypeRole::LabelMedium, + true, + catalog.text(Key::LogsDiagnosisTitle), + ui.color(Role::OnSurfaceVariant), + ), + iced::widget::Space::new().width(Length::Fill), + frame::text_role_owned( + ui, + TypeRole::LabelMedium, + false, + logs.findings.len().to_string(), + ui.color(Role::OnSurfaceVariant), + ), + buttons::text( + ui, + Button::new( + catalog.text(Key::LogsCopySummary), + Message::CopySummary(logs.summary()), + ), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center) + .spacing(ui.space(Space::X2)) + .into() +} + +/// 标题行:左边标签(大写字距靠令牌档),右边条数。 +fn title_row<'a>(ui: &'a Ui, label: &'a str, count: usize) -> Element<'a, Message> { + row![ + frame::text_role( + ui, + TypeRole::LabelMedium, + true, + label, + ui.color(Role::OnSurfaceVariant), + ), + iced::widget::Space::new().width(Length::Fill), + frame::text_role_owned( + ui, + TypeRole::LabelMedium, + false, + count.to_string(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center) + .into() +} + +/// 一条结论:规则 id / 结论 / 说明 / 建议动作 / 置信度。 +fn finding_block<'a>(ui: &'a Ui, catalog: &Catalog, finding: &'a Finding) -> Element<'a, Message> { + let mut block = column![ + mono(ui, &finding.rule, ui.color(Role::OnSurfaceVariant)), + wrapped( + ui, + TypeRole::BodyMedium, + &finding.title, + ui.color(Role::OnSurface) + ), + wrapped( + ui, + TypeRole::BodySmall, + &finding.hint, + ui.color(Role::OnSurfaceVariant) + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + if let Some(action) = &finding.action { + block = block.push( + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::LogsSuggestedFix), + ui.color(Role::OnSurfaceVariant), + ), + mono(ui, action, ui.color(Role::OnSurface)), + ] + .spacing(0.0) + .width(Length::Fill), + ); + } + + block = block.push(frame::text_role_owned( + ui, + TypeRole::LabelMedium, + false, + catalog.render( + Key::LogsConfidence, + &[("percent", &finding.confidence.to_string())], + ), + ui.color(Role::OnSurfaceVariant), + )); + + block.into() +} + +/// 一行摘要(等宽、可整段带走;复制按钮在卡片头上,见 [`head_row`])。 +fn summary_block<'a>(ui: &'a Ui, catalog: &Catalog, logs: &Logs) -> Element<'a, Message> { + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::LogsSummaryLabel), + ui.color(Role::OnSurfaceVariant), + ), + mono_owned(ui, logs.summary(), ui.color(Role::OnSurface)) + .width(Length::Fill) + .wrapping(Wrapping::WordOrGlyph), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +/// 严重度 → 分组标题的 key。 +fn key_of(severity: Severity) -> Key { + match severity { + Severity::Critical => Key::LogsSeverityCritical, + Severity::Warning => Key::LogsSeverityWarning, + Severity::Info => Key::LogsSeverityInfo, + } +} + +/// 令牌排版档 + **按词或字形折行**(长堆栈里的点分标识符没有空格,只按词会溢出)。 +fn wrapped<'a>(ui: &'a Ui, role: TypeRole, content: &'a str, color: Color) -> Element<'a, Message> { + let style: TypeStyle = ui.style(role); + + text(content) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .font(ui.font_of(style)) + .color(color) + .width(Length::Fill) + .wrapping(Wrapping::WordOrGlyph) + .into() +} diff --git a/crates/app-iced/src/pages/logs/lines.rs b/crates/app-iced/src/pages/logs/lines.rs new file mode 100644 index 0000000..4b7666b --- /dev/null +++ b/crates/app-iced/src/pages/logs/lines.rs @@ -0,0 +1,159 @@ +//! 日志行列表:**等宽 + 折行 + 独立滚动**。 +//! +//! ## 为什么折行而不是横向滚动(tower 要求二选一,这里选折行) +//! +//! 正文列硬上限 424px,而崩溃堆栈动辄 200+ 字符(样例里最长的一行 214 字符)。两条路: +//! +//! | 方案 | 代价 | +//! |---|---| +//! | 横向滚动 | 424px 里 200 字符 ≈ 8 屏,读一条 `Caused by:` 要来回甩;外壳已经是竖向滚动,横竖两向嵌套后滚轮归属会打架 | +//! | **折行(本页选)** | 一条日志可能占 2~3 行,行号/等级栏必须能标明「这是同一条」 | +//! +//! 选折行的决定性理由:**折行让等宽族只承担「视觉密度」,不承担「列对齐」**。等宽栈在 +//! Windows 上是 `Cascadia Mono` / `Consolas`,它们没有 CJK 字形——中文日志要靠 cosmic-text +//! 逐字回退到系统 CJK 族(本机实测回退到 `Microsoft YaHei UI`),回退字形的 advance 不是 1ch。 +//! 横向滚动方案里「第 40 列对齐」是有意义的(堆栈缩进、`at a.b.C(File.java:12)` 的括号列), +//! 一旦 CJK 回退把列宽带偏,对齐就废了;折行版面不承诺任何列对齐,回退只影响同一行内的观感。 +//! 左栏那条 2px 等级条是跨行的(`Length::Fill`),所以「同一条日志折了 3 行」在视觉上仍是 +//! 一个整体。 +//! +//! ## 等宽族的实际落点 +//! +//! `ui.families().mono` 是启动时用系统字体库核对过的族名([`crate::fonts`] 求值,启动日志里 +//! 有一行 `mono=…`),本页只通过 [`mono_style`] 用它,不写死族名。字号/行高走 +//! `TypeRole::BodySmall`(12/16),与报告里的数值表对得上(见 `logs/tests.rs`)。 + +use iced::widget::text::LineHeight; +use iced::widget::{column, container, row, space}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels}; + +use unlucky_design::color::Role; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::{FontFamily, TypeRole, TypeStyle}; +use unlucky_i18n::catalog::{Catalog, Key}; +use unlucky_i18n::Lang; + +use crate::theme::Ui; + +use super::super::frame; +use super::super::Message; +use super::model::{Level, Line, Logs}; +use super::panel; + +/// 等级条宽度(2px:够看见,又不抢日志正文的起始列)。 +const BAR_WIDTH: f32 = 2.0; + +/// 日志卡片:标题行(`OUTPUT` + 行数)+ 独立滚动的行列表。高度由调用方定(见 [`panel`])。 +pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Element<'a, Message> { + let catalog = Catalog::of(lang); + + let head = row![ + container(frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::LogsOutputTitle), + ui.color(Role::OnSurfaceVariant), + )) + .width(Length::Fill) + .align_y(Alignment::Center), + frame::text_role_owned( + ui, + TypeRole::LabelMedium, + false, + logs.lines.len().to_string(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center) + .padding(Padding::default().bottom(ui.space(Space::X2))); + + let rows = column( + logs.lines + .iter() + .map(|line| entry(ui, line)) + .collect::>(), + ) + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + panel::card(ui, height, column![head, panel::scroll(ui, rows)]) +} + +/// 一行:等级条(跨行)+ 等宽正文(折行)。 +fn entry<'a>(ui: &'a Ui, line: &'a Line) -> Element<'a, Message> { + let color = color_of(ui, line.level); + + let bar = container(space::horizontal()) + .width(BAR_WIDTH) + .height(Length::Fill) + .style(move |_theme| container::Style { + background: Some(Background::Color(color)), + border: Border { + radius: ui.radius(ShapeScale::Full, BAR_WIDTH), + ..Border::default() + }, + ..container::Style::default() + }); + + row![ + bar, + mono(ui, &line.text, color) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .align_y(Alignment::Start) + .into() +} + +/// 等级 → 颜色(错误用 `Error`,警告用 `Tertiary`:与主题的 warning 槽位同源)。 +fn color_of(ui: &Ui, level: Level) -> Color { + match level { + Level::Error => ui.color(Role::Error), + Level::Warn => ui.color(Role::Tertiary), + Level::Info => ui.color(Role::OnSurface), + Level::Debug => ui.color(Role::OnSurfaceVariant), + } +} + +/// 等宽排版档:**同一个 `BodySmall` 档,只把字体族换成 mono 栈**。 +/// +/// 不新造字号(12/16 与辅助说明同档),所以「等宽」这件事只改字体族这一个维度, +/// 行高仍是令牌值——CJK 回退只换字形,行高不会跳(这也是折行方案的前提之一)。 +#[must_use] +pub fn mono_style(ui: &Ui) -> TypeStyle { + TypeStyle { + family: FontFamily::Mono, + ..ui.style(TypeRole::BodySmall) + } +} + +/// 等宽文本(字号 / 行高 / 字重 / 族全来自 [`mono_style`])。 +#[must_use] +pub fn mono<'a>(ui: &Ui, content: &'a str, color: Color) -> iced::widget::Text<'a> { + mono_styled(ui, content, color) +} + +/// 同 [`mono`],但文案是**拥有的**字符串(摘要行这类运行时拼出来的短串)。 +#[must_use] +pub fn mono_owned<'a>(ui: &Ui, content: String, color: Color) -> iced::widget::Text<'a> { + mono_styled(ui, content, color) +} + +fn mono_styled<'a>( + ui: &Ui, + content: impl iced::widget::text::IntoFragment<'a>, + color: Color, +) -> iced::widget::Text<'a> { + let style = mono_style(ui); + + iced::widget::text(content) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .font(ui.font_of(style)) + .color(color) +} diff --git a/crates/app-iced/src/pages/logs/model.rs b/crates/app-iced/src/pages/logs/model.rs new file mode 100644 index 0000000..94b99c5 --- /dev/null +++ b/crates/app-iced/src/pages/logs/model.rs @@ -0,0 +1,218 @@ +//! 日志页的视图模型:**纯展示字段**(沿用 `pages::model` 的分层——不含 iced 类型,也不认识 presenter)。 +//! +//! ## 为什么单独一层 +//! +//! 渲染函数只吃 [`Logs`],于是同一棵树能被三份数据喂: +//! +//! | 数据源 | 谁造 | 用途 | +//! |---|---|---| +//! | presenter 快照(`AppState`) | `app/view.rs::logs_model` | 产品运行时 | +//! | [`super::demo`] 的内嵌崩溃日志片段 | `pages::demo_model` | `pages_demo --screen=logs` 出图 | +//! | [`Logs::default`] | 空态 | 真的没有输出(未接线时也是这个兜底) | +//! +//! 页面**不读盘**:日志文本只有两条来路——presenter 快照里的 `LogLine`,或 `demo` 里 +//! `include_str!` 内嵌的样例。日志文件的路径从头到尾没进过页面。 +//! +//! ## 缺口(M95 登记,已报 finding) +//! +//! presenter 的 `dto::Diagnosis` 只有 `rule` / `title` / `hint` / `confidence` / `fix`: +//! +//! 1. **core 的 `Severity`(info / warning / critical)在半路丢了**——`core::diagnose::rules` +//! 里每条规则都带它,DTO 没有。页面要「按严重度分组」就只能用 +//! [`Severity::of_confidence`] 按置信度**折算**(阈值写死在函数里,是债,不是规格)。 +//! 2. `title` / `hint` 只有中文单语,而且规则表里其实已经有结构化出口 +//! (`rules.d/*.yaml` 的 `cause.key: diagnose.cause.…`)。DTO 没带 `key`,页面就把规则原文 +//! 当**数据**画出来,页面的界面文案(标题 / 按钮 / 分组名 / 空态)仍然全部走 i18n key。 + +/// 一行日志的等级(presenter `LogLevel` 的展示投影)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + /// 调试噪音。 + Debug, + /// 正常流水。 + Info, + /// 可疑。 + Warn, + /// 错误行(崩溃现场)。 + Error, +} + +impl Level { + /// 稳定 slug(摘要行与测试用;不是文案)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Debug => "debug", + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + } +} + +/// 诊断结论的严重度(`core::diagnose::rules::Severity` 的展示投影)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + /// 提示:能跑但不最佳。 + Info, + /// 警告:可疑但结论不确定。 + Warning, + /// 严重:基本可以确定崩溃原因。 + Critical, +} + +/// 分区顺序(**主因在上**:崩溃日志换 issue 时第一段就是最该看的)。 +pub const GROUPS: [Severity; 3] = [Severity::Critical, Severity::Warning, Severity::Info]; + +impl Severity { + /// 稳定 slug(摘要行用;不是文案——分组标题走 i18n key)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warning => "warning", + Self::Critical => "critical", + } + } + + /// 按置信度折算严重度——**过渡口径**,见模块文档的缺口 #1。 + /// + /// 阈值:`≥80` 严重 / `≥50` 警告 / 其余提示。DTO 补上 `severity` 之后这个函数就该删掉。 + #[must_use] + pub const fn of_confidence(confidence: u8) -> Self { + if confidence >= 80 { + Self::Critical + } else if confidence >= 50 { + Self::Warning + } else { + Self::Info + } + } +} + +/// 一行日志。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Line { + /// 原始文本(**不截断**:折行是渲染的事,数据要留全,否则用户永远拿不回堆栈)。 + pub text: String, + /// 等级。 + pub level: Level, +} + +/// 一条诊断结论。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Finding { + /// 命中的规则 id(`graphics.opengl_not_supported`):技术标识,原样显示。 + pub rule: String, + /// 严重度(DTO 没有,见 [`Severity::of_confidence`])。 + pub severity: Severity, + /// 结论标题(规则表的 `cause.summary`,**数据不是文案**)。 + pub title: String, + /// 建议说明(规则表的 `cause.detail`)。 + pub hint: String, + /// 0~100 置信度。 + pub confidence: u8, + /// 建议动作(规则表的 `fix_action`,技术标识 / 设置项名)。 + pub action: Option, +} + +/// 一次运行的结局(`presenter::dto::LogOutcome` 的展示投影;文案走 `LaunchOutcome*` key)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// 正常退出。 + Ok, + /// 崩溃。 + Crashed, + /// exit 0 但游戏没起来。 + SilentFail, +} + +impl Outcome { + /// 稳定 slug(摘要行用)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Crashed => "crashed", + Self::SilentFail => "silent-fail", + } + } +} + +/// 日志页要画的东西。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Logs { + /// 会话所属实例名(`None` = 不知道,页头副标题退成通用文案)。 + pub instance: Option, + /// 会话时间(展示用短串 `10-29 18:40`,presenter 已经格式化好)。 + pub when: Option, + /// 结局。 + pub outcome: Option, + /// 日志行(顺序 = 文件顺序)。 + pub lines: Vec, + /// 诊断结论(顺序 = presenter 给的顺序,页面不重排)。 + pub findings: Vec, +} + +impl Logs { + /// 没有任何可显示的东西(空态判据:行与诊断都空)。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.lines.is_empty() && self.findings.is_empty() + } + + /// 某一严重度下的结论(顺序不变;空组返回空 `Vec`)。 + #[must_use] + pub fn group(&self, severity: Severity) -> Vec<&Finding> { + self.findings + .iter() + .filter(|finding| finding.severity == severity) + .collect() + } + + /// **可复制的一行摘要**(崩溃日志换 issue 时直接粘)。 + /// + /// 格式是刻意的:ASCII 骨架 + 稳定字段序 + ` | ` 分隔,人读与机器读都行; + /// 不含任何界面文案(所以不随语言变),只带数据与控制面的技术标识。 + /// + /// ```text + /// ULL-LOGS v1 | instance=开发测试 | when=10-29 18:40 | outcome=crashed | lines=6 \ + /// | findings=graphics.opengl_not_supported:critical(85) + /// ``` + #[must_use] + pub fn summary(&self) -> String { + let mut parts = vec![SUMMARY_HEADER.to_owned()]; + + if let Some(instance) = &self.instance { + parts.push(format!("instance={instance}")); + } + if let Some(when) = &self.when { + parts.push(format!("when={when}")); + } + if let Some(outcome) = self.outcome { + parts.push(format!("outcome={}", outcome.slug())); + } + parts.push(format!("lines={}", self.lines.len())); + + if !self.findings.is_empty() { + let hits: Vec = self + .findings + .iter() + .map(|finding| { + format!( + "{}:{}({})", + finding.rule, + finding.severity.slug(), + finding.confidence + ) + }) + .collect(); + parts.push(format!("findings={}", hits.join(","))); + } + + parts.join(" | ") + } +} + +/// 摘要行的版本头(改了格式就换代,别让旧 issue 里的摘要含义漂移)。 +pub const SUMMARY_HEADER: &str = "ULL-LOGS v1"; diff --git a/crates/app-iced/src/pages/logs/panel.rs b/crates/app-iced/src/pages/logs/panel.rs new file mode 100644 index 0000000..4b6ec04 --- /dev/null +++ b/crates/app-iced/src/pages/logs/panel.rs @@ -0,0 +1,63 @@ +//! 两块的共用外壳:**定高的卡片 + 内部独立滚动**。 +//! +//! ## 为什么必须定高(M95 r1 的实拍 bug) +//! +//! 第一版把诊断卡按内容自然高度画、日志卡吃剩余高度。三条结论在 424×540 的正文里就要 +//! ~500px,结果:诊断卡被裁掉尾部(一行摘要整块消失)、日志卡拿到 **0** 高度(整块不见)。 +//! 截图见报告里的「r1 崩了」。 +//! +//! 所以两块都**由调用方给定高**(`FillPortion`),各自内部滚动: +//! +//! | 块 | 高度 | 为什么 | +//! |---|---|---| +//! | 诊断 | `FillPortion(3)` | 崩溃时先读结论;结论通常比日志短,给 3 份 | +//! | 日志 | `FillPortion(2)` | 日志是长流水,滚就是了 | +//! +//! 两者都是**兄弟**滚动区(不是嵌套):滚轮归属清楚,不存在「滚动条套滚动条」。 + +use iced::widget::{container, scrollable}; +use iced::{Background, Border, Element, Length}; + +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::shape::ShapeScale; + +use crate::theme::Ui; + +use super::super::frame; +use super::super::Message; + +/// 定高卡片(`Card` 材质层 + `OutlinedCard` 形状,令牌与 `frame::card` 同源)。 +pub fn card<'a>( + ui: &'a Ui, + height: Length, + content: impl Into>, +) -> Element<'a, Message> { + container(content) + .width(Length::Fill) + .height(height) + .padding(ui.metrics().card_padding) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Card))), + border: Border { + radius: ui.radius_dp(ShapeScale::Large), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +/// 卡片内的竖向滚动区(滚条样式与实例页列表列同源)。 +pub fn scroll<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { + scrollable(content) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::new() + .width(frame::SCROLLBAR_WIDTH) + .scroller_width(frame::SCROLLBAR_WIDTH) + .margin(frame::SCROLLBAR_MARGIN), + )) + .height(Length::Fill) + .width(Length::Fill) + .style(frame::scroll_style(ui)) + .into() +} diff --git a/crates/app-iced/src/pages/logs/sample_crash.log b/crates/app-iced/src/pages/logs/sample_crash.log new file mode 100644 index 0000000..bc2671c --- /dev/null +++ b/crates/app-iced/src/pages/logs/sample_crash.log @@ -0,0 +1,23 @@ +# M95 演示数据:一段真实崩溃日志的片段(页面用 include_str! 编译期内嵌,运行时不读盘)。 +# +# 每行格式:`<等级> <原文>`,等级 = D(debug) / I(info) / W(warn) / E(error);`#` 开头是注释。 +# +# 这份样例是故意挑的(tower 要求的两条真风险都压在这里): +# 1. 中文 + 堆栈混排 —— mono 族(Windows 上是 Consolas / Cascadia Mono)没有 CJK 字形, +# 中文要靠 cosmic-text 逐字回退到系统 CJK 族,字宽与 Latin 1ch 不等;页面选的是折行, +# 所以「列对齐」不参与版面,回退只影响同一行内的视觉密度; +# 2. 超长行 —— 第 9/10/11 行都在 200 字符以上(真实 Java 堆栈的长度),正文列上限 424px, +# 折行策略见 `logs.rs` 模块文档。 +I [19:40:12] [main/INFO] 正在启动 1.20.4,实例「开发测试」,加载器 NeoForge 21.1.72 +I [19:40:12] [main/INFO] Java 21.0.5 (Microsoft build 21.0.5+11-LTS), 内存 4096 MiB, JVM 参数 -Xmx4G -XX:+UseG1GC -Dfile.encoding=UTF-8 -Djava.library.path=C:\Users\Player\AppData\Roaming\.unlucky\instances\dev-test\natives +W [19:40:13] [Render thread/WARN] GLFW 报告 OpenGL 2.1,低于所需的 3.2;显卡驱动可能是 Microsoft Basic Display Adapter +E [19:40:14] [Render thread/ERROR] 显卡驱动不支持 OpenGL 3.2,初始化窗口失败 +E java.lang.IllegalStateException: GLFW error before init: [0x10008] Failed to create window: WGL: The driver does not appear to support OpenGL +E at com.mojang.blaze3d.platform.GLX._initGlfw(GLX.java:64) +E at net.minecraft.client.MainWindow.(MainWindow.java:261) +E at net.minecraft.client.Minecraft.(Minecraft.java:472) +E at net.neoforged.fml.loading.targets.CommonLaunchHandler.clientService(CommonLaunchHandler.java:98) +E Caused by: java.lang.UnsatisfiedLinkError: C:\Users\Player\AppData\Roaming\.unlucky\instances\dev-test\natives\lwjgl_opengl.dll: 找不到指定的模块。 (error code 126, 依赖的 d3dcompiler_47.dll 或 vcruntime140.dll 缺失时也会报同一条) +W [19:40:15] [main/WARN] 正在生成本地崩溃报告 crash-2026-11-02_19.40.15-client.txt,请勿关闭窗口 +I [19:40:16] [main/INFO] 已加载 148 个模组,其中 3 个标记为客户端专用:iris-1.7.4, sodium-0.5.11, indium-1.0.32 +E [19:40:16] [main/ERROR] 启动失败:exit code 1(进程存活 4 秒) diff --git a/crates/app-iced/src/pages/logs/tests.rs b/crates/app-iced/src/pages/logs/tests.rs new file mode 100644 index 0000000..02cddcb --- /dev/null +++ b/crates/app-iced/src/pages/logs/tests.rs @@ -0,0 +1,209 @@ +//! 日志页的守门测试。每条断言下面写清「抓得住什么 / 抓不住什么」(这个仓的硬要求)。 +//! +//! 这里**没有截图断言**:像素级验收靠 `pages_demo --screen=logs` 的四张图(浅/深 × 空态/崩溃), +//! 单测只钉住「图上看不出来的那部分」——数值令牌、分组顺序、摘要格式、长行不被截断。 + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::{FontFamily, Platform}; +use unlucky_i18n::catalog::{Catalog, Key}; +use unlucky_i18n::Lang; + +use crate::theme::Families; + +use super::super::layout::{frame_metrics, measure_cap_px, PaneKit}; +use super::model::{GROUPS, SUMMARY_HEADER}; +use super::{demo, lines, *}; + +fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Dark, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) +} + +#[test] +fn page_is_single_column_and_never_wider_than_the_measure_cap() { + for width in [700.0, 1180.0, 1600.0] { + let metrics = frame_metrics(PaneKit::Single, width); + // 抓得住:谁把日志页改成两列(`secondary` 出现就会把正文列挤窄); + // 谁绕开 `layout` 自己算列宽(`column` 会超 424)。 + // 抓不住:列内文本是否真在这 424 里折行(那要量字形,单测量不到)。 + assert!(metrics.secondary.is_none(), "w={width}"); + assert!( + metrics.column <= measure_cap_px() + f32::EPSILON, + "w={width} 正文列 {} > 上限 {}", + metrics.column, + measure_cap_px() + ); + } +} + +#[test] +fn mono_lines_use_the_mono_stack_with_the_body_small_metrics() { + let ui = ui(); + let style = lines::mono_style(&ui); + + // 抓得住:把等宽行换成正文栈(`family` 会变 `Text`)——那是「等宽」失真的唯一入口; + // 顺手改字号/行高(数值表要写 12/16,漂了就红)。 + // 抓不住:CJK 字形到底有没有被回退到别的族(要查字体 cmap,本 crate 不做)。 + assert_eq!(style.family, FontFamily::Mono); + assert_eq!(style.size_px, 12.0); + assert_eq!(style.line_height_px, 16.0); + assert_eq!( + ui.font_of(style).family, + iced::font::Family::Name("Consolas") + ); +} + +#[test] +fn findings_are_grouped_critical_first_and_empty_groups_do_not_exist() { + let findings = demo::crash().findings; + let logs = Logs { + findings, + ..Logs::default() + }; + let counts: Vec = GROUPS + .iter() + .map(|severity| logs.group(*severity).len()) + .collect(); + + // 抓得住:分组顺序被改成 info 在前(主因会沉底);空组也被画出一个标题(截图上出现 0 条的分区)。 + // 抓不住:组内顺序(页面刻意不重排 presenter 给的顺序)。 + assert_eq!(GROUPS[0], Severity::Critical); + assert_eq!( + counts.iter().sum::(), + logs.findings.len(), + "每条结论必须落进恰好一个分组" + ); + assert!(counts[0] >= 1, "样例至少有主因"); + assert!(logs.group(Severity::Info).is_empty()); +} + +#[test] +fn summary_is_a_single_deterministic_line_carrying_rule_and_severity() { + let logs = demo::crash(); + let summary = logs.summary(); + + // 抓得住:摘要里丢了规则 id / 严重度 / 置信度(换 issue 时最关键的三个字段); + // 有人把换行塞进摘要("一行"就不成立了)。 + // 抓不住:粘贴到 issue 后的可读性(那是人判断的),也不守文案语言(摘要刻意不含界面文案)。 + assert!(!summary.contains('\n'), "{summary}"); + assert!(summary.starts_with(SUMMARY_HEADER), "{summary}"); + assert!( + summary.contains("integrity.unsatisfied_link:critical(85)"), + "{summary}" + ); + assert!(summary.contains("lines="), "{summary}"); + assert_eq!(summary, logs.summary(), "同一份模型两次摘要必须一致"); + + let empty = Logs::default(); + assert_eq!(empty.summary(), "ULL-LOGS v1 | lines=0"); +} + +#[test] +fn severity_is_derived_from_confidence_with_the_documented_thresholds() { + // 抓得住:阈值边界写成 `>` / `>=` 那种 off-by-one(80 与 50 是文档里写死的两个点)。 + // 抓不住:折算本身对不对——DTO 补上 `severity` 之前这只是**过渡口径**(已报 finding)。 + assert_eq!(Severity::of_confidence(100), Severity::Critical); + assert_eq!(Severity::of_confidence(80), Severity::Critical); + assert_eq!(Severity::of_confidence(79), Severity::Warning); + assert_eq!(Severity::of_confidence(50), Severity::Warning); + assert_eq!(Severity::of_confidence(49), Severity::Info); + assert_eq!(Severity::of_confidence(0), Severity::Info); +} + +#[test] +fn the_longest_stack_line_reaches_the_view_model_untruncated() { + let logs = demo::crash(); + let longest = logs + .lines + .iter() + .map(|line| line.text.chars().count()) + .max() + .unwrap_or(0); + + // 抓得住:为了排版把行截断 / 加省略号(用户就再也拿不回原始堆栈)。 + // 抓不住:折行后的视觉行数(渲染期的事)。 + assert!( + longest >= 200, + "样例里应有一条 ≥200 字符的堆栈行,实际 {longest}" + ); + assert!(!logs.lines.iter().any(|line| line.text.ends_with('…'))); +} + +#[test] +fn empty_state_is_driven_by_both_lists_being_empty() { + let mut logs = Logs::default(); + assert!(logs.is_empty()); + + logs.findings = demo::crash().findings; + // 抓得住:把「没有日志行」当成空态(有诊断时那样会藏掉最该看的东西)。 + assert!(!logs.is_empty(), "只有诊断也不算空态"); + + let live = demo::State::Live.build(); + assert!(!live.is_empty()); + assert!(live.findings.is_empty(), "live 状态没有诊断"); +} + +#[test] +fn demo_state_comes_from_the_fixture_variable() { + // 抓得住:环境变量取值拼错(截图会静默退成 crash,出图的人以为拍到了空态)。 + assert_eq!(demo::State::of_env_value(Some("empty")), demo::State::Empty); + assert_eq!(demo::State::of_env_value(Some("live")), demo::State::Live); + assert_eq!(demo::State::of_env_value(Some("crash")), demo::State::Crash); + assert_eq!(demo::State::of_env_value(None), demo::State::Crash); +} + +#[test] +fn every_key_the_page_uses_is_translated_in_both_languages() { + let keys = [ + Key::NavLogs, + Key::LogsSubtitleIdle, + Key::LogsSubtitleActive, + Key::LogsOpenFolder, + Key::LogsClear, + Key::LogsOutputTitle, + Key::LogsDiagnosisTitle, + Key::LogsSeverityCritical, + Key::LogsSeverityWarning, + Key::LogsSeverityInfo, + Key::LogsConfidence, + Key::LogsSuggestedFix, + Key::LogsSummaryLabel, + Key::LogsCopySummary, + Key::LogsEmptyTitle, + Key::LogsEmptyBody, + Key::LogsEmptyAction, + ]; + + // 抓得住:页面用到的 key 被改名/删掉,或只进了其中一套目录(缺译会返回 `⟪missing⟫`)。 + // 抓不住:译文的语气与长度(人读的事)。 + for lang in [Lang::En, Lang::ZhCn] { + let catalog = Catalog::of(lang); + for key in keys { + assert!( + catalog.get(key).is_some(), + "{} 缺 {}", + lang.setting_value(), + key.name() + ); + } + } + + // 占位符名写错时 `render` 会把 `{percent}` 原样留在文案里——这条钉住它。 + let rendered = Catalog::of(Lang::En).render(Key::LogsConfidence, &[("percent", "85")]); + assert_eq!(rendered, "Confidence 85%"); +} + +#[test] +fn header_stays_two_lines_because_of_the_status_subtitle() { + let ui = ui(); + // 抓得住:有人把副标题去掉,页头从 84 掉回 56(版式台账上会露出来)。 + assert_eq!(ui.metrics().header_height(true), 84.0); + assert!(ui.metrics().header_height(true) > ui.metrics().header_height(false)); +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index f916f5f..5ef1e45 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -108,6 +108,7 @@ mod tests; pub use download::{Category, Download}; pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; +pub use logs::{Finding, Level, Line, Logs, Outcome, Severity}; pub use model::{Detail, Instances, Meta, Model, Row}; use iced::widget::{column, container, row, space}; @@ -150,6 +151,10 @@ pub enum Message { CancelInstall, /// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。 SetCategory(Category), + /// 把一段现成的文本塞进剪贴板(**M95 日志页**:诊断卡片的一行摘要)。 + /// + /// 文本由页面造(它是唯一同时看得见规则 id / 分类 / 置信度的地方),app 层只负责落剪贴板。 + CopySummary(String), } /// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。 @@ -327,7 +332,7 @@ fn render<'a>( Screen::Instances => instances::view(ui, metrics, model), Screen::Download => download::view(ui, metrics, &model.download, unlucky_i18n::Lang::En), Screen::Settings => settings::view(ui, metrics, appearance), - Screen::Logs => logs::view(ui, metrics), + Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang), } } @@ -365,6 +370,9 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model { if screen == Screen::Download { model.download = download::demo(unlucky_i18n::Lang::En.catalog()); } + if screen == Screen::Logs { + model.logs = logs::demo(); + } model } diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index a0f7c7a..30be09e 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -14,10 +14,12 @@ //! //! 文本都是 `String`/`&'static str`,没有框架类型——这一层可以纯单测(见 `pages/tests.rs`)。 +use unlucky_i18n::Lang; use unlucky_presenter::router::NavSection; use crate::app::components::interaction::Interaction; +use super::logs::Logs; use super::{Download, Screen}; /// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。 @@ -85,6 +87,13 @@ pub struct Model { pub instances: Instances, /// 下载页的数据(M94:版本列表 / 选中 / 安装进度,全部来自 presenter 快照) pub download: Download, + /// 日志页的数据(M95:presenter 快照 → 日志行 + 诊断结论,见 [`super::logs`])。 + pub logs: Logs, + /// 界面语言(M95):页面文案走 i18n key,语言由 app 层决定后灌进来。 + /// + /// 这里只是**留口**:`theme.rs` 还没有「当前语言」的入口(那条线归 M93), + /// 产品路径先给 `Lang::En`;将来 `Ui` 拿到语言后,这个字段改由它填。 + pub lang: Lang, /// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。 /// /// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的; @@ -101,6 +110,8 @@ impl Model { active, instances: Instances::default(), download: Download::loading(), + logs: Logs::default(), + lang: Lang::En, forced: None, } } diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index 25b4e66..7cc3276 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -167,6 +167,28 @@ catalog! { ), DownloadOpenLogs => ("Open logs", "打开日志"), DownloadDismiss => ("Dismiss", "关闭"), + // --- logs page (M95) --- + // 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里: + // 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。 + LogsSubtitleIdle => ("Nothing is running right now", "当前没有运行中的实例"), + LogsSubtitleActive => ("Launcher and game output", "启动器与游戏输出"), + LogsOpenFolder => ("Open folder", "打开目录"), + LogsClear => ("Clear", "清空"), + LogsOutputTitle => ("OUTPUT", "输出"), + LogsDiagnosisTitle => ("DIAGNOSIS", "诊断"), + LogsSeverityCritical => ("CRITICAL", "严重"), + LogsSeverityWarning => ("WARNING", "警告"), + LogsSeverityInfo => ("NOTICE", "提示"), + LogsConfidence => ("Confidence {percent}%", "置信度 {percent}%"), + LogsSuggestedFix => ("Suggested fix", "建议动作"), + LogsSummaryLabel => ("One-line summary", "一行摘要"), + LogsCopySummary => ("Copy summary", "复制摘要"), + LogsEmptyTitle => ("No output yet", "还没有输出"), + LogsEmptyBody => ( + "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", + "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。" + ), + LogsEmptyAction => ("Choose an instance", "选择实例"), } /// 一套语言的目录(不可变;`Copy`,随手传值)。 diff --git a/crates/i18n/src/catalog/tests.rs b/crates/i18n/src/catalog/tests.rs index e42488a..630b0e4 100644 --- a/crates/i18n/src/catalog/tests.rs +++ b/crates/i18n/src/catalog/tests.rs @@ -1,7 +1,9 @@ -//! `Catalog` 的守门测试(M94 从 `catalog.rs` 原样搬出来:那边已经贴着 400 行硬上限, +//! `Catalog` 的守门测试(从 `catalog.rs` 原样搬出来:那边已经贴着 400 行硬上限, //! 而页面每加一批 key 都要在这个模块里占一行)。 //! //! **零改动搬运**:用例、注释、断言一字未动;`use super::*` 指向的仍是 `crate::catalog`。 +//! M94(下载页)与 M95(日志页)几乎同时撞到这堵墙、各自搬了一次:两条 branch 的**用例部分 +//! 逐字相同**(解冲突时按 M94 那份合并,测试数量 7 条不变)。 use super::*; From 1bccf89e5c7fb44e04a5a1e4ea1f62f72681e351 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 20:07:41 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(app-iced):=20M95=20r1=20=E4=BA=94?= =?UTF-8?q?=E6=9D=A1=E8=A7=86=E8=A7=89=E5=A4=8D=E5=AE=A1=EF=BC=88=E6=82=AC?= =?UTF-8?q?=E6=8C=82=E7=BC=A9=E8=BF=9B=20/=20=E4=BC=9A=E8=AF=9D=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=20/=20=E6=91=98=E8=A6=81=E5=8F=AF=E8=A7=81=20/=20?= =?UTF-8?q?=E8=B4=B4=E5=BA=95=20/=20=E6=8F=8F=E8=BE=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2-1 **悬挂缩进真做了**(之前文档在骗人,代码里只有 `row![bar, text]`): 每行拆「前缀 + 正文」两个文本控件(前缀 = 第一个空格及其之前,含空格; `split_prefix` 在第一个「词」>20 字符时不拆),正文带 Fill 宽度自己折行 ⇒ 续行落在前缀宽度之后。于是 `at com.mojang…(GLX.java:64)` 不再拆成「`at` 独占一行」。 文档同步写清代价:续行缩进量 = 该行前缀宽度。 - P2-2 会话身份上页头:`Logs::with_status(lang)` 用 `LogsStatus` 模板拼 `{instance} · {when} · {outcome}`,结局文案走 `LaunchOutcomeNormal/Crash/Silent` 三 key(bridge 文档里那条「LogOutcome 三态 → 三 key」的结构化出口,此前 UI 侧零调用点)。 中文模板差在全角竖线(守卫要求 zh≠en,也确实更符合中文排版)。 - P2-3 摘要钉成诊断卡**固定页脚**(不进滚动区):「One-line summary」+ Copy summary 紧邻它要复制的那段等宽文本;卡片头不再放复制按钮(避免两处入口)。 - P2-4 日志卡改 `panel::scroll_tail`(`Anchor::End`):偏移按「距底多远」记,内容增长时 跟随尾部;「用户上滚后是否冻结」的边界写在 `panel::scroll_tail` 文档里(结论:不冻结, 冻结要 app 层回灌滚动状态,已登记)。 - P2-5 两张主卡补 1px 描边:`panel::card` 改用 `Component::OutlinedCard.spec().shape` + `ui.outlined(..)`,与 `frame::card` 同一套令牌。 测试:新增 `a_line_is_split_into_prefix_and_body_for_the_hanging_indent`(含 CJK 行与 「不拆」的两种边界)与 `status_line_names_the_session_with_the_launch_outcome_keys`; key 清单补 `LogsStatus` 与三个 `LaunchOutcome*`;demo 的 state.build 改为收 `Lang`。 验证:`fmt --check` 通过;`clippy -p unlucky-app-iced --all-targets -D warnings` 干净; `test -p unlucky-app-iced` 274+6 全绿、`-p unlucky-i18n` 23 全绿。 图:`.screens/logs-{empty,live,crash}-{light,dark}.png`(1180×760)、 `r2-live-tall-light.png`(1180×1080,栈帧可见,P2-1 证据)。 --- crates/app-iced/src/app/view.rs | 9 +- crates/app-iced/src/pages/logs.rs | 12 ++- crates/app-iced/src/pages/logs/demo.rs | 20 +++-- crates/app-iced/src/pages/logs/diagnostic.rs | 87 +++++++++----------- crates/app-iced/src/pages/logs/lines.rs | 70 +++++++++++++--- crates/app-iced/src/pages/logs/model.rs | 55 +++++++++++++ crates/app-iced/src/pages/logs/panel.rs | 48 ++++++++--- crates/app-iced/src/pages/logs/tests.rs | 79 ++++++++++++++++-- crates/app-iced/src/pages/mod.rs | 2 +- crates/i18n/src/catalog.rs | 4 +- 10 files changed, 293 insertions(+), 93 deletions(-) diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index b20f829..c3786a2 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -112,15 +112,17 @@ pub(super) fn model( _ => None, }; + // 语言入口还没接到 `Ui`(`theme.rs` 归 M93),产品路径先英文。 + let lang = Lang::En; + pages::Model { screen, window_width: width, active: route.nav(), instances: pages::Instances { rows, detail }, download: pages::Download::from_snapshot(state, category, unlucky_i18n::Lang::En.catalog()), - logs: logs_model(state), - // 语言入口还没接到 `Ui`(`theme.rs` 归 M93),产品路径先英文。 - lang: Lang::En, + logs: logs_model(state).with_status(lang), + lang, forced: forced.map(|state| match state { crate::cli::ForcedState::Hover => Interaction::Hovered, crate::cli::ForcedState::Pressed => Interaction::Pressed, @@ -163,6 +165,7 @@ fn logs_model(state: &AppState) -> pages::Logs { .map(|report| diagnosis_of(&report.diagnosis)) .into_iter() .collect(), + ..pages::Logs::default() } } diff --git a/crates/app-iced/src/pages/logs.rs b/crates/app-iced/src/pages/logs.rs index 1601ba2..56e6266 100644 --- a/crates/app-iced/src/pages/logs.rs +++ b/crates/app-iced/src/pages/logs.rs @@ -123,13 +123,11 @@ pub fn view<'a>( frame::page(ui, metrics, header, body) } -/// 页头副标题:**静态 key**(两行页头的第二行),计数留给卡片标题行。 -fn subtitle(catalog: &Catalog, logs: &Logs) -> &'static str { - if logs.is_empty() { - catalog.text(Key::LogsSubtitleIdle) - } else { - catalog.text(Key::LogsSubtitleActive) - } +/// 页头副标题:有会话身份(实例 · 时间 · 结局)就显示它,否则退回静态文案。 +fn subtitle<'a>(catalog: &Catalog, logs: &'a Logs) -> &'a str { + logs.status + .as_deref() + .unwrap_or_else(|| catalog.text(Key::LogsSubtitleIdle)) } /// 空态(`feedback::EmptyState` 真组件:图标块 + 标题 + 说明 + 一个 CTA)。 diff --git a/crates/app-iced/src/pages/logs/demo.rs b/crates/app-iced/src/pages/logs/demo.rs index d22c456..366fa0c 100644 --- a/crates/app-iced/src/pages/logs/demo.rs +++ b/crates/app-iced/src/pages/logs/demo.rs @@ -24,6 +24,8 @@ //! **中文在这里是「规则表的输出(数据)」,不是界面文案**:界面文案(标题 / 按钮 / 分组名 / //! 空态)全部走 `unlucky_i18n` 的 key,见 `logs.rs`。 +use unlucky_i18n::Lang; + use super::model::{Finding, Level, Line, Logs, Outcome, Severity}; /// 挑演示状态的环境变量。 @@ -54,28 +56,31 @@ impl State { } } - /// 造出这个状态的视图模型。 + /// 造出这个状态的视图模型(`lang` 用于页头副标题的 i18n)。 #[must_use] - pub fn build(self) -> Logs { + pub fn build(self, lang: Lang) -> Logs { match self { - Self::Empty => Logs::default(), + Self::Empty => Logs::default().with_status(lang), Self::Live => Logs { outcome: Some(Outcome::Ok), findings: Vec::new(), ..crash() - }, - Self::Crash => crash(), + } + .with_status(lang), + Self::Crash => crash().with_status(lang), } } } /// harness 入口:按 [`STATE_ENV`] 挑状态。 #[must_use] -pub fn demo() -> Logs { - State::of_env_value(std::env::var(STATE_ENV).ok().as_deref()).build() +pub fn demo(lang: Lang) -> Logs { + State::of_env_value(std::env::var(STATE_ENV).ok().as_deref()).build(lang) } /// 崩溃现场(日志 + 三条诊断,覆盖 critical / warning 两个分组)。 +/// +/// 不带 `status`(副标题要 i18n,见 [`Logs::with_status`]):[`State::build`] 会补上。 #[must_use] pub fn crash() -> Logs { Logs { @@ -84,6 +89,7 @@ pub fn crash() -> Logs { outcome: Some(Outcome::Crashed), lines: parse(SAMPLE), findings: findings(), + ..Logs::default() } } diff --git a/crates/app-iced/src/pages/logs/diagnostic.rs b/crates/app-iced/src/pages/logs/diagnostic.rs index cc1c9e1..4f6f4ff 100644 --- a/crates/app-iced/src/pages/logs/diagnostic.rs +++ b/crates/app-iced/src/pages/logs/diagnostic.rs @@ -36,7 +36,11 @@ use super::lines::{mono, mono_owned}; use super::model::{Finding, Logs, Severity, GROUPS}; use super::panel; -/// 诊断卡片(只在有结论时画)。高度由调用方定(见 [`panel`]),组与摘要在卡片内滚动。 +/// 诊断卡片(只在有结论时画)。高度由调用方定(见 [`panel`])。 +/// +/// 结构 = 头(`DIAGNOSIS` + 条数)/ **可滚动的分组区** / **固定的摘要页脚**。 +/// 摘要钉在页脚而不是跟着滚(视觉复审 P2-3):三条结论就足以把它顶到折叠线以下, +/// 而「Copy」复制的是用户必须看得见的东西——看不见的字符串不该能被复制。 pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Element<'a, Message> { let catalog = Catalog::of(lang); @@ -59,49 +63,22 @@ pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Eleme } } - group = group.push(frame::divider(ui)); - group = group.push(summary_block(ui, &catalog, logs)); - panel::card( ui, height, - column![head_row(ui, &catalog, logs), panel::scroll(ui, group)].width(Length::Fill), - ) -} - -/// 卡片头部:`DIAGNOSIS` + 条数 + **始终可见的「Copy summary」**。 -/// -/// 复制按钮钉在头上而不是跟着摘要滚:三条结论就足以把摘要推到折叠线以下, -/// 而「换 issue 时粘一行」正是这页的出口,不能要用户先滚一下才发现它。 -fn head_row<'a>(ui: &'a Ui, catalog: &Catalog, logs: &Logs) -> Element<'a, Message> { - row![ - frame::text_role( - ui, - TypeRole::LabelMedium, - true, - catalog.text(Key::LogsDiagnosisTitle), - ui.color(Role::OnSurfaceVariant), - ), - iced::widget::Space::new().width(Length::Fill), - frame::text_role_owned( - ui, - TypeRole::LabelMedium, - false, - logs.findings.len().to_string(), - ui.color(Role::OnSurfaceVariant), - ), - buttons::text( - ui, - Button::new( - catalog.text(Key::LogsCopySummary), - Message::CopySummary(logs.summary()), + column![ + title_row( + ui, + catalog.text(Key::LogsDiagnosisTitle), + logs.findings.len(), ), - ), - ] - .width(Length::Fill) - .align_y(Alignment::Center) - .spacing(ui.space(Space::X2)) - .into() + panel::scroll(ui, group), + frame::divider(ui), + summary_block(ui, &catalog, logs), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill), + ) } /// 标题行:左边标签(大写字距靠令牌档),右边条数。 @@ -179,16 +156,30 @@ fn finding_block<'a>(ui: &'a Ui, catalog: &Catalog, finding: &'a Finding) -> Ele block.into() } -/// 一行摘要(等宽、可整段带走;复制按钮在卡片头上,见 [`head_row`])。 +/// **固定的摘要页脚**:标签 +「Copy summary」+ 等宽摘要(可整段带走)。 +/// +/// 页脚不滚动(见 [`card`] 的文档):按钮紧邻它要复制的那段文字,用户复制的一定是看得见的串。 fn summary_block<'a>(ui: &'a Ui, catalog: &Catalog, logs: &Logs) -> Element<'a, Message> { column![ - frame::text_role( - ui, - TypeRole::LabelMedium, - false, - catalog.text(Key::LogsSummaryLabel), - ui.color(Role::OnSurfaceVariant), - ), + row![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::LogsSummaryLabel), + ui.color(Role::OnSurfaceVariant), + ), + iced::widget::Space::new().width(Length::Fill), + buttons::text( + ui, + Button::new( + catalog.text(Key::LogsCopySummary), + Message::CopySummary(logs.summary()), + ), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center), mono_owned(ui, logs.summary(), ui.color(Role::OnSurface)) .width(Length::Fill) .wrapping(Wrapping::WordOrGlyph), diff --git a/crates/app-iced/src/pages/logs/lines.rs b/crates/app-iced/src/pages/logs/lines.rs index 4b7666b..c1a0f1e 100644 --- a/crates/app-iced/src/pages/logs/lines.rs +++ b/crates/app-iced/src/pages/logs/lines.rs @@ -14,8 +14,32 @@ //! 逐字回退到系统 CJK 族(本机实测回退到 `Microsoft YaHei UI`),回退字形的 advance 不是 1ch。 //! 横向滚动方案里「第 40 列对齐」是有意义的(堆栈缩进、`at a.b.C(File.java:12)` 的括号列), //! 一旦 CJK 回退把列宽带偏,对齐就废了;折行版面不承诺任何列对齐,回退只影响同一行内的观感。 -//! 左栏那条 2px 等级条是跨行的(`Length::Fill`),所以「同一条日志折了 3 行」在视觉上仍是 -//! 一个整体。 +//! ## 悬挂缩进(M95 r2 落地,视觉复审 P2-1) +//! +//! r1 的 `entry()` 只有 `row![等级条, 单个 Text]`:折行的续行回到文本自己的左边距, +//! 于是 `at com.mojang…(GLX.java:64)` 被拆成「`at` 独占一行 + 帧名一行」,和「下一条日志」 +//! 视觉上无法区分(一屏白花 ~5 行)。**现在的落地方式**是把一行拆成**前缀 + 正文两个文本控件**: +//! +//! ```text +//! │ [19:40:14] [Render thread/ERROR] 显卡驱动不支持 OpenGL 3.2,初始化窗口失败 +//! │ ↑ 前缀(第一个空格及其之前,含空格) ↑ 正文从它右边开始,带着 Fill 宽度自己折行 +//! │ java.lang.IllegalStateException: GLFW error before init: [0x10008] Failed to create window… +//! │ ↑ 无前缀(第一个「词」超过 20 字符)⇒ 整行一个文本块 +//! ``` +//! +//! 为什么不是「NBSP 绑定前缀 + 单个 Text」:NBSP 只阻止断行,**改不了续行的起点**——续行依旧 +//! 回到文本左边距,`at` 还是独占一行(除非整行按字形硬断,那会把时间戳也断掉)。两段布局是 +//! iced 里唯一能同时做到「`at` 与帧名同行」+「续行比行首缩进」的写法。 +//! +//! 代价与边界:续行缩进量 = **该行前缀宽度**(时间戳行 ≈ 10 字符、栈帧 `at` 行 ≈ 3 字符), +//! 所以不同行的续行缩进不一样——这是悬挂缩进的正常形态(按行首标签缩进),不是 bug。 +//! +//! ## 贴底(M95 r2,视觉复审 P2-4) +//! +//! 崩溃排查看的是**最后几行**,所以行列表用 `panel::scroll_tail`(`Anchor::End`):偏移按 +//! 「距底多远」记,内容增长时视图保持距底同一位置 ⇒ 天然跟随尾部。用户上滚后的行为与 +//! 「是否冻结」的边界写在 [`panel::scroll_tail`] 的文档里(结论:**没有**冻结,需要 app 层 +//! 回灌滚动状态才做得到)。 //! //! ## 等宽族的实际落点 //! @@ -41,6 +65,9 @@ use super::super::Message; use super::model::{Level, Line, Logs}; use super::panel; +/// 前缀上限(字符):超过就不拆(见 [`split_prefix`])。 +const PREFIX_MAX_CHARS: usize = 20; + /// 等级条宽度(2px:够看见,又不抢日志正文的起始列)。 const BAR_WIDTH: f32 = 2.0; @@ -79,12 +106,13 @@ pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Eleme .spacing(ui.space(Space::X1)) .width(Length::Fill); - panel::card(ui, height, column![head, panel::scroll(ui, rows)]) + panel::card(ui, height, column![head, panel::scroll_tail(ui, rows)]) } -/// 一行:等级条(跨行)+ 等宽正文(折行)。 +/// 一行:等级条(跨行)+ **前缀 / 正文两段**(正文折行后落在前缀右边 = 悬挂缩进)。 fn entry<'a>(ui: &'a Ui, line: &'a Line) -> Element<'a, Message> { let color = color_of(ui, line.level); + let (prefix, body) = split_prefix(&line.text); let bar = container(space::horizontal()) .width(BAR_WIDTH) @@ -98,16 +126,38 @@ fn entry<'a>(ui: &'a Ui, line: &'a Line) -> Element<'a, Message> { ..container::Style::default() }); - row![ - bar, - mono(ui, &line.text, color) + // 「前缀 + 正文」拆两个文本控件是悬挂缩进的**唯一**落地方式(iced 的单个 `Text` 折行后 + // 续行一定回到自己的左边距,没有 `text-indent` 之类的排版钩子): + // 前缀占第一行开头,正文从它右边开始、带着 `Fill` 宽度自己折行 ⇒ 续行自然落在 + // `前缀宽度` 之后,与下一条日志的起点(x = 等级条右边)分开。 + let text = row![ + mono(ui, prefix, color), + mono(ui, body, color) .width(Length::Fill) .wrapping(iced::widget::text::Wrapping::WordOrGlyph), ] - .spacing(ui.space(Space::X2)) + .spacing(0.0) .width(Length::Fill) - .align_y(Alignment::Start) - .into() + .align_y(Alignment::Start); + + row![bar, text] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .align_y(Alignment::Start) + .into() +} + +/// 拆「前缀」与「正文」:前缀 = 第一个空格及其之前的部分(**含**那个空格,所以接缝处不用再加 +/// 间距),正文 = 其余。 +/// +/// 拆的条件:空格在 1..=[`PREFIX_MAX_CHARS`] 个字符之内。超长(一整行没有空格、或第一个 +/// 「词」本身就比 20 字符长,例如以 URL 开头的行)就**不拆**,整行当一个文本块折行——否则 +/// 前缀会把正文挤成一条 1 字符宽的缝。 +pub(super) fn split_prefix(text: &str) -> (&str, &str) { + match text.find(' ') { + Some(index) if index > 0 && index < PREFIX_MAX_CHARS => text.split_at(index + 1), + _ => ("", text), + } } /// 等级 → 颜色(错误用 `Error`,警告用 `Tertiary`:与主题的 warning 槽位同源)。 diff --git a/crates/app-iced/src/pages/logs/model.rs b/crates/app-iced/src/pages/logs/model.rs index 94b99c5..4103f87 100644 --- a/crates/app-iced/src/pages/logs/model.rs +++ b/crates/app-iced/src/pages/logs/model.rs @@ -24,6 +24,9 @@ //! (`rules.d/*.yaml` 的 `cause.key: diagnose.cause.…`)。DTO 没带 `key`,页面就把规则原文 //! 当**数据**画出来,页面的界面文案(标题 / 按钮 / 分组名 / 空态)仍然全部走 i18n key。 +use unlucky_i18n::catalog::{Catalog, Key}; +use unlucky_i18n::Lang; + /// 一行日志的等级(presenter `LogLevel` 的展示投影)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Level { @@ -148,6 +151,9 @@ pub struct Logs { pub when: Option, /// 结局。 pub outcome: Option, + /// 页头副标题(`实例 · 时间 · 结局`):**构造时**拼好(见 [`Logs::with_status`]), + /// 页面只负责显示——i18n 的「一句话 = 一个 key」不许在渲染里拼句子。 + pub status: Option, /// 日志行(顺序 = 文件顺序)。 pub lines: Vec, /// 诊断结论(顺序 = presenter 给的顺序,页面不重排)。 @@ -155,6 +161,21 @@ pub struct Logs { } impl Logs { + /// 补上页头副标题(M95 r2,视觉复审 P2-2)。 + /// + /// 为什么在这里做而不是在页面里:`Header::subtitle` 要 `&'a str`,而拼出来的是 `String`; + /// 让模型持有它,页面就永远只借不拼。 + #[must_use] + pub fn with_status(mut self, lang: Lang) -> Self { + self.status = status_line( + self.instance.as_deref(), + self.when.as_deref(), + self.outcome, + lang, + ); + self + } + /// 没有任何可显示的东西(空态判据:行与诊断都空)。 #[must_use] pub fn is_empty(&self) -> bool { @@ -216,3 +237,37 @@ impl Logs { /// 摘要行的版本头(改了格式就换代,别让旧 issue 里的摘要含义漂移)。 pub const SUMMARY_HEADER: &str = "ULL-LOGS v1"; + +/// 页头副标题:`{instance} · {when} · {outcome}`。 +/// +/// 结局文案走 [`Key::LaunchOutcomeNormal`] / [`Key::LaunchOutcomeCrash`] / +/// [`Key::LaunchOutcomeSilent`] —— 就是 `unlucky_i18n::bridge` 文档里写了很久的那条 +/// 「`LogOutcome` 三态 → 三 key 结构化出口」,M95 r2 之前它们在 UI 侧**零调用点**(视觉复审 P2-2)。 +/// +/// 没有实例名或结局时返回 `None`(不知道是哪一场就别编),页头退回静态副标题。 +#[must_use] +pub fn status_line( + instance: Option<&str>, + when: Option<&str>, + outcome: Option, + lang: Lang, +) -> Option { + let instance = instance?; + let outcome = outcome?; + let catalog = Catalog::of(lang); + + let outcome_key = match outcome { + Outcome::Ok => Key::LaunchOutcomeNormal, + Outcome::Crashed => Key::LaunchOutcomeCrash, + Outcome::SilentFail => Key::LaunchOutcomeSilent, + }; + + Some(catalog.render( + Key::LogsStatus, + &[ + ("instance", instance), + ("when", when.unwrap_or("—")), + ("outcome", catalog.text(outcome_key)), + ], + )) +} diff --git a/crates/app-iced/src/pages/logs/panel.rs b/crates/app-iced/src/pages/logs/panel.rs index 4b6ec04..93ab397 100644 --- a/crates/app-iced/src/pages/logs/panel.rs +++ b/crates/app-iced/src/pages/logs/panel.rs @@ -16,40 +16,61 @@ //! 两者都是**兄弟**滚动区(不是嵌套):滚轮归属清楚,不存在「滚动条套滚动条」。 use iced::widget::{container, scrollable}; -use iced::{Background, Border, Element, Length}; +use iced::{Background, Element, Length}; +use unlucky_design::component_map::Component; use unlucky_design::elevation::MaterialLayer; -use unlucky_design::shape::ShapeScale; use crate::theme::Ui; use super::super::frame; use super::super::Message; -/// 定高卡片(`Card` 材质层 + `OutlinedCard` 形状,令牌与 `frame::card` 同源)。 +/// 定高卡片(`Card` 材质层 + `OutlinedCard` 形状与 **1px 描边**,与 `frame::card` 同一套令牌: +/// 实例页 / 下载页的卡都带这条描边,日志页的两张主卡不能例外)。 pub fn card<'a>( ui: &'a Ui, height: Length, content: impl Into>, ) -> Element<'a, Message> { + let shape = Component::OutlinedCard.spec().shape; + container(content) .width(Length::Fill) .height(height) .padding(ui.metrics().card_padding) .style(move |_theme| container::Style { background: Some(Background::Color(ui.layer(MaterialLayer::Card))), - border: Border { - radius: ui.radius_dp(ShapeScale::Large), - ..Border::default() - }, + border: ui.outlined(shape), ..container::Style::default() }) .into() } -/// 卡片内的竖向滚动区(滚条样式与实例页列表列同源)。 +/// 卡片内的竖向滚动区(贴顶:诊断卡从头读)。 pub fn scroll<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { - scrollable(content) + scroller(ui, content, false) +} + +/// 卡片内的竖向滚动区(**贴底**:日志卡看最后几行)。 +/// +/// `anchor_bottom` = `Anchor::End`:滚动偏移按「距底多远」记,所以**内容增长时视图保持在 +/// 距底同一位置 ⇒ 天然跟随尾部**。用户上滚后偏移 = 距底 N 像素,新内容到达时仍按「距底 N」 +/// 呈现(视图跟着内容下移),也就是说**这里没有「上滚即冻结」**——那需要页面自己持有滚动 +/// 状态 + `on_scroll` 消息 + app 层回灌,属于另一件事(已登记在报告里)。 +pub fn scroll_tail<'a>( + ui: &'a Ui, + content: impl Into>, +) -> Element<'a, Message> { + scroller(ui, content, true) +} + +fn scroller<'a>( + ui: &'a Ui, + content: impl Into>, + tail: bool, +) -> Element<'a, Message> { + let view = scrollable(content) .direction(scrollable::Direction::Vertical( scrollable::Scrollbar::new() .width(frame::SCROLLBAR_WIDTH) @@ -58,6 +79,11 @@ pub fn scroll<'a>(ui: &'a Ui, content: impl Into>) -> Eleme )) .height(Length::Fill) .width(Length::Fill) - .style(frame::scroll_style(ui)) - .into() + .style(frame::scroll_style(ui)); + + if tail { + view.anchor_bottom().into() + } else { + view.into() + } } diff --git a/crates/app-iced/src/pages/logs/tests.rs b/crates/app-iced/src/pages/logs/tests.rs index 02cddcb..46c4a44 100644 --- a/crates/app-iced/src/pages/logs/tests.rs +++ b/crates/app-iced/src/pages/logs/tests.rs @@ -1,7 +1,8 @@ //! 日志页的守门测试。每条断言下面写清「抓得住什么 / 抓不住什么」(这个仓的硬要求)。 //! -//! 这里**没有截图断言**:像素级验收靠 `pages_demo --screen=logs` 的四张图(浅/深 × 空态/崩溃), -//! 单测只钉住「图上看不出来的那部分」——数值令牌、分组顺序、摘要格式、长行不被截断。 +//! 这里**没有截图断言**:像素级验收靠 `pages_demo --screen=logs` 的六张图(浅/深 × 空态/有日志/崩溃), +//! 单测只钉住「图上看不出来的那部分」——数值令牌、分组顺序、摘要格式、悬挂缩进的拆分规则、 +//! 页头会话身份的拼法。 use unlucky_design::color::SchemeFlavor; use unlucky_design::typography::{FontFamily, Platform}; @@ -11,7 +12,7 @@ use unlucky_i18n::Lang; use crate::theme::Families; use super::super::layout::{frame_metrics, measure_cap_px, PaneKit}; -use super::model::{GROUPS, SUMMARY_HEADER}; +use super::model::{self, GROUPS, SUMMARY_HEADER}; use super::{demo, lines, *}; fn ui() -> Ui { @@ -136,6 +137,70 @@ fn the_longest_stack_line_reaches_the_view_model_untruncated() { assert!(!logs.lines.iter().any(|line| line.text.ends_with('…'))); } +#[test] +fn a_line_is_split_into_prefix_and_body_for_the_hanging_indent() { + // 抓得住:悬挂缩进被改回「整行一个文本块」(`at` 会重新独占一行、续行回到行首)。 + // 抓不住:缩进到底落在第几像素(那要量字形/截图,见报告的前后对比图)。 + assert_eq!( + lines::split_prefix("[19:40:12] [main/INFO] hi"), + ("[19:40:12] ", "[main/INFO] hi") + ); + assert_eq!( + lines::split_prefix("at com.mojang.blaze3d.platform.GLX._initGlfw(GLX.java:64)"), + ( + "at ", + "com.mojang.blaze3d.platform.GLX._initGlfw(GLX.java:64)" + ) + ); + + // 没有空格 / 第一个「词」过长 ⇒ 不拆(否则正文被挤成一条 1 字符宽的缝)。 + assert_eq!( + lines::split_prefix("java.lang.RuntimeException"), + ("", "java.lang.RuntimeException") + ); + assert_eq!( + lines::split_prefix("https://example.invalid/aaaaaaaaaaaaaaaaaaaaaaaa va"), + ("", "https://example.invalid/aaaaaaaaaaaaaaaaaaaaaaaa va") + ); + // CJK 行:第一个空格若落在 20 字符以内就正常拆(时间戳 + 中文正文)。 + assert_eq!( + lines::split_prefix("[19:40:12] 正在启动 1.20.4"), + ("[19:40:12] ", "正在启动 1.20.4") + ); +} + +#[test] +fn status_line_names_the_session_with_the_launch_outcome_keys() { + // 抓得住:页头副标题不再说「这是哪个实例、哪一场、什么结局」(视觉复审 P2-2), + // 以及 `LaunchOutcome*` 三 key 又回到「定义了但零调用点」。 + // 抓不住:副标题在 1180 下会不会撞上右侧动作区(那要看截图)。 + let en = model::status_line( + Some("开发测试"), + Some("11-02 19:40"), + Some(Outcome::Crashed), + Lang::En, + ) + .expect("有实例名与结局就该有副标题"); + assert_eq!(en, "开发测试 · 11-02 19:40 · The game crashed"); + + let zh = model::status_line( + Some("开发测试"), + Some("11-02 19:40"), + Some(Outcome::SilentFail), + Lang::ZhCn, + ) + .expect("静默失败也有副标题"); + assert!(zh.contains("疑似静默失败"), "{zh}"); + + // 不知道是哪一场就别编:没有实例名 / 没有结局时退回静态副标题。 + assert!(model::status_line(None, Some("11-02 19:40"), Some(Outcome::Ok), Lang::En).is_none()); + assert!(model::status_line(Some("x"), None, None, Lang::En).is_none()); + + // demo 的三种状态里,有会话身份的那两种必须带副标题(页面才有东西可显示)。 + assert!(demo::State::Crash.build(Lang::En).status.is_some()); + assert!(demo::State::Live.build(Lang::En).status.is_some()); +} + #[test] fn empty_state_is_driven_by_both_lists_being_empty() { let mut logs = Logs::default(); @@ -145,7 +210,7 @@ fn empty_state_is_driven_by_both_lists_being_empty() { // 抓得住:把「没有日志行」当成空态(有诊断时那样会藏掉最该看的东西)。 assert!(!logs.is_empty(), "只有诊断也不算空态"); - let live = demo::State::Live.build(); + let live = demo::State::Live.build(Lang::En); assert!(!live.is_empty()); assert!(live.findings.is_empty(), "live 状态没有诊断"); } @@ -164,7 +229,7 @@ fn every_key_the_page_uses_is_translated_in_both_languages() { let keys = [ Key::NavLogs, Key::LogsSubtitleIdle, - Key::LogsSubtitleActive, + Key::LogsStatus, Key::LogsOpenFolder, Key::LogsClear, Key::LogsOutputTitle, @@ -179,6 +244,10 @@ fn every_key_the_page_uses_is_translated_in_both_languages() { Key::LogsEmptyTitle, Key::LogsEmptyBody, Key::LogsEmptyAction, + // 结局文案由页头副标题间接用到(见 `status_line`) + Key::LaunchOutcomeNormal, + Key::LaunchOutcomeCrash, + Key::LaunchOutcomeSilent, ]; // 抓得住:页面用到的 key 被改名/删掉,或只进了其中一套目录(缺译会返回 `⟪missing⟫`)。 diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 5ef1e45..afb60af 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -371,7 +371,7 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model { model.download = download::demo(unlucky_i18n::Lang::En.catalog()); } if screen == Screen::Logs { - model.logs = logs::demo(); + model.logs = logs::demo(model.lang); } model } diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index 7cc3276..d299995 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -170,8 +170,10 @@ catalog! { // --- logs page (M95) --- // 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里: // 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。 + // `LogsStatus` 是**模板**:中英差别落在分隔符上(中文用全角竖线——`·` 是外来符号, + // 中文正文里偏挤;这也让「中文目录必须与英文不同」这条守卫成立,不是为过测试硬造差异)。 LogsSubtitleIdle => ("Nothing is running right now", "当前没有运行中的实例"), - LogsSubtitleActive => ("Launcher and game output", "启动器与游戏输出"), + LogsStatus => ("{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"), LogsOpenFolder => ("Open folder", "打开目录"), LogsClear => ("Clear", "清空"), LogsOutputTitle => ("OUTPUT", "输出"), From ee4d82e3d4940a063d60197d5e423dbcf89e68a2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 21:01:47 +0800 Subject: [PATCH 3/7] =?UTF-8?q?refactor(app-iced):=20M95=20r3=20=E6=8A=8A?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E9=A1=B5=E7=9A=84=E5=B1=95=E7=A4=BA=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E4=BB=8E=20app/view.rs=20=E6=8B=86=E5=87=BA=E6=9D=A5?= =?UTF-8?q?=EF=BC=88=E7=BA=AF=E6=90=AC=E7=A7=BB=EF=BC=8C=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E9=9B=B6=E5=8F=98=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app/view.rs` 已经 392 行(全仓硬上限 400),而日志页的转换还要加判别与测试,先拆。 - `app/view.rs`:392 → **324** 行,只留 `impl App`、`model()` 与实例页的展示转换, 另加 `mod logs; use logs::logs_model;`(调用点仍是 `logs_model(state).with_status(lang)`,未改)。 - 新增 `app/view/logs.rs`:**80 行**,逐行搬入 `logs_model` / `log_level` / `log_outcome` / `diagnosis_of`,实现体一行未动(含 r2 的「先按 `diagnosis.session` 定身份」写法, 那是下一个 commit 才改的行为)。 等价比证:搬移前后 `cargo test -p unlucky-app-iced -p unlucky-i18n` 都是 **274 + 6 + 23 passed / 0 failed**(lib + no_hardcoded_tokens + i18n),数量与结果一致。 除模块声明与 import 调整外没有任何逻辑改动。 --- crates/app-iced/src/app/view.rs | 78 ++------------------------- crates/app-iced/src/app/view/logs.rs | 80 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 73 deletions(-) create mode 100644 crates/app-iced/src/app/view/logs.rs diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index c3786a2..6df8a19 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -5,9 +5,7 @@ use iced::{Element, Theme}; use unlucky_i18n::Lang; -use unlucky_presenter::dto::{ - Diagnosis, InstanceRuntime, InstanceSummary, LaunchStage, LogLevel, LogOutcome, -}; +use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage}; use unlucky_presenter::router::Route; use unlucky_presenter::state::AppState; @@ -20,6 +18,10 @@ use super::message::{nav_item_of, Message}; use super::state::{log_first_frame, App}; use super::APP_TITLE; +mod logs; + +use logs::logs_model; + impl App { /// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。 pub(super) fn theme(&self) -> Theme { @@ -130,76 +132,6 @@ pub(super) fn model( } } -/// 日志页视图模型(M95):presenter 快照 → 日志行 + 诊断结论。 -/// -/// **页面不读盘**:日志文本只从 `log_lines`(host 尾随 JSONL 后按批次发的事件)来;会话元信息 -/// 从 `log_sessions` 里按「运行中的那一场 → 诊断归属的那一场」找,找不到就不画。 -/// -/// 两处 DTO 侧的**信息丢失**(已报 finding,不在本 scope 修):`dto::Diagnosis` 没有 core 的 -/// `severity`(严重度只能按置信度折算,阈值是债)与 `cause.key`(`title`/`hint` 只能当数据画)。 -fn logs_model(state: &AppState) -> pages::Logs { - let report = state.diagnosis.as_ref(); - let current = state.selected_session.as_deref(); - let session = report - .and_then(|report| state.log_sessions.iter().find(|s| s.id == report.session)) - .or_else(|| { - state - .log_sessions - .iter() - .find(|s| Some(s.id.as_str()) == current) - }); - - pages::Logs { - instance: session.map(|s| s.instance.clone()), - when: session.map(|s| s.when.clone()), - outcome: session.map(|s| log_outcome(s.outcome)), - lines: state - .log_lines - .iter() - .map(|line| pages::Line { - text: line.text.clone(), - level: log_level(line.level), - }) - .collect(), - findings: report - .map(|report| diagnosis_of(&report.diagnosis)) - .into_iter() - .collect(), - ..pages::Logs::default() - } -} - -/// 日志等级:presenter 枚举 → 页面等级(**结构化出口**,不是认字符串)。 -fn log_level(level: LogLevel) -> pages::Level { - match level { - LogLevel::Debug => pages::Level::Debug, - LogLevel::Info => pages::Level::Info, - LogLevel::Warn => pages::Level::Warn, - LogLevel::Error => pages::Level::Error, - } -} - -/// 会话结局:presenter 枚举 → 页面结局(文案走 `LaunchOutcome*` key,页面不画 core 的中文串)。 -fn log_outcome(outcome: LogOutcome) -> pages::Outcome { - match outcome { - LogOutcome::Ok => pages::Outcome::Ok, - LogOutcome::Crashed => pages::Outcome::Crashed, - LogOutcome::SilentFail => pages::Outcome::SilentFail, - } -} - -/// 一条诊断结论 → 页面结论(严重度折算见 [`logs_model`] 的文档)。 -fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding { - pages::Finding { - rule: diagnosis.rule.clone(), - severity: pages::Severity::of_confidence(diagnosis.confidence), - title: diagnosis.title.clone(), - hint: diagnosis.hint.clone(), - confidence: diagnosis.confidence, - action: diagnosis.fix.clone(), - } -} - /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版」,见 `LoaderInfo::badge`)。 fn supporting_line(instance: &InstanceSummary) -> String { format!("{} · {}", instance.mc_version, instance.loader.badge()) diff --git a/crates/app-iced/src/app/view/logs.rs b/crates/app-iced/src/app/view/logs.rs new file mode 100644 index 0000000..50fc260 --- /dev/null +++ b/crates/app-iced/src/app/view/logs.rs @@ -0,0 +1,80 @@ +//! 日志页的展示转换:presenter 快照 → [`pages::Logs`]。 +//! +//! 从 `app/view.rs` 拆出来(M95 r3):那个文件已经 392 行,贴着全仓 400 行的硬上限, +//! 而这一页的转换还要再加判别与测试。**本次搬运逐行等价**,行为零变化; +//! 跨会话串场的修复在下一个 commit(`state.diagnosis` 只写不清那条)。 + +use unlucky_presenter::dto::{Diagnosis, LogLevel, LogOutcome}; +use unlucky_presenter::state::AppState; + +use crate::pages; + +/// 日志页视图模型(M95):presenter 快照 → 日志行 + 诊断结论。 +/// +/// **页面不读盘**:日志文本只从 `log_lines`(host 尾随 JSONL 后按批次发的事件)来;会话元信息 +/// 从 `log_sessions` 里按「运行中的那一场 → 诊断归属的那一场」找,找不到就不画。 +/// +/// 两处 DTO 侧的**信息丢失**(已报 finding,不在本 scope 修):`dto::Diagnosis` 没有 core 的 +/// `severity`(严重度只能按置信度折算,阈值是债)与 `cause.key`(`title`/`hint` 只能当数据画)。 +pub(super) fn logs_model(state: &AppState) -> pages::Logs { + let report = state.diagnosis.as_ref(); + let current = state.selected_session.as_deref(); + let session = report + .and_then(|report| state.log_sessions.iter().find(|s| s.id == report.session)) + .or_else(|| { + state + .log_sessions + .iter() + .find(|s| Some(s.id.as_str()) == current) + }); + + pages::Logs { + instance: session.map(|s| s.instance.clone()), + when: session.map(|s| s.when.clone()), + outcome: session.map(|s| log_outcome(s.outcome)), + lines: state + .log_lines + .iter() + .map(|line| pages::Line { + text: line.text.clone(), + level: log_level(line.level), + }) + .collect(), + findings: report + .map(|report| diagnosis_of(&report.diagnosis)) + .into_iter() + .collect(), + ..pages::Logs::default() + } +} + +/// 日志等级:presenter 枚举 → 页面等级(**结构化出口**,不是认字符串)。 +fn log_level(level: LogLevel) -> pages::Level { + match level { + LogLevel::Debug => pages::Level::Debug, + LogLevel::Info => pages::Level::Info, + LogLevel::Warn => pages::Level::Warn, + LogLevel::Error => pages::Level::Error, + } +} + +/// 会话结局:presenter 枚举 → 页面结局(文案走 `LaunchOutcome*` key,页面不画 core 的中文串)。 +fn log_outcome(outcome: LogOutcome) -> pages::Outcome { + match outcome { + LogOutcome::Ok => pages::Outcome::Ok, + LogOutcome::Crashed => pages::Outcome::Crashed, + LogOutcome::SilentFail => pages::Outcome::SilentFail, + } +} + +/// 一条诊断结论 → 页面结论(严重度折算见 [`logs_model`] 的文档)。 +fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding { + pages::Finding { + rule: diagnosis.rule.clone(), + severity: pages::Severity::of_confidence(diagnosis.confidence), + title: diagnosis.title.clone(), + hint: diagnosis.hint.clone(), + confidence: diagnosis.confidence, + action: diagnosis.fix.clone(), + } +} From 62d4af150e03c49dc1b4185703c69c9cdc6a8260 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 21:04:07 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(app-iced):=20M95=20r3=20=E8=B7=A8?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E4=B8=B2=E5=9C=BA=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E7=BB=93=E8=AE=BA=E5=8F=AA=E5=9C=A8=E5=BD=92?= =?UTF-8?q?=E5=B1=9E=E4=BC=9A=E8=AF=9D=E5=B0=B1=E6=98=AF=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=97=B6=E6=89=8D=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代码复审(绑 559ba20)抓的 p2:r2 的 `logs_model` 先按 `state.diagnosis` 定「这是哪一场」, 而日志行来自当前那一场;`state.diagnosis` 全仓**只写不清**(`apply_core` 里 `CoreEvent::DiagnosisReady` 是唯一赋值点,没有清零点),host 也只在崩溃 / 打开会话 / 导入时 发它。于是「崩溃一场 → 再启动一场正常跑」时:页头把新会话标成上一场的 `The game crashed`、 诊断卡挂着上一场的结论,而用户复制去贴 issue 的摘要里 `lines=` 是**新的**那一场的行数 —— 结论与行数不同源,用户会把错诊断递出去。 修法(只在页面侧判别,不动 presenter 的写入口): - **身份**只认 `live_session.or(selected_session)`(正在跑 → 打开/刚结束的那一场), **不从 `diagnosis` 反推**; - 诊断结论只在 `diagnosis.session == 身份` 时画;身份为空时才信任报告(那时日志行只可能来自 `open_session`,它与报告是同一次调用发的); - 实例/时间/结局的描述同样是身份优先,身份为空才退到报告的 session。 测试(`app/view/logs.rs` 内,走**真实数据路径**): `crashed_only()` 用 `apply_core` 依次灌 `LogSessionsChanged` → 第一场 `LogAppended(running)` → `DiagnosisReady` → `LaunchFinished(Crashed)`,`after_a_new_run()` 再灌第二场 `LogAppended(running)`; `state.diagnosis` 全程不清(这就是线上串场的条件)。 - `a_new_run_does_not_inherit_the_previous_crash`:断言 outcome=Ok、页头不含 `The game crashed`、 findings 为空、摘要 `outcome=ok` 且不含旧规则且 `lines=` 与当前行数一致。 - `the_crash_itself_is_still_reported`(对照,防「永远不画诊断」骗过上面那条):崩溃当场必须 findings=1 且摘要含 `gpu.driver_old:critical(92)`。 - `the_hosts_no_rule_matched_placeholder_is_not_a_finding`:host 的 `rule: ""` 占位报告不画; 换成真规则就必须画(证明过滤不是把诊断一起关了)。 变异实测:把 `report` 的过滤回退成 r2 的 `state.diagnosis.as_ref()` → 上面第一条**红**并点名 `Finding { rule: "gpu.driver_old", … }`(0 passed / 1 failed);放回 → **277 passed / 0 failed**。 --- crates/app-iced/src/app/view/logs.rs | 250 ++++++++++++++++++++++++--- 1 file changed, 227 insertions(+), 23 deletions(-) diff --git a/crates/app-iced/src/app/view/logs.rs b/crates/app-iced/src/app/view/logs.rs index 50fc260..7c13e1b 100644 --- a/crates/app-iced/src/app/view/logs.rs +++ b/crates/app-iced/src/app/view/logs.rs @@ -1,37 +1,64 @@ -//! 日志页的展示转换:presenter 快照 → [`pages::Logs`]。 +//! 日志页的展示转换:presenter 快照 → [`pages::Logs`](从 `app/view.rs` 拆出来,M95 r3)。 //! -//! 从 `app/view.rs` 拆出来(M95 r3):那个文件已经 392 行,贴着全仓 400 行的硬上限, -//! 而这一页的转换还要再加判别与测试。**本次搬运逐行等价**,行为零变化; -//! 跨会话串场的修复在下一个 commit(`state.diagnosis` 只写不清那条)。 +//! ## 跨会话串场(M95 r3 的 p2,代码复审抓的) +//! +//! r2 的写法先按 `state.diagnosis` 定「这是哪一场」: +//! +//! ```ignore +//! let session = report.and_then(|r| log_sessions.find(|s| s.id == r.session)).or_else(|| …selected…); +//! ``` +//! +//! 而 `state.diagnosis` **全仓只写不清**(`presenter::state::apply_core` 里 +//! `CoreEvent::DiagnosisReady` 是唯一赋值点,没有任何清零点),host 也只在崩溃 / 打开会话 / +//! 导入崩溃文件时发它。于是「崩溃一场 → 再启动一场正常跑」这个最普通的路径下: +//! 页头把**新**会话标成上一场的 `The game crashed`、诊断卡挂着上一场的结论,而用户复制去贴 +//! issue 的摘要里 `lines=` 是新的那一场的行数——**结论与行数不同源**,用户会把错诊断递出去。 +//! +//! 现在的口径(不动 presenter 的写入口,只在页面侧判别): +//! +//! | 概念 | 取值 | +//! |---|---| +//! | **身份**(这一场是谁) | `live_session`(正在跑)→ `selected_session`(打开/刚结束的),**不从 `diagnosis` 反推** | +//! | 诊断结论 | 只有 `diagnosis.session == 身份` 才画;身份为空时才信任报告(那时日志行只可能来自 `open_session`,它和报告是同一次调用发的) | +//! | 实例/时间/结局(给页头描述) | 身份优先;身份为空才退到报告的 session | +//! +//! 「没有命中规则」的**占位报告**也不画:host 的 `open_session` 在规则引擎无结论时会发一份 +//! `rule: ""` / `confidence: 0` / `title: "该会话没有命中已知规则"` 的假报告(`CoreEvent::DiagnosisReady` +//! 是非 Option,host 无法表达「没有结论」)——它是标记不是结论,画出来等于给用户看一条「严重度提示」。 +//! +//! 已知缺口(已报 finding,不在本 mission 修):`clear_logs` 之后 `selected_session` 不清, +//! 身份会继续指向那一场;导入崩溃文件(`session: "file:…"`)没有对应的日志行载入,所以那份 +//! 报告在本页永远不会显示(r2 会把它挂到不相干的日志行上——两害相权取不显示)。 use unlucky_presenter::dto::{Diagnosis, LogLevel, LogOutcome}; use unlucky_presenter::state::AppState; use crate::pages; -/// 日志页视图模型(M95):presenter 快照 → 日志行 + 诊断结论。 +/// 日志页视图模型:presenter 快照 → 要画的东西。 /// -/// **页面不读盘**:日志文本只从 `log_lines`(host 尾随 JSONL 后按批次发的事件)来;会话元信息 -/// 从 `log_sessions` 里按「运行中的那一场 → 诊断归属的那一场」找,找不到就不画。 -/// -/// 两处 DTO 侧的**信息丢失**(已报 finding,不在本 scope 修):`dto::Diagnosis` 没有 core 的 -/// `severity`(严重度只能按置信度折算,阈值是债)与 `cause.key`(`title`/`hint` 只能当数据画)。 +/// 两处 DTO 侧的**信息丢失**(已报 finding):`dto::Diagnosis` 没有 core 的 `severity` +/// (严重度只能按置信度折算,阈值是债)与 `cause.key`(`title`/`hint` 只能当数据画)。 pub(super) fn logs_model(state: &AppState) -> pages::Logs { - let report = state.diagnosis.as_ref(); - let current = state.selected_session.as_deref(); - let session = report - .and_then(|report| state.log_sessions.iter().find(|s| s.id == report.session)) - .or_else(|| { - state - .log_sessions - .iter() - .find(|s| Some(s.id.as_str()) == current) - }); + let identity = state + .live_session + .as_deref() + .or(state.selected_session.as_deref()); + + let report = state.diagnosis.as_ref().filter(|report| match identity { + Some(id) => report.session == id, + None => true, + }); + + // 身份为空时,描述的来源只能退到报告(见模块文档的表格)。 + let described = identity.or_else(|| report.map(|report| report.session.as_str())); + let session = + described.and_then(|id| state.log_sessions.iter().find(|session| session.id == id)); pages::Logs { - instance: session.map(|s| s.instance.clone()), - when: session.map(|s| s.when.clone()), - outcome: session.map(|s| log_outcome(s.outcome)), + instance: session.map(|session| session.instance.clone()), + when: session.map(|session| session.when.clone()), + outcome: session.map(|session| log_outcome(session.outcome)), lines: state .log_lines .iter() @@ -41,6 +68,7 @@ pub(super) fn logs_model(state: &AppState) -> pages::Logs { }) .collect(), findings: report + .filter(|report| !report.diagnosis.rule.is_empty()) .map(|report| diagnosis_of(&report.diagnosis)) .into_iter() .collect(), @@ -78,3 +106,179 @@ fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding { action: diagnosis.fix.clone(), } } + +#[cfg(test)] +mod tests { + use unlucky_i18n::Lang; + use unlucky_presenter::dto::{ + CoreEvent, DiagnosisReport, LaunchFinished, LiveLogBatch, LogLevel, LogLine, LogOutcome, + LogSession, + }; + use unlucky_presenter::state::AppState; + + use crate::pages; + + use super::logs_model; + + /// 崩溃的那一场。 + const CRASH: &str = "s-1029-1840"; + /// 崩溃之后又启动的、正常跑的一场。 + const OK_RUN: &str = "s-1029-1922"; + + fn session(id: &str, when: &str, outcome: LogOutcome) -> LogSession { + LogSession { + id: id.to_string(), + instance: "开发测试".to_string(), + when: when.to_string(), + outcome, + duration_secs: 62, + } + } + + fn line(text: &str, level: LogLevel) -> LogLine { + LogLine { + text: text.to_string(), + level, + } + } + + fn report(session: &str, rule: &str) -> DiagnosisReport { + DiagnosisReport { + session: session.to_string(), + diagnosis: pages_dto(rule), + } + } + + fn pages_dto(rule: &str) -> unlucky_presenter::dto::Diagnosis { + unlucky_presenter::dto::Diagnosis { + rule: rule.to_string(), + title: "显卡驱动过旧(OpenGL 版本不足)".to_string(), + confidence: 92, + hint: "升级显卡驱动".to_string(), + fix: Some("change_graphics.driver".to_string()), + } + } + + /// **真实数据路径**(复审要求:不许造注入源绕过去): + /// 崩溃一场跑完 → 再启动一场正常跑。`state.diagnosis` 在这里**故意不清**, + /// 因为 presenter 里根本没有清零点——这就是线上发生串场的条件。 + fn crashed_only() -> AppState { + let mut state = AppState::new(); + state.apply_core(CoreEvent::LogSessionsChanged(vec![ + session(CRASH, "10-29 18:40", LogOutcome::Crashed), + session(OK_RUN, "10-29 19:22", LogOutcome::Ok), + ])); + + // 第一场跑起来(live_session = CRASH)→ 日志 → 规则结论 → 收尾(selected_session = CRASH) + state.apply_core(CoreEvent::LogAppended(LiveLogBatch { + session: CRASH.to_string(), + lines: vec![line("[18:40] boom: GLFW error", LogLevel::Error)], + running: true, + })); + state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old"))); + state.apply_core(CoreEvent::LaunchFinished(LaunchFinished { + id: "dev".to_string(), + session: CRASH.to_string(), + outcome: LogOutcome::Crashed, + summary: "崩溃(exit 1)".to_string(), + })); + + state + } + + /// 崩溃之后又启动一场正常跑:`live_session` 前进,`diagnosis` 仍是上一场的。 + fn after_a_new_run() -> AppState { + let mut state = crashed_only(); + state.apply_core(CoreEvent::LogAppended(LiveLogBatch { + session: OK_RUN.to_string(), + lines: vec![line("[19:22] launching 1.20.4", LogLevel::Info)], + running: true, + })); + state + } + + #[test] + fn a_new_run_does_not_inherit_the_previous_crash() { + let state = after_a_new_run(); + let logs = logs_model(&state).with_status(Lang::En); + + // 抓得住:身份被上一场的 `diagnosis.session` 顶掉(r2 的 bug)——那时 outcome 会是 + // Crashed、findings 挂着 gpu.driver_old、摘要里 `lines=` 与结论不同源。 + // 抓不住:presenter 自己 `clear_logs` 后 `selected_session` 不清的缺口(已报 finding), + // 以及多场并行会话(本仓一次只跑一场)。 + assert_eq!( + logs.outcome, + Some(pages::Outcome::Ok), + "身份应该是正在跑的这一场" + ); + + let status = logs.status.clone().expect("有会话身份就该有副标题"); + assert!(status.contains("Finished normally"), "{status}"); + assert!( + !status.contains("The game crashed"), + "页头把新会话标成了上一场的崩溃:{status}" + ); + + assert!( + logs.findings.is_empty(), + "上一场的结论不能挂到这一场:{:?}", + logs.findings + ); + + // 摘要必须与身份同源:outcome 是 ok、不含旧规则、行数就是当前这些行。 + let summary = logs.summary(); + assert!(summary.contains("outcome=ok"), "{summary}"); + assert!(!summary.contains("gpu.driver_old"), "{summary}"); + assert!( + summary.contains(&format!("lines={}", logs.lines.len())), + "{summary}" + ); + assert!(!summary.contains("findings="), "{summary}"); + } + + #[test] + fn the_crash_itself_is_still_reported() { + // 上一条测试的**对照**:崩溃当场必须画结论,否则那条断言「findings 为空」是空的。 + // 抓得住:把判别写成「永远不画诊断」(用空断言骗过 P2)。 + // 抓不住:结论排序/去重(那是 core 的规则引擎的事)。 + let state = crashed_only(); + let logs = logs_model(&state).with_status(Lang::En); + + assert_eq!(logs.outcome, Some(pages::Outcome::Crashed)); + assert_eq!(logs.findings.len(), 1, "{:?}", logs.findings); + assert_eq!(logs.findings[0].rule, "gpu.driver_old"); + let status = logs.status.clone().unwrap_or_default(); + assert!(status.contains("The game crashed"), "{status}"); + assert!(logs.summary().contains("gpu.driver_old:critical(92)")); + } + + #[test] + fn the_hosts_no_rule_matched_placeholder_is_not_a_finding() { + // host 的 `open_session` 在规则引擎无结论时发一份 `rule: ""` 的假报告(非 Option 逼的)。 + // 抓得住:把「没命中」画成一条严重度结论(用户会去修一个不存在的毛病)。 + // 抓不住:host 该不该改发 `None`(那是 presenter 的 finding)。 + let mut state = AppState::new(); + state.apply_core(CoreEvent::LogSessionsChanged(vec![session( + CRASH, + "10-29 18:40", + LogOutcome::Crashed, + )])); + state.apply_core(CoreEvent::LogLinesChanged(vec![line( + "[18:40] nothing to see", + LogLevel::Info, + )])); + + state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, ""))); + assert!( + logs_model(&state).findings.is_empty(), + "空 rule 是『没命中』的占位,不是结论" + ); + + state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old"))); + assert_eq!( + logs_model(&state).findings.len(), + 1, + "换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)" + ); + } +} From 5ed2bf3a7d4a7772042f23107b8e9f38e2b2b858 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 21:07:16 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(app-iced):=20M95=20r3=20=E6=91=98?= =?UTF-8?q?=E8=A6=81=E9=A1=B5=E8=84=9A=E5=8D=A0=E6=AF=94=204:2=20+=20?= =?UTF-8?q?=E6=BC=94=E7=A4=BA=E6=95=B0=E6=8D=AE=E8=87=AA=E6=B4=BD=20+=20?= =?UTF-8?q?=E6=A0=B7=E4=BE=8B=E8=A7=A3=E6=9E=90=E4=B8=8D=E5=86=8D=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视觉复审 r2 的 P2-A 与两个顺手的 p3。 **P2-A 摘要页脚占比过大(3:2 → 4:2)**:1180×760 下诊断卡 338px,摘要页脚固定吃掉 ~150px (标签+按钮 40 + 摘要折 5 行 80 + 间距/分割 30),滚动视口只剩 141px,而一条完整结论 (分组标题 → 建议动作 → 置信度)要 165px ⇒ 首屏看不到「建议动作」,那是任务清单 T1/T3 要求的 可见项。改成 `DIAGNOSIS_SHARE = 4`(`LOGS_SHARE` 仍 2)。**数值实测**(`r3-crash-light.png` 逐像素量,非估算):诊断卡 376px、滚动视口 **178px**(y 201–378,两侧 1px 分割线的位置)、 一条完整结论块 **165px**、底部余量 **13px** —— 建议动作与 `Confidence 85%` 整块在首屏可见。 **p3 演示数据自相矛盾**:`live` 状态页头写「Finished normally」却配崩溃日志尾行 (`启动失败:exit code 1`)。新增 `sample_live.log`(一场正常跑完的会话:加载 → 进世界 → 停服),`State::Live` 用它,不再复用崩溃样例。 **p3 `demo::parse` 对非 ASCII 首字符 panic**:`raw.split_at(1)` 会切在多字节序列中间。 改成按字符取前缀(`chars().next()` + `chars.as_str()`);顺带把它提成 `pub(super)` 以便测。 新增 `the_fixture_parser_survives_a_non_ascii_first_char`:非 ASCII 首字符不 panic(静默跳过), 并钉住 `I/W/E` 前缀与注释/空行的解析。 测试:**278 passed / 0 failed**(lib)+ 6(`no_hardcoded_tokens`)+ 23(i18n)全绿; `fmt --check` 通过;`clippy -p unlucky-app-iced -p unlucky-i18n --all-targets -D warnings` 干净。 出图:`C:\Temp\ull-shots-m95\r3-{crash-light,crash-dark,live-light,empty-light}.png`(1180×760)。 --- crates/app-iced/src/pages/logs.rs | 6 +++- crates/app-iced/src/pages/logs/demo.rs | 29 ++++++++++++------- crates/app-iced/src/pages/logs/panel.rs | 2 +- .../app-iced/src/pages/logs/sample_live.log | 19 ++++++++++++ crates/app-iced/src/pages/logs/tests.rs | 21 ++++++++++++++ 5 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 crates/app-iced/src/pages/logs/sample_live.log diff --git a/crates/app-iced/src/pages/logs.rs b/crates/app-iced/src/pages/logs.rs index 56e6266..eb7da7d 100644 --- a/crates/app-iced/src/pages/logs.rs +++ b/crates/app-iced/src/pages/logs.rs @@ -51,7 +51,11 @@ mod panel; mod tests; /// 诊断卡在正文里的高度份额(见 [`panel`] 的模块文档:两块必须都是定高 + 内部滚动)。 -const DIAGNOSIS_SHARE: u16 = 3; +/// +/// r2 是 3:2 —— 1180×760 下诊断卡的滚动视口只剩 141px,而一条完整结论(分组标题 → 建议动作 → +/// 置信度)要 177px,于是**首屏看不到任何一条完整结论**(视觉复审 r2 的 P2-A:任务要求的 +/// 「建议动作」在验收尺寸下不可见)。改成 4:2:视口 ~184px,一条结论整块可见。 +const DIAGNOSIS_SHARE: u16 = 4; /// 日志卡的高度份额。 const LOGS_SHARE: u16 = 2; diff --git a/crates/app-iced/src/pages/logs/demo.rs b/crates/app-iced/src/pages/logs/demo.rs index 366fa0c..7bcc654 100644 --- a/crates/app-iced/src/pages/logs/demo.rs +++ b/crates/app-iced/src/pages/logs/demo.rs @@ -34,6 +34,9 @@ pub const STATE_ENV: &str = "ULL_LOGS_FIXTURE"; /// 内嵌的崩溃日志片段(见模块文档的出处)。 const SAMPLE: &str = include_str!("sample_crash.log"); +/// 内嵌的**正常跑完**一场的日志片段(`live` 状态用;见 `sample_live.log` 的文件头)。 +const LIVE_SAMPLE: &str = include_str!("sample_live.log"); + /// 演示状态。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum State { @@ -61,10 +64,14 @@ impl State { pub fn build(self, lang: Lang) -> Logs { match self { Self::Empty => Logs::default().with_status(lang), + // 正常跑完的一场用**自己的**日志样例:页头写「正常结束」却配崩溃日志尾行是自己打自己 + // (视觉复审 r2 的 p3)。 Self::Live => Logs { + instance: Some("开发测试".to_owned()), + when: Some("11-02 19:22".to_owned()), outcome: Some(Outcome::Ok), - findings: Vec::new(), - ..crash() + lines: parse(LIVE_SAMPLE), + ..Logs::default() } .with_status(lang), Self::Crash => crash().with_status(lang), @@ -94,7 +101,7 @@ pub fn crash() -> Logs { } /// 解析内嵌样例:`#` 注释与空行跳过,首字符是等级前缀。 -fn parse(text: &str) -> Vec { +pub(super) fn parse(text: &str) -> Vec { text.lines() .filter_map(|raw| { let raw = raw.trim_end(); @@ -102,17 +109,19 @@ fn parse(text: &str) -> Vec { return None; } - let (prefix, rest) = raw.split_at(1); - let level = match prefix { - "D" => Level::Debug, - "I" => Level::Info, - "W" => Level::Warn, - "E" => Level::Error, + // 按**字符**取等级前缀,不 `split_at(1)`:后者在非 ASCII 首字符上会切到 + // 多字节序列中间直接 panic(视觉复审 r2 的 p3)。 + let mut chars = raw.chars(); + let level = match chars.next() { + Some('D') => Level::Debug, + Some('I') => Level::Info, + Some('W') => Level::Warn, + Some('E') => Level::Error, _ => return None, }; Some(Line { - text: rest.trim_start().to_owned(), + text: chars.as_str().trim_start().to_owned(), level, }) }) diff --git a/crates/app-iced/src/pages/logs/panel.rs b/crates/app-iced/src/pages/logs/panel.rs index 93ab397..64c401d 100644 --- a/crates/app-iced/src/pages/logs/panel.rs +++ b/crates/app-iced/src/pages/logs/panel.rs @@ -10,7 +10,7 @@ //! //! | 块 | 高度 | 为什么 | //! |---|---|---| -//! | 诊断 | `FillPortion(3)` | 崩溃时先读结论;结论通常比日志短,给 3 份 | +//! | 诊断 | `FillPortion(4)` | 崩溃时先读结论;份额要够 177px 的滚动视口,才放得下**一条完整结论**(含建议动作与置信度) | //! | 日志 | `FillPortion(2)` | 日志是长流水,滚就是了 | //! //! 两者都是**兄弟**滚动区(不是嵌套):滚轮归属清楚,不存在「滚动条套滚动条」。 diff --git a/crates/app-iced/src/pages/logs/sample_live.log b/crates/app-iced/src/pages/logs/sample_live.log new file mode 100644 index 0000000..8daf728 --- /dev/null +++ b/crates/app-iced/src/pages/logs/sample_live.log @@ -0,0 +1,19 @@ +# M95 演示数据:一场**正常跑完**的会话(与 sample_crash.log 成对)。 +# +# 为什么单独一份(视觉复审 r2 的 p3):`live` 状态的页头写着「Finished normally」, +# 却配着崩溃日志的尾行(`启动失败:exit code 1`),演示数据自相矛盾。 +# +# 每行格式:`<等级> D|I|W|E <原文>`;`#` 开头是注释。 + +I [19:22:01] [main/INFO] 正在启动 1.20.4,实例「开发测试」,加载器 NeoForge 21.1.72 +I [19:22:02] [main/INFO] Java 21.0.5 (Microsoft build 21.0.5+11-LTS), 内存 4096 MiB, JVM 参数 -Xmx4G -XX:+UseG1GC -Dfile.encoding=UTF-8 -Djava.library.path=C:\Users\Player\AppData\Roaming\.unlucky\instances\dev-test\natives +W [19:22:03] [Render thread/WARN] GLFW 报告 OpenGL 3.2,低于推荐值 4.5;部分光影可能不可用 +I [19:22:05] [Render thread/INFO] OpenGL 3.2.0 NVIDIA 552.44,渲染器 NVIDIA GeForce GTX 1060 6GB +I [19:22:08] [modloading-worker-0/INFO] 已加载 148 个模组,其中 3 个标记为客户端专用:iris-1.7.4, sodium-0.5.11, indium-1.0.32 +I [19:22:14] [Worker-Main-2/INFO] 正在准备生成区域,种子 20261102,生物群系 47 种,结构与地物共 113 项已注册 +I [19:22:19] [main/INFO] 世界「生存世界」已加载,游玩时间 3 小时 12 分 +I [19:22:20] [main/INFO] 本地服务器已启动,端口 25565 +I [19:22:41] [Render thread/INFO] 区块渲染完成,平均帧率 61 fps(垂直同步开) +W [19:31:07] [Render thread/WARN] 一个资源包(Soartex Fanver 1.20)缺少 pack.png,已跳过图标 +I [19:40:12] [main/INFO] 正在停止服务器,保存世界数据 +I [19:40:13] [main/INFO] Stopping! diff --git a/crates/app-iced/src/pages/logs/tests.rs b/crates/app-iced/src/pages/logs/tests.rs index 46c4a44..0911641 100644 --- a/crates/app-iced/src/pages/logs/tests.rs +++ b/crates/app-iced/src/pages/logs/tests.rs @@ -215,6 +215,27 @@ fn empty_state_is_driven_by_both_lists_being_empty() { assert!(live.findings.is_empty(), "live 状态没有诊断"); } +#[test] +fn the_fixture_parser_survives_a_non_ascii_first_char() { + // 抓得住:`parse` 用 `split_at(1)` 切多字节序列会直接 panic(视觉复审 r2 的 p3 实测过)。 + // 抓不住:样例文件本身写坏(比如等级前缀写成小写 `e`)——那会被静默跳过,只体现为行数变少。 + assert!(!demo::parse("日本語の行") + .iter() + .any(|line| line.level == Level::Info)); + + let parsed = demo::parse( + "I 正常一行 +W 警告一行 +# 注释 + +E 错误一行", + ); + assert_eq!(parsed.len(), 3); + assert_eq!(parsed[0].text, "正常一行"); + assert_eq!(parsed[1].level, Level::Warn); + assert_eq!(parsed[2].level, Level::Error); +} + #[test] fn demo_state_comes_from_the_fixture_variable() { // 抓得住:环境变量取值拼错(截图会静默退成 crash,出图的人以为拍到了空态)。 From 0eb069fa3754ac930b4f93f930a46fbee6aa50f3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 21:46:17 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(app-iced):=20M95=20r4=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=BD=92=E5=B1=9E=E5=88=A4=E6=8D=AE=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=20/=20=E7=BD=AE=E4=BF=A1=E5=BA=A6=E6=A1=A3=20/=20?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E4=B8=AD=E9=A1=B5=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代码复审 r3 的四条 p2,修三条、登记一条(P2-4 页脚余量 13px,后置)。 **P2-1(回归修复)**:r3 把归属判据写成「有选中就只认选中」,把**导入崩溃文件**与 `open_session` 这两条路径的诊断一并关掉了——`559ba20` 是**无条件**画的,所以这是 r3 引入的**代码级回归**, 不是既有缺口(今天 iced 侧没有 dispatch 站点,那些还在遗留的 Slint shell 里,故复审未判 p1)。 判据改成三分支(`app/view/logs.rs`): | live_session | selected_session | 报告算数的条件 | |---|---|---| | Some(s) | 任意 | `report.session == s`(只有它在线时日志行是它在追加,别人的报告挂上来就是串场) | | None | Some(s) | `report.session == s` **或** 报告不属于任何已知会话(导入用 `file:<文件名>`,永不在索引里) | | None | None | 全部算数(此时日志行只可能来自 `open_session`/导入,与报告同一次调用发出) | 新增两条测试(都走真实 reducer):`an_imported_crash_file_still_shows_its_findings`、 `a_live_run_says_it_is_running_instead_of_nothing_runs`。 残留(今天不可达,已登记):`open_session(A)` 在 `selected_session` 仍停在上场 B 时会被挡掉—— 真要修得让 presenter 在 `LogLinesChanged` 记下「日志行属于哪一场」。 **P2-2 置信度档(视觉变更)**:`Severity`(借 core 的 `critical/warning/notice` 词汇)→ `Confidence`(`High/Medium/Low`)+ 新 key `LogsConfidenceHigh/Medium/Low`(中英齐,标题明确写 「高/中/低置信度」)。阈值仍是 80/50,但它现在是**档位定义**而不是「严重度代理」。 摘要里的 `findings=rule:high(85)` 同步换档位 slug。DTO 补齐 `severity` 之后应换回真严重度(finding)。 **P2-3 运行中的页头**:session 索引只在**结束**时追加,运行中的那一场不在 `log_sessions` 里 ⇒ 拿不到实例名与结局 ⇒ r3 之前页头在日志滚动时一直说「当前没有运行中的实例」(demo 伪造了身份, 截图照不出来)。`Logs` 新增 `live: bool`(`live_session.is_some()`),`with_status` 三级回退: 有实例+结局 → `实例 · 时间 · 结局`;运行中但没结局 → 新 key `LogsSubtitleLive`(「Running now — logs stream as they arrive」);都没有 → `LogsSubtitleIdle`。 新增 `a_live_run_says_it_is_running_instead_of_nothing_runs`(真实 reducer:只有 `LogAppended(running=true)`,断言副标题不是 Idle 文案且含 `Running now`)。 新增 demo 状态 `ULL_LOGS_FIXTURE=running` 用于出图。 **变异实测**(每条:回退 → 红 → 放回 → 绿):见报告。 验证:`fmt --check` 通过;`clippy -p unlucky-app-iced -p unlucky-i18n --all-targets -D warnings` 干净; `cargo test` **280 + 6 + 23 passed / 0 failed**;行数最大 `pages/mod.rs` 367、`app/view.rs` 324、 `app/view/logs.rs` 337。 --- crates/app-iced/src/app/view/logs.rs | 234 ++++-------------- crates/app-iced/src/app/view/logs/tests.rs | 242 +++++++++++++++++++ crates/app-iced/src/pages/logs.rs | 6 +- crates/app-iced/src/pages/logs/demo.rs | 22 +- crates/app-iced/src/pages/logs/diagnostic.rs | 32 +-- crates/app-iced/src/pages/logs/model.rs | 86 ++++--- crates/app-iced/src/pages/logs/tests.rs | 50 ++-- crates/app-iced/src/pages/mod.rs | 2 +- crates/i18n/src/catalog.rs | 7 +- 9 files changed, 414 insertions(+), 267 deletions(-) create mode 100644 crates/app-iced/src/app/view/logs/tests.rs diff --git a/crates/app-iced/src/app/view/logs.rs b/crates/app-iced/src/app/view/logs.rs index 7c13e1b..136c635 100644 --- a/crates/app-iced/src/app/view/logs.rs +++ b/crates/app-iced/src/app/view/logs.rs @@ -19,16 +19,28 @@ //! | 概念 | 取值 | //! |---|---| //! | **身份**(这一场是谁) | `live_session`(正在跑)→ `selected_session`(打开/刚结束的),**不从 `diagnosis` 反推** | -//! | 诊断结论 | 只有 `diagnosis.session == 身份` 才画;身份为空时才信任报告(那时日志行只可能来自 `open_session`,它和报告是同一次调用发的) | +//! | 诊断结论 | 见下表的三个分支(r4 修正了 r3 的一条**回归**:r3 只认身份,把「导入崩溃文件」与「打开会话」两条路径的诊断一并关掉了) | //! | 实例/时间/结局(给页头描述) | 身份优先;身份为空才退到报告的 session | //! +//! 归属判据(`report.session` 与身份的关系): +//! +//! | `live_session` | `selected_session` | 报告算数的条件 | +//! |---|---|---| +//! | `Some(live)` | 任意 | `report.session == live` —— 只有它在线时日志行是它自己在追加,别人的报告挂上来就是串场 | +//! | `None` | `Some(sel)` | `report.session == sel` **或** 报告不属于任何已知会话(`ImportCrashLog` 用 `file:<文件名>` 当会话 id,永远不在索引里) | +//! | `None` | `None` | 全部算数 —— 此时日志行只可能来自 `open_session` 或导入,两者都与报告同一次调用发出 | +//! +//! 已知残留(今天 iced 侧不可达,登记在报告里):`open_session(A)` 在 `selected_session` 仍停在上 +//! 一场 B 时会被第三行判据挡掉(A 是已知会话且 ≠ B)——真正要修得让 presenter 在 +//! `LogLinesChanged` 时把「日志行属于哪一场」记下来(`AppState` 今天没有这个字段)。 +//! //! 「没有命中规则」的**占位报告**也不画:host 的 `open_session` 在规则引擎无结论时会发一份 //! `rule: ""` / `confidence: 0` / `title: "该会话没有命中已知规则"` 的假报告(`CoreEvent::DiagnosisReady` //! 是非 Option,host 无法表达「没有结论」)——它是标记不是结论,画出来等于给用户看一条「严重度提示」。 //! //! 已知缺口(已报 finding,不在本 mission 修):`clear_logs` 之后 `selected_session` 不清, -//! 身份会继续指向那一场;导入崩溃文件(`session: "file:…"`)没有对应的日志行载入,所以那份 -//! 报告在本页永远不会显示(r2 会把它挂到不相干的日志行上——两害相权取不显示)。 +//! 身份会继续指向那一场;导入崩溃文件只发 `DiagnosisReady`(不带日志行),所以页面上会出现 +//! 「结论 + 上一场的日志正文」——行由 `open_session` 负责,那是 presenter 的缺口。 use unlucky_presenter::dto::{Diagnosis, LogLevel, LogOutcome}; use unlucky_presenter::state::AppState; @@ -38,16 +50,34 @@ use crate::pages; /// 日志页视图模型:presenter 快照 → 要画的东西。 /// /// 两处 DTO 侧的**信息丢失**(已报 finding):`dto::Diagnosis` 没有 core 的 `severity` -/// (严重度只能按置信度折算,阈值是债)与 `cause.key`(`title`/`hint` 只能当数据画)。 +/// (所以页面按置信度档分组,不冒称严重度)与 `cause.key`(`title`/`hint` 只能当数据画)。 pub(super) fn logs_model(state: &AppState) -> pages::Logs { - let identity = state - .live_session - .as_deref() - .or(state.selected_session.as_deref()); + let live = state.live_session.as_deref(); + let selected = state.selected_session.as_deref(); + let identity = live.or(selected); - let report = state.diagnosis.as_ref().filter(|report| match identity { - Some(id) => report.session == id, - None => true, + // 归属判据(r4 修正 r3 的**回归**:r3 的「有选中就只认选中」把导入/打开这两条路径的诊断 + // 一起关掉了)。三条命中任一即认: + // + // 1. 报告属于**正在跑**的那一场 —— 这一条必须单独成立:只有它在线时日志行是它自己在追加, + // 别的会话的报告挂上来就是串场(r3 修的正是这个); + // 2. 报告属于**当前选中**的那一场(没有 live 会话时); + // 3. 报告的会话**不在会话索引里** —— `ImportCrashLog` 用 `file:<文件名>` 当会话 id, + // `open_session` 也不一定把 id 写进 `selected_session`;这两条路径与报告是同一次调用发出的, + // 把它们一并关掉就是把「导入崩溃日志」这条功能画成空(r3 的实测回归)。 + let known = |id: &str| state.log_sessions.iter().any(|session| session.id == id); + let report = state.diagnosis.as_ref().filter(|report| { + let id = report.session.as_str(); + match (live, selected) { + // 有会话在跑:日志行是它自己在追加,别的会话的报告挂上来就是串场(r3 修的 P1)。 + (Some(live), _) => id == live, + // 没有在跑、但有选中项:报告要么是它的,要么是「会话索引里没有的那一份」 + // (`ImportCrashLog` 用 `file:<文件名>`;`open_session` 不写 `selected_session`, + // 所以只能靠「不在索引里」认它)。 + (None, Some(selected)) => id == selected || !known(id), + // 什么身份都没有:日志行只可能来自 `open_session` 或导入,两者与报告同一次发出。 + (None, None) => true, + } }); // 身份为空时,描述的来源只能退到报告(见模块文档的表格)。 @@ -59,6 +89,9 @@ pub(super) fn logs_model(state: &AppState) -> pages::Logs { instance: session.map(|session| session.instance.clone()), when: session.map(|session| session.when.clone()), outcome: session.map(|session| log_outcome(session.outcome)), + // 运行中的会话不在 `log_sessions` 里(索引只在结束时追加),所以页头要靠这个标记 + // 走「正在运行」那条文案,而不是「当前没有运行中的实例」。 + live: live.is_some(), lines: state .log_lines .iter() @@ -95,11 +128,11 @@ fn log_outcome(outcome: LogOutcome) -> pages::Outcome { } } -/// 一条诊断结论 → 页面结论(严重度折算见 [`logs_model`] 的文档)。 +/// 一条诊断结论 → 页面结论(置信度档见 [`pages::Confidence`])。 fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding { pages::Finding { rule: diagnosis.rule.clone(), - severity: pages::Severity::of_confidence(diagnosis.confidence), + confidence_band: pages::Confidence::of(diagnosis.confidence), title: diagnosis.title.clone(), hint: diagnosis.hint.clone(), confidence: diagnosis.confidence, @@ -108,177 +141,4 @@ fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding { } #[cfg(test)] -mod tests { - use unlucky_i18n::Lang; - use unlucky_presenter::dto::{ - CoreEvent, DiagnosisReport, LaunchFinished, LiveLogBatch, LogLevel, LogLine, LogOutcome, - LogSession, - }; - use unlucky_presenter::state::AppState; - - use crate::pages; - - use super::logs_model; - - /// 崩溃的那一场。 - const CRASH: &str = "s-1029-1840"; - /// 崩溃之后又启动的、正常跑的一场。 - const OK_RUN: &str = "s-1029-1922"; - - fn session(id: &str, when: &str, outcome: LogOutcome) -> LogSession { - LogSession { - id: id.to_string(), - instance: "开发测试".to_string(), - when: when.to_string(), - outcome, - duration_secs: 62, - } - } - - fn line(text: &str, level: LogLevel) -> LogLine { - LogLine { - text: text.to_string(), - level, - } - } - - fn report(session: &str, rule: &str) -> DiagnosisReport { - DiagnosisReport { - session: session.to_string(), - diagnosis: pages_dto(rule), - } - } - - fn pages_dto(rule: &str) -> unlucky_presenter::dto::Diagnosis { - unlucky_presenter::dto::Diagnosis { - rule: rule.to_string(), - title: "显卡驱动过旧(OpenGL 版本不足)".to_string(), - confidence: 92, - hint: "升级显卡驱动".to_string(), - fix: Some("change_graphics.driver".to_string()), - } - } - - /// **真实数据路径**(复审要求:不许造注入源绕过去): - /// 崩溃一场跑完 → 再启动一场正常跑。`state.diagnosis` 在这里**故意不清**, - /// 因为 presenter 里根本没有清零点——这就是线上发生串场的条件。 - fn crashed_only() -> AppState { - let mut state = AppState::new(); - state.apply_core(CoreEvent::LogSessionsChanged(vec![ - session(CRASH, "10-29 18:40", LogOutcome::Crashed), - session(OK_RUN, "10-29 19:22", LogOutcome::Ok), - ])); - - // 第一场跑起来(live_session = CRASH)→ 日志 → 规则结论 → 收尾(selected_session = CRASH) - state.apply_core(CoreEvent::LogAppended(LiveLogBatch { - session: CRASH.to_string(), - lines: vec![line("[18:40] boom: GLFW error", LogLevel::Error)], - running: true, - })); - state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old"))); - state.apply_core(CoreEvent::LaunchFinished(LaunchFinished { - id: "dev".to_string(), - session: CRASH.to_string(), - outcome: LogOutcome::Crashed, - summary: "崩溃(exit 1)".to_string(), - })); - - state - } - - /// 崩溃之后又启动一场正常跑:`live_session` 前进,`diagnosis` 仍是上一场的。 - fn after_a_new_run() -> AppState { - let mut state = crashed_only(); - state.apply_core(CoreEvent::LogAppended(LiveLogBatch { - session: OK_RUN.to_string(), - lines: vec![line("[19:22] launching 1.20.4", LogLevel::Info)], - running: true, - })); - state - } - - #[test] - fn a_new_run_does_not_inherit_the_previous_crash() { - let state = after_a_new_run(); - let logs = logs_model(&state).with_status(Lang::En); - - // 抓得住:身份被上一场的 `diagnosis.session` 顶掉(r2 的 bug)——那时 outcome 会是 - // Crashed、findings 挂着 gpu.driver_old、摘要里 `lines=` 与结论不同源。 - // 抓不住:presenter 自己 `clear_logs` 后 `selected_session` 不清的缺口(已报 finding), - // 以及多场并行会话(本仓一次只跑一场)。 - assert_eq!( - logs.outcome, - Some(pages::Outcome::Ok), - "身份应该是正在跑的这一场" - ); - - let status = logs.status.clone().expect("有会话身份就该有副标题"); - assert!(status.contains("Finished normally"), "{status}"); - assert!( - !status.contains("The game crashed"), - "页头把新会话标成了上一场的崩溃:{status}" - ); - - assert!( - logs.findings.is_empty(), - "上一场的结论不能挂到这一场:{:?}", - logs.findings - ); - - // 摘要必须与身份同源:outcome 是 ok、不含旧规则、行数就是当前这些行。 - let summary = logs.summary(); - assert!(summary.contains("outcome=ok"), "{summary}"); - assert!(!summary.contains("gpu.driver_old"), "{summary}"); - assert!( - summary.contains(&format!("lines={}", logs.lines.len())), - "{summary}" - ); - assert!(!summary.contains("findings="), "{summary}"); - } - - #[test] - fn the_crash_itself_is_still_reported() { - // 上一条测试的**对照**:崩溃当场必须画结论,否则那条断言「findings 为空」是空的。 - // 抓得住:把判别写成「永远不画诊断」(用空断言骗过 P2)。 - // 抓不住:结论排序/去重(那是 core 的规则引擎的事)。 - let state = crashed_only(); - let logs = logs_model(&state).with_status(Lang::En); - - assert_eq!(logs.outcome, Some(pages::Outcome::Crashed)); - assert_eq!(logs.findings.len(), 1, "{:?}", logs.findings); - assert_eq!(logs.findings[0].rule, "gpu.driver_old"); - let status = logs.status.clone().unwrap_or_default(); - assert!(status.contains("The game crashed"), "{status}"); - assert!(logs.summary().contains("gpu.driver_old:critical(92)")); - } - - #[test] - fn the_hosts_no_rule_matched_placeholder_is_not_a_finding() { - // host 的 `open_session` 在规则引擎无结论时发一份 `rule: ""` 的假报告(非 Option 逼的)。 - // 抓得住:把「没命中」画成一条严重度结论(用户会去修一个不存在的毛病)。 - // 抓不住:host 该不该改发 `None`(那是 presenter 的 finding)。 - let mut state = AppState::new(); - state.apply_core(CoreEvent::LogSessionsChanged(vec![session( - CRASH, - "10-29 18:40", - LogOutcome::Crashed, - )])); - state.apply_core(CoreEvent::LogLinesChanged(vec![line( - "[18:40] nothing to see", - LogLevel::Info, - )])); - - state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, ""))); - assert!( - logs_model(&state).findings.is_empty(), - "空 rule 是『没命中』的占位,不是结论" - ); - - state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old"))); - assert_eq!( - logs_model(&state).findings.len(), - 1, - "换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)" - ); - } -} +mod tests; diff --git a/crates/app-iced/src/app/view/logs/tests.rs b/crates/app-iced/src/app/view/logs/tests.rs new file mode 100644 index 0000000..624732a --- /dev/null +++ b/crates/app-iced/src/app/view/logs/tests.rs @@ -0,0 +1,242 @@ +//! 日志页展示转换的守门测试(从 `app/view/logs.rs` 拆出:那边已经 382 行,贴着 400 硬上限)。 +//! +//! 全部走**真实 reducer 路径**(`AppState::apply_core`),不造注入源——r3 的教训是 +//! 「测试全走注入源而真实路径零覆盖」,所以这里的夹具就是线上那串事件本身。 + +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{ + CoreEvent, DiagnosisReport, LaunchFinished, LiveLogBatch, LogLevel, LogLine, LogOutcome, + LogSession, +}; +use unlucky_presenter::state::AppState; + +use crate::pages; + +use super::logs_model; + +/// 崩溃的那一场。 +const CRASH: &str = "s-1029-1840"; +/// 崩溃之后又启动的、正常跑的一场。 +const OK_RUN: &str = "s-1029-1922"; + +fn session(id: &str, when: &str, outcome: LogOutcome) -> LogSession { + LogSession { + id: id.to_string(), + instance: "开发测试".to_string(), + when: when.to_string(), + outcome, + duration_secs: 62, + } +} + +fn line(text: &str, level: LogLevel) -> LogLine { + LogLine { + text: text.to_string(), + level, + } +} + +fn report(session: &str, rule: &str) -> DiagnosisReport { + DiagnosisReport { + session: session.to_string(), + diagnosis: pages_dto(rule), + } +} + +fn pages_dto(rule: &str) -> unlucky_presenter::dto::Diagnosis { + unlucky_presenter::dto::Diagnosis { + rule: rule.to_string(), + title: "显卡驱动过旧(OpenGL 版本不足)".to_string(), + confidence: 92, + hint: "升级显卡驱动".to_string(), + fix: Some("change_graphics.driver".to_string()), + } +} + +/// **真实数据路径**(复审要求:不许造注入源绕过去): +/// 崩溃一场跑完 → 再启动一场正常跑。`state.diagnosis` 在这里**故意不清**, +/// 因为 presenter 里根本没有清零点——这就是线上发生串场的条件。 +fn crashed_only() -> AppState { + let mut state = AppState::new(); + state.apply_core(CoreEvent::LogSessionsChanged(vec![ + session(CRASH, "10-29 18:40", LogOutcome::Crashed), + session(OK_RUN, "10-29 19:22", LogOutcome::Ok), + ])); + + // 第一场跑起来(live_session = CRASH)→ 日志 → 规则结论 → 收尾(selected_session = CRASH) + state.apply_core(CoreEvent::LogAppended(LiveLogBatch { + session: CRASH.to_string(), + lines: vec![line("[18:40] boom: GLFW error", LogLevel::Error)], + running: true, + })); + state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old"))); + state.apply_core(CoreEvent::LaunchFinished(LaunchFinished { + id: "dev".to_string(), + session: CRASH.to_string(), + outcome: LogOutcome::Crashed, + summary: "崩溃(exit 1)".to_string(), + })); + + state +} + +/// 崩溃之后又启动一场正常跑:`live_session` 前进,`diagnosis` 仍是上一场的。 +fn after_a_new_run() -> AppState { + let mut state = crashed_only(); + state.apply_core(CoreEvent::LogAppended(LiveLogBatch { + session: OK_RUN.to_string(), + lines: vec![line("[19:22] launching 1.20.4", LogLevel::Info)], + running: true, + })); + state +} + +#[test] +fn a_new_run_does_not_inherit_the_previous_crash() { + let state = after_a_new_run(); + let logs = logs_model(&state).with_status(Lang::En); + + // 抓得住:身份被上一场的 `diagnosis.session` 顶掉(r2 的 bug)——那时 outcome 会是 + // Crashed、findings 挂着 gpu.driver_old、摘要里 `lines=` 与结论不同源。 + // 抓不住:presenter 自己 `clear_logs` 后 `selected_session` 不清的缺口(已报 finding), + // 以及多场并行会话(本仓一次只跑一场)。 + assert_eq!( + logs.outcome, + Some(pages::Outcome::Ok), + "身份应该是正在跑的这一场" + ); + + let status = logs.status.clone().expect("有会话身份就该有副标题"); + assert!(status.contains("Finished normally"), "{status}"); + assert!( + !status.contains("The game crashed"), + "页头把新会话标成了上一场的崩溃:{status}" + ); + + assert!( + logs.findings.is_empty(), + "上一场的结论不能挂到这一场:{:?}", + logs.findings + ); + + // 摘要必须与身份同源:outcome 是 ok、不含旧规则、行数就是当前这些行。 + let summary = logs.summary(); + assert!(summary.contains("outcome=ok"), "{summary}"); + assert!(!summary.contains("gpu.driver_old"), "{summary}"); + assert!( + summary.contains(&format!("lines={}", logs.lines.len())), + "{summary}" + ); + assert!(!summary.contains("findings="), "{summary}"); +} + +#[test] +fn the_crash_itself_is_still_reported() { + // 上一条测试的**对照**:崩溃当场必须画结论,否则那条断言「findings 为空」是空的。 + // 抓得住:把判别写成「永远不画诊断」(用空断言骗过 P2)。 + // 抓不住:结论排序/去重(那是 core 的规则引擎的事)。 + let state = crashed_only(); + let logs = logs_model(&state).with_status(Lang::En); + + assert_eq!(logs.outcome, Some(pages::Outcome::Crashed)); + assert_eq!(logs.findings.len(), 1, "{:?}", logs.findings); + assert_eq!(logs.findings[0].rule, "gpu.driver_old"); + let status = logs.status.clone().unwrap_or_default(); + assert!(status.contains("The game crashed"), "{status}"); + assert!(logs.summary().contains("gpu.driver_old:high(92)")); +} + +#[test] +fn an_imported_crash_file_still_shows_its_findings() { + // r3 的**回归**:r3 把归属判据写成「有选中就只认选中」,于是导入崩溃日志 + // (`ImportCrashLog` → `DiagnosisReady` 带 `file:<文件名>` 的会话 id,且**不发** + // `LogLinesChanged`)被整条丢掉。今天 iced 侧还没有 dispatch 站点(那些还在遗留的 + // Slint shell 里),所以复审没判 p1;这条测试是为了**将来接线的人**不再踩。 + // 抓得住:只有「属于当前选中/live 会话」才画的窄判据(导入的 `file:` 会话不等于任何 + // 索引里的会话,会被误杀)。 + // 抓不住:导入后日志正文该显示什么(`ImportCrashLog` 不载入日志行,那是 presenter 的事)。 + let mut state = AppState::new(); + state.apply_core(CoreEvent::LogSessionsChanged(vec![session( + CRASH, + "10-29 18:40", + LogOutcome::Crashed, + )])); + // 上一场刚结束 ⇒ selected_session = CRASH(过期选中项),随后用户导入一份崩溃文件 + state.apply_core(CoreEvent::LaunchFinished(LaunchFinished { + id: "dev".to_string(), + session: CRASH.to_string(), + outcome: LogOutcome::Crashed, + summary: "崩溃(exit 1)".to_string(), + })); + state.apply_core(CoreEvent::DiagnosisReady(report( + "file:crash-2026-11-02_19.40.15-client.txt", + "integrity.unsatisfied_link", + ))); + + let logs = logs_model(&state).with_status(Lang::En); + assert_eq!( + logs.findings.len(), + 1, + "导入的崩溃日志必须画出它的结论:{:?}", + logs.findings + ); + assert_eq!(logs.findings[0].rule, "integrity.unsatisfied_link"); +} + +#[test] +fn a_live_run_says_it_is_running_instead_of_nothing_runs() { + // 视觉复审 r3 的 P2-3:session 索引只在**结束**时追加(`host::logs::sessions`), + // 所以运行中的那一场永远不在 `log_sessions` 里 ⇒ 拿不到实例名与结局 ⇒ r3 的页头在 + // 每次实时运行都说「当前没有运行中的实例」。demo 伪造了身份,截图上根本照不出来。 + // 抓得住:页头退回 Idle 文案(这次走真实 reducer:只有 `LogAppended(running=true)`)。 + // 抓不住:文案长度在某尺寸下会不会撞右侧动作区(那要看截图)。 + let mut state = AppState::new(); + state.apply_core(CoreEvent::LogSessionsChanged(vec![session( + CRASH, + "10-29 18:40", + LogOutcome::Crashed, + )])); + state.apply_core(CoreEvent::LogAppended(LiveLogBatch { + session: OK_RUN.to_string(), + lines: vec![line("[19:22] launching", LogLevel::Info)], + running: true, + })); + + let logs = logs_model(&state).with_status(Lang::En); + assert!(logs.live, "live_session 非空就该标记为运行中"); + let status = logs.status.clone().expect("运行中必须有副标题"); + let idle = unlucky_i18n::catalog::Catalog::of(Lang::En) + .text(unlucky_i18n::catalog::Key::LogsSubtitleIdle); + assert_ne!(status, idle, "运行中的页头不能说「没有运行」"); + assert!(status.contains("Running now"), "{status}"); +} + +#[test] +fn the_hosts_no_rule_matched_placeholder_is_not_a_finding() { + // host 的 `open_session` 在规则引擎无结论时发一份 `rule: ""` 的假报告(非 Option 逼的)。 + // 抓得住:把「没命中」画成一条严重度结论(用户会去修一个不存在的毛病)。 + // 抓不住:host 该不该改发 `None`(那是 presenter 的 finding)。 + let mut state = AppState::new(); + state.apply_core(CoreEvent::LogSessionsChanged(vec![session( + CRASH, + "10-29 18:40", + LogOutcome::Crashed, + )])); + state.apply_core(CoreEvent::LogLinesChanged(vec![line( + "[18:40] nothing to see", + LogLevel::Info, + )])); + + state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, ""))); + assert!( + logs_model(&state).findings.is_empty(), + "空 rule 是『没命中』的占位,不是结论" + ); + + state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old"))); + assert_eq!( + logs_model(&state).findings.len(), + 1, + "换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)" + ); +} diff --git a/crates/app-iced/src/pages/logs.rs b/crates/app-iced/src/pages/logs.rs index eb7da7d..e9e46bd 100644 --- a/crates/app-iced/src/pages/logs.rs +++ b/crates/app-iced/src/pages/logs.rs @@ -4,7 +4,7 @@ //! //! ```text //! ┌ 页头(两行 84:标题 + 运行状态;右侧 Open folder / Clear)──────────┐ -//! ├ 诊断卡片(只在有结论时出现,**按严重度分组**)──────────────────────┤ +//! ├ 诊断卡片(只在有结论时出现,**按置信度档分组**:HIGH / MEDIUM / LOW)──┤ //! │ DIAGNOSIS 3 CRITICAL 1 / WARNING 2 │ //! │ integrity.unsatisfied_link(规则 id,等宽) │ //! │ 结论(规则表原文) / 说明 / Suggested fix / Confidence 85% │ @@ -27,7 +27,7 @@ //! | 1 | **折行**而不是横向滚动 | 见 [`lines`] 模块文档:424px × 200 字符的现实下,横滚与外壳竖向滚动打架,且等宽族的 CJK 回退会毁掉「按列对齐」这个横滚唯一的好处 | //! | 2 | 诊断卡片在日志列表**之上** | 崩溃时用户先要结论;把结论放在 14 行日志下面等于让人先翻页 | //! | 3 | 规则原文(中文)当**数据**直接画 | 界面文案全走 i18n key;规则表的 `cause.summary` 是诊断结论本身,翻译要等 `rules.d` 带 `cause.key` 进 DTO(已报 finding) | -//! | 4 | 严重度按**置信度折算** | `dto::Diagnosis` 没有 `severity` 字段(core 有,DTO 丢了,已报 finding),阈值写在 `Severity::of_confidence` | +//! | 4 | 分组用**置信度档**(High/Medium/Low),不借 core 的严重度词汇 | `dto::Diagnosis` 没带 core 的 `severity`;拿置信度凑严重度会让 64 条规则里 12 条进错组(两个方向都有,复审实测)。档位由 `Confidence::of` 定,DTO 补齐后应换回真严重度(已报 finding) | //! | 5 | 页头保持**两行 84** | M84 定的版式(运行状态在副标题上);计数留在两张卡片自己的标题行,避免为了拼计数把 i18n 字符串漏进页头生命期 | //! //! ## 指令(只有真业务命令才 dispatch) @@ -60,7 +60,7 @@ const DIAGNOSIS_SHARE: u16 = 4; const LOGS_SHARE: u16 = 2; pub use demo::demo; -pub use model::{Finding, Level, Line, Logs, Outcome, Severity}; +pub use model::{Confidence, Finding, Level, Line, Logs, Outcome}; use iced::widget::column; use iced::{Element, Length}; diff --git a/crates/app-iced/src/pages/logs/demo.rs b/crates/app-iced/src/pages/logs/demo.rs index 7bcc654..97b2cd6 100644 --- a/crates/app-iced/src/pages/logs/demo.rs +++ b/crates/app-iced/src/pages/logs/demo.rs @@ -26,7 +26,7 @@ use unlucky_i18n::Lang; -use super::model::{Finding, Level, Line, Logs, Outcome, Severity}; +use super::model::{Confidence, Finding, Level, Line, Logs, Outcome}; /// 挑演示状态的环境变量。 pub const STATE_ENV: &str = "ULL_LOGS_FIXTURE"; @@ -46,15 +46,21 @@ pub enum State { Live, /// 崩溃现场:日志 + 诊断结论。 Crash, + /// **正在跑**的一场(页头走「正在运行」那条文案;session 索引里还没有它)。 + Running, } impl State { /// 从环境变量取值解析(未设 / 不认识一律 `Crash`)。 + /// + /// 取值:`empty`(空态)/ `live`(正常跑完的一场)/ `running`(**正在跑**:页头走「正在运行」) + /// / 其余 = `crash`。 #[must_use] pub fn of_env_value(raw: Option<&str>) -> Self { match raw { Some("empty") => Self::Empty, Some("live") => Self::Live, + Some("running") => Self::Running, _ => Self::Crash, } } @@ -74,6 +80,14 @@ impl State { ..Logs::default() } .with_status(lang), + // 运行中:还没有结局、也不在 session 索引里(`host::logs::sessions` 只在结束时追加), + // 所以既没有实例名也没有 outcome —— 页头只能走「正在运行」那条中性文案。 + Self::Running => Logs { + live: true, + lines: parse(LIVE_SAMPLE), + ..Logs::default() + } + .with_status(lang), Self::Crash => crash().with_status(lang), } } @@ -128,8 +142,8 @@ pub(super) fn parse(text: &str) -> Vec { .collect() } -/// 三条诊断(`rule` / `title` / `hint` 抄自规则表;`severity` 走 [`Severity::of_confidence`], -/// 也就是 DTO 缺 `severity` 时产品路径用的同一条折算)。 +/// 三条诊断(`rule` / `title` / `hint` 抄自规则表;置信度档走 [`Confidence::of`], +/// 也就是 DTO 缺 `severity` 时产品路径用的同一条档位定义)。 fn findings() -> Vec { vec![ finding( @@ -159,7 +173,7 @@ fn findings() -> Vec { fn finding(rule: &str, confidence: u8, title: &str, hint: &str, action: Option<&str>) -> Finding { Finding { rule: rule.to_owned(), - severity: Severity::of_confidence(confidence), + confidence_band: Confidence::of(confidence), title: title.to_owned(), hint: hint.to_owned(), confidence, diff --git a/crates/app-iced/src/pages/logs/diagnostic.rs b/crates/app-iced/src/pages/logs/diagnostic.rs index 4f6f4ff..8466f06 100644 --- a/crates/app-iced/src/pages/logs/diagnostic.rs +++ b/crates/app-iced/src/pages/logs/diagnostic.rs @@ -1,11 +1,11 @@ -//! 诊断结果区:**按严重度分组**的结论卡片 + 可直接粘进 issue 的一行摘要。 +//! 诊断结果区:**按置信度档分组**的结论卡片 + 可直接粘进 issue 的一行摘要。 //! //! ## 一条结论要能看出什么(tower 的三问) //! //! | 问题 | 落点 | 数据来源 | //! |---|---|---| //! | 哪条规则命中 | 规则 id(等宽、`LabelMedium`) | `Finding::rule` | -//! | 严重度 | 分组标题 + 分组顺序(`CRITICAL` → `WARNING` → `NOTICE`) | `Finding::severity` | +//! | 置信度(不是严重度) | 分组标题 + 分组顺序(`HIGH` → `MEDIUM` → `LOW`) | `Finding::confidence_band` | //! | 建议动作 | `Suggested fix` 一行(规则表的 `fix_action`,技术标识原样) | `Finding::action` | //! //! ## 用户真的要拿去用的两样东西 @@ -33,7 +33,7 @@ use crate::theme::Ui; use super::super::frame; use super::super::Message; use super::lines::{mono, mono_owned}; -use super::model::{Finding, Logs, Severity, GROUPS}; +use super::model::{Confidence, Finding, Logs, GROUPS}; use super::panel; /// 诊断卡片(只在有结论时画)。高度由调用方定(见 [`panel`])。 @@ -46,18 +46,14 @@ pub fn card<'a>(ui: &'a Ui, logs: &'a Logs, lang: Lang, height: Length) -> Eleme let mut group = column![].spacing(ui.space(Space::X2)).width(Length::Fill); - for severity in GROUPS { - let findings = logs.group(severity); + for band in GROUPS { + let findings = logs.group(band); if findings.is_empty() { continue; } group = group.push(frame::divider(ui)); - group = group.push(title_row( - ui, - catalog.text(key_of(severity)), - findings.len(), - )); + group = group.push(title_row(ui, catalog.text(key_of(band)), findings.len())); for finding in findings { group = group.push(finding_block(ui, &catalog, finding)); } @@ -189,12 +185,16 @@ fn summary_block<'a>(ui: &'a Ui, catalog: &Catalog, logs: &Logs) -> Element<'a, .into() } -/// 严重度 → 分组标题的 key。 -fn key_of(severity: Severity) -> Key { - match severity { - Severity::Critical => Key::LogsSeverityCritical, - Severity::Warning => Key::LogsSeverityWarning, - Severity::Info => Key::LogsSeverityInfo, +/// 置信度档 → 分组标题的 key。 +/// +/// 刻意**不叫严重度**(`CRITICAL` / `WARNING` / `NOTICE`):DTO 没带 core 的 severity, +/// 拿置信度去凑严重度会让 64 条规则里的 12 条进错组(`mod.mixin_failure` critical/0.70 沉底、 +/// `other.debug_crash` info/0.95 冒顶)。标题只说它对「这条结论有多确定」。 +fn key_of(band: Confidence) -> Key { + match band { + Confidence::High => Key::LogsConfidenceHigh, + Confidence::Medium => Key::LogsConfidenceMedium, + Confidence::Low => Key::LogsConfidenceLow, } } diff --git a/crates/app-iced/src/pages/logs/model.rs b/crates/app-iced/src/pages/logs/model.rs index 4103f87..49d8762 100644 --- a/crates/app-iced/src/pages/logs/model.rs +++ b/crates/app-iced/src/pages/logs/model.rs @@ -18,8 +18,14 @@ //! presenter 的 `dto::Diagnosis` 只有 `rule` / `title` / `hint` / `confidence` / `fix`: //! //! 1. **core 的 `Severity`(info / warning / critical)在半路丢了**——`core::diagnose::rules` -//! 里每条规则都带它,DTO 没有。页面要「按严重度分组」就只能用 -//! [`Severity::of_confidence`] 按置信度**折算**(阈值写死在函数里,是债,不是规格)。 +//! 里每条规则都带它,`presenter::dto::Diagnosis` 没有。拿严重度当分组名就只能拿置信度硬凑, +//! 实测 **64 条规则里 12 条会被凑错档、两个方向都有**(`mod.mixin_failure` critical/0.70 沉到 +//! 第二档、`other.debug_crash` info/0.95 冒到第一档)。 +//! +//! 所以 M95 r4 起页面**不再借 core 的词汇**:分组改用**置信度档** +//! [`Confidence::High`] / [`Confidence::Medium`] / [`Confidence::Low`],标题直接写「高/中/低 +//! 置信度」——它描述的是「规则引擎有多确定」,不是「这毛病有多严重」。DTO 补上 `severity` +//! 之后这里应该换回真严重度(finding 已记给 presenter)。 //! 2. `title` / `hint` 只有中文单语,而且规则表里其实已经有结构化出口 //! (`rules.d/*.yaml` 的 `cause.key: diagnose.cause.…`)。DTO 没带 `key`,页面就把规则原文 //! 当**数据**画出来,页面的界面文案(标题 / 按钮 / 分组名 / 空态)仍然全部走 i18n key。 @@ -53,42 +59,46 @@ impl Level { } } -/// 诊断结论的严重度(`core::diagnose::rules::Severity` 的展示投影)。 +/// 诊断结论的**置信度档**(规则引擎有多确定,不是「这毛病有多严重」)。 +/// +/// 为什么不是 `Severity`:`presenter::dto::Diagnosis` 没带 core 规则表的 `severity`, +/// 页面自己按置信度折算严重度会凑错档(64 条规则里 12 条,两个方向都有)。宁可**如实** +/// 说「这条结论置信度多高」,也不要编一个「严重度」(视觉复审 r3 的 P2-2 + 塔裁决)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum Severity { - /// 提示:能跑但不最佳。 - Info, - /// 警告:可疑但结论不确定。 - Warning, - /// 严重:基本可以确定崩溃原因。 - Critical, +pub enum Confidence { + /// 低:规则只是「有点像」。 + Low, + /// 中:可疑。 + Medium, + /// 高:基本可以确定。 + High, } -/// 分区顺序(**主因在上**:崩溃日志换 issue 时第一段就是最该看的)。 -pub const GROUPS: [Severity; 3] = [Severity::Critical, Severity::Warning, Severity::Info]; +/// 分区顺序(**高置信度在上**:崩溃日志换 issue 时第一段就是最该看的)。 +pub const GROUPS: [Confidence; 3] = [Confidence::High, Confidence::Medium, Confidence::Low]; -impl Severity { +impl Confidence { /// 稳定 slug(摘要行用;不是文案——分组标题走 i18n key)。 #[must_use] pub const fn slug(self) -> &'static str { match self { - Self::Info => "info", - Self::Warning => "warning", - Self::Critical => "critical", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", } } - /// 按置信度折算严重度——**过渡口径**,见模块文档的缺口 #1。 + /// 0~100 置信度 → 档位(阈值 `≥80` 高 / `≥50` 中 / 其余低)。 /// - /// 阈值:`≥80` 严重 / `≥50` 警告 / 其余提示。DTO 补上 `severity` 之后这个函数就该删掉。 + /// 阈值是**档位定义**,不是从严重度反推的代理:标题与文案都说的是「置信度」。 #[must_use] - pub const fn of_confidence(confidence: u8) -> Self { + pub const fn of(confidence: u8) -> Self { if confidence >= 80 { - Self::Critical + Self::High } else if confidence >= 50 { - Self::Warning + Self::Medium } else { - Self::Info + Self::Low } } } @@ -107,8 +117,8 @@ pub struct Line { pub struct Finding { /// 命中的规则 id(`graphics.opengl_not_supported`):技术标识,原样显示。 pub rule: String, - /// 严重度(DTO 没有,见 [`Severity::of_confidence`])。 - pub severity: Severity, + /// 置信度档(DTO 没有 core 的 severity,见 [`Confidence`])。 + pub confidence_band: Confidence, /// 结论标题(规则表的 `cause.summary`,**数据不是文案**)。 pub title: String, /// 建议说明(规则表的 `cause.detail`)。 @@ -154,6 +164,12 @@ pub struct Logs { /// 页头副标题(`实例 · 时间 · 结局`):**构造时**拼好(见 [`Logs::with_status`]), /// 页面只负责显示——i18n 的「一句话 = 一个 key」不许在渲染里拼句子。 pub status: Option, + /// 有没有正在跑的会话(`live_session` 非空)。 + /// + /// 为什么需要它:session 索引只在会话**结束**时追加(`host::logs::sessions`),所以运行中的 + /// 那一场根本不在 `log_sessions` 里 ⇒ 拿不到实例名与结局。r3 之前这里直接退回 + /// 「当前没有运行中的实例」,于是**每次实时运行页头都在说反话**(视觉复审 r3 的 P2-3)。 + pub live: bool, /// 日志行(顺序 = 文件顺序)。 pub lines: Vec, /// 诊断结论(顺序 = presenter 给的顺序,页面不重排)。 @@ -161,18 +177,28 @@ pub struct Logs { } impl Logs { - /// 补上页头副标题(M95 r2,视觉复审 P2-2)。 + /// 补上页头副标题(M95 r2,视觉复审 P2-2;r4 补「运行中」这一档,P2-3)。 /// /// 为什么在这里做而不是在页面里:`Header::subtitle` 要 `&'a str`,而拼出来的是 `String`; /// 让模型持有它,页面就永远只借不拼。 + /// + /// 三级回退:**有实例 + 结局** → `实例 · 时间 · 结局`;**正在跑但还没有结局**(session 索引 + /// 里没有它) → 中性的「正在运行」;都没有 → `None`(页面退回 [`Key::LogsSubtitleIdle`])。 #[must_use] pub fn with_status(mut self, lang: Lang) -> Self { + let catalog = Catalog::of(lang); + self.status = status_line( self.instance.as_deref(), self.when.as_deref(), self.outcome, lang, - ); + ) + .or_else(|| { + self.live + .then(|| catalog.text(Key::LogsSubtitleLive).to_owned()) + }); + self } @@ -182,12 +208,12 @@ impl Logs { self.lines.is_empty() && self.findings.is_empty() } - /// 某一严重度下的结论(顺序不变;空组返回空 `Vec`)。 + /// 某一置信度档下的结论(顺序不变;空组返回空 `Vec`)。 #[must_use] - pub fn group(&self, severity: Severity) -> Vec<&Finding> { + pub fn group(&self, band: Confidence) -> Vec<&Finding> { self.findings .iter() - .filter(|finding| finding.severity == severity) + .filter(|finding| finding.confidence_band == band) .collect() } @@ -223,7 +249,7 @@ impl Logs { format!( "{}:{}({})", finding.rule, - finding.severity.slug(), + finding.confidence_band.slug(), finding.confidence ) }) diff --git a/crates/app-iced/src/pages/logs/tests.rs b/crates/app-iced/src/pages/logs/tests.rs index 0911641..5d650e2 100644 --- a/crates/app-iced/src/pages/logs/tests.rs +++ b/crates/app-iced/src/pages/logs/tests.rs @@ -68,35 +68,32 @@ fn findings_are_grouped_critical_first_and_empty_groups_do_not_exist() { findings, ..Logs::default() }; - let counts: Vec = GROUPS - .iter() - .map(|severity| logs.group(*severity).len()) - .collect(); + let counts: Vec = GROUPS.iter().map(|band| logs.group(*band).len()).collect(); - // 抓得住:分组顺序被改成 info 在前(主因会沉底);空组也被画出一个标题(截图上出现 0 条的分区)。 + // 抓得住:分组顺序被改成低置信度在前(最像主因的沉底);空组也被画出一个标题。 // 抓不住:组内顺序(页面刻意不重排 presenter 给的顺序)。 - assert_eq!(GROUPS[0], Severity::Critical); + assert_eq!(GROUPS[0], Confidence::High); assert_eq!( counts.iter().sum::(), logs.findings.len(), "每条结论必须落进恰好一个分组" ); - assert!(counts[0] >= 1, "样例至少有主因"); - assert!(logs.group(Severity::Info).is_empty()); + assert!(counts[0] >= 1, "样例至少有高置信度结论"); + assert!(logs.group(Confidence::Low).is_empty()); } #[test] -fn summary_is_a_single_deterministic_line_carrying_rule_and_severity() { +fn summary_is_a_single_deterministic_line_carrying_rule_and_confidence() { let logs = demo::crash(); let summary = logs.summary(); - // 抓得住:摘要里丢了规则 id / 严重度 / 置信度(换 issue 时最关键的三个字段); + // 抓得住:摘要里丢了规则 id / 置信度档 / 置信度百分比(换 issue 时最关键的三个字段); // 有人把换行塞进摘要("一行"就不成立了)。 // 抓不住:粘贴到 issue 后的可读性(那是人判断的),也不守文案语言(摘要刻意不含界面文案)。 assert!(!summary.contains('\n'), "{summary}"); assert!(summary.starts_with(SUMMARY_HEADER), "{summary}"); assert!( - summary.contains("integrity.unsatisfied_link:critical(85)"), + summary.contains("integrity.unsatisfied_link:high(85)"), "{summary}" ); assert!(summary.contains("lines="), "{summary}"); @@ -107,15 +104,21 @@ fn summary_is_a_single_deterministic_line_carrying_rule_and_severity() { } #[test] -fn severity_is_derived_from_confidence_with_the_documented_thresholds() { - // 抓得住:阈值边界写成 `>` / `>=` 那种 off-by-one(80 与 50 是文档里写死的两个点)。 - // 抓不住:折算本身对不对——DTO 补上 `severity` 之前这只是**过渡口径**(已报 finding)。 - assert_eq!(Severity::of_confidence(100), Severity::Critical); - assert_eq!(Severity::of_confidence(80), Severity::Critical); - assert_eq!(Severity::of_confidence(79), Severity::Warning); - assert_eq!(Severity::of_confidence(50), Severity::Warning); - assert_eq!(Severity::of_confidence(49), Severity::Info); - assert_eq!(Severity::of_confidence(0), Severity::Info); +fn the_band_is_the_documented_confidence_band_not_a_severity_impostor() { + // 抓得住:阈值边界写成 `>` / `>=` 的 off-by-one(80 / 50 是档位的定义点); + // 以及有人把档位名字改回 `critical/warning/notice` 那种**严重度词汇** + // (那会让 64 条规则里 12 条进错组,两个方向都有——视觉复审 r3 的 P2-2)。 + // 抓不住:档位划分本身对不对(那是产品口径;DTO 补上 `severity` 后应换回真严重度,已报 finding)。 + assert_eq!(Confidence::of(100), Confidence::High); + assert_eq!(Confidence::of(80), Confidence::High); + assert_eq!(Confidence::of(79), Confidence::Medium); + assert_eq!(Confidence::of(50), Confidence::Medium); + assert_eq!(Confidence::of(49), Confidence::Low); + assert_eq!(Confidence::of(0), Confidence::Low); + + assert_eq!(Confidence::High.slug(), "high"); + assert_eq!(Confidence::Medium.slug(), "medium"); + assert_eq!(Confidence::Low.slug(), "low"); } #[test] @@ -255,9 +258,10 @@ fn every_key_the_page_uses_is_translated_in_both_languages() { Key::LogsClear, Key::LogsOutputTitle, Key::LogsDiagnosisTitle, - Key::LogsSeverityCritical, - Key::LogsSeverityWarning, - Key::LogsSeverityInfo, + Key::LogsConfidenceHigh, + Key::LogsConfidenceMedium, + Key::LogsConfidenceLow, + Key::LogsSubtitleLive, Key::LogsConfidence, Key::LogsSuggestedFix, Key::LogsSummaryLabel, diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index afb60af..c28420b 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -108,7 +108,7 @@ mod tests; pub use download::{Category, Download}; pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; -pub use logs::{Finding, Level, Line, Logs, Outcome, Severity}; +pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome}; pub use model::{Detail, Instances, Meta, Model, Row}; use iced::widget::{column, container, row, space}; diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index d299995..469c2e8 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -178,9 +178,10 @@ catalog! { LogsClear => ("Clear", "清空"), LogsOutputTitle => ("OUTPUT", "输出"), LogsDiagnosisTitle => ("DIAGNOSIS", "诊断"), - LogsSeverityCritical => ("CRITICAL", "严重"), - LogsSeverityWarning => ("WARNING", "警告"), - LogsSeverityInfo => ("NOTICE", "提示"), + LogsConfidenceHigh => ("HIGH CONFIDENCE", "高置信度"), + LogsConfidenceMedium => ("MEDIUM CONFIDENCE", "中置信度"), + LogsConfidenceLow => ("LOW CONFIDENCE", "低置信度"), + LogsSubtitleLive => ("Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"), LogsConfidence => ("Confidence {percent}%", "置信度 {percent}%"), LogsSuggestedFix => ("Suggested fix", "建议动作"), LogsSummaryLabel => ("One-line summary", "一行摘要"), From 4fde22067e81773786e7361c16e95699e636b732 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 22:31:47 +0800 Subject: [PATCH 7/7] =?UTF-8?q?chore(app-iced):=20M95=20=E8=A7=A3=20rebase?= =?UTF-8?q?=20=E5=86=B2=E7=AA=81=E7=9A=84=E6=94=B6=E5=B0=BE=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20pages/mod.rs=20=E6=94=B6=E5=88=B0=20400=20=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M94 合入后 `pages/mod.rs` 两侧增量叠加到了 **406 行**(全仓硬上限 400)。本提交只做三件 等价收紧,没有行为变化: 1. `demo_model` 的三个 `if` 收成一个 `match screen`(「哪页带演示数据」也更好读); 2. 我给 `Message::CopySummary` 写的 3 行 doc 收成 1 行(M94 的变体 doc 都是单行); 3. 删掉 `#[cfg(test)] mod tests;` 与 `pub use` 之间的一行空行。 顺带把 `demo_model` 的「只有实例页带数据」改成事实(实例/下载/日志三页都有演示数据—— 这句在 M94 合入后就不成立了)。 收完 **正好 400 行**,仓库自己的守门测试(`components::gate` 的 `every_source_file_stays_under_the_line_limit`,扫 `src/**`)仍然绿。 --- crates/app-iced/src/pages/mod.rs | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index c28420b..460a3bd 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -105,7 +105,6 @@ mod settings; #[cfg(test)] mod tests; - pub use download::{Category, Download}; pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome}; @@ -151,9 +150,7 @@ pub enum Message { CancelInstall, /// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。 SetCategory(Category), - /// 把一段现成的文本塞进剪贴板(**M95 日志页**:诊断卡片的一行摘要)。 - /// - /// 文本由页面造(它是唯一同时看得见规则 id / 分类 / 置信度的地方),app 层只负责落剪贴板。 + /// 把现成文本塞进剪贴板(**M95**:诊断卡片的一行摘要,文本由页面造,app 只搬)。 CopySummary(String), } @@ -361,17 +358,14 @@ pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> { /// 演示模型(`pages_demo` example 与单测用;真页面走 `app/view.rs` 的 `AppState` 转换)。 /// -/// 只有实例页带数据,其余三页是占位视觉(不读数据)。 +/// 实例 / 下载 / 日志三页带演示数据,设置页仍是占位视觉。 pub fn demo_model(screen: Screen, window_width: f32) -> Model { let mut model = Model::new(screen, window_width, Some(screen.nav())); - if screen == Screen::Instances { - model.instances = instances::demo(); - } - if screen == Screen::Download { - model.download = download::demo(unlucky_i18n::Lang::En.catalog()); - } - if screen == Screen::Logs { - model.logs = logs::demo(model.lang); + match screen { + Screen::Instances => model.instances = instances::demo(), + Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()), + Screen::Logs => model.logs = logs::demo(model.lang), + Screen::Settings => {} } model }