fix(app-iced): IconButton Outlined 真的画环 + ELEVATED CARD 值串单行(M229 复审 P1)

P1-1:`icon()` 把 `StyleSpec.stroke` 写死 `0.0`,`IconVariant::Outlined` 的描边角色只到
`roles()` 为止、没画出来 —— Outlined 与 Plain 逐像素相同。改成「有描边角色就是 1px」,
并把「角色 → 填充/内容/描边」抽成 `icon_style_spec()`(`icon()` 直接吃它),单测因此能断言
**接线**而不只是断言 `icon_stroke()` 这个纯函数(第一版正是这样漏掉调用点的)。

回归门两条:纯函数/接线(`buttons/tests/icon.rs`,变异回 0.0 时红)+ 渲染层
(`gallery/probe/rows.rs`:图标按钮左缘 4px 带里 Outlined 有环墨、Plain 必须 0 px;
变异后重出图再跑会红)。环在软渲染里是 Outline 叠画布的抗锯齿混色(实测 110/124/110,
本体 123/138/123),所以判据是「这一带有墨/没墨」而不是颜色匹配。

P1-2:ELEVATED CARD 内距 24 + 值 16px 让「2026-10-29 19:22 UTC」折行,同排卡底不齐;
旧测试还断言 20/14(不变量 118+2×padding ≤ 160 变成 166 > 160,假绿)。
按塔裁决「收内距/字号、不破卡片宽度契约」:`PAD` 24 → 20(= card_padding)、值字级 → BodySmall 12。
量测:时戳 14px 墨水宽 138px(160 外框内宽 120 放不下)、12px 为 118px(放得下,余 2px)。
测试改成四条绑一起(字级 12 / 内距 20 / 160−2×PAD ≥ 118 / 2 列实际宽度也 ≥ 118),
并反证「14px 确实放不下」防登记被当噪音删掉;变异回 14px 时红。

P2(顺带):
- 截图通道的主题档由 `--screenshot-theme` 钉死(此前读机器 launcher.json,设置页主题行会漂)
- `gallery::probe` 只认 gallery sheet(必须给 `ULL_GALLERY_SECTION`,否则整页图的 chip 会被当按钮量)、
  行表 8 → 9 行(四式图标按钮)、分隔线判据只在渲染了 divider 段时执行、逐行阈值 40 → 30 并加
  「满档色像素必须少于 38% 像素」的反向判据、probe 拆出 `probe/rows.rs` 守 400 行
- FAB extended 的重复分支合成一处,右内距提取 `EXTENDED_PAD_END`;iced 0.14 无 `Length::Min`,
  「最小宽随标签增长」退化为固定宽已登记
- 五处注释谎言改真(theme/tests、general/theme、elevated、launch_args、app/tests/theme)

`buttons/tests.rs` 加用例后 402 行 ⇒ 图标按钮那组拆到 `buttons/tests/icon.rs`。
This commit is contained in:
AskaEth committed 2026-11-11 01:49:15 +08:00
1 parent 6ed26626db
commit 8958b7f67d
18 files changed
+398 -124

No files matched your search

