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:
1 parent
c0396544c5
commit
063a1dba39
16 files changed
+1392
-157
No files matched your search
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user