A1:SelectedDisabledContainerColor=OnSurface + SelectedDisabledContainerOpacity=0.38(不是普通禁用容器的 12%)。断言同时钉住三件事:fill == 38% 叠色、fill != 12% 容器色、未选中禁用描边与它是同一个 38%。 (同一提交还带着 P2-2 的命中区单测:两处断言都在 tests.rs,无法按 hunk 切分。)
340 lines
13 KiB
Rust
340 lines
13 KiB
Rust
//! 选择类家族的单测:几何取自令牌、角色取自 A1(`#[cfg(test)]` 下的纯值断言)。
|
||
//!
|
||
//! **这些测试测的是「值与映射」**——它们抓不住「函数对、图错」(M68 的教训),
|
||
//! 像素级几何在 `components/gallery/selection.rs` 的 `probe` 里对着真截图量。
|
||
|
||
use unlucky_design::color::{Role, SchemeFlavor};
|
||
use unlucky_design::typography::Platform;
|
||
|
||
use super::super::interaction::{self, Interaction};
|
||
use super::geometry::{ChipGeometry, ControlGeometry, SwitchGeometry};
|
||
use super::paint::{self, ChipVariant};
|
||
use crate::theme::{Families, Ui};
|
||
|
||
const FAMILIES: Families = Families {
|
||
text: "Segoe UI",
|
||
display: "Segoe UI",
|
||
mono: "Consolas",
|
||
};
|
||
|
||
fn ui() -> Ui {
|
||
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
|
||
}
|
||
|
||
/// 复审 P2-2:命中区恒 **48×48**——Ø40 状态层之外左右各 4dp 透明内边距(无标签时行宽才不是 40)。
|
||
#[test]
|
||
fn hit_area_is_48_square_even_without_a_label() {
|
||
let ui = ui();
|
||
for (name, geometry) in [
|
||
("checkbox", ControlGeometry::checkbox(&ui)),
|
||
("radio", ControlGeometry::radio(&ui)),
|
||
] {
|
||
assert_eq!(geometry.hit, 48.0, "{name} 命中区必须是 48");
|
||
assert_eq!(geometry.state_layer, 40.0, "{name} 状态层 Ø40");
|
||
assert_eq!(geometry.hit_padding(), 4.0, "{name} 左右各留 4dp");
|
||
assert_eq!(
|
||
geometry.state_layer + geometry.hit_padding() * 2.0,
|
||
geometry.hit,
|
||
"{name}:可见 40 + 左右各 4 = 命中 48"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// §18.1 / §18.5 / §18.9:Checkbox 的四个参考值全部来自令牌层。
|
||
#[test]
|
||
fn checkbox_geometry_comes_from_the_token_layer() {
|
||
let ui = ui();
|
||
let g = ControlGeometry::checkbox(&ui);
|
||
assert_eq!(g.side, 20.0, "18.1:容器 20×20(ContainerWidth=18 是死值)");
|
||
assert_eq!(g.radius, 2.0, "18.1:圆角 2(ComponentRadius::Micro)");
|
||
assert_eq!(g.stroke, 2.0, "18.2:未选中描边 2px");
|
||
assert_eq!(g.state_layer, 40.0, "18.5:状态层 Ø40");
|
||
assert_eq!(g.hit, 48.0, "18.9:命中区 48");
|
||
assert_eq!(g.gap, 8.0, "18.9:框↔标签 8([估] 团队口径)");
|
||
assert_eq!(g.inner_dot, 0.0, "Checkbox 没有内点");
|
||
// 18.4:勾形横向端点跨度 = 0.8−0.2 = 0.6 × 20 = 12
|
||
assert!((g.check_span() - 12.0).abs() < 0.001, "18.4:勾横向 12dp");
|
||
// 方头外扩:每端 2/√2 ≈ 1.41 → 墨迹跨度 ≈ 14.8(参考值 12 是端点跨度,不是墨迹)
|
||
let bleed = g.square_cap_bleed();
|
||
assert!(
|
||
(bleed - std::f32::consts::SQRT_2).abs() < 0.001,
|
||
"方头外扩 1/√2×线宽"
|
||
);
|
||
assert!(
|
||
(g.check_span() + bleed * 2.0 - 14.83).abs() < 0.05,
|
||
"墨迹跨度 ≈14.83"
|
||
);
|
||
}
|
||
|
||
/// §19.1 / §19.2:Radio 外圈 20、环线 2、**内点 10 不是 12**、真圆。
|
||
#[test]
|
||
fn radio_inner_dot_is_ten_not_twelve() {
|
||
let ui = ui();
|
||
let g = ControlGeometry::radio(&ui);
|
||
assert_eq!(g.side, 20.0, "19.1:外圈直径 20");
|
||
assert_eq!(g.stroke, 2.0, "19.1:环线 2px");
|
||
assert_eq!(g.radius, 10.0, "19.1:真圆(半径 = 边长/2)");
|
||
assert_eq!(g.inner_dot, 10.0, "19.2:内点可视直径 10");
|
||
assert_ne!(
|
||
g.inner_dot, 12.0,
|
||
"19.2:12 是含描边前的名义值,抄 12 会明显偏大"
|
||
);
|
||
assert_eq!(g.state_layer, 40.0, "19.7:状态层 Ø40");
|
||
assert_eq!(g.hit, 48.0, "19.7:命中区 48");
|
||
}
|
||
|
||
/// §10.1 / §10.2 / §10.6:Switch 的三档滑块与状态层,以及「状态层必然溢出轨道」这条布局不变量。
|
||
#[test]
|
||
fn switch_geometry_matches_the_tokens() {
|
||
let ui = ui();
|
||
let g = SwitchGeometry::of(&ui);
|
||
assert_eq!(g.track_width, 52.0, "10.1:轨道宽 52");
|
||
assert_eq!(g.track_height, 32.0, "10.1:轨道高 32");
|
||
assert_eq!(g.track_stroke, 2.0, "10.3:未选中态 2px Outline");
|
||
assert_eq!(g.thumb(false, false), 16.0, "10.2:未选中滑块 16");
|
||
assert_eq!(g.thumb(true, false), 24.0, "10.2:选中滑块 24");
|
||
assert_eq!(g.thumb(false, true), 28.0, "10.2:按下滑块 28");
|
||
assert_eq!(g.thumb(true, true), 28.0, "10.2:按下档与选中无关");
|
||
assert_eq!(g.icon, 16.0, "10.5:滑块内图标 16");
|
||
assert_eq!(g.state_layer, 40.0, "10.6:状态层 Ø40");
|
||
assert_eq!(g.hit, 48.0, "命中区 48");
|
||
|
||
// 内距公式在三个档位都成立(32−24=8→4、32−16=16→8、32−28=4→2)
|
||
for thumb in [24.0, 16.0, 28.0] {
|
||
assert!((g.thumb_inset(thumb) * 2.0 + thumb - g.track_height).abs() < 0.001);
|
||
}
|
||
// 状态层必然溢出轨道左右:未选中时滑块靠左、选中时靠右
|
||
assert_eq!(g.bleed(), 4.0, "状态层比轨道高 → 上下各溢出 4");
|
||
assert_eq!(g.draw_width(), 60.0, "绘制区宽 = 52 + 2×4");
|
||
for (selected, thumb) in [(false, 16.0), (true, 24.0), (false, 28.0), (true, 28.0)] {
|
||
let offset = g.layer_offset(selected, thumb);
|
||
assert!(
|
||
offset >= 0.0,
|
||
"状态层左缘不得为负(selected={selected} thumb={thumb} → {offset})"
|
||
);
|
||
// 圆心与滑块同心:offset + 半径 = 滑块圆心 + 外扩
|
||
let center = offset + g.layer_radius();
|
||
assert!((center - (g.thumb_center(selected, thumb) + g.bleed())).abs() < 0.001);
|
||
}
|
||
}
|
||
|
||
/// §13.1 / §13.3 / §13.4:Chip 高 32、圆角 8、内边距 8、图标 16(官方 18 → G14)。
|
||
#[test]
|
||
fn chip_geometry_matches_the_tokens() {
|
||
let ui = ui();
|
||
let g = ChipGeometry::of(&ui);
|
||
assert_eq!(g.height, 32.0, "13.1:容器高 32");
|
||
assert_eq!(g.radius, 8.0, "13.1:圆角 8(ContainerShape=CornerSmall)");
|
||
assert_eq!(g.pad, 8.0, "13.3:水平内边距 8");
|
||
assert_eq!(g.gap, 8.0, "13.3:元素间距 8");
|
||
assert_eq!(g.icon, 16.0, "13.4:图标 16(官方 18 → G14 偏离)");
|
||
}
|
||
|
||
/// §18.2 / A1:未选中 Checkbox 在 hover/focus/pressed 下描边提到 `OnSurface`(不是 `OnSurfaceVariant`)。
|
||
#[test]
|
||
fn checkbox_unselected_lifts_the_outline_to_on_surface() {
|
||
let ui = ui();
|
||
let g = ControlGeometry::checkbox(&ui);
|
||
let at_rest = paint::checkbox(&ui, false, false, Interaction::Enabled, g.radius, g.stroke);
|
||
assert_eq!(
|
||
at_rest.frame.outline,
|
||
Some(ui.color(Role::OnSurfaceVariant))
|
||
);
|
||
for state in [
|
||
Interaction::Hovered,
|
||
Interaction::Focused,
|
||
Interaction::Pressed,
|
||
] {
|
||
let lifted = paint::checkbox(&ui, false, false, state, g.radius, g.stroke);
|
||
assert_eq!(
|
||
lifted.frame.outline,
|
||
Some(ui.color(Role::OnSurface)),
|
||
"A1:{state:?} 的 UnselectedOutlineColor = OnSurface"
|
||
);
|
||
assert_eq!(lifted.frame.stroke, 2.0, "18.8:描边宽度不随态变");
|
||
}
|
||
// 错误态优先于 hover
|
||
let error = paint::checkbox(&ui, false, true, Interaction::Hovered, g.radius, g.stroke);
|
||
assert_eq!(error.frame.outline, Some(ui.color(Role::Error)));
|
||
}
|
||
|
||
/// §18.8:禁用不是「变淡变细」——描边仍 2;选中禁用是 `OnSurface`@38% 容器 + `Surface` 勾。
|
||
#[test]
|
||
fn checkbox_disabled_keeps_the_stroke_and_uses_the_a1_roles() {
|
||
let ui = ui();
|
||
let g = ControlGeometry::checkbox(&ui);
|
||
let unchecked = paint::checkbox(&ui, false, false, Interaction::Disabled, g.radius, g.stroke);
|
||
assert_eq!(unchecked.frame.stroke, 2.0, "18.8:禁用描边仍 2px");
|
||
assert_eq!(unchecked.frame.fill, None, "18.8:未选中禁用内部仍透明");
|
||
assert!(
|
||
unchecked
|
||
.frame
|
||
.outline
|
||
.is_some_and(|color| color != ui.color(Role::OnSurfaceVariant)),
|
||
"18.8:禁用描边是 OnSurface@38%,不是满档角色色"
|
||
);
|
||
|
||
let checked = paint::checkbox(&ui, true, false, Interaction::Disabled, g.radius, g.stroke);
|
||
assert_eq!(
|
||
checked.glyph,
|
||
ui.color(Role::Surface),
|
||
"A1:SelectedDisabledIconColor = Surface"
|
||
);
|
||
// 复审 P2-3:**选中且禁用**的填充是 OnSurface@38%(`SelectedDisabledContainerOpacity`),
|
||
// 不是普通禁用容器的 12%。两格差一眼就能看出来,所以逐字钉住。
|
||
let ink38 = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
|
||
let container12 = interaction::disabled_colors(&ui, Role::Surface).0;
|
||
assert_eq!(
|
||
checked.frame.fill,
|
||
Some(ink38),
|
||
"A1:SelectedDisabledContainerOpacity = 0.38"
|
||
);
|
||
assert_ne!(
|
||
checked.frame.fill,
|
||
Some(container12),
|
||
"选中禁用不是 12% 容器色"
|
||
);
|
||
assert_eq!(
|
||
unchecked.frame.outline,
|
||
Some(ink38),
|
||
"同一行里未选中禁用描边与选中禁用填充是同一个 38% 叠色"
|
||
);
|
||
}
|
||
|
||
/// §19.4:**选中态 hover/pressed 不换环色**(只有状态层变)。
|
||
#[test]
|
||
fn radio_keeps_primary_on_hover_when_selected() {
|
||
let ui = ui();
|
||
let g = ControlGeometry::radio(&ui);
|
||
let primary = Some(ui.color(Role::Primary));
|
||
for state in Interaction::ALL {
|
||
if state.is_disabled() {
|
||
continue;
|
||
}
|
||
let paint = paint::radio(&ui, true, state, g.radius, g.stroke);
|
||
assert_eq!(
|
||
paint.ring.outline, primary,
|
||
"19.4:{state:?} 环仍是 Primary"
|
||
);
|
||
assert_eq!(paint.dot, primary, "19.4:{state:?} 点仍是 Primary");
|
||
}
|
||
// 未选中:静息 OnSurfaceVariant → hover/focus/pressed 提到 OnSurface
|
||
let at_rest = paint::radio(&ui, false, Interaction::Enabled, g.radius, g.stroke);
|
||
assert_eq!(at_rest.dot, None, "未选中不画内点");
|
||
assert_eq!(at_rest.ring.outline, Some(ui.color(Role::OnSurfaceVariant)));
|
||
let lifted = paint::radio(&ui, false, Interaction::Hovered, g.radius, g.stroke);
|
||
assert_eq!(lifted.ring.outline, Some(ui.color(Role::OnSurface)));
|
||
}
|
||
|
||
/// §10.4 + A1:选中态 hover 的滑块是 `PrimaryContainer`;禁用选中滑块是 `Surface`(不透明度 1)。
|
||
#[test]
|
||
fn switch_handle_roles_follow_the_tokens() {
|
||
let ui = ui();
|
||
let at_rest = paint::switch(&ui, true, Interaction::Enabled);
|
||
assert_eq!(at_rest.track.fill, Some(ui.color(Role::Primary)));
|
||
assert_eq!(at_rest.thumb, ui.color(Role::OnPrimary));
|
||
assert_eq!(at_rest.icon, Some(ui.color(Role::OnPrimaryContainer)));
|
||
|
||
let hovered = paint::switch(&ui, true, Interaction::Hovered);
|
||
assert_eq!(
|
||
hovered.thumb,
|
||
ui.color(Role::PrimaryContainer),
|
||
"A1:选中 hover 滑块 PrimaryContainer"
|
||
);
|
||
assert_eq!(
|
||
hovered.track.fill,
|
||
Some(ui.color(Role::Primary)),
|
||
"轨道不随 hover 变"
|
||
);
|
||
|
||
let off = paint::switch(&ui, false, Interaction::Enabled);
|
||
assert_eq!(
|
||
off.track.outline,
|
||
Some(ui.color(Role::Outline)),
|
||
"10.3:未选中 2px Outline"
|
||
);
|
||
assert_eq!(off.track.stroke, 2.0);
|
||
assert_eq!(
|
||
off.thumb,
|
||
ui.color(Role::Outline),
|
||
"10.4:未选中滑块 Outline"
|
||
);
|
||
assert_eq!(off.icon, None, "未选中态图标与轨道同色(不画)");
|
||
|
||
let disabled = paint::switch(&ui, true, Interaction::Disabled);
|
||
assert_eq!(
|
||
disabled.thumb,
|
||
ui.color(Role::Surface),
|
||
"A1:DisabledSelectedHandleColor=Surface"
|
||
);
|
||
}
|
||
|
||
/// §13.5 / §13.6 / §13.7:选中态**无描边**、Assist 不可选中、禁用文字 @38%。
|
||
#[test]
|
||
fn chip_roles_follow_the_tokens() {
|
||
let ui = ui();
|
||
let radius = ChipGeometry::of(&ui).radius;
|
||
|
||
let selected = paint::chip(&ui, ChipVariant::Filter, true, Interaction::Enabled, radius);
|
||
assert_eq!(
|
||
selected.frame.fill,
|
||
Some(ui.color(Role::SecondaryContainer))
|
||
);
|
||
assert_eq!(selected.frame.outline, None, "13.5:选中描边 0dp");
|
||
assert_eq!(selected.label, ui.color(Role::OnSecondaryContainer));
|
||
|
||
let unselected = paint::chip(
|
||
&ui,
|
||
ChipVariant::Filter,
|
||
false,
|
||
Interaction::Enabled,
|
||
radius,
|
||
);
|
||
assert_eq!(
|
||
unselected.frame.outline,
|
||
Some(ui.color(Role::OutlineVariant))
|
||
);
|
||
assert_eq!(unselected.label, ui.color(Role::OnSurfaceVariant));
|
||
|
||
// Assist 没有选中态:即使传 selected = true 也不会变 SecondaryContainer
|
||
let assist = paint::chip(&ui, ChipVariant::Assist, true, Interaction::Enabled, radius);
|
||
assert_ne!(assist.frame.fill, Some(ui.color(Role::SecondaryContainer)));
|
||
assert_eq!(
|
||
assist.icon,
|
||
ui.color(Role::Primary),
|
||
"13.4:Assist 前置图标 Primary"
|
||
);
|
||
|
||
let disabled = paint::chip(
|
||
&ui,
|
||
ChipVariant::Filter,
|
||
false,
|
||
Interaction::Disabled,
|
||
radius,
|
||
);
|
||
assert_ne!(disabled.label, unselected.label, "13.7:禁用文字降到 38%");
|
||
}
|
||
|
||
/// 状态层只在三态存在,且**不是**满档角色色(α 由 `interaction` 给)。
|
||
#[test]
|
||
fn state_layer_only_exists_on_lifted_states() {
|
||
let ui = ui();
|
||
for (state, expected) in [
|
||
(Interaction::Enabled, false),
|
||
(Interaction::Hovered, true),
|
||
(Interaction::Focused, true),
|
||
(Interaction::Pressed, true),
|
||
(Interaction::Disabled, false),
|
||
] {
|
||
let layer = paint::state_layer(&ui, state, Role::OnSurfaceVariant);
|
||
assert_eq!(layer.is_some(), expected, "{state:?} 的状态层有无");
|
||
if let Some(color) = layer {
|
||
assert_eq!(color.a, 1.0, "状态层必须是实色(D27:CPU/GPU 一致)");
|
||
}
|
||
}
|
||
// 同态下 hover 比 pressed 浅(8% < 10%)
|
||
let hover = paint::state_layer(&ui, Interaction::Hovered, Role::OnSurface).unwrap();
|
||
let pressed = paint::state_layer(&ui, Interaction::Pressed, Role::OnSurface).unwrap();
|
||
assert_ne!(hover, pressed);
|
||
}
|