feat(app-iced): M95 日志页接真组件 + 诊断面板(等宽折行 / 按严重度分组 / 一行摘要)

日志页从 M84 占位换成真组件,数据全部来自 presenter 快照(页面不读盘):

- 日志行列表:`pages/logs/lines.rs`,等宽(`BodySmall` 12/16 + mono 族)+ 按词或字形
  折行 + 等级色条;**选折行不选横滚**的理由写在模块文档(424px × 200 字符的现实 +
  mono 族的 CJK 回退会毁掉横滚唯一的收益「列对齐」)。
- 诊断面板:`pages/logs/diagnostic.rs`,按严重度分组(CRITICAL → WARNING → NOTICE),
  每条给规则 id / 结论 / 说明 / 建议动作 / 置信度;卡片头钉住「Copy summary」,
  摘要是稳定单行的 `ULL-LOGS v1 | …`(崩溃日志换 issue 时直接粘)。
- 空态:真组件 `feedback::EmptyState`(图标块 + 标题 + 说明 + CTA 回实例页)。
- 两块都在 `pages/logs/panel.rs` 里**定高 + 内部独立滚动**(`FillPortion(3):(2)`):
  第一版让诊断卡按内容撑开,三条结论就把日志卡压成 0 高度(实拍复现后改的)。
- 视图模型:`pages/logs/model.rs`(纯展示字段)+ `app/view.rs::logs_model` 做快照转换。
- 接线(只追加):`pages/mod.rs` 的 `pub use logs::…` / `page()` 那行 /
  `Message::CopySummary` / `demo_model` 一行;`pages::Model` 加 `logs` 与 `lang` 两个字段;
  `app/update.rs` 把 `CopySummary` 落到 `iced::clipboard::write`、`ClearLogs` 清 presenter 的
  `log_lines`(呈现层操作,不造命令)。
- i18n:`crates/i18n/src/catalog.rs` 自己锚点下 16 个 key(中英同表);按塔裁决把
  `#[cfg(test)] mod tests` 逐字外置到 `crates/i18n/src/catalog/tests.rs`
  (catalog.rs 415 → 257 行,tests.rs 164 行;7 个用例逐字未动、i18n 23 个用例全绿)。

验证:`fmt --check` / `clippy -p unlucky-app-iced --all-targets -- -D warnings` 干净;
`test -p unlucky-app-iced` 272 + 6 全绿、`-p unlucky-i18n` 23 全绿;截图
`.screens/logs-{empty,crash,live}-{light,dark}.png`(1180×760,本地 gitignore)。

遗留(已报 finding):`dto::Diagnosis` 缺 core 的 `severity` 与规则表 `cause.key`;
`Action::OpenLogs` 没有对应 `AppCommand`(点了只有占位日志)。
This commit is contained in:
AskaEth committed 2026-11-02 22:26:53 +08:00
1 parent c0396544c5
commit 063a1dba39
16 files changed
+1392 -157

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
//! 日志页的守门测试。每条断言下面写清「抓得住什么 / 抓不住什么」(这个仓的硬要求)。
//!
//! 这里**没有截图断言**:像素级验收靠 `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, PaneKit};
use super::model::{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()
);
}
}
#[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(|severity| logs.group(*severity).len())
.collect();
// 抓得住:分组顺序被改成 info 在前(主因会沉底);空组也被画出一个标题(截图上出现 0 条的分区)。
// 抓不住:组内顺序(页面刻意不重排 presenter 给的顺序)。
assert_eq!(GROUPS[0], Severity::Critical);
assert_eq!(
counts.iter().sum::<usize>(),
logs.findings.len(),
"每条结论必须落进恰好一个分组"
);
assert!(counts[0] >= 1, "样例至少有主因");
assert!(logs.group(Severity::Info).is_empty());
}
#[test]
fn summary_is_a_single_deterministic_line_carrying_rule_and_severity() {
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:critical(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 severity_is_derived_from_confidence_with_the_documented_thresholds() {
// 抓得住:阈值边界写成 `>` / `>=` 那种 off-by-one(80 与 50 是文档里写死的两个点)。
// 抓不住:折算本身对不对——DTO 补上 `severity` 之前这只是**过渡口径**(已报 finding)。
assert_eq!(Severity::of_confidence(100), Severity::Critical);
assert_eq!(Severity::of_confidence(80), Severity::Critical);
assert_eq!(Severity::of_confidence(79), Severity::Warning);
assert_eq!(Severity::of_confidence(50), Severity::Warning);
assert_eq!(Severity::of_confidence(49), Severity::Info);
assert_eq!(Severity::of_confidence(0), Severity::Info);
}
#[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 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();
assert!(!live.is_empty());
assert!(live.findings.is_empty(), "live 状态没有诊断");
}
#[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::LogsSubtitleActive,
Key::LogsOpenFolder,
Key::LogsClear,
Key::LogsOutputTitle,
Key::LogsDiagnosisTitle,
Key::LogsSeverityCritical,
Key::LogsSeverityWarning,
Key::LogsSeverityInfo,
Key::LogsConfidence,
Key::LogsSuggestedFix,
Key::LogsSummaryLabel,
Key::LogsCopySummary,
Key::LogsEmptyTitle,
Key::LogsEmptyBody,
Key::LogsEmptyAction,
];
// 抓得住:页面用到的 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));
}