代码复审 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。
145 lines
7.9 KiB
Rust
145 lines
7.9 KiB
Rust
//! 日志页的展示转换: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;
|