fix(app-iced): M95 r4 —— 归属判据回归 / 置信度档 / 运行中页头

代码复审 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。
This commit is contained in:
AskaEth committed 2026-11-02 22:27:01 +08:00
1 parent 5ed2bf3a7d
commit 0eb069fa37
9 files changed
+414 -267

No files matched your search

+47 -187
View File
@@ -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;
+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,
"换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)"
);
}
+3 -3
View File
@@ -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};
+18 -4
View File
@@ -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<Line> {
.collect()
}
/// 三条诊断(`rule` / `title` / `hint` 抄自规则表;`severity` 走 [`Severity::of_confidence`],
/// 也就是 DTO 缺 `severity` 时产品路径用的同一条折算)。
/// 三条诊断(`rule` / `title` / `hint` 抄自规则表;置信度档走 [`Confidence::of`],
/// 也就是 DTO 缺 `severity` 时产品路径用的同一条档位定义)。
fn findings() -> Vec<Finding> {
vec![
finding(
@@ -159,7 +173,7 @@ fn findings() -> Vec<Finding> {
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,
+16 -16
View File
@@ -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,
}
}
+56 -30
View File
@@ -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<String>,
/// 有没有正在跑的会话(`live_session` 非空)。
///
/// 为什么需要它:session 索引只在会话**结束**时追加(`host::logs::sessions`),所以运行中的
/// 那一场根本不在 `log_sessions` 里 ⇒ 拿不到实例名与结局。r3 之前这里直接退回
/// 「当前没有运行中的实例」,于是**每次实时运行页头都在说反话**(视觉复审 r3 的 P2-3)。
pub live: bool,
/// 日志行(顺序 = 文件顺序)。
pub lines: Vec<Line>,
/// 诊断结论(顺序 = 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
)
})
+27 -23
View File
@@ -68,35 +68,32 @@ fn findings_are_grouped_critical_first_and_empty_groups_do_not_exist() {
findings,
..Logs::default()
};
let counts: Vec<usize> = GROUPS
.iter()
.map(|severity| logs.group(*severity).len())
.collect();
let counts: Vec<usize> = 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::<usize>(),
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,
+1 -1
View File
@@ -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};
+4 -3
View File
@@ -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", "一行摘要"),