Files
Unlucky-Launcher/crates/app-iced/src/pages/logs/tests.rs
T
AskaEth 4b26a0a9dc test(app-iced): M102 r5 行距护栏追绑定(M4 假绿做死)+ 等价重构不再假红(M3/M6)
r4 复审的结论是「产品代码没问题,两条 p1 都在护栏的『抓得住』与实测不符」:

- **M4(真正的假绿,做死)**:`rows = rows.spacing(ui.space(Space::X1));` 只要另起一条语句
  就能绕过「只锁构建链」的旧护栏。现在 `list_column_spaces_rows_only_from_the_shared_constant`
  用 `function_region` 取整段函数,再用 `source::call_arguments` 拿每一处 `rows.spacing(…)`
  的实参,并用新增的 `source::derived_from` 顺 `let` / `Some(x) =` 绑定往回追(最多三跳):
  实参必须追回 `ROW_SPACING`。于是
  · 直接 `ui.space(spacing)`(来自 `if let Some(spacing) = ROW_SPACING`)→ 绿;
  · `let spacing = ROW_SPACING;` 再中转(H3)→ 绿;
  · 先把令牌挪进 `let extra = Space::X1;` 再 `rows.spacing(ui.space(extra))`(M4)→ 红。
- **M3(假红)**:日志接线护栏改用 `function_region`,于是 `wrap::breakable` 外面套一层同义
  包装(`fn soft(…) { mono_owned(ui, wrap::breakable(…)) }`)不再假红;整条链路都没碰
  `wrap::breakable` 照样红。
- **M6(假红)**:新增 `source::region_across`(多文件转调跟踪,`frame.rs` + `layout/block.rs`),
  把盒子原样搬进 `block::body_box` 再转调不再假红;`align_x` 改判**实参**(`Alignment::Start`
  与 `iced::Alignment::Start` 同等放行),于是搬进别的模块后路径写全也不假红。
- **M1/M2 按塔的裁定不加断言**:与 `frame::body` 逐像素等价的另写就是等价重构;docstring 写明
  「抓得住:语义变化;抓不住:逐像素等价的另写」。

最终变异矩阵(本轮自跑、每条跑完 `git checkout --` 回滚、白名单已核):
基线 429 passed / 0 failed;M4 红(只红 list_column_spaces_rows_only_from_the_shared_constant);
M3 绿;M6 绿;G 红(只红 single_column_pages_call_the_body_box_not_the_centred_one);
I 红(只红 the_page_wires_the_soft_breaks_into_both_rendering_paths);
旧 p1 复跑 A 红、D 红,等价重构 J 绿。
2026-11-04 19:53:45 +08:00

