Files
Unlucky-Launcher/crates/app-iced/src/app/view/logs.rs
T
AskaEth 62d4af150e fix(app-iced): M95 r3 跨会话串场 —— 诊断结论只在归属会话就是当前会话时才画
代码复审(绑 559ba20)抓的 p2:r2 的 `logs_model` 先按 `state.diagnosis` 定「这是哪一场」,
而日志行来自当前那一场;`state.diagnosis` 全仓**只写不清**(`apply_core` 里
`CoreEvent::DiagnosisReady` 是唯一赋值点,没有清零点),host 也只在崩溃 / 打开会话 / 导入时
发它。于是「崩溃一场 → 再启动一场正常跑」时:页头把新会话标成上一场的 `The game crashed`、
诊断卡挂着上一场的结论,而用户复制去贴 issue 的摘要里 `lines=` 是**新的**那一场的行数 ——
结论与行数不同源,用户会把错诊断递出去。

修法(只在页面侧判别,不动 presenter 的写入口):

- **身份**只认 `live_session.or(selected_session)`(正在跑 → 打开/刚结束的那一场),
  **不从 `diagnosis` 反推**;
- 诊断结论只在 `diagnosis.session == 身份` 时画;身份为空时才信任报告(那时日志行只可能来自
  `open_session`,它与报告是同一次调用发的);
- 实例/时间/结局的描述同样是身份优先,身份为空才退到报告的 session。

测试(`app/view/logs.rs` 内,走**真实数据路径**):
`crashed_only()` 用 `apply_core` 依次灌 `LogSessionsChanged` → 第一场 `LogAppended(running)`
→ `DiagnosisReady` → `LaunchFinished(Crashed)`,`after_a_new_run()` 再灌第二场 `LogAppended(running)`;
`state.diagnosis` 全程不清(这就是线上串场的条件)。

- `a_new_run_does_not_inherit_the_previous_crash`:断言 outcome=Ok、页头不含 `The game crashed`、
  findings 为空、摘要 `outcome=ok` 且不含旧规则且 `lines=` 与当前行数一致。
- `the_crash_itself_is_still_reported`(对照,防「永远不画诊断」骗过上面那条):崩溃当场必须
  findings=1 且摘要含 `gpu.driver_old:critical(92)`。
- `the_hosts_no_rule_matched_placeholder_is_not_a_finding`:host 的 `rule: ""` 占位报告不画;
  换成真规则就必须画(证明过滤不是把诊断一起关了)。

变异实测:把 `report` 的过滤回退成 r2 的 `state.diagnosis.as_ref()` → 上面第一条**红**并点名
`Finding { rule: "gpu.driver_old", … }`(0 passed / 1 failed);放回 → **277 passed / 0 failed**。
2026-11-02 22:27:00 +08:00

285 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 日志页的展示转换: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,
"换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)"
);
}
}