reviewer r1 判 p2-2items(fix-then-merge),本轮按条目收口: P2-1 冻结测试补全(变异实测证明原来可静默漂移): - tests/type_scale_pins.rs:baseline 15 档 (size,line_height,weight,tracking,family) 与 Expressive 强调 15 档逐值对拍,期望值独立于实现重写 - tests/token_pins.rs:49 条 tone(light+dark 全表)、7 条 easing 曲线参数、 16 档时长、9 组弹簧、28 个 IconKind 下标、形状阶梯 - 自证:TitleMedium 16→17 与 EmphasizedDecelerate 换曲线两次变异均 FAILED P2-2 emphasized 口径事实错误(用户点名): - 取回 A 级官方值(androidx TypeScaleTokens v0_103 的 *Emphasized* 四元组): label-*/title-medium/title-small 强调是 700(此前给 600)、body-large 0.5→0.15、 title-medium 0.2→0.15、body-medium 0.2→0.25、display-large -0.2→0 - TypeRole::emphasized() 改为官方表(typography/emphasized.rs); 原「只加一档字重」规则改名 emphasized_simplified() 并标注非官方 - 顺带按官方订正 baseline tracking(display-large -0.2、body-medium 0.2、 title-medium 0.2)与 TitleLarge 的 Brand 族归属 - crate 文档新增「baseline MD3 vs M3 Expressive 差异清单」+ 未支持能力 gap 弹簧改为两套 token 结构(用户追加令): - motion::spring(scheme × category × speed) 二维表 12 格: standard spatial ζ=0.9 (k 1400/700/300)、expressive spatial ζ=0.6~0.8 (k 800/380/200)、 effects 两套同值 ζ=1.0 (k 3800/1600/800),均取 A 级 MotionTokens 原值 - Spring 改存阻尼比(与 Compose 令牌同口径),9 个具名常量 中文字重覆盖(用户追加令): - FontFace 带 WeightCoverage(可变区间/静态表),Platform::resolve_weight 给 Native/Nearest/Synthetic 三档结论;雅黑无 500、苹方无 700 两处 gap 显式登记 component_map 高程按官方回调(P2 并入 finding): - FilledCard L2→L0、OutlinedCard L1→L0、Menu L4→L2、Dialog L5→L3(Menu 圆角同时 按 MenuTokens 改 4dp);新增官方值 pin 测试 + R4「全屏都在飘」守门断言 P3 四条: - 同族容器撞色:彩度按共享基准等比压缩(Palettes.reference_chroma), primary/secondary-container 不再同值,且强调色覆盖时表面逐像素不变 - femtovg 引用改函数名(行号已漂);删掉 contrast_aa 的死代码后门 - 骨架数值跟 M63:nav 56→44、titlebar 40→48,control 40/touch 48 换算依据入文档 - 图标折算口径订正为「居中描边,视觉外扩 <1px」
225 lines
8.9 KiB
Rust
225 lines
8.9 KiB
Rust
//! 令牌契约测试:从**消费方视角**(只用公开 API)钉住 M61 承诺的那些性质。
|
||
//!
|
||
//! 这份测试的作用是「冻结」:`IconKind`/`Role` 的次序与个数、type scale 的取值、形状/高程阶梯、
|
||
//! 描边的 SVG 语义换算。后续 iced 页面线靠它保证不会因为一次「顺手改动」而悄悄换观感。
|
||
|
||
use unlucky_design::color::{Role, Scheme, SchemeFlavor, TonalPalette};
|
||
use unlucky_design::component_map::Component;
|
||
use unlucky_design::elevation::{Elevation, MaterialLayer};
|
||
use unlucky_design::icons::{
|
||
stroke_width_for, visual_stroke_px, IconKind, IconPaint, IconSize, VIEWBOX,
|
||
};
|
||
use unlucky_design::motion::{
|
||
DurationToken, Easing, MotionScale, SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||
};
|
||
use unlucky_design::shape::ShapeScale;
|
||
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||
use unlucky_design::typography::{FontFamily, Platform, TypeRole, Weight};
|
||
use unlucky_design::DesignTokens;
|
||
|
||
#[test]
|
||
fn color_role_table_is_frozen() {
|
||
assert_eq!(Role::ALL.len(), 49, "M3 基础角色集共 49 个");
|
||
assert_eq!(Role::ALL[0], Role::Primary);
|
||
assert_eq!(Role::ALL[16], Role::Surface);
|
||
assert_eq!(Role::ALL[48], Role::OnTertiaryFixedVariant);
|
||
// 每个角色都能从两套 scheme 里取到值,且前景角色都带 on- 语义。
|
||
let light = Scheme::baseline(SchemeFlavor::Light);
|
||
let dark = Scheme::baseline(SchemeFlavor::Dark);
|
||
for role in Role::ALL {
|
||
assert!(
|
||
light.get(role).is_opaque(),
|
||
"{} 应当是不透明色",
|
||
role.name()
|
||
);
|
||
assert!(dark.get(role).is_opaque());
|
||
if role.name().starts_with("on-") {
|
||
assert!(role.is_foreground(), "{} 应被判为前景色", role.name());
|
||
}
|
||
}
|
||
assert!(light.get(Role::Primary).is_opaque());
|
||
}
|
||
|
||
#[test]
|
||
fn tone_mapping_is_the_m3_table() {
|
||
// 抽查几行关键映射(light / dark),防止有人改 tone 表时静默换观感。
|
||
assert_eq!(Scheme::tone_of(Role::Primary, SchemeFlavor::Light), 40.0);
|
||
assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Light), 98.0);
|
||
assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Dark), 6.0);
|
||
assert_eq!(
|
||
Scheme::tone_of(Role::OnPrimaryContainer, SchemeFlavor::Dark),
|
||
90.0
|
||
);
|
||
assert_eq!(Scheme::tone_of(Role::Outline, SchemeFlavor::Light), 50.0);
|
||
}
|
||
|
||
#[test]
|
||
fn type_scale_values_are_the_m3_baseline() {
|
||
assert_eq!(TypeRole::ALL.len(), 15);
|
||
let title = TypeRole::TitleLarge.style();
|
||
assert_eq!((title.size_px, title.line_height_px), (22.0, 28.0));
|
||
let body = TypeRole::BodyMedium.style();
|
||
assert_eq!((body.size_px, body.line_height_px), (14.0, 20.0));
|
||
assert_eq!(body.weight, Weight::Regular);
|
||
let label = TypeRole::LabelLarge.style();
|
||
assert_eq!(label.weight, Weight::Medium);
|
||
assert_eq!(TypeRole::BodySmall.style().size_px, 12.0);
|
||
// 强调档只动字重。
|
||
assert_eq!(TypeRole::TitleLarge.emphasized().size_px, 22.0);
|
||
assert!(TypeRole::TitleMedium.emphasized().weight.value() > Weight::Medium.value());
|
||
}
|
||
|
||
#[test]
|
||
fn font_stacks_never_embed_font_files() {
|
||
for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
|
||
for family in [FontFamily::Display, FontFamily::Text, FontFamily::Mono] {
|
||
let stack = platform.font_stack(family);
|
||
assert!(!stack.is_empty());
|
||
for face in stack {
|
||
let lower = face.family.to_ascii_lowercase();
|
||
for banned in [".ttf", ".otf", ".ttc", "woff", "file"] {
|
||
assert!(
|
||
!lower.contains(banned),
|
||
"{} 的字体栈出现文件引用 {}(只允许族名,禁止内嵌受限字体)",
|
||
platform.name(),
|
||
face.family
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cjk_weight_gap_is_registered() {
|
||
// 中文 700(Expressive 强调档要用的)在三平台都要能找到原生落点,否则就是合成加粗降级。
|
||
use unlucky_design::typography::{Weight, WeightMatch};
|
||
// Windows:Segoe UI Variable 覆盖 700(原生);CJK 落点是雅黑 700(原生,雅黑有 Bold)。
|
||
let win = Platform::Windows.resolve_weight(FontFamily::Text, Weight::Bold);
|
||
assert_eq!(win.quality, WeightMatch::Native);
|
||
assert!(Platform::Windows.cjk_face().supports(Weight::Bold));
|
||
// macOS:苹方只到 600 → CJK 的 700 只能合成/换族,这条 gap 已在文档登记。
|
||
assert!(!Platform::MacOs.cjk_face().supports(Weight::Bold));
|
||
// Linux:Noto Sans CJK 可变字体全覆盖。
|
||
for weight in [Weight::Regular, Weight::Medium, Weight::Bold] {
|
||
assert!(Platform::Linux.cjk_face().supports(weight));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn icon_kind_is_frozen_and_append_only() {
|
||
assert_eq!(IconKind::ALL.len(), 28, "M61 冻结 28 个图标");
|
||
// 迁移来的 24 个必须保持原次序(序号即持久化 id)。
|
||
assert_eq!(IconKind::ALL[0], IconKind::Instances);
|
||
assert_eq!(IconKind::ALL[4], IconKind::Ai);
|
||
assert_eq!(IconKind::ALL[20], IconKind::More);
|
||
assert_eq!(IconKind::ALL[23], IconKind::User);
|
||
// 新增的 4 个追加在末尾。
|
||
assert_eq!(IconKind::ALL[24], IconKind::Refresh);
|
||
assert_eq!(IconKind::ALL[27], IconKind::Trash);
|
||
for kind in IconKind::ALL {
|
||
assert!(kind.icon().path.starts_with('M'));
|
||
assert_eq!(kind.icon().kind, kind);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn icon_stroke_follows_svg_semantics_not_slint() {
|
||
// Slint 口径(绝对 1.5px)在 20px 渲染下会得到 1.5px 视觉线宽;SVG 口径要写 1.8。
|
||
let nav_width = IconKind::Settings.icon().stroke_width(IconSize::Nav.px());
|
||
assert!(
|
||
(nav_width - 1.8).abs() < 1.0e-6,
|
||
"20px 渲染应取 1.8 个 viewBox 单位"
|
||
);
|
||
let button_width = IconKind::Settings
|
||
.icon()
|
||
.stroke_width(IconSize::Button.px());
|
||
assert!((button_width - 2.25).abs() < 1.0e-6);
|
||
// 视觉宽度的往返一致性(这条是「别照抄 Slint 的 1.5」的守门测试)。
|
||
for size in IconSize::ALL {
|
||
let w = stroke_width_for(size.px(), 1.5);
|
||
assert!((visual_stroke_px(size.px(), w) - 1.5).abs() < 1.0e-6);
|
||
}
|
||
assert!((VIEWBOX - 24.0).abs() < f32::EPSILON);
|
||
// 实心图标不描边。
|
||
assert_eq!(IconKind::Play.icon().paint, IconPaint::Fill);
|
||
assert_eq!(IconKind::Play.icon().stroke_width(24.0), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn shape_and_elevation_ladders() {
|
||
assert_eq!(ShapeScale::ExtraSmall.dp(), 4.0);
|
||
assert_eq!(ShapeScale::Medium.dp(), 12.0);
|
||
assert_eq!(ShapeScale::ExtraLarge.dp(), 28.0);
|
||
assert_eq!(ShapeScale::Full.radius_px(40.0), 20.0);
|
||
assert!(ShapeScale::ExtraLargeIncreased.is_expressive());
|
||
|
||
let dp: Vec<f32> = Elevation::ALL.iter().map(|e| e.dp()).collect();
|
||
assert_eq!(dp, vec![0.0, 1.0, 3.0, 6.0, 8.0, 12.0]);
|
||
assert_eq!(
|
||
MaterialLayer::Card.surface_role(),
|
||
Role::SurfaceContainerLow
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn motion_tokens_are_usable_as_data() {
|
||
assert_eq!(DurationToken::Medium2.ms(), 300);
|
||
assert!((Easing::Standard.sample(0.0)).abs() < 1.0e-6);
|
||
assert!((Easing::Standard.sample(1.0) - 1.0).abs() < 1.0e-6);
|
||
let settle = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.settle_ms(0.001);
|
||
assert!(settle > 0.0 && settle < 2000.0);
|
||
assert_eq!(MotionScale::NONE.ms(DurationToken::Long4), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn spacing_and_layout_metrics_are_shared() {
|
||
assert_eq!(Space::X4.dp(), 16.0);
|
||
let metrics = LayoutMetrics::DESKTOP;
|
||
assert_eq!(metrics.header_height(false), 56.0);
|
||
assert_eq!(metrics.header_height(true), 84.0);
|
||
}
|
||
|
||
#[test]
|
||
fn component_map_covers_the_page_skeleton() {
|
||
assert_eq!(Component::ALL.len(), 20);
|
||
for component in Component::ALL {
|
||
let spec = component.spec();
|
||
assert_eq!(spec.component, component);
|
||
assert!(spec.text.style().size_px >= 11.0);
|
||
}
|
||
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
|
||
// 浮层必须高于卡片层。
|
||
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
|
||
}
|
||
|
||
#[test]
|
||
fn design_tokens_entry_point() {
|
||
let light = DesignTokens::baseline(SchemeFlavor::Light);
|
||
let dark = DesignTokens::baseline(SchemeFlavor::Dark);
|
||
assert_eq!(light.flavor, SchemeFlavor::Light);
|
||
assert!(light.color(Role::Surface).relative_luminance() > 0.8);
|
||
assert!(dark.color(Role::Surface).relative_luminance() < 0.05);
|
||
assert!(!light.motion_scale.is_off());
|
||
|
||
let accent = DesignTokens::with_accent(
|
||
SchemeFlavor::Light,
|
||
unlucky_design::Color::from_hex("#006A5C").unwrap(),
|
||
);
|
||
assert_ne!(accent.color(Role::Primary), light.color(Role::Primary));
|
||
assert_eq!(accent.color(Role::Surface), light.color(Role::Surface));
|
||
}
|
||
|
||
#[test]
|
||
fn tonal_palette_hits_requested_tones() {
|
||
let palette = TonalPalette::new(150.0, 0.1);
|
||
for tone in [20.0_f32, 40.0, 60.0, 80.0] {
|
||
let color = palette.tone(tone);
|
||
assert!(
|
||
(color.lstar() - tone).abs() <= 1.0,
|
||
"tone {tone} 实际 {}",
|
||
color.lstar()
|
||
);
|
||
}
|
||
}
|