371 lines
15 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.
//! 日志页的守门测试。每条断言下面写清「抓得住什么 / 抓不住什么」(这个仓的硬要求)。
//!
//! 这里**没有截图断言**:像素级验收靠 `pages_demo --screen=logs` 的六张图(浅/深 × 空态/有日志/崩溃),
//! 单测只钉住「图上看不出来的那部分」——数值令牌、分组顺序、摘要格式、悬挂缩进的拆分规则、
//! 页头会话身份的拼法。
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::{FontFamily, Platform};
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
use crate::theme::Families;
use super::super::layout::{frame_metrics, measure_cap_px, source, wrap, PaneKit};
use super::model::{self, GROUPS, SUMMARY_HEADER};
use super::{demo, lines, *};
fn ui() -> Ui {
Ui::with_platform(
SchemeFlavor::Dark,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn page_is_single_column_and_never_wider_than_the_measure_cap() {
for width in [700.0, 1180.0, 1600.0] {
let metrics = frame_metrics(PaneKit::Single, width);
// 抓得住:谁把日志页改成两列(`secondary` 出现就会把正文列挤窄);
// 谁绕开 `layout` 自己算列宽(`column` 会超 424)。
// 抓不住:列内文本是否真在这 424 里折行(那要量字形,单测量不到)。
assert!(metrics.secondary.is_none(), "w={width}");
assert!(
metrics.column <= measure_cap_px() + f32::EPSILON,
"w={width} 正文列 {} > 上限 {}",
metrics.column,
measure_cap_px()
);
}
}
/// **M102 ③**:`wrap::breakable` 这张表本身是好的,且**只改显示**(原文能还原)。
///
/// **抓得住**:断点表被掏空(`BREAK_AFTER` 少了一档 ⇒ 长标识符又没有断点)、
/// `breakable` 开始污染原文(`strip` 还不了原);
/// **抓不住**:页面到底有没有把 `breakable` 接在渲染路径上——那条由
/// [`the_page_wires_the_soft_breaks_into_both_rendering_paths`] 扫调用点守
/// (r2 复审的变异 I:两处接线去掉后这条仍全绿,所以 docstring 不能替它吹牛)。
#[test]
fn log_text_carries_soft_breaks_but_keeps_the_original() {
let logs = demo::crash();
let summary = wrap::breakable(&logs.summary());
assert!(
!summary.contains("gl_operation_failure"),
"摘要里没有软断点,长标识符会被按字形劈开:{summary}"
);
assert_eq!(
wrap::strip(&summary),
logs.summary(),
"剪贴板拿到的仍是原文"
);
// 每条日志行的正文(拆前缀后的那一半)都要过同一件事:至少最长的那几行里有断点。
let broken = logs
.lines
.iter()
.filter(|line| {
!wrap::breakable(lines::split_prefix(&line.text).1).contains(wrap::SOFT_BREAK)
})
.count();
assert!(
broken * 2 < logs.lines.len(),
"{} / {} 行日志没有任何软断点(断点表可能被掏空了)",
broken,
logs.lines.len()
);
}
/// **源码结构(调用点)**:日志页必须把 `wrap::breakable` 接在**两条渲染路径**上。
///
/// r2 复审的变异 I:把 `lines::entry()` 的 `mono_owned(ui, wrap::breakable(body), …)` 改回
/// `mono(ui, body, …)`、把 `diagnostic::summary_block()` 的 `wrap::breakable(&logs.summary())`
/// 改回原文 ⇒ 420 条全绿,而视觉复审 ③(`graphics.gl_o` / `peration_failure`)原样回来。
/// 断点表那侧已经 [`log_text_carries_soft_breaks_but_keeps_the_original`] 守着了,
/// 这里守的是**接线**:页面真把显示串过了一遍它。
///
/// **抓得住**:任一处接线被摘掉(日志行 / 一行摘要);
/// **抓不住**:接线写得对但断点表被掏空(那是上一条的事)。
///
/// 用 [`source::function_region`] 而不是 `function_body`(r4 复审的变异 M3):把
/// `wrap::breakable` 外面套一层同义包装(`fn soft(…) { wrap::breakable(…) }`)再调用,是
/// **等价重构**,不该假红;而「整条链路都没碰 `wrap::breakable`」照样红。
#[test]
fn the_page_wires_the_soft_breaks_into_both_rendering_paths() {
let entry = source::function_region(include_str!("lines.rs"), "fn entry");
assert!(
entry.contains("wrap::breakable("),
"日志行没接软断点(`graphics.gl_operation_failure` 会被按字形劈开):\n{entry}"
);
let summary = source::function_region(include_str!("diagnostic.rs"), "fn summary_block");
assert!(
summary.contains("wrap::breakable("),
"一行摘要没接软断点:\n{summary}"
);
}
#[test]
fn mono_lines_use_the_mono_stack_with_the_body_small_metrics() {
let ui = ui();
let style = lines::mono_style(&ui);
// 抓得住:把等宽行换成正文栈(`family` 会变 `Text`)——那是「等宽」失真的唯一入口;
// 顺手改字号/行高(数值表要写 12/16,漂了就红)。
// 抓不住:CJK 字形到底有没有被回退到别的族(要查字体 cmap,本 crate 不做)。
assert_eq!(style.family, FontFamily::Mono);
assert_eq!(style.size_px, 12.0);
assert_eq!(style.line_height_px, 16.0);
assert_eq!(
ui.font_of(style).family,
iced::font::Family::Name("Consolas")
);
}
#[test]
fn findings_are_grouped_critical_first_and_empty_groups_do_not_exist() {
let findings = demo::crash().findings;
let logs = Logs {
findings,
..Logs::default()
};
let counts: Vec<usize> = GROUPS.iter().map(|band| logs.group(*band).len()).collect();
// 抓得住:分组顺序被改成低置信度在前(最像主因的沉底);空组也被画出一个标题。
// 抓不住:组内顺序(页面刻意不重排 presenter 给的顺序)。
assert_eq!(GROUPS[0], Confidence::High);
assert_eq!(
counts.iter().sum::<usize>(),
logs.findings.len(),
"每条结论必须落进恰好一个分组"
);
assert!(counts[0] >= 1, "样例至少有高置信度结论");
assert!(logs.group(Confidence::Low).is_empty());
}
#[test]
fn summary_is_a_single_deterministic_line_carrying_rule_and_confidence() {
let logs = demo::crash();
let summary = logs.summary();
// 抓得住:摘要里丢了规则 id / 置信度档 / 置信度百分比(换 issue 时最关键的三个字段);
// 有人把换行塞进摘要("一行"就不成立了)。
// 抓不住:粘贴到 issue 后的可读性(那是人判断的),也不守文案语言(摘要刻意不含界面文案)。
assert!(!summary.contains('\n'), "{summary}");
assert!(summary.starts_with(SUMMARY_HEADER), "{summary}");
assert!(
summary.contains("integrity.unsatisfied_link:high(85)"),
"{summary}"
);
assert!(summary.contains("lines="), "{summary}");
assert_eq!(summary, logs.summary(), "同一份模型两次摘要必须一致");
let empty = Logs::default();
assert_eq!(empty.summary(), "ULL-LOGS v1 | lines=0");
}
#[test]
fn the_band_is_the_documented_confidence_band_not_a_severity_impostor() {
// 抓得住:阈值边界写成 `>` / `>=` 的 off-by-one(80 / 50 是档位的定义点);
// 以及有人把档位名字改回 `critical/warning/notice` 那种**严重度词汇**
// (那会让 64 条规则里 12 条进错组,两个方向都有——视觉复审 r3 的 P2-2)。
// 抓不住:档位划分本身对不对(那是产品口径;DTO 补上 `severity` 后应换回真严重度,已报 finding)。
assert_eq!(Confidence::of(100), Confidence::High);
assert_eq!(Confidence::of(80), Confidence::High);
assert_eq!(Confidence::of(79), Confidence::Medium);
assert_eq!(Confidence::of(50), Confidence::Medium);
assert_eq!(Confidence::of(49), Confidence::Low);
assert_eq!(Confidence::of(0), Confidence::Low);
assert_eq!(Confidence::High.slug(), "high");
assert_eq!(Confidence::Medium.slug(), "medium");
assert_eq!(Confidence::Low.slug(), "low");
}
#[test]
fn the_longest_stack_line_reaches_the_view_model_untruncated() {
let logs = demo::crash();
let longest = logs
.lines
.iter()
.map(|line| line.text.chars().count())
.max()
.unwrap_or(0);
// 抓得住:为了排版把行截断 / 加省略号(用户就再也拿不回原始堆栈)。
// 抓不住:折行后的视觉行数(渲染期的事)。
assert!(
longest >= 200,
"样例里应有一条 ≥200 字符的堆栈行,实际 {longest}"
);
assert!(!logs.lines.iter().any(|line| line.text.ends_with('…')));
}
#[test]
fn a_line_is_split_into_prefix_and_body_for_the_hanging_indent() {
// 抓得住:悬挂缩进被改回「整行一个文本块」(`at` 会重新独占一行、续行回到行首)。
// 抓不住:缩进到底落在第几像素(那要量字形/截图,见报告的前后对比图)。
assert_eq!(
lines::split_prefix("[19:40:12] [main/INFO] hi"),
("[19:40:12] ", "[main/INFO] hi")
);
assert_eq!(
lines::split_prefix("at com.mojang.blaze3d.platform.GLX._initGlfw(GLX.java:64)"),
(
"at ",
"com.mojang.blaze3d.platform.GLX._initGlfw(GLX.java:64)"
)
);
// 没有空格 / 第一个「词」过长 ⇒ 不拆(否则正文被挤成一条 1 字符宽的缝)。
assert_eq!(
lines::split_prefix("java.lang.RuntimeException"),
("", "java.lang.RuntimeException")
);
assert_eq!(
lines::split_prefix("https://example.invalid/aaaaaaaaaaaaaaaaaaaaaaaa va"),
("", "https://example.invalid/aaaaaaaaaaaaaaaaaaaaaaaa va")
);
// CJK 行:第一个空格若落在 20 字符以内就正常拆(时间戳 + 中文正文)。
assert_eq!(
lines::split_prefix("[19:40:12] 正在启动 1.20.4"),
("[19:40:12] ", "正在启动 1.20.4")
);
}
#[test]
fn status_line_names_the_session_with_the_launch_outcome_keys() {
// 抓得住:页头副标题不再说「这是哪个实例、哪一场、什么结局」(视觉复审 P2-2),
// 以及 `LaunchOutcome*` 三 key 又回到「定义了但零调用点」。
// 抓不住:副标题在 1180 下会不会撞上右侧动作区(那要看截图)。
let en = model::status_line(
Some("开发测试"),
Some("11-02 19:40"),
Some(Outcome::Crashed),
Lang::En,
)
.expect("有实例名与结局就该有副标题");
assert_eq!(en, "开发测试 · 11-02 19:40 · The game crashed");
let zh = model::status_line(
Some("开发测试"),
Some("11-02 19:40"),
Some(Outcome::SilentFail),
Lang::ZhCn,
)
.expect("静默失败也有副标题");
assert!(zh.contains("疑似静默失败"), "{zh}");
// 不知道是哪一场就别编:没有实例名 / 没有结局时退回静态副标题。
assert!(model::status_line(None, Some("11-02 19:40"), Some(Outcome::Ok), Lang::En).is_none());
assert!(model::status_line(Some("x"), None, None, Lang::En).is_none());
// demo 的三种状态里,有会话身份的那两种必须带副标题(页面才有东西可显示)。
assert!(demo::State::Crash.build(Lang::En).status.is_some());
assert!(demo::State::Live.build(Lang::En).status.is_some());
}
#[test]
fn empty_state_is_driven_by_both_lists_being_empty() {
let mut logs = Logs::default();
assert!(logs.is_empty());
logs.findings = demo::crash().findings;
// 抓得住:把「没有日志行」当成空态(有诊断时那样会藏掉最该看的东西)。
assert!(!logs.is_empty(), "只有诊断也不算空态");
let live = demo::State::Live.build(Lang::En);
assert!(!live.is_empty());
assert!(live.findings.is_empty(), "live 状态没有诊断");
}
#[test]
fn the_fixture_parser_survives_a_non_ascii_first_char() {
// 抓得住:`parse` 用 `split_at(1)` 切多字节序列会直接 panic(视觉复审 r2 的 p3 实测过)。
// 抓不住:样例文件本身写坏(比如等级前缀写成小写 `e`)——那会被静默跳过,只体现为行数变少。
assert!(!demo::parse("日本語の行")
.iter()
.any(|line| line.level == Level::Info));
let parsed = demo::parse(
"I 正常一行
W 警告一行
# 注释
E 错误一行",
);
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[0].text, "正常一行");
assert_eq!(parsed[1].level, Level::Warn);
assert_eq!(parsed[2].level, Level::Error);
}
#[test]
fn demo_state_comes_from_the_fixture_variable() {
// 抓得住:环境变量取值拼错(截图会静默退成 crash,出图的人以为拍到了空态)。
assert_eq!(demo::State::of_env_value(Some("empty")), demo::State::Empty);
assert_eq!(demo::State::of_env_value(Some("live")), demo::State::Live);
assert_eq!(demo::State::of_env_value(Some("crash")), demo::State::Crash);
assert_eq!(demo::State::of_env_value(None), demo::State::Crash);
}
#[test]
fn every_key_the_page_uses_is_translated_in_both_languages() {
let keys = [
Key::NavLogs,
Key::LogsSubtitleIdle,
Key::LogsStatus,
Key::LogsOpenFolder,
Key::LogsClear,
Key::LogsOutputTitle,
Key::LogsDiagnosisTitle,
Key::LogsConfidenceHigh,
Key::LogsConfidenceMedium,
Key::LogsConfidenceLow,
Key::LogsSubtitleLive,
Key::LogsConfidence,
Key::LogsSuggestedFix,
Key::LogsSummaryLabel,
Key::LogsCopySummary,
Key::LogsEmptyTitle,
Key::LogsEmptyBody,
Key::LogsEmptyAction,
// 结局文案由页头副标题间接用到(见 `status_line`)
Key::LaunchOutcomeNormal,
Key::LaunchOutcomeCrash,
Key::LaunchOutcomeSilent,
];
// 抓得住:页面用到的 key 被改名/删掉,或只进了其中一套目录(缺译会返回 `⟪missing⟫`)。
// 抓不住:译文的语气与长度(人读的事)。
for lang in [Lang::En, Lang::ZhCn] {
let catalog = Catalog::of(lang);
for key in keys {
assert!(
catalog.get(key).is_some(),
"{} 缺 {}",
lang.setting_value(),
key.name()
);
}
}
// 占位符名写错时 `render` 会把 `{percent}` 原样留在文案里——这条钉住它。
let rendered = Catalog::of(Lang::En).render(Key::LogsConfidence, &[("percent", "85")]);
assert_eq!(rendered, "Confidence 85%");
}
#[test]
fn header_stays_two_lines_because_of_the_status_subtitle() {
let ui = ui();
// 抓得住:有人把副标题去掉,页头从 84 掉回 56(版式台账上会露出来)。
assert_eq!(ui.metrics().header_height(true), 84.0);
assert!(ui.metrics().header_height(true) > ui.metrics().header_height(false));
}