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**。
This commit is contained in:
1 parent
ee4d82e3d4
commit
62d4af150e
1 file changed
+227
-23
@@ -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,
|
||||
"换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user