//! 日志页的展示转换: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` 反推** | //! | 诊断结论 | 只有 `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; /// 日志页视图模型:presenter 快照 → 要画的东西。 /// /// 两处 DTO 侧的**信息丢失**(已报 finding):`dto::Diagnosis` 没有 core 的 `severity` /// (严重度只能按置信度折算,阈值是债)与 `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 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(|session| session.instance.clone()), when: session.map(|session| session.when.clone()), outcome: session.map(|session| log_outcome(session.outcome)), 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, } } /// 一条诊断结论 → 页面结论(严重度折算见 [`logs_model`] 的文档)。 fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding { pages::Finding { rule: diagnosis.rule.clone(), severity: pages::Severity::of_confidence(diagnosis.confidence), title: diagnosis.title.clone(), hint: diagnosis.hint.clone(), confidence: diagnosis.confidence, action: diagnosis.fix.clone(), } } #[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, "换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)" ); } }