+67 -21
View File
@@ -158,39 +158,85 @@ fn the_detail_note_goes_through_the_prose_box() {
);
}
/// 元信息卡必须装得下**卡内最长的那条值**(视觉 r2:136 漏算 padding,版本串折行让卡底不齐)。
/// 元信息卡必须装得下**卡内最长的那条值**(视觉 r2:136 漏算 padding;M229 复审 P1-2 换成
/// ELEVATED CARD 之后内距 24 + 值 16px 又让它折行,同排卡底不齐)。
///
/// ## 判据是「墨水宽 ≤ 内宽」,数值是**量出来的**(不是算出来的)
///
/// 最长的那条值是 LAST PLAYED 的时戳 `2026-10-29 19:22 UTC`(20 字符,比加载器版本串长)。
/// 量法:`page-instances-detail-dark.png`(1180×760,2 列布局)右卡的值行 y 440–448,
/// 墨水横向 706…823 = **118px**,而卡内宽 = 外框 − 2×内距。两个档位都量过:
///
/// | 值字级 | 时戳墨水宽 | 160 外框(内宽 120)下 |
/// |---|---|---|
/// | `BodyMedium` 14 | **138px** | 放不下(折行 ⇒ 卡底不齐) |
/// | `BodySmall` **12** | **118px** | 放得下(余 2px;实际 2 列宽 206 余 62px) |
///
/// 所以 `elevated::TITLE_ROLE` 是 `BodySmall`、`elevated::PAD` 是 20 —— 这两件事**必须一起改**,
/// 本测试把它们与卡片外框绑在同一条不等式上;任何一项回退(值改回 14、内距改回 24)都会红。
#[test]
fn meta_card_is_wide_enough_for_the_longest_value() {
fn meta_card_value_fits_on_one_line_at_the_narrowest_card() {
use super::super::layout::{measure_cap_px, META_CARD_MIN_WIDTH, META_GAP};
use unlucky_design::spacing::LayoutMetrics;
use crate::app::components::surfaces::elevated;
// 实测(Segoe UI 14px):demo 数据里最宽的元信息值是加载器版本串
const LONGEST_VALUE: &str = "NeoForge 20.4.237";
const LONGEST_VALUE_PX: f32 = 118.0;
/// 时戳在 `BodySmall`(12px) 下的墨水宽(量法见函数文档)。
const TIMESTAMP_PX_AT_12: f32 = 118.0;
/// 同一个串在 `BodyMedium`(14px) 下的墨水宽——留在这里是为了让「改回 14」的人一眼看到代价。
const TIMESTAMP_PX_AT_14: f32 = 138.0;
let padding = LayoutMetrics::DESKTOP.card_padding;
assert_eq!(padding, 20.0, "卡内边距来自令牌");
// 夹具与 demo 数据不许脱节:这个值必须真在 DEMO 里
assert!(
DEMO.iter().any(|entry| entry.loader == LONGEST_VALUE),
"夹具 {LONGEST_VALUE:?} 已经不在 DEMO 里了,请重新量最长值"
// 量测对象是**产品那条格式化函数的输出**(`app::view::instances::last_played`:
// `{date} {time} UTC`),它的格式与两个边界样例由 `app/view/tests.rs::…last_played…` 钉着,
// 所以这里只钉「这条串有多少字」——20 字符,是全站最长的元信息值。
const LONGEST_VALUE: &str = "2026-10-29 19:22 UTC";
assert_eq!(
LONGEST_VALUE.chars().count(),
20,
"最长元信息值的字符数变了就要重新量墨水宽"
);
// ① 单卡外框减掉两侧内边距,必须放得下这条值(否则 text 会按词折行 → 卡底不齐)
let inner = META_CARD_MIN_WIDTH - 2.0 * padding;
// ① 字级与内距是**一对**:先钉住两个常量,再钉不等式(否则改字级不会红)。
let role = elevated::TITLE_ROLE;
assert_eq!(role, unlucky_design::typography::TypeRole::BodySmall);
assert_eq!(
role.style().size_px,
12.0,
"值字级必须是 12px(14px 下时戳 138px 放不下)"
);
assert_eq!(
elevated::PAD,
20.0,
"卡内距是 20(规格卡的 24 会挤到 166 > 160)"
);
assert_eq!(
elevated::PAD,
unlucky_design::spacing::LayoutMetrics::DESKTOP.card_padding,
"内距与描边卡同源(20)"
);
// 绑定成局部变量再过断言:全是常量的话 clippy 会判「常量断言」(评审集里的既有写法)。
let measured_14 = TIMESTAMP_PX_AT_14;
let inner_min_width = META_CARD_MIN_WIDTH - 2.0 * elevated::PAD;
assert!(
inner >= LONGEST_VALUE_PX,
"卡内可用 {inner}px 装不下 {LONGEST_VALUE_PX}px 的 {LONGEST_VALUE:?}(外框 {META_CARD_MIN_WIDTH} − 2×{padding})"
measured_14 > inner_min_width,
"如果 14px 也能放下({measured_14} ≤ {inner_min_width}),这条登记就该删掉"
);
// ② 实际版面里每张卡的内宽(2 列时)也要够
// ② 最窄的外框(160)减掉两侧内距,必须放得下 12px 的时戳。
let inner_min = META_CARD_MIN_WIDTH - 2.0 * elevated::PAD;
assert!(
inner_min >= TIMESTAMP_PX_AT_12,
"最窄卡内宽 {inner_min}px 装不下 {TIMESTAMP_PX_AT_12}px 的时戳(外框 {META_CARD_MIN_WIDTH} − 2×{})",
elevated::PAD
);
// ③ 实际版面(2 列,行宽上限 424)也要放得下——这是出图那一档。
let two_columns = (measure_cap_px() - META_GAP) / 2.0;
assert!(
two_columns - 2.0 * padding >= LONGEST_VALUE_PX,
"两列布局下卡内可用 {}px 装不下 {LONGEST_VALUE_PX}px",
two_columns - 2.0 * padding
two_columns - 2.0 * elevated::PAD >= TIMESTAMP_PX_AT_12,
"两列布局下卡内可用 {}px 装不下 {TIMESTAMP_PX_AT_12}px",
two_columns - 2.0 * elevated::PAD
);
// 卡片宽度契约没动(复审要求「不破卡片宽度契约」)。
assert_eq!(META_CARD_MIN_WIDTH, 160.0);
}
#[test]