diff --git a/crates/app-iced/src/app/view/logs.rs b/crates/app-iced/src/app/view/logs.rs index 50fc260..7c13e1b 100644 --- a/crates/app-iced/src/app/view/logs.rs +++ b/crates/app-iced/src/app/view/logs.rs @@ -1,37 +1,64 @@ -//! 日志页的展示转换:presenter 快照 → [`pages::Logs`]。 +//! 日志页的展示转换:presenter 快照 → [`pages::Logs`](从 `app/view.rs` 拆出来,M95 r3)。 //! -//! 从 `app/view.rs` 拆出来(M95 r3):那个文件已经 392 行,贴着全仓 400 行的硬上限, -//! 而这一页的转换还要再加判别与测试。**本次搬运逐行等价**,行为零变化; -//! 跨会话串场的修复在下一个 commit(`state.diagnosis` 只写不清那条)。 +//! ## 跨会话串场(M95 r3 的 p2,代码复审抓的) +//! +//! r2 的写法先按 `state.diagnosis` 定「这是哪一场」: +//! +//! ```ignore +//! let session = report.and_then(|r| log_sessions.find(|s| s.id == r.session)).or_else(|| …selected…); +//! ``` +//! +//! 而 `state.diagnosis` **全仓只写不清**(`presenter::state::apply_core` 里 +//! `CoreEvent::DiagnosisReady` 是唯一赋值点,没有任何清零点),host 也只在崩溃 / 打开会话 / +//! 导入崩溃文件时发它。于是「崩溃一场 → 再启动一场正常跑」这个最普通的路径下: +//! 页头把**新**会话标成上一场的 `The game crashed`、诊断卡挂着上一场的结论,而用户复制去贴 +//! issue 的摘要里 `lines=` 是新的那一场的行数——**结论与行数不同源**,用户会把错诊断递出去。 +//! +//! 现在的口径(不动 presenter 的写入口,只在页面侧判别): +//! +//! | 概念 | 取值 | +//! |---|---| +//! | **身份**(这一场是谁) | `live_session`(正在跑)→ `selected_session`(打开/刚结束的),**不从 `diagnosis` 反推** | +//! | 诊断结论 | 只有 `diagnosis.session == 身份` 才画;身份为空时才信任报告(那时日志行只可能来自 `open_session`,它和报告是同一次调用发的) | +//! | 实例/时间/结局(给页头描述) | 身份优先;身份为空才退到报告的 session | +//! +//! 「没有命中规则」的**占位报告**也不画:host 的 `open_session` 在规则引擎无结论时会发一份 +//! `rule: ""` / `confidence: 0` / `title: "该会话没有命中已知规则"` 的假报告(`CoreEvent::DiagnosisReady` +//! 是非 Option,host 无法表达「没有结论」)——它是标记不是结论,画出来等于给用户看一条「严重度提示」。 +//! +//! 已知缺口(已报 finding,不在本 mission 修):`clear_logs` 之后 `selected_session` 不清, +//! 身份会继续指向那一场;导入崩溃文件(`session: "file:…"`)没有对应的日志行载入,所以那份 +//! 报告在本页永远不会显示(r2 会把它挂到不相干的日志行上——两害相权取不显示)。 use unlucky_presenter::dto::{Diagnosis, LogLevel, LogOutcome}; use unlucky_presenter::state::AppState; use crate::pages; -/// 日志页视图模型(M95):presenter 快照 → 日志行 + 诊断结论。 +/// 日志页视图模型:presenter 快照 → 要画的东西。 /// -/// **页面不读盘**:日志文本只从 `log_lines`(host 尾随 JSONL 后按批次发的事件)来;会话元信息 -/// 从 `log_sessions` 里按「运行中的那一场 → 诊断归属的那一场」找,找不到就不画。 -/// -/// 两处 DTO 侧的**信息丢失**(已报 finding,不在本 scope 修):`dto::Diagnosis` 没有 core 的 -/// `severity`(严重度只能按置信度折算,阈值是债)与 `cause.key`(`title`/`hint` 只能当数据画)。 +/// 两处 DTO 侧的**信息丢失**(已报 finding):`dto::Diagnosis` 没有 core 的 `severity` +/// (严重度只能按置信度折算,阈值是债)与 `cause.key`(`title`/`hint` 只能当数据画)。 pub(super) fn logs_model(state: &AppState) -> pages::Logs { - let report = state.diagnosis.as_ref(); - let current = state.selected_session.as_deref(); - let session = report - .and_then(|report| state.log_sessions.iter().find(|s| s.id == report.session)) - .or_else(|| { - state - .log_sessions - .iter() - .find(|s| Some(s.id.as_str()) == current) - }); + let identity = state + .live_session + .as_deref() + .or(state.selected_session.as_deref()); + + let report = state.diagnosis.as_ref().filter(|report| match identity { + Some(id) => report.session == id, + None => true, + }); + + // 身份为空时,描述的来源只能退到报告(见模块文档的表格)。 + let described = identity.or_else(|| report.map(|report| report.session.as_str())); + let session = + described.and_then(|id| state.log_sessions.iter().find(|session| session.id == id)); pages::Logs { - instance: session.map(|s| s.instance.clone()), - when: session.map(|s| s.when.clone()), - outcome: session.map(|s| log_outcome(s.outcome)), + instance: session.map(|session| session.instance.clone()), + when: session.map(|session| session.when.clone()), + outcome: session.map(|session| log_outcome(session.outcome)), lines: state .log_lines .iter() @@ -41,6 +68,7 @@ pub(super) fn logs_model(state: &AppState) -> pages::Logs { }) .collect(), findings: report + .filter(|report| !report.diagnosis.rule.is_empty()) .map(|report| diagnosis_of(&report.diagnosis)) .into_iter() .collect(), @@ -78,3 +106,179 @@ fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding { action: diagnosis.fix.clone(), } } + +#[cfg(test)] +mod tests { + use unlucky_i18n::Lang; + use unlucky_presenter::dto::{ + CoreEvent, DiagnosisReport, LaunchFinished, LiveLogBatch, LogLevel, LogLine, LogOutcome, + LogSession, + }; + use unlucky_presenter::state::AppState; + + use crate::pages; + + use super::logs_model; + + /// 崩溃的那一场。 + const CRASH: &str = "s-1029-1840"; + /// 崩溃之后又启动的、正常跑的一场。 + const OK_RUN: &str = "s-1029-1922"; + + fn session(id: &str, when: &str, outcome: LogOutcome) -> LogSession { + LogSession { + id: id.to_string(), + instance: "开发测试".to_string(), + when: when.to_string(), + outcome, + duration_secs: 62, + } + } + + fn line(text: &str, level: LogLevel) -> LogLine { + LogLine { + text: text.to_string(), + level, + } + } + + fn report(session: &str, rule: &str) -> DiagnosisReport { + DiagnosisReport { + session: session.to_string(), + diagnosis: pages_dto(rule), + } + } + + fn pages_dto(rule: &str) -> unlucky_presenter::dto::Diagnosis { + unlucky_presenter::dto::Diagnosis { + rule: rule.to_string(), + title: "显卡驱动过旧(OpenGL 版本不足)".to_string(), + confidence: 92, + hint: "升级显卡驱动".to_string(), + fix: Some("change_graphics.driver".to_string()), + } + } + + /// **真实数据路径**(复审要求:不许造注入源绕过去): + /// 崩溃一场跑完 → 再启动一场正常跑。`state.diagnosis` 在这里**故意不清**, + /// 因为 presenter 里根本没有清零点——这就是线上发生串场的条件。 + fn crashed_only() -> AppState { + let mut state = AppState::new(); + state.apply_core(CoreEvent::LogSessionsChanged(vec![ + session(CRASH, "10-29 18:40", LogOutcome::Crashed), + session(OK_RUN, "10-29 19:22", LogOutcome::Ok), + ])); + + // 第一场跑起来(live_session = CRASH)→ 日志 → 规则结论 → 收尾(selected_session = CRASH) + state.apply_core(CoreEvent::LogAppended(LiveLogBatch { + session: CRASH.to_string(), + lines: vec![line("[18:40] boom: GLFW error", LogLevel::Error)], + running: true, + })); + state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old"))); + state.apply_core(CoreEvent::LaunchFinished(LaunchFinished { + id: "dev".to_string(), + session: CRASH.to_string(), + outcome: LogOutcome::Crashed, + summary: "崩溃(exit 1)".to_string(), + })); + + state + } + + /// 崩溃之后又启动一场正常跑:`live_session` 前进,`diagnosis` 仍是上一场的。 + fn after_a_new_run() -> AppState { + let mut state = crashed_only(); + state.apply_core(CoreEvent::LogAppended(LiveLogBatch { + session: OK_RUN.to_string(), + lines: vec![line("[19:22] launching 1.20.4", LogLevel::Info)], + running: true, + })); + state + } + + #[test] + fn a_new_run_does_not_inherit_the_previous_crash() { + let state = after_a_new_run(); + let logs = logs_model(&state).with_status(Lang::En); + + // 抓得住:身份被上一场的 `diagnosis.session` 顶掉(r2 的 bug)——那时 outcome 会是 + // Crashed、findings 挂着 gpu.driver_old、摘要里 `lines=` 与结论不同源。 + // 抓不住:presenter 自己 `clear_logs` 后 `selected_session` 不清的缺口(已报 finding), + // 以及多场并行会话(本仓一次只跑一场)。 + assert_eq!( + logs.outcome, + Some(pages::Outcome::Ok), + "身份应该是正在跑的这一场" + ); + + let status = logs.status.clone().expect("有会话身份就该有副标题"); + assert!(status.contains("Finished normally"), "{status}"); + assert!( + !status.contains("The game crashed"), + "页头把新会话标成了上一场的崩溃:{status}" + ); + + assert!( + logs.findings.is_empty(), + "上一场的结论不能挂到这一场:{:?}", + logs.findings + ); + + // 摘要必须与身份同源:outcome 是 ok、不含旧规则、行数就是当前这些行。 + let summary = logs.summary(); + assert!(summary.contains("outcome=ok"), "{summary}"); + assert!(!summary.contains("gpu.driver_old"), "{summary}"); + assert!( + summary.contains(&format!("lines={}", logs.lines.len())), + "{summary}" + ); + assert!(!summary.contains("findings="), "{summary}"); + } + + #[test] + fn the_crash_itself_is_still_reported() { + // 上一条测试的**对照**:崩溃当场必须画结论,否则那条断言「findings 为空」是空的。 + // 抓得住:把判别写成「永远不画诊断」(用空断言骗过 P2)。 + // 抓不住:结论排序/去重(那是 core 的规则引擎的事)。 + let state = crashed_only(); + let logs = logs_model(&state).with_status(Lang::En); + + assert_eq!(logs.outcome, Some(pages::Outcome::Crashed)); + assert_eq!(logs.findings.len(), 1, "{:?}", logs.findings); + assert_eq!(logs.findings[0].rule, "gpu.driver_old"); + let status = logs.status.clone().unwrap_or_default(); + assert!(status.contains("The game crashed"), "{status}"); + assert!(logs.summary().contains("gpu.driver_old:critical(92)")); + } + + #[test] + fn the_hosts_no_rule_matched_placeholder_is_not_a_finding() { + // host 的 `open_session` 在规则引擎无结论时发一份 `rule: ""` 的假报告(非 Option 逼的)。 + // 抓得住:把「没命中」画成一条严重度结论(用户会去修一个不存在的毛病)。 + // 抓不住:host 该不该改发 `None`(那是 presenter 的 finding)。 + let mut state = AppState::new(); + state.apply_core(CoreEvent::LogSessionsChanged(vec![session( + CRASH, + "10-29 18:40", + LogOutcome::Crashed, + )])); + state.apply_core(CoreEvent::LogLinesChanged(vec![line( + "[18:40] nothing to see", + LogLevel::Info, + )])); + + state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, ""))); + assert!( + logs_model(&state).findings.is_empty(), + "空 rule 是『没命中』的占位,不是结论" + ); + + state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old"))); + assert_eq!( + logs_model(&state).findings.len(), + 1, + "换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)" + ); + } +}