Files
Unlucky-Launcher/crates/design/tests/tokext_pins.rs
T
AskaEth 141e9b12c7 feat(design): M69 令牌补齐 —— 圆角分层 / 组件微度量 / 骨架值与一像素线
G13(D32 分层):新增 shape::ComponentRadius(白名单只有 Micro=2)、
MICRO_CONTROL_MAX_SIZE=24 与纯函数 radius_layer_for();微半径走
radius_px(size) -> Option<f32>,尺寸 >24dp 一律 None,靠签名而不是自觉拦。
ShapeScale 与 rank() 一个字未动,2 不进刻度。

G11:LayoutMetrics 加 search_bar_height=56 / nav_rail_width=88 /
nav_drawer_width=240 / divider_thickness=1;删除语义相反的
nav_expanded_width=88 / nav_collapsed_width=48(D33:展开 drawer 240 /
折叠 rail 88;值 88 的后继是新名字 nav_rail_width,48 无后继)。

G10:新增 component_map::ComponentMetrics(12 字段 + 4 个命名常量 +
indicator_width),入口 Component::metrics();框内几何与 LayoutMetrics 的
骨架值分工写进模块文档,骨架级尺寸在 CM 里留 0 不许重复。

G9:Component 末尾追加 Checkbox / Radio / Search / NavigationDrawer /
Divider(20 -> 25),同步 table.rs 槽位、ALL 与 name();ComponentSpec 新增
micro_radius(Some 时 shape 必须是占位 None,由构造器 + 测试钉死)。

G12:新增 spacing::stroke::HAIRLINE = 1.0(crate 根再导出为
unlucky_design::stroke),divider_thickness 直接引用它。

其余:lib.rs 的「OKLCh 近似」文案改回真实口径(真 HCT / ColorSpec2021);
component_map 单测拆到 tests.rs 以免 mod.rs 超 400 行;测试 145 -> 165。
2026-11-01 16:53:30 +08:00

