fix(app-iced): M95 r1 五条视觉复审(悬挂缩进 / 会话身份 / 摘要可见 / 贴底 / 描边)

- P2-1 **悬挂缩进真做了**(之前文档在骗人,代码里只有 `row![bar, text]`):
  每行拆「前缀 + 正文」两个文本控件(前缀 = 第一个空格及其之前,含空格;
  `split_prefix` 在第一个「词」>20 字符时不拆),正文带 Fill 宽度自己折行 ⇒
  续行落在前缀宽度之后。于是 `at com.mojang…(GLX.java:64)` 不再拆成「`at` 独占一行」。
  文档同步写清代价:续行缩进量 = 该行前缀宽度。
- P2-2 会话身份上页头:`Logs::with_status(lang)` 用 `LogsStatus` 模板拼
  `{instance} · {when} · {outcome}`,结局文案走 `LaunchOutcomeNormal/Crash/Silent`
  三 key(bridge 文档里那条「LogOutcome 三态 → 三 key」的结构化出口,此前 UI 侧零调用点)。
  中文模板差在全角竖线(守卫要求 zh≠en,也确实更符合中文排版)。
- P2-3 摘要钉成诊断卡**固定页脚**(不进滚动区):「One-line summary」+ Copy summary
  紧邻它要复制的那段等宽文本;卡片头不再放复制按钮(避免两处入口)。
- P2-4 日志卡改 `panel::scroll_tail`(`Anchor::End`):偏移按「距底多远」记,内容增长时
  跟随尾部;「用户上滚后是否冻结」的边界写在 `panel::scroll_tail` 文档里(结论:不冻结,
  冻结要 app 层回灌滚动状态,已登记)。
- P2-5 两张主卡补 1px 描边:`panel::card` 改用 `Component::OutlinedCard.spec().shape` +
  `ui.outlined(..)`,与 `frame::card` 同一套令牌。

测试:新增 `a_line_is_split_into_prefix_and_body_for_the_hanging_indent`(含 CJK 行与
「不拆」的两种边界)与 `status_line_names_the_session_with_the_launch_outcome_keys`;
key 清单补 `LogsStatus` 与三个 `LaunchOutcome*`;demo 的 state.build 改为收 `Lang`。

验证:`fmt --check` 通过;`clippy -p unlucky-app-iced --all-targets -D warnings` 干净;
`test -p unlucky-app-iced` 274+6 全绿、`-p unlucky-i18n` 23 全绿。
图:`.screens/logs-{empty,live,crash}-{light,dark}.png`(1180×760)、
`r2-live-tall-light.png`(1180×1080,栈帧可见,P2-1 证据)。
This commit is contained in:
AskaEth committed 2026-11-02 22:26:59 +08:00
1 parent 063a1dba39
commit 1bccf89e5c
10 files changed
+293 -93

No files matched your search

+74 -5
View File
@@ -1,7 +1,8 @@
//! 日志页的守门测试。每条断言下面写清「抓得住什么 / 抓不住什么」(这个仓的硬要求)。
//!
//! 这里**没有截图断言**:像素级验收靠 `pages_demo --screen=logs` 的四张图(浅/深 × 空态/崩溃),
//! 单测只钉住「图上看不出来的那部分」——数值令牌、分组顺序、摘要格式、长行不被截断。
//! 这里**没有截图断言**:像素级验收靠 `pages_demo --screen=logs` 的六张图(浅/深 × 空态/有日志/崩溃),
//! 单测只钉住「图上看不出来的那部分」——数值令牌、分组顺序、摘要格式、悬挂缩进的拆分规则、
//! 页头会话身份的拼法。
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::{FontFamily, Platform};
@@ -11,7 +12,7 @@ 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::model::{self, GROUPS, SUMMARY_HEADER};
use super::{demo, lines, *};
fn ui() -> Ui {
@@ -136,6 +137,70 @@ fn the_longest_stack_line_reaches_the_view_model_untruncated() {
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();
@@ -145,7 +210,7 @@ fn empty_state_is_driven_by_both_lists_being_empty() {
// 抓得住:把「没有日志行」当成空态(有诊断时那样会藏掉最该看的东西)。
assert!(!logs.is_empty(), "只有诊断也不算空态");
let live = demo::State::Live.build();
let live = demo::State::Live.build(Lang::En);
assert!(!live.is_empty());
assert!(live.findings.is_empty(), "live 状态没有诊断");
}
@@ -164,7 +229,7 @@ fn every_key_the_page_uses_is_translated_in_both_languages() {
let keys = [
Key::NavLogs,
Key::LogsSubtitleIdle,
Key::LogsSubtitleActive,
Key::LogsStatus,
Key::LogsOpenFolder,
Key::LogsClear,
Key::LogsOutputTitle,
@@ -179,6 +244,10 @@ fn every_key_the_page_uses_is_translated_in_both_languages() {
Key::LogsEmptyTitle,
Key::LogsEmptyBody,
Key::LogsEmptyAction,
// 结局文案由页头副标题间接用到(见 `status_line`)
Key::LaunchOutcomeNormal,
Key::LaunchOutcomeCrash,
Key::LaunchOutcomeSilent,
];
// 抓得住:页面用到的 key 被改名/删掉,或只进了其中一套目录(缺译会返回 `⟪missing⟫`)。