Files
Unlucky-Launcher/crates/app-iced/src/components/selection/tests.rs
T
AskaEth 00dbb5a5d5 fix(selection): 选中禁用的 Checkbox 容器改用 OnSurface@38%,并补逐格断言(复审 P2-3)
A1:SelectedDisabledContainerColor=OnSurface + SelectedDisabledContainerOpacity=0.38(不是普通禁用容器的
12%)。断言同时钉住三件事:fill == 38% 叠色、fill != 12% 容器色、未选中禁用描边与它是同一个 38%。
(同一提交还带着 P2-2 的命中区单测:两处断言都在 tests.rs,无法按 hunk 切分。)
2026-11-02 06:55:25 +08:00

340 lines
13 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.
//! 选择类家族的单测:几何取自令牌、角色取自 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);
}