Merge branch 'feat/p5-logs-page-real-components-diagnostic'

This commit is contained in:
AskaEth committed 2026-11-02 22:51:32 +08:00
commit b16932a312
19 files changed
+2007 -162

No files matched your search

+2 -29
View File
@@ -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);
@@ -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,
@@ -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<Message> {
// 下载页的四支(含「刷新下载源」)在 `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
);
}
}
+10
View File
@@ -4,6 +4,7 @@
use iced::{Element, Theme};
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage};
use unlucky_presenter::router::Route;
use unlucky_presenter::state::AppState;
@@ -17,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 {
@@ -109,12 +114,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).with_status(lang),
lang,
forced: forced.map(|state| match state {
crate::cli::ForcedState::Hover => Interaction::Hovered,
crate::cli::ForcedState::Pressed => Interaction::Pressed,
+144
View File
@@ -0,0 +1,144 @@
//! 日志页的展示转换:presenter 快照 → [`pages::Logs`](从 `app/view.rs` 拆出来,M95 r3)。
//!
//! ## 跨会话串场(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` 反推** |
//! | 诊断结论 | 见下表的三个分支(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` 不清,
//! 身份会继续指向那一场;导入崩溃文件只发 `DiagnosisReady`(不带日志行),所以页面上会出现
//! 「结论 + 上一场的日志正文」——行由 `open_session` 负责,那是 presenter 的缺口。
use unlucky_presenter::dto::{Diagnosis, LogLevel, LogOutcome};
use unlucky_presenter::state::AppState;
use crate::pages;
/// 日志页视图模型:presenter 快照 → 要画的东西。
///
/// 两处 DTO 侧的**信息丢失**(已报 finding):`dto::Diagnosis` 没有 core 的 `severity`
/// (所以页面按置信度档分组,不冒称严重度)与 `cause.key`(`title`/`hint` 只能当数据画)。
pub(super) fn logs_model(state: &AppState) -> pages::Logs {
let live = state.live_session.as_deref();
let selected = state.selected_session.as_deref();
let identity = live.or(selected);
// 归属判据(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,
}
});
// 身份为空时,描述的来源只能退到报告(见模块文档的表格)。
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(|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()
.map(|line| pages::Line {
text: line.text.clone(),
level: log_level(line.level),
})
.collect(),
findings: report
.filter(|report| !report.diagnosis.rule.is_empty())
.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,
}
}
/// 一条诊断结论 → 页面结论(置信度档见 [`pages::Confidence`])。
fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding {
pages::Finding {
rule: diagnosis.rule.clone(),
confidence_band: pages::Confidence::of(diagnosis.confidence),
title: diagnosis.title.clone(),
hint: diagnosis.hint.clone(),
confidence: diagnosis.confidence,
action: diagnosis.fix.clone(),
}
}
#[cfg(test)]
mod tests;
+242
View File
@@ -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,
"换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)"
);
}
+126 -120
View File
@@ -1,145 +1,151 @@
//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤424)。
//! 日志页(M95):**单列**版面的第二个用例,也是本项目卖点(崩溃日志诊断)的落地页。
//!
//! 与下载页的区别在页头:日志页的页头是**两行**(标题 + 一行运行状态),
//! 高度走 `LayoutMetrics::page_header_tall_height`(84),这是 12b §16.1 那栏在骨架期的落地。
//! ## 一屏由三块组成
//!
//! 内容区给「日志源」一张卡(文件行)+ 空态位(当前没有输出)。真正的等宽日志视图属于
//! 后续「日志页接事件流」的 mission,本页只负责版式。
//! ```text
//! ┌ 页头(两行 84:标题 + 运行状态;右侧 Open folder / Clear)──────────┐
//! ├ 诊断卡片(只在有结论时出现,**按置信度档分组**:HIGH / MEDIUM / LOW)──┤
//! │ 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 | 分组用**置信度档**(High/Medium/Low),不借 core 的严重度词汇 | `dto::Diagnosis` 没带 core 的 `severity`;拿置信度凑严重度会让 64 条规则里 12 条进错组(两个方向都有,复审实测)。档位由 `Confidence::of` 定,DTO 补齐后应换回真严重度(已报 finding) |
//! | 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`] 的模块文档:两块必须都是定高 + 内部滚动)。
///
/// r2 是 3:2 —— 1180×760 下诊断卡的滚动视口只剩 141px,而一条完整结论(分组标题 → 建议动作 →
/// 置信度)要 177px,于是**首屏看不到任何一条完整结论**(视觉复审 r2 的 P2-A:任务要求的
/// 「建议动作」在验收尺寸下不可见)。改成 4:2:视口 ~184px,一条结论整块可见。
const DIAGNOSIS_SHARE: u16 = 4;
/// 日志卡的高度份额。
const LOGS_SHARE: u16 = 2;
pub use demo::demo;
pub use model::{Confidence, Finding, Level, Line, Logs, Outcome};
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)
/// 页头副标题:有会话身份(实例 · 时间 · 结局)就显示它,否则退回静态文案。
fn subtitle<'a>(catalog: &Catalog, logs: &'a Logs) -> &'a str {
logs.status
.as_deref()
.unwrap_or_else(|| catalog.text(Key::LogsSubtitleIdle))
}
#[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());
}
}
/// 空态(`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),
),
)
}
+182
View File
@@ -0,0 +1,182 @@
//! 日志页演示数据:**只给 `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 unlucky_i18n::Lang;
use super::model::{Confidence, Finding, Level, Line, Logs, Outcome};
/// 挑演示状态的环境变量。
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 {
/// 空态:没有输出,也没有诊断。
Empty,
/// 有日志、无诊断(正常跑着的一场)。
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,
}
}
/// 造出这个状态的视图模型(`lang` 用于页头副标题的 i18n)。
#[must_use]
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),
lines: parse(LIVE_SAMPLE),
..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),
}
}
}
/// harness 入口:按 [`STATE_ENV`] 挑状态。
#[must_use]
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 {
instance: Some("开发测试".to_owned()),
when: Some("11-02 19:40".to_owned()),
outcome: Some(Outcome::Crashed),
lines: parse(SAMPLE),
findings: findings(),
..Logs::default()
}
}
/// 解析内嵌样例:`#` 注释与空行跳过,首字符是等级前缀。
pub(super) fn parse(text: &str) -> Vec<Line> {
text.lines()
.filter_map(|raw| {
let raw = raw.trim_end();
if raw.is_empty() || raw.starts_with('#') {
return None;
}
// 按**字符**取等级前缀,不 `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: chars.as_str().trim_start().to_owned(),
level,
})
})
.collect()
}
/// 三条诊断(`rule` / `title` / `hint` 抄自规则表;置信度档走 [`Confidence::of`],
/// 也就是 DTO 缺 `severity` 时产品路径用的同一条档位定义)。
fn findings() -> Vec<Finding> {
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(),
confidence_band: Confidence::of(confidence),
title: title.to_owned(),
hint: hint.to_owned(),
confidence,
action: action.map(str::to_owned),
}
}
@@ -0,0 +1,213 @@
//! 诊断结果区:**按置信度档分组**的结论卡片 + 可直接粘进 issue 的一行摘要。
//!
//! ## 一条结论要能看出什么(tower 的三问)
//!
//! | 问题 | 落点 | 数据来源 |
//! |---|---|---|
//! | 哪条规则命中 | 规则 id(等宽、`LabelMedium`) | `Finding::rule` |
//! | 置信度(不是严重度) | 分组标题 + 分组顺序(`HIGH` → `MEDIUM` → `LOW`) | `Finding::confidence_band` |
//! | 建议动作 | `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::{Confidence, Finding, Logs, GROUPS};
use super::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);
let mut group = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
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(band)), findings.len()));
for finding in findings {
group = group.push(finding_block(ui, &catalog, finding));
}
}
panel::card(
ui,
height,
column![
title_row(
ui,
catalog.text(Key::LogsDiagnosisTitle),
logs.findings.len(),
),
panel::scroll(ui, group),
frame::divider(ui),
summary_block(ui, &catalog, logs),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill),
)
}
/// 标题行:左边标签(大写字距靠令牌档),右边条数。
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()
}
/// **固定的摘要页脚**:标签 +「Copy summary」+ 等宽摘要(可整段带走)。
///
/// 页脚不滚动(见 [`card`] 的文档):按钮紧邻它要复制的那段文字,用户复制的一定是看得见的串。
fn summary_block<'a>(ui: &'a Ui, catalog: &Catalog, logs: &Logs) -> Element<'a, Message> {
column![
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),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 置信度档 → 分组标题的 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,
}
}
/// 令牌排版档 + **按词或字形折行**(长堆栈里的点分标识符没有空格,只按词会溢出)。
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()
}
+209
View File
@@ -0,0 +1,209 @@
//! 日志行列表:**等宽 + 折行 + 独立滚动**。
//!
//! ## 为什么折行而不是横向滚动(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 回退把列宽带偏,对齐就废了;折行版面不承诺任何列对齐,回退只影响同一行内的观感。
//! ## 悬挂缩进(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 层
//! 回灌滚动状态才做得到)。
//!
//! ## 等宽族的实际落点
//!
//! `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;
/// 前缀上限(字符):超过就不拆(见 [`split_prefix`])。
const PREFIX_MAX_CHARS: usize = 20;
/// 等级条宽度(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::<Vec<_>>(),
)
.spacing(ui.space(Space::X1))
.width(Length::Fill);
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)
.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()
});
// 「前缀 + 正文」拆两个文本控件是悬挂缩进的**唯一**落地方式(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(0.0)
.width(Length::Fill)
.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 槽位同源)。
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)
}
+299
View File
@@ -0,0 +1,299 @@
//! 日志页的视图模型:**纯展示字段**(沿用 `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`
//! 里每条规则都带它,`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。
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
/// 一行日志的等级(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",
}
}
}
/// 诊断结论的**置信度档**(规则引擎有多确定,不是「这毛病有多严重」)。
///
/// 为什么不是 `Severity`:`presenter::dto::Diagnosis` 没带 core 规则表的 `severity`,
/// 页面自己按置信度折算严重度会凑错档(64 条规则里 12 条,两个方向都有)。宁可**如实**
/// 说「这条结论置信度多高」,也不要编一个「严重度」(视觉复审 r3 的 P2-2 + 塔裁决)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Confidence {
/// 低:规则只是「有点像」。
Low,
/// 中:可疑。
Medium,
/// 高:基本可以确定。
High,
}
/// 分区顺序(**高置信度在上**:崩溃日志换 issue 时第一段就是最该看的)。
pub const GROUPS: [Confidence; 3] = [Confidence::High, Confidence::Medium, Confidence::Low];
impl Confidence {
/// 稳定 slug(摘要行用;不是文案——分组标题走 i18n key)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
/// 0~100 置信度 → 档位(阈值 `≥80` 高 / `≥50` 中 / 其余低)。
///
/// 阈值是**档位定义**,不是从严重度反推的代理:标题与文案都说的是「置信度」。
#[must_use]
pub const fn of(confidence: u8) -> Self {
if confidence >= 80 {
Self::High
} else if confidence >= 50 {
Self::Medium
} else {
Self::Low
}
}
}
/// 一行日志。
#[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 没有 core 的 severity,见 [`Confidence`])。
pub confidence_band: Confidence,
/// 结论标题(规则表的 `cause.summary`,**数据不是文案**)。
pub title: String,
/// 建议说明(规则表的 `cause.detail`)。
pub hint: String,
/// 0~100 置信度。
pub confidence: u8,
/// 建议动作(规则表的 `fix_action`,技术标识 / 设置项名)。
pub action: Option<String>,
}
/// 一次运行的结局(`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<String>,
/// 会话时间(展示用短串 `10-29 18:40`,presenter 已经格式化好)。
pub when: Option<String>,
/// 结局。
pub outcome: Option<Outcome>,
/// 页头副标题(`实例 · 时间 · 结局`):**构造时**拼好(见 [`Logs::with_status`]),
/// 页面只负责显示——i18n 的「一句话 = 一个 key」不许在渲染里拼句子。
pub status: Option<String>,
/// 有没有正在跑的会话(`live_session` 非空)。
///
/// 为什么需要它:session 索引只在会话**结束**时追加(`host::logs::sessions`),所以运行中的
/// 那一场根本不在 `log_sessions` 里 ⇒ 拿不到实例名与结局。r3 之前这里直接退回
/// 「当前没有运行中的实例」,于是**每次实时运行页头都在说反话**(视觉复审 r3 的 P2-3)。
pub live: bool,
/// 日志行(顺序 = 文件顺序)。
pub lines: Vec<Line>,
/// 诊断结论(顺序 = presenter 给的顺序,页面不重排)。
pub findings: Vec<Finding>,
}
impl Logs {
/// 补上页头副标题(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
}
/// 没有任何可显示的东西(空态判据:行与诊断都空)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.lines.is_empty() && self.findings.is_empty()
}
/// 某一置信度档下的结论(顺序不变;空组返回空 `Vec`)。
#[must_use]
pub fn group(&self, band: Confidence) -> Vec<&Finding> {
self.findings
.iter()
.filter(|finding| finding.confidence_band == band)
.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<String> = self
.findings
.iter()
.map(|finding| {
format!(
"{}:{}({})",
finding.rule,
finding.confidence_band.slug(),
finding.confidence
)
})
.collect();
parts.push(format!("findings={}", hits.join(",")));
}
parts.join(" | ")
}
}
/// 摘要行的版本头(改了格式就换代,别让旧 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<Outcome>,
lang: Lang,
) -> Option<String> {
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)),
],
))
}
+89
View File
@@ -0,0 +1,89 @@
//! 两块的共用外壳:**定高的卡片 + 内部独立滚动**。
//!
//! ## 为什么必须定高(M95 r1 的实拍 bug)
//!
//! 第一版把诊断卡按内容自然高度画、日志卡吃剩余高度。三条结论在 424×540 的正文里就要
//! ~500px,结果:诊断卡被裁掉尾部(一行摘要整块消失)、日志卡拿到 **0** 高度(整块不见)。
//! 截图见报告里的「r1 崩了」。
//!
//! 所以两块都**由调用方给定高**(`FillPortion`),各自内部滚动:
//!
//! | 块 | 高度 | 为什么 |
//! |---|---|---|
//! | 诊断 | `FillPortion(4)` | 崩溃时先读结论;份额要够 177px 的滚动视口,才放得下**一条完整结论**(含建议动作与置信度) |
//! | 日志 | `FillPortion(2)` | 日志是长流水,滚就是了 |
//!
//! 两者都是**兄弟**滚动区(不是嵌套):滚轮归属清楚,不存在「滚动条套滚动条」。
use iced::widget::{container, scrollable};
use iced::{Background, Element, Length};
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
/// 定高卡片(`Card` 材质层 + `OutlinedCard` 形状与 **1px 描边**,与 `frame::card` 同一套令牌:
/// 实例页 / 下载页的卡都带这条描边,日志页的两张主卡不能例外)。
pub fn card<'a>(
ui: &'a Ui,
height: Length,
content: impl Into<Element<'a, Message>>,
) -> 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: ui.outlined(shape),
..container::Style::default()
})
.into()
}
/// 卡片内的竖向滚动区(贴顶:诊断卡从头读)。
pub fn scroll<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
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>>,
) -> Element<'a, Message> {
scroller(ui, content, true)
}
fn scroller<'a>(
ui: &'a Ui,
content: impl Into<Element<'a, Message>>,
tail: bool,
) -> Element<'a, Message> {
let view = 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));
if tail {
view.anchor_bottom().into()
} else {
view.into()
}
}
@@ -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.<init>(MainWindow.java:261)
E at net.minecraft.client.Minecraft.<init>(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 秒)
@@ -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!
+303
View File
@@ -0,0 +1,303 @@
//! 日志页的守门测试。每条断言下面写清「抓得住什么 / 抓不住什么」(这个仓的硬要求)。
//!
//! 这里**没有截图断言**:像素级验收靠 `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::{self, 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<usize> = GROUPS.iter().map(|band| logs.group(*band).len()).collect();
// 抓得住:分组顺序被改成低置信度在前(最像主因的沉底);空组也被画出一个标题。
// 抓不住:组内顺序(页面刻意不重排 presenter 给的顺序)。
assert_eq!(GROUPS[0], Confidence::High);
assert_eq!(
counts.iter().sum::<usize>(),
logs.findings.len(),
"每条结论必须落进恰好一个分组"
);
assert!(counts[0] >= 1, "样例至少有高置信度结论");
assert!(logs.group(Confidence::Low).is_empty());
}
#[test]
fn summary_is_a_single_deterministic_line_carrying_rule_and_confidence() {
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:high(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 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]
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 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();
assert!(logs.is_empty());
logs.findings = demo::crash().findings;
// 抓得住:把「没有日志行」当成空态(有诊断时那样会藏掉最该看的东西)。
assert!(!logs.is_empty(), "只有诊断也不算空态");
let live = demo::State::Live.build(Lang::En);
assert!(!live.is_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,出图的人以为拍到了空态)。
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::LogsStatus,
Key::LogsOpenFolder,
Key::LogsClear,
Key::LogsOutputTitle,
Key::LogsDiagnosisTitle,
Key::LogsConfidenceHigh,
Key::LogsConfidenceMedium,
Key::LogsConfidenceLow,
Key::LogsSubtitleLive,
Key::LogsConfidence,
Key::LogsSuggestedFix,
Key::LogsSummaryLabel,
Key::LogsCopySummary,
Key::LogsEmptyTitle,
Key::LogsEmptyBody,
Key::LogsEmptyAction,
// 结局文案由页头副标题间接用到(见 `status_line`)
Key::LaunchOutcomeNormal,
Key::LaunchOutcomeCrash,
Key::LaunchOutcomeSilent,
];
// 抓得住:页面用到的 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));
}
+10 -8
View File
@@ -105,9 +105,9 @@ 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};
pub use model::{Detail, Instances, Meta, Model, Row};
use iced::widget::{column, container, row, space};
@@ -150,6 +150,8 @@ pub enum Message {
CancelInstall,
/// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。
SetCategory(Category),
/// 把现成文本塞进剪贴板(**M95**:诊断卡片的一行摘要,文本由页面造,app 只搬)。
CopySummary(String),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
@@ -327,7 +329,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),
}
}
@@ -356,14 +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());
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
}
+11
View File
@@ -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,
}
}
+25
View File
@@ -167,6 +167,31 @@ catalog! {
),
DownloadOpenLogs => ("Open logs", "打开日志"),
DownloadDismiss => ("Dismiss", "关闭"),
// --- logs page (M95) ---
// 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里:
// 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。
// `LogsStatus` 是**模板**:中英差别落在分隔符上(中文用全角竖线——`·` 是外来符号,
// 中文正文里偏挤;这也让「中文目录必须与英文不同」这条守卫成立,不是为过测试硬造差异)。
LogsSubtitleIdle => ("Nothing is running right now", "当前没有运行中的实例"),
LogsStatus => ("{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
LogsOpenFolder => ("Open folder", "打开目录"),
LogsClear => ("Clear", "清空"),
LogsOutputTitle => ("OUTPUT", "输出"),
LogsDiagnosisTitle => ("DIAGNOSIS", "诊断"),
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", "一行摘要"),
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`,随手传值)。
+3 -1
View File
@@ -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::*;