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」
150 lines
5.2 KiB
Rust
150 lines
5.2 KiB
Rust
//! 字体栈与字重解析的测试(独立文件是为了守住单文件 ≤400 行的硬约束)。
|
||
|
||
use super::*;
|
||
use crate::typography::Weight;
|
||
|
||
const PLATFORMS: [Platform; 3] = [Platform::Windows, Platform::MacOs, Platform::Linux];
|
||
const FAMILIES: [FontFamily; 3] = [FontFamily::Display, FontFamily::Text, FontFamily::Mono];
|
||
|
||
#[test]
|
||
fn font_stacks_are_well_formed() {
|
||
for platform in PLATFORMS {
|
||
for family in FAMILIES {
|
||
let stack = platform.font_stack(family);
|
||
assert!(
|
||
!stack.is_empty(),
|
||
"{} 的 {family:?} 栈为空",
|
||
platform.name()
|
||
);
|
||
let last = stack[stack.len() - 1].family;
|
||
let generic = match family {
|
||
FontFamily::Mono => "monospace",
|
||
_ => "sans-serif",
|
||
};
|
||
assert_eq!(
|
||
last,
|
||
generic,
|
||
"{} 的 {family:?} 栈必须以通用族名兜底",
|
||
platform.name()
|
||
);
|
||
for face in stack {
|
||
assert!(!face.family.is_empty());
|
||
assert!(
|
||
!face.family.contains(['/', '\\', '.', ':']),
|
||
"字体栈只允许族名,不得出现路径/文件名:{}",
|
||
face.family
|
||
);
|
||
assert!(
|
||
face.family.is_ascii(),
|
||
"族名应为 ASCII(非 ASCII 名交给系统匹配):{}",
|
||
face.family
|
||
);
|
||
// 每条栈项都必须声明字重覆盖(连 400 都没有的族写进栈没有意义)。
|
||
assert!(
|
||
face.supports(Weight::Regular),
|
||
"{} 连 400 都没有,写它没意义",
|
||
face.family
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn display_and_text_stacks_differ_on_windows_and_macos() {
|
||
assert_ne!(
|
||
Platform::Windows.font_stack(FontFamily::Display)[0].family,
|
||
Platform::Windows.font_stack(FontFamily::Text)[0].family
|
||
);
|
||
assert_ne!(
|
||
Platform::MacOs.font_stack(FontFamily::Display)[0].family,
|
||
Platform::MacOs.font_stack(FontFamily::Text)[0].family
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn chinese_fallback_is_present_but_not_first() {
|
||
// 中文族名必须在栈里,但不能排第一(纯英文环境不加载中文字体,D28)。
|
||
for platform in PLATFORMS {
|
||
let stack = platform.font_stack(FontFamily::Text);
|
||
let cjk = ["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"];
|
||
assert!(
|
||
stack.iter().any(|face| cjk.contains(&face.family)),
|
||
"{} 的正文栈缺 CJK 兜底",
|
||
platform.name()
|
||
);
|
||
assert!(
|
||
!cjk.contains(&stack[0].family),
|
||
"{} 的首选字体不该是 CJK 族",
|
||
platform.name()
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cjk_weight_coverage_is_registered() {
|
||
// 用户点名的坑:中文字重覆盖三平台差别很大。
|
||
let win = Platform::Windows.cjk_face();
|
||
assert!(win.supports(Weight::Regular) && win.supports(Weight::Bold));
|
||
assert!(
|
||
!win.supports(Weight::Medium),
|
||
"雅黑没有 500——这条断言就是「中文 500 会掉到降级路径」的登记"
|
||
);
|
||
let mac = Platform::MacOs.cjk_face();
|
||
assert!(mac.supports(Weight::Medium));
|
||
assert!(
|
||
!mac.supports(Weight::Bold),
|
||
"苹方到 600 为止,700 要合成/换族"
|
||
);
|
||
let linux = Platform::Linux.cjk_face();
|
||
for weight in [Weight::Regular, Weight::Medium, Weight::Bold] {
|
||
assert!(
|
||
linux.supports(weight),
|
||
"思源黑体/Noto CJK 应覆盖 400/500/700"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_prefers_native_and_flags_synthetic() {
|
||
// Windows 正文 700:Segoe UI Variable 覆盖 → 原生。
|
||
let bold = Platform::Windows.resolve_weight(FontFamily::Text, Weight::Bold);
|
||
assert_eq!(bold.quality, WeightMatch::Native);
|
||
assert_eq!(bold.available, 700);
|
||
assert!(!bold.is_synthetic());
|
||
// Linux 正文 500:Roboto 可变 → 原生。
|
||
assert_eq!(
|
||
Platform::Linux
|
||
.resolve_weight(FontFamily::Text, Weight::Medium)
|
||
.quality,
|
||
WeightMatch::Native
|
||
);
|
||
// 静态族缺档:只有 400 的族请求 700 → nearest 400,差 300 → 判为合成降级。
|
||
let only_regular = FontFace::new("Courier New", WeightCoverage::Static(&[400]));
|
||
assert_eq!(only_regular.weights.nearest(700), 400);
|
||
let synthetic = FontChoice {
|
||
family: only_regular.family,
|
||
requested: 700,
|
||
available: only_regular.weights.nearest(700),
|
||
quality: WeightMatch::Synthetic,
|
||
};
|
||
assert!(synthetic.is_synthetic());
|
||
}
|
||
|
||
#[test]
|
||
fn nearest_picks_closest_static_weight() {
|
||
let coverage = WeightCoverage::Static(&[300, 400, 600, 700]);
|
||
assert_eq!(coverage.nearest(500), 400);
|
||
assert_eq!(coverage.nearest(650), 600);
|
||
assert_eq!(coverage.nearest(900), 700);
|
||
let variable = WeightCoverage::Variable { min: 100, max: 900 };
|
||
assert_eq!(variable.nearest(500), 500);
|
||
assert_eq!(variable.nearest(50), 100);
|
||
}
|
||
|
||
#[test]
|
||
fn current_platform_returns_a_stack() {
|
||
let platform = Platform::current();
|
||
assert!(!platform.font_stack(FontFamily::Text).is_empty());
|
||
}
|