288 lines
11 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.
//! M69(TOKEXT)的数值钉死:G9~G13 五项缺口逐条关门。
//!
//! 期望值全部**独立于实现重写**(照 `research/15` §18~§23 与 `00-decision-log.md` D32/D33 抄),
//! 实现改一个数、这里不改就红。覆盖:
//! - **G13** `ComponentRadius` 白名单 + 尺寸闸(2 不在 `ShapeScale` 里,刻度不许被加宽);
//! - **G11** `LayoutMetrics` 的 4 个新骨架值 + nav 语义修正;
//! - **G10** `ComponentMetrics` 逐字段;
//! - **G9** `Component` 25 个变体与档位表一行对一行;
//! - **G12** 一像素线单一真源。
use unlucky_design::component_map::{Component, ComponentMetrics};
use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconSize;
use unlucky_design::shape::{radius_layer_for, ComponentRadius, RadiusLayer, ShapeScale};
use unlucky_design::spacing::{stroke, LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
// ---------- G13:圆角分层(D32) ----------
#[test]
fn shape_scale_is_unchanged_and_never_contains_two() {
// 刻度一个字都不许动(D32 第 1 条:ShapeScale 保持严格)。
let ladder: [(ShapeScale, f32); 9] = [
(ShapeScale::None, 0.0),
(ShapeScale::ExtraSmall, 4.0),
(ShapeScale::Small, 8.0),
(ShapeScale::Medium, 12.0),
(ShapeScale::Large, 16.0),
(ShapeScale::LargeIncreased, 20.0),
(ShapeScale::ExtraLarge, 28.0),
(ShapeScale::ExtraLargeIncreased, 32.0),
(ShapeScale::ExtraExtraLarge, 48.0),
];
for (scale, dp) in ladder {
assert_eq!(scale.dp(), dp, "{scale:?} 的刻度被改动了");
// 2 是官方 Checkbox 的圆角,但它**不在**刻度里——加进来就等于废掉 K1 守门。
assert_ne!(dp, 2.0, "2dp 不许进 ShapeScale(走 ComponentRadius)");
}
assert_eq!(ShapeScale::Full.radius_px(64.0), 32.0);
}
#[test]
fn component_radius_is_a_one_item_whitelist() {
assert_eq!(ComponentRadius::ALL, [ComponentRadius::Micro]);
assert_eq!(
ComponentRadius::Micro.dp(),
2.0,
"官方 Checkbox RadiusSize = 2.dp"
);
// 尺寸闸:>24dp 一律拿不到微半径(D32 的「只允许 ≤24dp 微控件」)。
assert_eq!(ComponentRadius::Micro.radius_px(20.0), Some(2.0));
assert_eq!(ComponentRadius::Micro.radius_px(24.0), Some(2.0));
assert_eq!(ComponentRadius::Micro.radius_px(25.0), None);
assert_eq!(ComponentRadius::Micro.radius_px(240.0), None);
assert_eq!(ComponentRadius::Micro.radius_px(0.0), None);
}
#[test]
fn radius_layer_for_routes_by_size() {
assert_eq!(radius_layer_for(20.0), RadiusLayer::Component);
assert_eq!(radius_layer_for(24.0), RadiusLayer::Component);
assert_eq!(radius_layer_for(28.0), RadiusLayer::Scale);
assert_eq!(radius_layer_for(360.0), RadiusLayer::Scale);
// 抽屉/面板这类容器永远不该被判成微控件。
for container in [
LayoutMetrics::DESKTOP.nav_drawer_width,
LayoutMetrics::DESKTOP.ai_panel_width,
] {
assert_eq!(radius_layer_for(container), RadiusLayer::Scale);
}
}
// ---------- G11:骨架值 + nav 语义修正 ----------
#[test]
fn layout_metrics_new_skeleton_values_are_pinned() {
let m = LayoutMetrics::DESKTOP;
// A 级:SearchBarTokens.ContainerHeight = 56。
assert_eq!(m.search_bar_height, 56.0);
// A 级:NavigationDrawer 下限 240(官方 ContainerWidth = 360,1180 窗下过宽,D33 取 240)。
assert_eq!(m.nav_drawer_width, 240.0);
// 12b §5.1 登记偏离:官方 rail 96 → 本项目 88。
assert_eq!(m.nav_rail_width, 88.0);
// A 级:DividerTokens.Thickness = 1.0dp(一像素线,不受 4px 栅格约束)。
assert_eq!(m.divider_thickness, 1.0);
}
#[test]
fn nav_widths_mean_what_their_names_say() {
// D33:展开(drawer)比折叠(rail)宽;旧字段 nav_expanded_width=88 / nav_collapsed_width=48
// 的名字与值相反,已在 M69 改名并删除,这里钉住「新名字不会再次反向」。
let m = LayoutMetrics::DESKTOP;
assert!(
m.nav_drawer_width > m.nav_rail_width,
"展开态(drawer {})必须比折叠态(rail {})宽",
m.nav_drawer_width,
m.nav_rail_width
);
// rail 里 56 宽的选中指示器要放得下(88 − 2×16)。
assert_eq!(m.nav_rail_width - Space::X4.dp() * 2.0, 56.0);
// drawer 里 216 宽的指示器同样由「宽 − 2×12」推出(见 ComponentMetrics::indicator_width)。
assert_eq!(
ComponentMetrics::NAVIGATION_DRAWER.indicator_width(m.nav_drawer_width),
216.0
);
}
// ---------- G10:组件级微度量 ----------
#[test]
fn checkbox_metrics_are_pinned() {
let cm = ComponentMetrics::CHECKBOX;
// 20×20(不是 token 文件里那个死值 18,见 research/15 §26.2 矛盾 ①)。
assert_eq!((cm.width, cm.height), (20.0, 20.0));
assert_eq!(
cm.radius,
Some(ComponentRadius::Micro),
"圆角 2dp 走 D32 第二层"
);
assert_eq!(cm.stroke, 2.0, "CheckboxTokens.UnselectedOutlineWidth = 2");
assert_eq!(cm.state_layer, 40.0, "StateLayerSize = 40");
assert_eq!(cm.inner_dot, 0.0);
assert_eq!(cm.gap, Space::X2.dp(), "框 ↔ 标签 8(团队口径)");
assert_eq!(
Component::Checkbox.metrics(),
Some(ComponentMetrics::CHECKBOX)
);
}
#[test]
fn radio_metrics_are_pinned() {
let cm = ComponentMetrics::RADIO;
assert_eq!(
(cm.width, cm.height),
(20.0, 20.0),
"RadioButtonTokens.IconSize = 20"
);
assert_eq!(cm.stroke, 2.0, "RadioStrokeWidth = 2.dp");
// 内点 10 不是 12:12 是含描边前的名义值(绘制半径 = 6 − 1 = 5)。
assert_eq!(cm.inner_dot, 10.0);
assert_eq!(cm.state_layer, 40.0);
assert_eq!(cm.radius, None, "radio 是真圆,圆角走 ShapeScale::Full");
assert_eq!(Component::Radio.spec().shape, ShapeScale::Full);
}
#[test]
fn search_bar_metrics_are_pinned() {
let cm = ComponentMetrics::SEARCH_BAR;
// 高 56 属骨架级:CM 里必须留 0,不许重复一份。
assert_eq!((cm.width, cm.height), (0.0, 0.0));
assert_eq!(cm.icon, IconSize::Toolbar.px(), "leading/trailing 图标 24");
assert_eq!(cm.icon_offset, 4.0, "SearchBarIconOffsetX = 4");
// 可视左缘 16 = 文本框默认 12 + 偏移 4。
assert_eq!(cm.pad_start, Space::X4.dp());
assert_eq!(cm.pad_end, Space::X4.dp());
// 搜索条圆角 = 高/2 = 28,等于官方 SearchBarCornerRadius。
assert_eq!(
ShapeScale::Full.radius_px(LayoutMetrics::DESKTOP.search_bar_height),
28.0
);
}
#[test]
fn navigation_drawer_metrics_are_pinned() {
let cm = ComponentMetrics::NAVIGATION_DRAWER;
assert_eq!((cm.width, cm.height), (0.0, 0.0), "宽/项高属骨架级");
assert_eq!(
cm.inset, 12.0,
"NavigationDrawerItemDefaults.ItemPadding = 12"
);
assert_eq!(cm.pad_start, Space::X4.dp(), "项内 start 16");
assert_eq!(cm.pad_end, Space::X6.dp(), "项内 end 24");
assert_eq!(cm.icon, IconSize::Toolbar.px());
assert_eq!(cm.gap, Space::X3.dp(), "图标 ↔ label 12");
// 指示器宽 = 容器宽 − 24;官方 360 − 24 = 336,本项目 240 − 24 = 216。
assert_eq!(cm.indicator_width(360.0), 336.0);
assert_eq!(cm.indicator_width(240.0), 216.0);
}
#[test]
fn component_metrics_never_duplicate_layout_metrics() {
// 同一量只许一份:CM 里出现的骨架级尺寸必须留 0。
let m = LayoutMetrics::DESKTOP;
for component in [Component::Search, Component::NavigationDrawer] {
let cm = component.metrics().expect("这两个控件必须有 CM");
assert_eq!(cm.width, 0.0, "{} 的宽度属 LM", component.name());
assert_eq!(cm.height, 0.0, "{} 的高度属 LM", component.name());
assert_ne!(cm.height, m.search_bar_height);
assert_ne!(cm.width, m.nav_drawer_width);
}
// Divider 的厚度也是 LM(一像素线)→ 它没有 CM。
assert_eq!(Component::Divider.metrics(), None);
assert_eq!(m.divider_thickness, stroke::HAIRLINE);
}
// ---------- G12:一像素线 ----------
#[test]
fn hairline_is_the_single_source_of_one_pixel_lines() {
assert_eq!(stroke::HAIRLINE, 1.0);
// 分隔线厚度直接引用它,不是另写一个字面量。
assert_eq!(LayoutMetrics::DESKTOP.divider_thickness, stroke::HAIRLINE);
// 一像素线不受 4px 栅格约束(栅格测试里显式豁免,这里反向钉一次)。
assert_ne!(stroke::HAIRLINE % unlucky_design::spacing::GRID, 0.0);
}
// ---------- G9:枚举 25 变体 ↔ 表槽位 ----------
#[test]
fn component_enum_is_the_frozen_twenty_five() {
let names = [
"top-app-bar",
"navigation-item",
"primary-button",
"secondary-button",
"text-button",
"icon-button",
"outlined-card",
"filled-card",
"list-item",
"text-field",
"segmented-button",
"chip",
"switch",
"slider",
"progress-indicator",
"dialog",
"menu",
"snackbar",
"tooltip",
"empty-state",
// M69 末尾追加的 5 个
"checkbox",
"radio",
"search",
"navigation-drawer",
"divider",
];
assert_eq!(Component::ALL.len(), names.len(), "M66 的 G9:20 → 25");
for (i, component) in Component::ALL.iter().enumerate() {
assert_eq!(component.name(), names[i], "第 {i} 个组件被换位/改名了");
// 表槽位与枚举判别值一一对应(`spec()` 就是按下标取的)。
assert_eq!(*component as usize, i);
assert_eq!(component.spec().component, *component);
}
}
#[test]
fn new_components_carry_the_official_elevation_and_shape() {
// Checkbox / Radio / Search / NavigationDrawer / Divider:全部 L0(都不是浮层)。
for component in [
Component::Checkbox,
Component::Radio,
Component::Search,
Component::NavigationDrawer,
Component::Divider,
] {
assert_eq!(
component.spec().elevation,
Elevation::Level0,
"{} 不该有阴影(R4:只有浮层允许)",
component.name()
);
}
// Checkbox 的圆角真源是 micro_radius,shape 只是占位 None。
let checkbox = Component::Checkbox.spec();
assert_eq!(checkbox.micro_radius, Some(ComponentRadius::Micro));
assert_eq!(checkbox.shape, ShapeScale::None);
// 其余 4 个不碰微半径层。
for component in [
Component::Radio,
Component::Search,
Component::NavigationDrawer,
Component::Divider,
] {
assert_eq!(component.spec().micro_radius, None, "{}", component.name());
}
// NavigationDrawer = CornerLargeEnd 的内侧角 16;Divider 无圆角。
assert_eq!(Component::NavigationDrawer.spec().shape, ShapeScale::Large);
assert_eq!(Component::Divider.spec().shape, ShapeScale::None);
// Checkbox / Radio 的标签字级是团队口径(官方无「框 + 标签」token)。
assert_eq!(Component::Checkbox.spec().text, TypeRole::BodyLarge);
assert_eq!(Component::Radio.spec().text, TypeRole::BodyLarge);
// Search 的字级:输入 BodyLarge、占位/辅助 BodySmall。
assert_eq!(Component::Search.spec().text, TypeRole::BodyLarge);
assert_eq!(Component::Search.spec().support, Some(TypeRole::BodySmall));
}