Unlucky Launcher v0.2.1
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
This commit is contained in:
commit
5046f96f7e
1092 files changed
+211068
No files matched your search
@@ -0,0 +1,400 @@
|
||||
//! D43:内置色卡的**固定中性色相**——切卡只换强调色,窗口底色的冷暖不跟着换。
|
||||
//!
|
||||
//! ## 这条测试要钉住的五件事
|
||||
//!
|
||||
//! 1. **天蓝卡(出厂默认)逐值不变**:固定的中性色相等于天蓝档的中性色相,所以天蓝卡必须与
|
||||
//! [`Scheme::baseline`] 的 49 个角色**逐个相等**(M70 定案的不动点,p1 级硬约束)。
|
||||
//! 2. **八张卡的表面色完全相同**:`surface` 的 CAM16 色相一致(±1°)、实测彩度落在窄带内
|
||||
//! (±1.5),且改前那条「9 倍差」被同时量出来当反证(见 `the_old_per_card_tinting...`)。
|
||||
//! 3. **动态路径不许被顺手改成固定中性**:这条必须走 **`resolve_source` 公开入口**测——
|
||||
//! `Card::scheme` 那一层根本没有「取色」这回事,测它等于测空气(复审 r1 p1-1 就是这个:
|
||||
//! 把 `resolve::try_level` 的取色分支改成固定中性,8 条测试全绿)。见
|
||||
//! `resolved_image_scheme_keeps_tinting_from_the_seed`。
|
||||
//! 4. **WCAG 不因换中性色相而掉档**:8 张卡 × 浅/深两套 × 26 对文字/图标对全部 ≥ 4.5:1。
|
||||
//! 5. 想让「八卡同底色」的带宽松掉,`the_old_per_card_tinting...` 会红(它量的是改前路径)。
|
||||
//!
|
||||
//! ## 为什么容差是这个带宽(不是随手取整)
|
||||
//!
|
||||
//! - **色相 ±1°**:八张卡现在共用一个中性调色板,`surface` 是**同一个 hex**,跨卡差实测 0.00°。
|
||||
//! 容差留 1° 是为了挡住「将来某张卡偷偷换回 TonalSpot」——那种回退的最小偏离是 **43°**
|
||||
//! (dusk 280.31° vs 236.86°),1° 的带宽足以把 0 与 43° 分开,同时不误报 8 bit 量化。
|
||||
//! - **彩度 ±1.5(CAM16,单位与请求值 12 同刻度)**:tone 98 的 8 bit 量化会把「请求 12」
|
||||
//! 读回成 6.41(浅)/11.70(深),这层压缩是**同一个 hex 的常数偏移**,跨卡比较时抵消;
|
||||
//! 真正的危险是暖相回退——rose 1.29、amber 1.89,比基线低 4.5~5.1,远超 1.5 的带宽;
|
||||
//! 紫相(lavender 5.87)只低 0.54,靠上面的色相断言抓(298° 偏 61°)。带宽取 1.5 = 请求
|
||||
//! 彩度的 1/8,比「改前最小的一张卡偏离基线」还小,所以「9 倍差回来了」一定红。
|
||||
//!
|
||||
//! 改前/改后的 `surface` 实测表在子模块 [`dump`](那里同时是验收图的数据源)。
|
||||
|
||||
// 子模块放在 `tests/card_neutral/` 目录里(cargo 只把 `tests/*.rs` 顶层文件当一个 target,
|
||||
// 目录里的文件不会被当成独立 target;crate 根的子模块默认在 `tests/` 下找,所以显式 `#[path]`)。
|
||||
#[path = "card_neutral/common.rs"]
|
||||
mod common;
|
||||
#[path = "card_neutral/dump.rs"]
|
||||
mod dump;
|
||||
|
||||
use common::{before_d43, flavors, hue_gap};
|
||||
use unlucky_design::color::{
|
||||
Hct, Role, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, NEUTRAL_HUE_DEG,
|
||||
};
|
||||
use unlucky_design::palette::card::card_scheme_is_readable;
|
||||
use unlucky_design::palette::{
|
||||
resolve_source, Card, CardKey, ImageInput, Level, CARDS, DEFAULT_CARD, TEXT_PAIRS,
|
||||
};
|
||||
|
||||
/// 天蓝卡就是产品默认色:固定的中性色相**沿用**它,所以 49 个角色必须一位不差。
|
||||
///
|
||||
/// 这条是 M88 的 p1 级硬约束。它红了只说明一件事:把「沿用天蓝的中性」实现成了「另选一个中性」。
|
||||
#[test]
|
||||
fn sky_card_is_bit_identical_to_the_product_baseline() {
|
||||
for flavor in flavors() {
|
||||
let baseline = Scheme::baseline(flavor);
|
||||
let sky = DEFAULT_CARD.scheme(flavor);
|
||||
for (role, got) in sky.iter() {
|
||||
assert_eq!(
|
||||
got,
|
||||
baseline.get(role),
|
||||
"天蓝卡(出厂默认)的 {} / {} 变了:{} → {}",
|
||||
role.name(),
|
||||
flavor.name(),
|
||||
baseline.get(role).to_hex(),
|
||||
got.to_hex()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 八张卡的 `surface` 共用同一个中性调色板:色相一致(±1°)、彩度落在窄带(±1.5)。
|
||||
#[test]
|
||||
fn preset_cards_share_one_neutral_hue_and_chroma() {
|
||||
for flavor in flavors() {
|
||||
let hcts: Vec<(&str, Hct)> = CARDS
|
||||
.iter()
|
||||
.map(|card| {
|
||||
(
|
||||
card.key,
|
||||
Hct::from_color(card.scheme(flavor).get(Role::Surface)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let reference = hcts[0].1;
|
||||
for (key, hct) in &hcts {
|
||||
assert!(
|
||||
hue_gap(hct.hue, reference.hue) <= 1.0,
|
||||
"{key} / {} 的 surface 色相 {:.2}° 偏离基线 {:.2}° 超过 ±1°",
|
||||
flavor.name(),
|
||||
hct.hue,
|
||||
reference.hue
|
||||
);
|
||||
assert!(
|
||||
(hct.chroma - reference.chroma).abs() <= 1.5,
|
||||
"{key} / {} 的 surface 实测彩度 {:.2} 偏离基线 {:.2} 超过 ±1.5",
|
||||
flavor.name(),
|
||||
hct.chroma,
|
||||
reference.chroma
|
||||
);
|
||||
}
|
||||
// 更强的口径:八张卡的 surface 是**同一个色值**(不只是「相近」)。
|
||||
let surfaces: Vec<String> = CARDS
|
||||
.iter()
|
||||
.map(|card| card.scheme(flavor).get(Role::Surface).to_hex())
|
||||
.collect();
|
||||
assert!(
|
||||
surfaces.iter().all(|hex| hex == &surfaces[0]),
|
||||
"{} 模式下八张卡的 surface 不再逐值相同:{surfaces:?}",
|
||||
flavor.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 反证:上面那条断言**量得出**问题。改前的路径浅色档彩度差 9 倍,改成固定中性后只剩 1 倍。
|
||||
///
|
||||
/// 没有这条的话,「±1.5 的带宽」只是一句自我声明:谁把断言写松(比如改成 ±20),
|
||||
/// `preset_cards_share_one_neutral_hue_and_chroma` 照样绿,而窗口又开始逐卡染色。
|
||||
///
|
||||
/// ⚠️ **9 倍差只出现在浅色档**:tone 98(近白)的暖相色域装不下彩度 12,rose 只兑现到 1.29;
|
||||
/// 深色档的 tone 6 反而每张卡都装得下(11.63~12.45,只差 1.07 倍)。深色档的问题不是彩度,
|
||||
/// 而是**色相**(14.66°~300.57°,整整一个色环)——它在深底上表现为「有的卡偏紫、有的偏绿」。
|
||||
/// 所以两个模式的反证分开断言,别用一条 `> 8` 覆盖两个模式(那样深色档会红,且说不清为什么)。
|
||||
#[test]
|
||||
fn the_old_per_card_tinting_really_was_a_ninefold_gap() {
|
||||
let surface_hct = |scheme: &dyn Fn(Card) -> Scheme| {
|
||||
CARDS
|
||||
.iter()
|
||||
.map(|card| Hct::from_color(scheme(*card).get(Role::Surface)))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let chroma_ratio = |hcts: &[Hct]| {
|
||||
let max = hcts.iter().map(|h| h.chroma).fold(f64::MIN, f64::max);
|
||||
let min = hcts.iter().map(|h| h.chroma).fold(f64::MAX, f64::min);
|
||||
max / min
|
||||
};
|
||||
let hue_span = |hcts: &[Hct]| {
|
||||
// 色相是环:这里只关心「跨了多少」,用最大凸包跨度(简单排序后首尾差)就够粗判。
|
||||
let mut hues: Vec<f64> = hcts.iter().map(|h| h.hue).collect();
|
||||
hues.sort_by(|a, b| a.partial_cmp(b).expect("有限色相"));
|
||||
hues[hues.len() - 1] - hues[0]
|
||||
};
|
||||
|
||||
// 浅色档:彩度 9 倍差(改前)→ 1 倍(改后)。
|
||||
let old_light = surface_hct(&|card| before_d43(card, SchemeFlavor::Light));
|
||||
assert!(
|
||||
chroma_ratio(&old_light) > 8.0,
|
||||
"改前(浅色)的 surface 彩度差应当能量出「9 倍级」跳变,实测 {:.2} 倍——\
|
||||
这条反证已经失效,上面的 ±1.5 带宽就不可信了",
|
||||
chroma_ratio(&old_light)
|
||||
);
|
||||
let new_light = surface_hct(&|card| card.scheme(SchemeFlavor::Light));
|
||||
assert!(
|
||||
chroma_ratio(&new_light) < 1.5,
|
||||
"固定中性后(浅色)八张卡的 surface 彩度差还有 {:.2} 倍",
|
||||
chroma_ratio(&new_light)
|
||||
);
|
||||
|
||||
// 深色档:彩度本来就一致,问题是色相跨了整个环 → 改后色相跨度收敛到 0。
|
||||
let old_dark = surface_hct(&|card| before_d43(card, SchemeFlavor::Dark));
|
||||
assert!(
|
||||
hue_span(&old_dark) > 180.0,
|
||||
"改前(深色)的 surface 色相应当横跨大半个色环,实测 {:.2}°",
|
||||
hue_span(&old_dark)
|
||||
);
|
||||
assert!(
|
||||
chroma_ratio(&old_dark) < 1.5,
|
||||
"改前(深色)的彩度本来就一致(tone 6 装得下彩度 12),实测 {:.2} 倍",
|
||||
chroma_ratio(&old_dark)
|
||||
);
|
||||
let new_dark = surface_hct(&|card| card.scheme(SchemeFlavor::Dark));
|
||||
assert!(
|
||||
hue_span(&new_dark) < 1.0,
|
||||
"改后(深色)色相跨度 {:.2}°",
|
||||
hue_span(&new_dark)
|
||||
);
|
||||
assert!(chroma_ratio(&new_dark) < 1.5);
|
||||
}
|
||||
|
||||
/// 换卡只换强调族:不随强调色变的角色必须逐值相同,强调族必须真的换。
|
||||
///
|
||||
/// 「哪些角色属于强调族」不靠手抄清单,而是**实测**:拿另一个强调色再生成一套方案,值会变的
|
||||
/// 那些角色就是强调族(primary/secondary/tertiary 全族 + `surface-tint` + `inverse-primary` +
|
||||
/// 12 个 `*-fixed*` = 26 个),不随强调色变的 23 个(中性表面、描边、error、shadow/scrim)
|
||||
/// 则必须留在基线——这样将来往 tone 表里加角色也不会漏掉分类。
|
||||
///
|
||||
/// ⚠️ 浅色档只有 **23** 个会变:`on-primary` / `on-secondary` / `on-tertiary` 的 tone 是 100,
|
||||
/// 任何色相都解析成纯白,所以强调色换了它们也不变(深色档的 tone 是 20,会变)。
|
||||
#[test]
|
||||
fn cards_only_change_the_accent_families() {
|
||||
for flavor in flavors() {
|
||||
let baseline = Scheme::baseline(flavor);
|
||||
let recolored = Scheme::from_accent(flavor, CardKey::Teal.card().seed);
|
||||
let (mut accent_roles, mut neutral_roles) = (Vec::new(), Vec::new());
|
||||
for role in Role::ALL {
|
||||
if recolored.get(role) == baseline.get(role) {
|
||||
neutral_roles.push(role);
|
||||
} else {
|
||||
accent_roles.push(role);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
(accent_roles.len(), neutral_roles.len()),
|
||||
if flavor.is_dark() { (26, 23) } else { (23, 26) },
|
||||
"强调族 / 中性族的划分变了({} 模式下换强调色会动 {} 个)",
|
||||
flavor.name(),
|
||||
accent_roles.len()
|
||||
);
|
||||
|
||||
let sky = DEFAULT_CARD.scheme(flavor);
|
||||
for card in CARDS {
|
||||
let scheme = card.scheme(flavor);
|
||||
for role in &neutral_roles {
|
||||
assert_eq!(
|
||||
scheme.get(*role),
|
||||
sky.get(*role),
|
||||
"{} / {} 的 {}(不随强调色变的角色)不该跟着卡变",
|
||||
card.key,
|
||||
flavor.name(),
|
||||
role.name()
|
||||
);
|
||||
}
|
||||
if card.key != DEFAULT_CARD.key {
|
||||
assert_ne!(
|
||||
scheme.get(Role::Primary),
|
||||
sky.get(Role::Primary),
|
||||
"{} / {} 的主色与天蓝卡相同:卡没生效",
|
||||
card.key,
|
||||
flavor.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡方案 = 用该卡种子做「自定义强调色」:中性表面留在基线,强调族与官方 `from_seed` 对得上。
|
||||
///
|
||||
/// 这条把 D43 接到已有的 `tests/official_accent.rs`(192 项官方对账)上:色卡的强调族不是
|
||||
/// 另一套算法,就是那条已经对过账的路径。
|
||||
#[test]
|
||||
fn card_scheme_matches_from_accent_when_chroma_is_the_default() {
|
||||
for card in CARDS {
|
||||
if card.neutral_chroma != DEFAULT_NEUTRAL_CHROMA {
|
||||
continue; // 将来的卡可以带别的中性彩度;那时它的表面色与 baseline 不同是有意的
|
||||
}
|
||||
for flavor in flavors() {
|
||||
assert_eq!(
|
||||
card.scheme(flavor),
|
||||
Scheme::from_accent(flavor, card.seed),
|
||||
"{} / {} 的色卡方案与 from_accent 不一致",
|
||||
card.key,
|
||||
flavor.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **生成器层**:同一张图,两张不同色卡下强调色相同、底色由图像 seed 决定。
|
||||
///
|
||||
/// ⚠️ 只测 `Seed::scheme` 这一层,**不覆盖接线**:取色分支在实际入口
|
||||
/// `palette::resolve::try_level` 里,接线由 `resolved_image_scheme_keeps_tinting_from_the_seed`
|
||||
/// 守(复审 r1 p1-1 的病根就是这条用例看着像守护、其实测不到接线)。
|
||||
#[test]
|
||||
fn image_scheme_ignores_the_preset_neutral_hue() {
|
||||
// 4×4 纯 `#B23A48`(rose 卡的种子色)——色相远离固定的 239.5°,好坏都能分辨。
|
||||
let pixels = [0xB2u8, 0x3A, 0x48, 0xFF].repeat(16);
|
||||
let seed = unlucky_design::palette::seed_from_rgba(&pixels, 4, 4).expect("纯色图有种子");
|
||||
|
||||
let cards = [CardKey::Sky.card(), CardKey::Teal.card()];
|
||||
let schemes: Vec<Scheme> = cards
|
||||
.iter()
|
||||
.map(|card| seed.scheme(SchemeFlavor::Light, card.neutral_chroma))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
schemes[0].get(Role::Primary),
|
||||
schemes[1].get(Role::Primary),
|
||||
"取色方案的主色只由图像种子决定,不该跟着当前色卡变"
|
||||
);
|
||||
assert_eq!(
|
||||
schemes[0].get(Role::Surface),
|
||||
schemes[1].get(Role::Surface),
|
||||
"取色方案的底色由图像种子决定(当前八张卡的中性彩度相同,所以两套方案的表面色相同)"
|
||||
);
|
||||
let surface_hue = Hct::from_color(schemes[0].get(Role::Surface)).hue;
|
||||
// 精确口径:中性**调色板**的色相必须来自图像种子(不是量化后的表面色——tone 98 的
|
||||
// 8 bit 量化会让实测色相漂 2.7°~7°,见模块文档;拿它做等式断言只会引入假红)。
|
||||
for card in cards {
|
||||
let neutral_hue = seed.palettes(card.neutral_chroma).neutral.hue_deg();
|
||||
assert!(
|
||||
(neutral_hue - seed.hue).abs() < 1.0e-9,
|
||||
"取色路径的中性色相 {neutral_hue:.2}° 没有跟着图像种子 {:.2}° 走",
|
||||
seed.hue
|
||||
);
|
||||
assert!(
|
||||
hue_gap(neutral_hue, NEUTRAL_HUE_DEG) > 100.0,
|
||||
"取色路径的中性色相被拉到了色卡的固定中性色相——动态路径的逐色染色被弄丢了(D43 保留它)"
|
||||
);
|
||||
}
|
||||
// 观感层:实测的表面色相明显靠近图像种子,而不是靠色卡的冷调中性色相。
|
||||
assert!(
|
||||
hue_gap(surface_hue, seed.hue) < hue_gap(surface_hue, NEUTRAL_HUE_DEG),
|
||||
"取色方案的 surface 色相 {surface_hue:.2}° 更接近色卡中性色相而不是图像种子 {:.2}°",
|
||||
seed.hue
|
||||
);
|
||||
for card in cards {
|
||||
assert_ne!(
|
||||
schemes[0].get(Role::Surface),
|
||||
card.scheme(SchemeFlavor::Light).get(Role::Surface),
|
||||
"取色方案的表面色与色卡路径相同,说明取色路径没在按图染色"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **接线的**守护测试(复审 r1 p1-1):走产品公开入口 [`resolve_source`],不是 `Card::scheme`。
|
||||
///
|
||||
/// 上一版只有生成器层那条用例:实测把 `palette::resolve::try_level` 的取色分支改成固定中性
|
||||
/// 时**8 条测试全绿**,而同一张 `#B23A48` 图的 `surface` 已从 `#FFF8F7`(暖白,跟着图走)
|
||||
/// 退化成 `#F6FAFF`(色卡冷白)。断言测 A 层、接线在 B 层——这条把整条链跑通。
|
||||
#[test]
|
||||
fn resolved_image_scheme_keeps_tinting_from_the_seed() {
|
||||
// 4×4 纯 `#B23A48`:种子色相 15.5°(底色 8 bit 量化后读回 22.09°),离 239.51° 很远。
|
||||
let pixels = [0xB2u8, 0x3A, 0x48, 0xFF].repeat(16);
|
||||
let flavor = SchemeFlavor::Light;
|
||||
let mut resolved: Vec<(CardKey, Scheme)> = Vec::new();
|
||||
for key in [CardKey::Sky, CardKey::Teal] {
|
||||
let (scheme, provenance) = resolve_source(
|
||||
Some(ImageInput::pixels(&pixels, 4, 4)),
|
||||
None,
|
||||
Some(key),
|
||||
flavor,
|
||||
);
|
||||
assert_eq!(
|
||||
provenance.level,
|
||||
Level::BackgroundImage,
|
||||
"{key:?}:背景图这一级没被用上(回落链本身变了)"
|
||||
);
|
||||
resolved.push((key, scheme));
|
||||
}
|
||||
|
||||
let surface = resolved[0].1.get(Role::Surface);
|
||||
// ① 底色在按图染色:必须是暖白,而不是色卡那条冷调中性。这一格就是「变异检测器」。
|
||||
assert_eq!(
|
||||
surface.to_hex(),
|
||||
"#FFF8F7",
|
||||
"取色方案的 surface 被拉回了色卡中性色相"
|
||||
);
|
||||
let surface_hue = Hct::from_color(surface).hue;
|
||||
assert!(
|
||||
hue_gap(surface_hue, 22.0) < 5.0,
|
||||
"surface 色相 {surface_hue:.2}° 没跟着图走"
|
||||
);
|
||||
assert!(
|
||||
hue_gap(surface_hue, NEUTRAL_HUE_DEG) > 100.0,
|
||||
"surface 色相 {surface_hue:.2}° 贴到了色卡固定的 {NEUTRAL_HUE_DEG:.2}°"
|
||||
);
|
||||
// ② 两张卡下强调色与底色都相同:底色只由图像决定,色卡只贡献中性彩度。
|
||||
assert_eq!(
|
||||
(resolved[0].1.get(Role::Primary), surface),
|
||||
(
|
||||
resolved[1].1.get(Role::Primary),
|
||||
resolved[1].1.get(Role::Surface)
|
||||
),
|
||||
"同一张图在两张卡下的主色与底色都该相同(八张卡的中性彩度都是 12)"
|
||||
);
|
||||
// ③ 与色卡路径明确不同:否则「取色」就等于「色卡 + 换强调色」。
|
||||
assert_ne!(
|
||||
surface,
|
||||
DEFAULT_CARD.scheme(flavor).get(Role::Surface),
|
||||
"取色方案的 surface 与天蓝卡的相同——取色路径退化成色卡路径了"
|
||||
);
|
||||
}
|
||||
|
||||
/// 8 张卡 × 浅/深 × 26 对:换中性色相后仍全部过 WCAG AA(`wcag::check` 全表),
|
||||
/// 并把最受中性色相影响的一对(on-surface / surface)钉在实测量级上。
|
||||
#[test]
|
||||
fn every_card_scheme_meets_wcag_aa_under_the_fixed_neutral() {
|
||||
for card in CARDS {
|
||||
for flavor in flavors() {
|
||||
let scheme = card.scheme(flavor);
|
||||
assert!(
|
||||
card_scheme_is_readable(card, flavor),
|
||||
"{} / {} 未过 WCAG AA 全表({} 对逐对断言在 `palette::wcag::check`)",
|
||||
card.key,
|
||||
flavor.name(),
|
||||
TEXT_PAIRS.len()
|
||||
);
|
||||
// 中性色相变了,最受影响的一对是 on-surface / surface(改前逐卡 16.23~16.44 浅 /
|
||||
// 14.30~14.42 深)。改后八张卡同一个底色,这一对也逐卡相同,钉住量级免得漂移。
|
||||
// 区间按模式分开:写成 `(浅区间) || (深区间)` 会让浅/深两个值互换也照样绿。
|
||||
let body = scheme.contrast(Role::OnSurface, Role::Surface);
|
||||
let band = match flavor {
|
||||
SchemeFlavor::Light => 16.30..=16.40,
|
||||
SchemeFlavor::Dark => 14.28..=14.36,
|
||||
};
|
||||
assert!(
|
||||
band.contains(&body),
|
||||
"{} / {}:on-surface/surface = {body:.3},超出 {} 模式的实测区间 {band:?}",
|
||||
card.key,
|
||||
flavor.name(),
|
||||
flavor.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! `card_neutral` 测试组共用的帮手:改前路径的复原、模式列表、色相环距离。
|
||||
//!
|
||||
//! 拆成子模块是为了单文件 ≤400 行的纪律(`card_neutral.rs` 是 crate 根,`common` / `dump`
|
||||
//! 只是它的子模块;cargo 只把 `tests/card_neutral.rs` 当一个 test target)。
|
||||
|
||||
use unlucky_design::color::{Palettes, Scheme, SchemeFlavor};
|
||||
use unlucky_design::palette::Card;
|
||||
|
||||
/// 改前的路径(M87 及之前):中性调色板跟着种子染色(官方 TonalSpot)。
|
||||
///
|
||||
/// 产品代码已经不用它了,但公开 API 仍在(取色路径就靠它逐色染色),所以这里能原样复现「改前」。
|
||||
pub fn before_d43(card: Card, flavor: SchemeFlavor) -> Scheme {
|
||||
Scheme::from_palettes(
|
||||
&Palettes::from_seed_with_neutral_chroma(card.seed, card.neutral_chroma),
|
||||
flavor,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn flavors() -> [SchemeFlavor; 2] {
|
||||
[SchemeFlavor::Light, SchemeFlavor::Dark]
|
||||
}
|
||||
|
||||
/// 色相环上的最短距离(度)。
|
||||
pub fn hue_gap(a: f64, b: f64) -> f64 {
|
||||
let raw = (a - b).abs();
|
||||
raw.min(360.0 - raw)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! 出图脚本读的 hex 行(无断言,纯打印)+ D43 的实测表(本文件就是那张表的来源)。
|
||||
//!
|
||||
//! 跑法:`cargo test -p unlucky-design --test card_neutral -- --nocapture`
|
||||
//!
|
||||
//! ## 改前 / 改后 `surface` 实测(2026-11)
|
||||
//!
|
||||
//! | 卡 | 改前 `surface`(浅) | 改后 `surface`(浅) | 改前 HCT(浅) | 改后 HCT(浅) |
|
||||
//! |---|---|---|---|---|
|
||||
//! | sky | `#F6FAFF` | `#F6FAFF` | 236.86° / 6.41 | 236.86° / 6.41 |
|
||||
//! | lavender | `#FDF7FF` | `#F6FAFF` | 298.10° / 5.87 | 236.86° / 6.41 |
|
||||
//! | forest | `#EEFDEE` | `#F6FAFF` | 157.01° / 11.77 | 236.86° / 6.41 |
|
||||
//! | amber | `#FFF8F4` | `#F6FAFF` | 71.48° / 1.89 | 236.86° / 6.41 |
|
||||
//! | rose | `#FFF8F7` | `#F6FAFF` | 22.09° / 1.29 | 236.86° / 6.41 |
|
||||
//! | teal | `#EBFEF8` | `#F6FAFF` | 182.42° / 12.03 | 236.86° / 6.41 |
|
||||
//! | dusk | `#FBF8FF` | `#F6FAFF` | 280.31° / 5.44 | 236.86° / 6.41 |
|
||||
//! | olive | `#F9FBE1` | `#F6FAFF` | 120.99° / 11.84 | 236.86° / 6.41 |
|
||||
//!
|
||||
//! 深色档同理:改前 `#09151C`~`#1F0F10`、彩度 11.63~12.45,改后八张卡统一 `#09151C`
|
||||
//! (235.70° / 11.70)。
|
||||
//!
|
||||
//! ## 这条用例为什么没有断言
|
||||
//!
|
||||
//! 它是**数据导出**(`CARD|` 行给 `/d/tmp/m88/render.py` 排版成验收图),断言在同目录的
|
||||
//! `card_neutral.rs` 里。放这里是为了让根文件留在 ≤400 行以内。
|
||||
|
||||
use super::common::{before_d43, flavors};
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::palette::CARDS;
|
||||
|
||||
/// 出图脚本读的 hex 行(改前 + 改后并排),跑法:
|
||||
/// `cargo test -p unlucky-design --test card_neutral -- --nocapture`。
|
||||
///
|
||||
/// 格式 `CARD|key|flavor|before|role=hex;role=hex;…`(承接那张「8 张卡 surface 色块并排 +
|
||||
/// 切 sky→teal 窗口底色」的验收图;渲染脚本 `/d/tmp/m88/render.py` 只做排版,数据全在这里)。
|
||||
#[test]
|
||||
fn dump_card_palette_for_the_color_card() {
|
||||
const SHOWN: [Role; 10] = [
|
||||
Role::Surface,
|
||||
Role::OnSurface,
|
||||
Role::OnSurfaceVariant,
|
||||
Role::SurfaceVariant,
|
||||
Role::SurfaceContainer,
|
||||
Role::SurfaceContainerHigh,
|
||||
Role::Outline,
|
||||
Role::Primary,
|
||||
Role::OnPrimary,
|
||||
Role::PrimaryContainer,
|
||||
];
|
||||
for card in CARDS {
|
||||
for flavor in flavors() {
|
||||
for (tag, scheme) in [
|
||||
("before", before_d43(card, flavor)),
|
||||
("after", card.scheme(flavor)),
|
||||
] {
|
||||
let pairs: Vec<String> = SHOWN
|
||||
.iter()
|
||||
.map(|role| format!("{}={}", role.name(), scheme.get(*role).to_hex()))
|
||||
.collect();
|
||||
println!(
|
||||
"CARD|{}|{}|{tag}|{}",
|
||||
card.key,
|
||||
flavor.name(),
|
||||
pairs.join(";")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! WCAG AA 对比度门禁(M61 任务:「on-* 与其承载色至少满足 4.5:1,不达标的角色要显式标注例外」)。
|
||||
//!
|
||||
//! 三个性质让这条门禁可以做到「**换任何种子色都成立**」,而不是只对出厂紫成立:
|
||||
//! 1. 颜色建在 CIELAB `L*`(tone)轴上,`TonalPalette::tone` 精确命中 tone(误差 ≤1);
|
||||
//! 2. WCAG 相对亮度只取决于同一条 Y 轴(`L*` ↔ Y 是双射);
|
||||
//! 3. 角色 → tone 的映射与色相/彩度无关。
|
||||
//!
|
||||
//! 所以对比度主要由「两个角色的 tone 差」决定,与 user 选的强调色无关——下面对多组种子
|
||||
//! 逐一验证,就是在钉这个性质。
|
||||
|
||||
use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
|
||||
|
||||
/// 正文文字:要求 ≥ 4.5:1(WCAG AA normal text)。
|
||||
const AA_TEXT: f32 = 4.5;
|
||||
/// 非文字元素(描边、图标块):要求 ≥ 3:1(WCAG AA non-text)。
|
||||
const AA_NON_TEXT: f32 = 3.0;
|
||||
|
||||
/// 文字/图标对(前景, 承载色)。两个模式都要过。
|
||||
const TEXT_PAIRS: [(Role, Role); 26] = [
|
||||
(Role::OnPrimary, Role::Primary),
|
||||
(Role::OnPrimaryContainer, Role::PrimaryContainer),
|
||||
(Role::OnSecondary, Role::Secondary),
|
||||
(Role::OnSecondaryContainer, Role::SecondaryContainer),
|
||||
(Role::OnTertiary, Role::Tertiary),
|
||||
(Role::OnTertiaryContainer, Role::TertiaryContainer),
|
||||
(Role::OnError, Role::Error),
|
||||
(Role::OnErrorContainer, Role::ErrorContainer),
|
||||
(Role::OnSurface, Role::Surface),
|
||||
(Role::OnSurfaceVariant, Role::SurfaceVariant),
|
||||
(Role::OnBackground, Role::Background),
|
||||
(Role::InverseOnSurface, Role::InverseSurface),
|
||||
(Role::InversePrimary, Role::InverseSurface),
|
||||
(Role::OnPrimaryFixed, Role::PrimaryFixed),
|
||||
(Role::OnPrimaryFixedVariant, Role::PrimaryFixedDim),
|
||||
(Role::OnSecondaryFixed, Role::SecondaryFixed),
|
||||
(Role::OnSecondaryFixedVariant, Role::SecondaryFixedDim),
|
||||
(Role::OnTertiaryFixed, Role::TertiaryFixed),
|
||||
(Role::OnTertiaryFixedVariant, Role::TertiaryFixedDim),
|
||||
// 卡片/浮层容器上的文字(M3 现行口径:层级靠 surface-container-* 表达)
|
||||
(Role::OnSurface, Role::SurfaceContainerLowest),
|
||||
(Role::OnSurface, Role::SurfaceContainerLow),
|
||||
(Role::OnSurface, Role::SurfaceContainer),
|
||||
(Role::OnSurface, Role::SurfaceContainerHigh),
|
||||
(Role::OnSurface, Role::SurfaceContainerHighest),
|
||||
(Role::OnSurfaceVariant, Role::SurfaceContainerHighest),
|
||||
(Role::OnSurfaceVariant, Role::SurfaceContainerLow),
|
||||
];
|
||||
|
||||
/// 显式登记的例外:`(前景, 承载色, 目标阈值, 原因)`。
|
||||
///
|
||||
/// 这些不是缺陷,是 M3 定义决定的(非文字元素 3:1、装饰件无要求)。
|
||||
const EXCEPTIONS: [(Role, Role, f32, &str); 3] = [
|
||||
(
|
||||
Role::Outline,
|
||||
Role::Surface,
|
||||
AA_NON_TEXT,
|
||||
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.26:1、dark 5.89:1 已高于目标)",
|
||||
),
|
||||
(
|
||||
Role::OutlineVariant,
|
||||
Role::Surface,
|
||||
AA_NON_TEXT * 0.5,
|
||||
"分隔线是纯装饰,M3 不要求对比度下限;light 1.62:1 / dark 1.99:1 是「几乎看不见但要能数出层级」的取值",
|
||||
),
|
||||
(
|
||||
Role::SurfaceVariant,
|
||||
Role::Surface,
|
||||
1.0,
|
||||
"surface-variant 是容器色而不是前景色,不参与对比度门禁(M3 已不推荐用它做容器,保留仅为兼容旧值)",
|
||||
),
|
||||
];
|
||||
|
||||
fn seeds() -> Vec<Color> {
|
||||
vec![
|
||||
// 出厂基线(M3 baseline 紫)
|
||||
Color::from_hex("#6750A4").expect("基线种子"),
|
||||
// 冷蓝(原 Fluent 强调色,用户大概率会选这一族)
|
||||
Color::from_hex("#0078D4").expect("蓝"),
|
||||
// 青绿
|
||||
Color::from_hex("#006A5C").expect("青"),
|
||||
// 暖橙(高彩度,gamut 压力大)
|
||||
Color::from_hex("#E8710A").expect("橙"),
|
||||
// 亮黄(最容易被白字吃掉的极端)
|
||||
Color::from_hex("#FFD400").expect("黄"),
|
||||
// 近灰(低彩度种子,检验 neutral 不塌)
|
||||
Color::from_hex("#6E6E6E").expect("灰"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_pairs_meet_wcag_aa_for_every_seed_and_flavor() {
|
||||
let mut failures = Vec::new();
|
||||
for seed in seeds() {
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let scheme = Scheme::from_seed(flavor, seed);
|
||||
for (fg, bg) in TEXT_PAIRS {
|
||||
let ratio = scheme.contrast(fg, bg);
|
||||
if ratio < AA_TEXT {
|
||||
failures.push(format!(
|
||||
"seed {} / {} / {} on {} = {ratio:.2}",
|
||||
seed.to_hex(),
|
||||
flavor.name(),
|
||||
fg.name(),
|
||||
bg.name()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"以下角色对未达 WCAG AA(4.5:1):\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exception_list_is_honest_about_its_thresholds() {
|
||||
// 例外必须**真的**是「非文字级」,不能悄悄把低于 3:1 的文字对塞进来。
|
||||
for seed in seeds() {
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let scheme = Scheme::from_seed(flavor, seed);
|
||||
for (fg, bg, threshold, reason) in EXCEPTIONS {
|
||||
assert!(
|
||||
!fg.is_foreground(),
|
||||
"例外 {} 不该是文字前景色({reason})",
|
||||
fg.name()
|
||||
);
|
||||
let ratio = scheme.contrast(fg, bg);
|
||||
assert!(
|
||||
ratio >= threshold,
|
||||
"例外 {} on {} 实测 {ratio:.2},低于登记阈值 {threshold}(seed {} / {})",
|
||||
fg.name(),
|
||||
bg.name(),
|
||||
seed.to_hex(),
|
||||
flavor.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_light_and_dark_both_pass() {
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let scheme = Scheme::baseline(flavor);
|
||||
for (fg, bg) in TEXT_PAIRS {
|
||||
let ratio = scheme.contrast(fg, bg);
|
||||
assert!(
|
||||
ratio >= AA_TEXT,
|
||||
"baseline {} 的 {} on {} 只有 {ratio:.2}",
|
||||
flavor.name(),
|
||||
fg.name(),
|
||||
bg.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sky_seed_candidates_meet_wcag_aa() {
|
||||
// M70:四档默认色候选(种子 + 中性彩度)必须过同一条 26 组门禁。
|
||||
// 中性彩度改变只影响色相/彩度,不影响 tone 轴,所以理论上恒过——这里就是要**证明**它恒过,
|
||||
// 而不是相信它。任何一档不达标,该档直接判为不可用(不许为了好看放宽阈值)。
|
||||
let candidates = [
|
||||
("① 保守蓝", "#2196F3", 6.0_f64),
|
||||
("② 冷调蓝", "#2196F3", 12.0),
|
||||
("③ 浅冷蓝", "#87CEEB", 12.0),
|
||||
("④ 天蓝", "#3C8DBC", 12.0),
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
let mut worst = (f32::MAX, String::new());
|
||||
let mut checked = 0;
|
||||
for (title, seed_hex, neutral_chroma) in candidates {
|
||||
let seed = Color::from_hex(seed_hex).expect("候选种子");
|
||||
let palettes =
|
||||
unlucky_design::color::Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma);
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let scheme = Scheme::from_palettes(&palettes, flavor);
|
||||
for (fg, bg) in TEXT_PAIRS {
|
||||
let ratio = scheme.contrast(fg, bg);
|
||||
checked += 1;
|
||||
if ratio < worst.0 {
|
||||
worst = (
|
||||
ratio,
|
||||
format!(
|
||||
"{title}/{} 的 {} on {}",
|
||||
flavor.name(),
|
||||
fg.name(),
|
||||
bg.name()
|
||||
),
|
||||
);
|
||||
}
|
||||
if ratio < AA_TEXT {
|
||||
failures.push(format!(
|
||||
"{title}({seed_hex} / 中性 {neutral_chroma})/ {} / {} on {} = {ratio:.2}",
|
||||
flavor.name(),
|
||||
fg.name(),
|
||||
bg.name()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"候选 WCAG:{checked} 项,最紧的一对 {} = {:.2}:1(阈值 {AA_TEXT})",
|
||||
worst.1, worst.0
|
||||
);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"以下候选未达 WCAG AA(4.5:1),该档不可用:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_override_cannot_break_contrast() {
|
||||
// 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。
|
||||
let base = Scheme::baseline(SchemeFlavor::Light);
|
||||
for seed in seeds() {
|
||||
let custom = Scheme::from_accent(SchemeFlavor::Light, seed);
|
||||
for (fg, bg) in [
|
||||
(Role::OnPrimary, Role::Primary),
|
||||
(Role::OnPrimaryContainer, Role::PrimaryContainer),
|
||||
(Role::OnSecondary, Role::Secondary),
|
||||
(Role::OnTertiary, Role::Tertiary),
|
||||
] {
|
||||
let ratio = custom.contrast(fg, bg);
|
||||
assert!(
|
||||
ratio >= AA_TEXT,
|
||||
"强调色 {} 下 {} on {} 只有 {ratio:.2}",
|
||||
seed.to_hex(),
|
||||
fg.name(),
|
||||
bg.name()
|
||||
);
|
||||
}
|
||||
// 表面色不受强调色影响(这条同时是 `from_accent` 的语义断言)。
|
||||
assert_eq!(custom.get(Role::Surface), base.get(Role::Surface));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_is_registered_as_non_text_exception() {
|
||||
// 把「为什么 outline 不是 AA 例外失败项」钉死:它在 light 下 4.29:1(低于 4.5 但高于 3:1)。
|
||||
let light = Scheme::baseline(SchemeFlavor::Light);
|
||||
let ratio = light.contrast(Role::Outline, Role::Surface);
|
||||
assert!(
|
||||
ratio < AA_TEXT,
|
||||
"light 的 outline 若已达 4.5 应把例外删掉:{ratio:.2}"
|
||||
);
|
||||
assert!(
|
||||
ratio >= AA_NON_TEXT,
|
||||
"light 的 outline 低于非文字 3:1:{ratio:.2}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! 自定义强调色 × 官方算法的**逐角色对齐**(`Scheme::from_accent`)。
|
||||
//!
|
||||
//! 期望值来自官方 Java 实现:`SchemeTonalSpot(seed, isDark, 0, SPEC_2021)` 的强调族角色。
|
||||
//! 判据:每通道误差 ≤1(见 `official_baseline.rs` 的说明)。
|
||||
//!
|
||||
//! 为什么只比强调族(primary/secondary/tertiary 共 12 个角色):本项目的产品语义是
|
||||
//! 「换强调色不动底色」——中性表面与 error 刻意留在 baseline(见 `Palettes::with_accent`),
|
||||
//! 而官方 `from_seed` 会把中性色也染上种子色相。中性派生的正确性由本文件末尾的
|
||||
//! `from_seed_*` 用例单独验证(那一处必须与官方完全一致)。
|
||||
|
||||
use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
|
||||
|
||||
/// 每通道允许的最大误差。
|
||||
const CHANNEL_TOLERANCE: i16 = 1;
|
||||
|
||||
/// 一条官方期望值:角色 + light 色值 + dark 色值。
|
||||
type RoleRow = (Role, &'static str, &'static str);
|
||||
|
||||
/// 一个种子 + 它 12 个强调族角色的官方色值。
|
||||
type SeedRows = (&'static str, [RoleRow; 12]);
|
||||
|
||||
/// 八个种子:官方基线紫、纯红/绿/蓝、低彩度灰蓝、高彩度紫,外加纯黑与纯白两个边缘用例
|
||||
/// (无彩色种子的色相是病态的,官方的处理方式见 `Hct`:黑 → 色相 0、白 → 色相 209.5)。
|
||||
/// 每个种子带 12 个强调族角色的官方色值(light, dark)。
|
||||
const ACCENT_SEEDS: [SeedRows; 8] = [
|
||||
(
|
||||
"#6750A4",
|
||||
[
|
||||
(Role::Primary, "#65558F", "#CFBDFE"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#36275D"),
|
||||
(Role::PrimaryContainer, "#E9DDFF", "#4D3D75"),
|
||||
(Role::OnPrimaryContainer, "#4D3D75", "#E9DDFF"),
|
||||
(Role::Secondary, "#625B71", "#CBC2DB"),
|
||||
(Role::OnSecondary, "#FFFFFF", "#332D41"),
|
||||
(Role::SecondaryContainer, "#E8DEF8", "#4A4458"),
|
||||
(Role::OnSecondaryContainer, "#4A4458", "#E8DEF8"),
|
||||
(Role::Tertiary, "#7E5260", "#EFB8C8"),
|
||||
(Role::OnTertiary, "#FFFFFF", "#4A2532"),
|
||||
(Role::TertiaryContainer, "#FFD9E3", "#633B48"),
|
||||
(Role::OnTertiaryContainer, "#633B48", "#FFD9E3"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"#FF0000",
|
||||
[
|
||||
(Role::Primary, "#904B40", "#FFB4A8"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#561E16"),
|
||||
(Role::PrimaryContainer, "#FFDAD4", "#73342A"),
|
||||
(Role::OnPrimaryContainer, "#73342A", "#FFDAD4"),
|
||||
(Role::Secondary, "#775651", "#E7BDB6"),
|
||||
(Role::OnSecondary, "#FFFFFF", "#442925"),
|
||||
(Role::SecondaryContainer, "#FFDAD4", "#5D3F3B"),
|
||||
(Role::OnSecondaryContainer, "#5D3F3B", "#FFDAD4"),
|
||||
(Role::Tertiary, "#705C2E", "#DEC48C"),
|
||||
(Role::OnTertiary, "#FFFFFF", "#3E2E04"),
|
||||
(Role::TertiaryContainer, "#FBDFA6", "#564419"),
|
||||
(Role::OnTertiaryContainer, "#564419", "#FBDFA6"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"#00FF00",
|
||||
[
|
||||
(Role::Primary, "#406836", "#A5D395"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#11380B"),
|
||||
(Role::PrimaryContainer, "#C0EFB0", "#285020"),
|
||||
(Role::OnPrimaryContainer, "#285020", "#C0EFB0"),
|
||||
(Role::Secondary, "#54634D", "#BBCBB2"),
|
||||
(Role::OnSecondary, "#FFFFFF", "#263422"),
|
||||
(Role::SecondaryContainer, "#D7E8CD", "#3C4B37"),
|
||||
(Role::OnSecondaryContainer, "#3C4B37", "#D7E8CD"),
|
||||
(Role::Tertiary, "#386568", "#A0CFD2"),
|
||||
(Role::OnTertiary, "#FFFFFF", "#003739"),
|
||||
(Role::TertiaryContainer, "#BCEBEE", "#1E4D50"),
|
||||
(Role::OnTertiaryContainer, "#1E4D50", "#BCEBEE"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"#0000FF",
|
||||
[
|
||||
(Role::Primary, "#555992", "#BEC2FF"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#272B60"),
|
||||
(Role::PrimaryContainer, "#E0E0FF", "#3E4278"),
|
||||
(Role::OnPrimaryContainer, "#3E4278", "#E0E0FF"),
|
||||
(Role::Secondary, "#5C5D72", "#C5C4DD"),
|
||||
(Role::OnSecondary, "#FFFFFF", "#2E2F42"),
|
||||
(Role::SecondaryContainer, "#E1E0F9", "#444559"),
|
||||
(Role::OnSecondaryContainer, "#444559", "#E1E0F9"),
|
||||
(Role::Tertiary, "#78536B", "#E8B9D5"),
|
||||
(Role::OnTertiary, "#FFFFFF", "#46263B"),
|
||||
(Role::TertiaryContainer, "#FFD8EE", "#5E3C52"),
|
||||
(Role::OnTertiaryContainer, "#5E3C52", "#FFD8EE"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"#6E7C8A",
|
||||
[
|
||||
(Role::Primary, "#2B638B", "#98CCF9"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#003350"),
|
||||
(Role::PrimaryContainer, "#CCE5FF", "#044B71"),
|
||||
(Role::OnPrimaryContainer, "#044B71", "#CCE5FF"),
|
||||
(Role::Secondary, "#50606F", "#B8C8D9"),
|
||||
(Role::OnSecondary, "#FFFFFF", "#23323F"),
|
||||
(Role::SecondaryContainer, "#D4E4F6", "#394856"),
|
||||
(Role::OnSecondaryContainer, "#394856", "#D4E4F6"),
|
||||
(Role::Tertiary, "#66587B", "#D1BFE7"),
|
||||
(Role::OnTertiary, "#FFFFFF", "#372A4A"),
|
||||
(Role::TertiaryContainer, "#ECDCFF", "#4E4162"),
|
||||
(Role::OnTertiaryContainer, "#4E4162", "#ECDCFF"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"#D000FF",
|
||||
[
|
||||
(Role::Primary, "#794F81", "#E9B5EF"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#472150"),
|
||||
(Role::PrimaryContainer, "#FDD6FF", "#603768"),
|
||||
(Role::OnPrimaryContainer, "#603768", "#FDD6FF"),
|
||||
(Role::Secondary, "#6A596C", "#D6C0D6"),
|
||||
(Role::OnSecondary, "#FFFFFF", "#3A2B3C"),
|
||||
(Role::SecondaryContainer, "#F3DBF2", "#524153"),
|
||||
(Role::OnSecondaryContainer, "#524153", "#F3DBF2"),
|
||||
(Role::Tertiary, "#82524C", "#F5B7B0"),
|
||||
(Role::OnTertiary, "#FFFFFF", "#4C2521"),
|
||||
(Role::TertiaryContainer, "#FFDAD6", "#673B36"),
|
||||
(Role::OnTertiaryContainer, "#673B36", "#FFDAD6"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"#000000",
|
||||
[
|
||||
(Role::Primary, "#8C4A60", "#FFB1C8"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#541D32"),
|
||||
(Role::PrimaryContainer, "#FFD9E2", "#703348"),
|
||||
(Role::OnPrimaryContainer, "#703348", "#FFD9E2"),
|
||||
(Role::Secondary, "#74565F", "#E3BDC6"),
|
||||
(Role::OnSecondary, "#FFFFFF", "#422931"),
|
||||
(Role::SecondaryContainer, "#FFD9E2", "#5A3F47"),
|
||||
(Role::OnSecondaryContainer, "#5A3F47", "#FFD9E2"),
|
||||
(Role::Tertiary, "#7C5635", "#EFBD94"),
|
||||
(Role::OnTertiary, "#FFFFFF", "#48290B"),
|
||||
(Role::TertiaryContainer, "#FFDCC1", "#623F20"),
|
||||
(Role::OnTertiaryContainer, "#623F20", "#FFDCC1"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"#FFFFFF",
|
||||
[
|
||||
(Role::Primary, "#006874", "#82D3E0"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#00363D"),
|
||||
(Role::PrimaryContainer, "#9EEFFD", "#004F58"),
|
||||
(Role::OnPrimaryContainer, "#004F58", "#9EEFFD"),
|
||||
(Role::Secondary, "#4A6267", "#B1CBD0"),
|
||||
(Role::OnSecondary, "#FFFFFF", "#1C3438"),
|
||||
(Role::SecondaryContainer, "#CDE7EC", "#334B4F"),
|
||||
(Role::OnSecondaryContainer, "#334B4F", "#CDE7EC"),
|
||||
(Role::Tertiary, "#525E7D", "#BAC6EA"),
|
||||
(Role::OnTertiary, "#FFFFFF", "#24304D"),
|
||||
(Role::TertiaryContainer, "#DAE2FF", "#3B4664"),
|
||||
(Role::OnTertiaryContainer, "#3B4664", "#DAE2FF"),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// 官方 `SchemeTonalSpot(seed)` 的中性表面(surface 与 surface-container-highest,light/dark)。
|
||||
const SEED_NEUTRALS: [(&str, &str, &str, &str, &str); 8] = [
|
||||
("#6750A4", "#FDF7FF", "#141218", "#E6E0E9", "#36343A"),
|
||||
("#FF0000", "#FFF8F6", "#1A1110", "#F1DFDC", "#3D3230"),
|
||||
("#00FF00", "#F8FBF1", "#11140F", "#E1E4DA", "#32362F"),
|
||||
("#0000FF", "#FBF8FF", "#131318", "#E4E1E9", "#34343A"),
|
||||
("#6E7C8A", "#F7F9FF", "#101418", "#E0E2E8", "#313539"),
|
||||
("#D000FF", "#FFF7FA", "#171217", "#EAE0E7", "#393338"),
|
||||
("#000000", "#FFF8F8", "#191113", "#EFDFE1", "#3C3235"),
|
||||
("#FFFFFF", "#F5FAFB", "#0E1415", "#DEE3E5", "#303637"),
|
||||
];
|
||||
|
||||
fn worst_channel(got: Color, want: Color) -> i16 {
|
||||
[(got.r, want.r), (got.g, want.g), (got.b, want.b)]
|
||||
.iter()
|
||||
.map(|(a, b)| (i16::from(*a) - i16::from(*b)).abs())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_schemes_match_official_on_the_accent_families() {
|
||||
let mut worst = 0;
|
||||
let mut off_by_one = 0;
|
||||
let mut checked = 0;
|
||||
let mut failures = Vec::new();
|
||||
for (seed_hex, roles) in ACCENT_SEEDS {
|
||||
let accent = Color::from_hex(seed_hex).expect("合法 hex");
|
||||
for (flavor, is_dark) in [(SchemeFlavor::Light, false), (SchemeFlavor::Dark, true)] {
|
||||
let scheme = Scheme::from_accent(flavor, accent);
|
||||
for (role, want_light, want_dark) in roles {
|
||||
let want_hex = if is_dark { want_dark } else { want_light };
|
||||
let got = scheme.get(role);
|
||||
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
|
||||
let delta = worst_channel(got, want);
|
||||
worst = worst.max(delta);
|
||||
checked += 1;
|
||||
if delta > 0 {
|
||||
off_by_one += 1;
|
||||
}
|
||||
if delta > CHANNEL_TOLERANCE {
|
||||
failures.push(format!(
|
||||
"{seed_hex}/{}/{} 是 {},官方 {want_hex}(差 {delta})",
|
||||
flavor.name(),
|
||||
role.name(),
|
||||
got.to_hex()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"{} 项强调色与官方不一致(容差 {CHANNEL_TOLERANCE}):\n{}",
|
||||
failures.len(),
|
||||
failures.join("\n")
|
||||
);
|
||||
println!("强调色对齐:{checked} 项,最大通道误差 {worst},非零误差 {off_by_one} 项");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_scheme_keeps_baseline_surfaces() {
|
||||
// 产品语义:换强调色不许让底色变脏(这是 `from_accent` 与官方 `from_seed` 的唯一差别)。
|
||||
let base = Scheme::baseline(SchemeFlavor::Light);
|
||||
for (seed_hex, _) in ACCENT_SEEDS {
|
||||
let accent = Color::from_hex(seed_hex).expect("合法 hex");
|
||||
let custom = Scheme::from_accent(SchemeFlavor::Light, accent);
|
||||
assert_eq!(custom.get(Role::Surface), base.get(Role::Surface));
|
||||
assert_eq!(custom.get(Role::Outline), base.get(Role::Outline));
|
||||
assert_eq!(custom.get(Role::Error), base.get(Role::Error));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_seed_matches_official_neutrals() {
|
||||
// 全量 `from_seed` 的中性派生必须与官方一致(否则「整窗染色」的色相就是错的)。
|
||||
for (seed_hex, light_surface, dark_surface, light_highest, dark_highest) in SEED_NEUTRALS {
|
||||
let seed = Color::from_hex(seed_hex).expect("合法 hex");
|
||||
for (flavor, want_surface, want_highest) in [
|
||||
(SchemeFlavor::Light, light_surface, light_highest),
|
||||
(SchemeFlavor::Dark, dark_surface, dark_highest),
|
||||
] {
|
||||
let scheme = Scheme::from_seed(flavor, seed);
|
||||
for (role, want_hex) in [
|
||||
(Role::Surface, want_surface),
|
||||
(Role::SurfaceContainerHighest, want_highest),
|
||||
] {
|
||||
let got = scheme.get(role);
|
||||
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
|
||||
assert!(
|
||||
worst_channel(got, want) <= CHANNEL_TOLERANCE,
|
||||
"{seed_hex}/{}/{} 是 {},官方 {want_hex}",
|
||||
flavor.name(),
|
||||
role.name(),
|
||||
got.to_hex()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_seed_and_from_accent_agree_on_the_accent_families() {
|
||||
// 两个入口的强调族应当逐像素相同:换种子时中性色不同,强调色只取决于种子色相。
|
||||
for (seed_hex, _) in ACCENT_SEEDS {
|
||||
let seed = Color::from_hex(seed_hex).expect("合法 hex");
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let by_seed = Scheme::from_seed(flavor, seed);
|
||||
let by_accent = Scheme::from_accent(flavor, seed);
|
||||
for role in [
|
||||
Role::Primary,
|
||||
Role::OnPrimary,
|
||||
Role::PrimaryContainer,
|
||||
Role::OnPrimaryContainer,
|
||||
Role::Secondary,
|
||||
Role::SecondaryContainer,
|
||||
Role::Tertiary,
|
||||
Role::TertiaryContainer,
|
||||
] {
|
||||
assert_eq!(
|
||||
by_seed.get(role),
|
||||
by_accent.get(role),
|
||||
"{seed_hex}/{} 的 {} 两个入口不一致",
|
||||
flavor.name(),
|
||||
role.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_hue_is_not_clipped_away() {
|
||||
// 旧缺陷②:强调色色相被 gamut 硬裁导致偏移。现在色相由 HCT 保住(只压彩度)。
|
||||
use unlucky_design::color::Hct;
|
||||
for (seed_hex, _) in ACCENT_SEEDS {
|
||||
let seed = Color::from_hex(seed_hex).expect("合法 hex");
|
||||
let seed_hue = Hct::from_color(seed).hue;
|
||||
let primary = Scheme::from_accent(SchemeFlavor::Light, seed).get(Role::Primary);
|
||||
let got = Hct::from_color(primary).hue;
|
||||
let delta = (got - seed_hue).abs().min(360.0 - (got - seed_hue).abs());
|
||||
assert!(delta < 1.0, "{seed_hex} 的 primary 色相偏了 {delta}°");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! 与官方 `material-color-utilities` 的**逐角色对齐**(baseline 方案)。
|
||||
//!
|
||||
//! 期望值不是「我们觉得对的颜色」,而是把官方 Java 实现(Apache-2.0)编译跑出来的结果:
|
||||
//! `new SchemeTonalSpot(Hct.fromInt(0xFF6750A4), isDark, 0.0, SpecVersion.SPEC_2021, Platform.PHONE)`
|
||||
//! 配上 `ColorSpec2021` 的 49 个动态色。跑法见 `NOTICE` 的归属声明——期望值表就是那份输出的
|
||||
//! 抄本(`seed,flavor,role,hex`)。
|
||||
//!
|
||||
//! 判据是**每通道误差 ≤1**:8 bit 量化本身就有半格误差,再加上 Java `double` 与 Rust `f64`
|
||||
//! 的末位差异,1 是「同一个算法」的合理上限;2 以上就是实现真的不一样了。
|
||||
|
||||
use unlucky_design::color::{Color, Hct, Role, Scheme, SchemeFlavor, BASELINE_SEED};
|
||||
|
||||
/// 每通道允许的最大误差。
|
||||
const CHANNEL_TOLERANCE: i16 = 1;
|
||||
|
||||
/// 官方 baseline 输出(seed `#6750A4`,Tonal Spot,ColorSpec2021,contrastLevel 0)。
|
||||
const BASELINE: [(Role, &str, &str); 49] = [
|
||||
(Role::Primary, "#65558F", "#CFBDFE"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#36275D"),
|
||||
(Role::PrimaryContainer, "#E9DDFF", "#4D3D75"),
|
||||
(Role::OnPrimaryContainer, "#4D3D75", "#E9DDFF"),
|
||||
(Role::Secondary, "#625B71", "#CBC2DB"),
|
||||
(Role::OnSecondary, "#FFFFFF", "#332D41"),
|
||||
(Role::SecondaryContainer, "#E8DEF8", "#4A4458"),
|
||||
(Role::OnSecondaryContainer, "#4A4458", "#E8DEF8"),
|
||||
(Role::Tertiary, "#7E5260", "#EFB8C8"),
|
||||
(Role::OnTertiary, "#FFFFFF", "#4A2532"),
|
||||
(Role::TertiaryContainer, "#FFD9E3", "#633B48"),
|
||||
(Role::OnTertiaryContainer, "#633B48", "#FFD9E3"),
|
||||
(Role::Error, "#BA1A1A", "#FFB4AB"),
|
||||
(Role::OnError, "#FFFFFF", "#690005"),
|
||||
(Role::ErrorContainer, "#FFDAD6", "#93000A"),
|
||||
(Role::OnErrorContainer, "#93000A", "#FFDAD6"),
|
||||
(Role::Surface, "#FDF7FF", "#141218"),
|
||||
(Role::OnSurface, "#1D1B20", "#E6E0E9"),
|
||||
(Role::SurfaceVariant, "#E7E0EB", "#49454E"),
|
||||
(Role::OnSurfaceVariant, "#49454E", "#CAC4CF"),
|
||||
(Role::Background, "#FDF7FF", "#141218"),
|
||||
(Role::OnBackground, "#1D1B20", "#E6E0E9"),
|
||||
(Role::SurfaceDim, "#DED8E0", "#141218"),
|
||||
(Role::SurfaceBright, "#FDF7FF", "#3B383E"),
|
||||
(Role::SurfaceContainerLowest, "#FFFFFF", "#0F0D13"),
|
||||
(Role::SurfaceContainerLow, "#F8F2FA", "#1D1B20"),
|
||||
(Role::SurfaceContainer, "#F2ECF4", "#211F24"),
|
||||
(Role::SurfaceContainerHigh, "#ECE6EE", "#2B292F"),
|
||||
(Role::SurfaceContainerHighest, "#E6E0E9", "#36343A"),
|
||||
(Role::Outline, "#7A757F", "#948F99"),
|
||||
(Role::OutlineVariant, "#CAC4CF", "#49454E"),
|
||||
(Role::InverseSurface, "#322F35", "#E6E0E9"),
|
||||
(Role::InverseOnSurface, "#F5EFF7", "#322F35"),
|
||||
(Role::InversePrimary, "#CFBDFE", "#65558F"),
|
||||
(Role::Shadow, "#000000", "#000000"),
|
||||
(Role::Scrim, "#000000", "#000000"),
|
||||
(Role::SurfaceTint, "#65558F", "#CFBDFE"),
|
||||
(Role::PrimaryFixed, "#E9DDFF", "#E9DDFF"),
|
||||
(Role::PrimaryFixedDim, "#CFBDFE", "#CFBDFE"),
|
||||
(Role::OnPrimaryFixed, "#201047", "#201047"),
|
||||
(Role::OnPrimaryFixedVariant, "#4D3D75", "#4D3D75"),
|
||||
(Role::SecondaryFixed, "#E8DEF8", "#E8DEF8"),
|
||||
(Role::SecondaryFixedDim, "#CBC2DB", "#CBC2DB"),
|
||||
(Role::OnSecondaryFixed, "#1E192B", "#1E192B"),
|
||||
(Role::OnSecondaryFixedVariant, "#4A4458", "#4A4458"),
|
||||
(Role::TertiaryFixed, "#FFD9E3", "#FFD9E3"),
|
||||
(Role::TertiaryFixedDim, "#EFB8C8", "#EFB8C8"),
|
||||
(Role::OnTertiaryFixed, "#31101D", "#31101D"),
|
||||
(Role::OnTertiaryFixedVariant, "#633B48", "#633B48"),
|
||||
];
|
||||
|
||||
/// 官方 `Hct.from(hue, chroma, tone)` 的色值(求解器与 gamut 映射的直接证据)。
|
||||
const HCT_PROBES: [(f64, f64, f64, &str); 12] = [
|
||||
(95.0, 36.0, 40.0, "#705D0D"),
|
||||
(95.0, 36.0, 90.0, "#FCE186"),
|
||||
(95.0, 16.0, 90.0, "#F0E2BB"),
|
||||
(96.0, 24.0, 80.0, "#D7C68D"),
|
||||
(25.0, 84.0, 40.0, "#BA1A1A"),
|
||||
(0.0, 0.0, 50.0, "#777777"),
|
||||
(200.0, 120.0, 70.0, "#00BEC3"),
|
||||
(30.0, 0.5, 98.0, "#FDF8F7"),
|
||||
(270.0, 200.0, 10.0, "#001847"),
|
||||
(120.0, 60.0, 99.0, "#FCFFDD"),
|
||||
(60.0, 6.0, 6.0, "#19120C"),
|
||||
(95.0, 36.0, 30.0, "#554500"),
|
||||
];
|
||||
|
||||
/// 三通道里最大的绝对误差。
|
||||
fn worst_channel(got: Color, want: Color) -> i16 {
|
||||
let pairs = [(got.r, want.r), (got.g, want.g), (got.b, want.b)];
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(a, b)| (i16::from(*a) - i16::from(*b)).abs())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_matches_official_on_all_forty_nine_roles() {
|
||||
// 注意:对齐的是**官方锚点**(种子 #6750A4 + 中性彩度 6),不是产品默认(M70 起是浅冷蓝)。
|
||||
let light = Scheme::official_baseline(SchemeFlavor::Light);
|
||||
let dark = Scheme::official_baseline(SchemeFlavor::Dark);
|
||||
let mut worst = 0;
|
||||
let mut off_by_one = 0;
|
||||
let mut failures = Vec::new();
|
||||
for (role, want_light, want_dark) in BASELINE {
|
||||
for (flavor, scheme, want_hex) in [
|
||||
(SchemeFlavor::Light, light, want_light),
|
||||
(SchemeFlavor::Dark, dark, want_dark),
|
||||
] {
|
||||
let got = scheme.get(role);
|
||||
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
|
||||
let delta = worst_channel(got, want);
|
||||
worst = worst.max(delta);
|
||||
if delta > 0 {
|
||||
off_by_one += 1;
|
||||
}
|
||||
if delta > CHANNEL_TOLERANCE {
|
||||
failures.push(format!(
|
||||
"{}/{} 是 {},官方 {want_hex}(差 {delta})",
|
||||
flavor.name(),
|
||||
role.name(),
|
||||
got.to_hex()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"{} 个角色与官方不一致(容差 {CHANNEL_TOLERANCE}):\n{}",
|
||||
failures.len(),
|
||||
failures.join("\n")
|
||||
);
|
||||
// 把误差统计打出来(`cargo test -- --nocapture` 可见),报告里要引用。
|
||||
println!("baseline 对齐:98 项,最大通道误差 {worst},非零误差 {off_by_one} 项");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hct_solver_matches_official_color_spec_2021() {
|
||||
for (hue, chroma, tone, want_hex) in HCT_PROBES {
|
||||
let got = Hct::from_components(hue, chroma, tone).to_color();
|
||||
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
|
||||
assert!(
|
||||
worst_channel(got, want) <= CHANNEL_TOLERANCE,
|
||||
"HCT({hue}, {chroma}, {tone}) 是 {},官方 {want_hex}",
|
||||
got.to_hex()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_primary_is_not_the_seed_and_that_is_official() {
|
||||
// 常见误解:官方基线的 primary 应该等于种子 #6750A4。官方不是——Tonal Spot 变体把
|
||||
// primary 的彩度固定成 36,而 #6750A4 的 HCT 彩度是 47.86,所以 primary 被收到 #65558F。
|
||||
// 这也是 Flutter `ColorScheme.fromSeed(0xFF6750A4)` 的 primary 值。
|
||||
let seed = Hct::from_color(BASELINE_SEED);
|
||||
assert!(
|
||||
(seed.chroma - 47.856_526).abs() < 1.0e-4,
|
||||
"种子彩度 {}",
|
||||
seed.chroma
|
||||
);
|
||||
let primary = Scheme::official_baseline(SchemeFlavor::Light).get(Role::Primary);
|
||||
assert_eq!(primary.to_hex(), "#65558F");
|
||||
assert_ne!(primary, BASELINE_SEED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_tone_axis_is_still_exact_lstar() {
|
||||
// gamut 映射只动彩度,不动 tone:每个角色的实测 L* 与它声明的 tone 相差 ≤1。
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let scheme = Scheme::official_baseline(flavor);
|
||||
for (role, color) in scheme.iter() {
|
||||
let tone = Scheme::tone_of(role, flavor);
|
||||
let got = f64::from(color.lstar());
|
||||
assert!(
|
||||
(got - tone).abs() <= 1.0,
|
||||
"{}/{} 的 tone 声明 {tone}、实测 {got}",
|
||||
flavor.name(),
|
||||
role.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_family_containers_are_separated_by_hct_chroma() {
|
||||
// 旧缺陷①:light 下 primary-container 与 secondary-container 撞色。
|
||||
// 现在两者彩度不同(36 vs 16),在 tone 90 上都不会被色域裁到同一个上限。
|
||||
let light = Scheme::official_baseline(SchemeFlavor::Light);
|
||||
let primary = light.get(Role::PrimaryContainer);
|
||||
let secondary = light.get(Role::SecondaryContainer);
|
||||
assert_ne!(primary, secondary);
|
||||
let c_primary = Hct::from_color(primary).chroma;
|
||||
let c_secondary = Hct::from_color(secondary).chroma;
|
||||
assert!(
|
||||
c_secondary < c_primary,
|
||||
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
|
||||
);
|
||||
// 深层原因:tone 90 上彩度 36 装得下,所以容器色是「真的有彩度的浅色」而不是被裁到灰。
|
||||
assert!(c_primary > 12.0, "primary-container 彩度只剩 {c_primary}");
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! M70:默认色候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)——四档的**唯一真源**与 pin。
|
||||
//!
|
||||
//! ## 这档候选到底改了什么
|
||||
//!
|
||||
//! 四档走的是**同一条** HCT 管道(`Palettes::from_seed_with_neutral_chroma` → `ColorSpec2021`
|
||||
//! tone 表 → `HctSolver` gamut 映射),差别只有两个入参:
|
||||
//!
|
||||
//! | 档 | 种子 | 种子 HCT 色相 | 中性调色板彩度 | 表面 | 主色观感 |
|
||||
//! |---|---|---|---|---|---|
|
||||
//! | ① 保守蓝 | `#2196F3` | 253.7° | 6(官方) | 近白/近黑,几乎无色 | 钢蓝 |
|
||||
//! | ② 冷调蓝 | `#2196F3` | 253.7° | 12 | 冷白/冷黑,带淡蓝调 | 钢蓝 |
|
||||
//! | ③ 浅冷蓝(备选) | `#87CEEB`(CSS `skyblue`) | 225.7° | 12 | 浅天蓝 | 偏青(teal) |
|
||||
//! | ④ 天蓝(**出厂默认,用户 2026-11-01 拍板**) | `#3C8DBC` | 239.5° | 12 | 浅天蓝 | 偏蓝的天蓝 |
|
||||
//!
|
||||
//! ③ 与 ④ 是同一件事的两种「蓝」:色相越低越偏青(teal),越高越偏钢蓝;
|
||||
//! 239.5° 正好夹在中间,主色从 ③ 的 `#0C6780` 变成 `#236488`。
|
||||
//!
|
||||
//! 两条必须知道的物理事实(写在测试里,免得下次又有人想「把主色调浅一点」):
|
||||
//!
|
||||
//! 1. **种子彩度是废参数**:Tonal Spot 把 primary 彩度钉在 36,种子只贡献色相。
|
||||
//! 所以「低彩度天蓝种子」得到的色板与「高彩度天蓝种子」**逐像素相同**
|
||||
//! (`seed_chroma_never_leaks_into_the_primary_ramp` 在 `palettes.rs` 里也钉了这条)。
|
||||
//! ③ 与 ② 的真正差别是**色相**(226° vs 254°),不是彩度。
|
||||
//! 2. **primary 的 tone 被钉在 40**,所以四档主色都是「中蓝」,不会更浅。
|
||||
//! 「浅」只能落在中性色上——这就是②/③/④提高中性彩度的原因,也是本项目唯一一处
|
||||
//! 对官方 Tonal Spot 的**有意偏离**(登记在 `color` 模块文档的「出厂默认色」小节)。
|
||||
//!
|
||||
//! ## 换默认色要动哪里
|
||||
//!
|
||||
//! `palettes::DEFAULT_SEED` / `palettes::DEFAULT_NEUTRAL_CHROMA` 两行常量,
|
||||
//! 然后重跑本文件:pin 表会逐角色告诉你哪几个 hex 变了,照抄即可(pin 是**抄本**,不是推导)。
|
||||
|
||||
use unlucky_design::color::{
|
||||
Color, Hct, Palettes, Role, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED,
|
||||
};
|
||||
|
||||
/// 一档候选:只有两个变量(种子 + 中性彩度),其余全是官方常数。
|
||||
struct Candidate {
|
||||
/// 短标签(色卡与 CSV 用)。
|
||||
tag: &'static str,
|
||||
/// 中文档名。
|
||||
title: &'static str,
|
||||
/// 种子 hex。
|
||||
seed_hex: &'static str,
|
||||
/// 中性调色板彩度(官方 Tonal Spot 是 6)。
|
||||
neutral_chroma: f64,
|
||||
/// 9 个展示角色的 **(角色, light, dark)** 抄本。
|
||||
pins: [(Role, &'static str, &'static str); 9],
|
||||
}
|
||||
|
||||
/// 四档候选(色卡 `DevDocs/ops/skyseed-candidates/` 用的就是这份数据)。
|
||||
const CANDIDATES: [Candidate; 4] = [
|
||||
Candidate {
|
||||
tag: "C1",
|
||||
title: "① 保守蓝(中性彩度 6 = 官方)",
|
||||
seed_hex: "#2196F3",
|
||||
neutral_chroma: 6.0,
|
||||
pins: [
|
||||
(Role::Primary, "#36618E", "#A0CAFD"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#003258"),
|
||||
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
|
||||
(Role::Secondary, "#535F70", "#BBC7DB"),
|
||||
(Role::Tertiary, "#6B5778", "#D6BEE4"),
|
||||
(Role::Surface, "#F8F9FF", "#111418"),
|
||||
(Role::SurfaceContainerLow, "#F2F3FA", "#191C20"),
|
||||
(Role::SurfaceContainerHighest, "#E1E2E8", "#32353A"),
|
||||
(Role::Outline, "#73777F", "#8D9199"),
|
||||
],
|
||||
},
|
||||
Candidate {
|
||||
tag: "C2",
|
||||
title: "② 冷调蓝(中性彩度 12)",
|
||||
seed_hex: "#2196F3",
|
||||
neutral_chroma: 12.0,
|
||||
pins: [
|
||||
(Role::Primary, "#36618E", "#A0CAFD"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#003258"),
|
||||
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
|
||||
(Role::Secondary, "#535F70", "#BBC7DB"),
|
||||
(Role::Tertiary, "#6B5778", "#D6BEE4"),
|
||||
(Role::Surface, "#F8F9FF", "#0C141E"),
|
||||
(Role::SurfaceContainerLow, "#EEF4FF", "#141C26"),
|
||||
(Role::SurfaceContainerHighest, "#DBE3F1", "#2D3540"),
|
||||
(Role::Outline, "#6D7887", "#8791A1"),
|
||||
],
|
||||
},
|
||||
Candidate {
|
||||
tag: "C3",
|
||||
title: "③ 浅冷蓝(中性彩度 12 + 偏青天蓝种子)",
|
||||
seed_hex: "#87CEEB",
|
||||
neutral_chroma: 12.0,
|
||||
pins: [
|
||||
(Role::Primary, "#0C6780", "#89D0ED"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#003544"),
|
||||
(Role::PrimaryContainer, "#BAEAFF", "#004D62"),
|
||||
(Role::Secondary, "#4C626B", "#B3CAD5"),
|
||||
(Role::Tertiary, "#5B5B7E", "#C4C3EB"),
|
||||
(Role::Surface, "#F3FAFF", "#07151B"),
|
||||
(Role::SurfaceContainerLow, "#E7F6FE", "#0F1D23"),
|
||||
(Role::SurfaceContainerHighest, "#D6E5ED", "#29373D"),
|
||||
(Role::Outline, "#687A82", "#81949C"),
|
||||
],
|
||||
},
|
||||
Candidate {
|
||||
tag: "C4",
|
||||
title: "④ 天蓝(中性彩度 12 + 夹在中间的天蓝种子)",
|
||||
seed_hex: "#3C8DBC",
|
||||
neutral_chroma: 12.0,
|
||||
pins: [
|
||||
(Role::Primary, "#236488", "#93CDF6"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#00344C"),
|
||||
(Role::PrimaryContainer, "#C8E6FF", "#004C6D"),
|
||||
(Role::Secondary, "#4F616E", "#B7C9D8"),
|
||||
(Role::Tertiary, "#63597C", "#CDC0E9"),
|
||||
(Role::Surface, "#F6FAFF", "#09151C"),
|
||||
(Role::SurfaceContainerLow, "#EBF5FF", "#111D25"),
|
||||
(Role::SurfaceContainerHighest, "#D8E4EF", "#2B363F"),
|
||||
(Role::Outline, "#6A7985", "#84939F"),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/// 四档共同点:种子的 HCT 坐标(色相是候选之间**唯一**有效的差别)。
|
||||
fn seed_hct(candidate: &Candidate) -> Hct {
|
||||
Hct::from_color(Color::from_hex(candidate.seed_hex).expect("候选种子必须是合法 hex"))
|
||||
}
|
||||
|
||||
/// 按候选参数走**产品同一条**派生路径。
|
||||
fn scheme(candidate: &Candidate, flavor: SchemeFlavor) -> Scheme {
|
||||
Scheme::from_palettes(
|
||||
&Palettes::from_seed_with_neutral_chroma(
|
||||
seed_hct(candidate).to_color(),
|
||||
candidate.neutral_chroma,
|
||||
),
|
||||
flavor,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_match_their_pins() {
|
||||
let mut drift = Vec::new();
|
||||
for candidate in &CANDIDATES {
|
||||
for (role, want_light, want_dark) in candidate.pins {
|
||||
for (flavor, want_hex) in [
|
||||
(SchemeFlavor::Light, want_light),
|
||||
(SchemeFlavor::Dark, want_dark),
|
||||
] {
|
||||
let got = scheme(candidate, flavor).get(role).to_hex();
|
||||
if got != want_hex {
|
||||
drift.push(format!(
|
||||
"{}{} / {}/{} 实测 {got},pin {want_hex}",
|
||||
candidate.tag,
|
||||
if flavor.is_dark() { "-dark" } else { "" },
|
||||
candidate.title,
|
||||
role.name()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
drift.is_empty(),
|
||||
"候选色值漂了(换默认色后请照抄下面这些 hex 到 pin 表):\n{}",
|
||||
drift.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_only_move_the_two_declared_inputs() {
|
||||
// 「不绕算法」的证据:四档的调色板彩度与官方 Tonal Spot 逐项一致,
|
||||
// 唯一的差别是中性彩度的入参(以及种子色相)。
|
||||
let official = Palettes::official_baseline();
|
||||
for candidate in &CANDIDATES {
|
||||
let p = Palettes::from_seed_with_neutral_chroma(
|
||||
seed_hct(candidate).to_color(),
|
||||
candidate.neutral_chroma,
|
||||
);
|
||||
assert_eq!(p.primary.chroma(), official.primary.chroma());
|
||||
assert_eq!(p.secondary.chroma(), official.secondary.chroma());
|
||||
assert_eq!(p.tertiary.chroma(), official.tertiary.chroma());
|
||||
assert_eq!(p.error, official.error, "error 族不该被候选影响");
|
||||
assert_eq!(p.neutral.chroma(), candidate.neutral_chroma);
|
||||
assert!(
|
||||
(p.tertiary.hue_deg() - (p.primary.hue_deg() + 60.0)).abs() < 1.0e-9,
|
||||
"{} 的 tertiary 不再是 +60°",
|
||||
candidate.tag
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_candidates_are_actually_distinguishable() {
|
||||
// 「四档不像同一档」的机器判据。注意 light 的 surface(tone 98)在 8 bit 下已经贴顶,
|
||||
// 中性彩度 6→12 会量化到同一个 #F8F9FF——所以 ①② 的判据落在 container 系与 dark 模式上,
|
||||
// 这也是色卡上 ①② 的差别主要体现在卡片/浮层与深色模式的原因。
|
||||
let light = |c: &Candidate| scheme(c, SchemeFlavor::Light);
|
||||
let dark = |c: &Candidate| scheme(c, SchemeFlavor::Dark);
|
||||
let (c1, c2, c3, c4) = (
|
||||
light(&CANDIDATES[0]),
|
||||
light(&CANDIDATES[1]),
|
||||
light(&CANDIDATES[2]),
|
||||
light(&CANDIDATES[3]),
|
||||
);
|
||||
for role in [Role::SurfaceContainerLow, Role::SurfaceContainerHighest] {
|
||||
assert_ne!(
|
||||
c1.get(role),
|
||||
c2.get(role),
|
||||
"①与②的 {} 相同:中性彩度没起作用",
|
||||
role.name()
|
||||
);
|
||||
assert_ne!(c2.get(role), c3.get(role), "②与③的 {} 相同", role.name());
|
||||
// ③④ 的中性彩度相同,差别只允许来自色相
|
||||
assert_ne!(c3.get(role), c4.get(role), "③与④的 {} 相同", role.name());
|
||||
}
|
||||
assert_ne!(
|
||||
c3.get(Role::Primary),
|
||||
c4.get(Role::Primary),
|
||||
"③与④的主色必须真的不同(否则第 4 档白加)"
|
||||
);
|
||||
assert_ne!(
|
||||
dark(&CANDIDATES[0]).get(Role::Surface),
|
||||
dark(&CANDIDATES[1]).get(Role::Surface),
|
||||
"深色模式的 surface 应当能看出中性彩度差别"
|
||||
);
|
||||
let hue_gap = |a: &Candidate, b: &Candidate| {
|
||||
let (x, y) = (seed_hct(a).hue, seed_hct(b).hue);
|
||||
(x - y).abs().min(360.0 - (x - y).abs())
|
||||
};
|
||||
assert!(
|
||||
hue_gap(&CANDIDATES[0], &CANDIDATES[1]) < 0.01,
|
||||
"①② 应当只差中性彩度"
|
||||
);
|
||||
// ③ 偏青 / ④ 居中的分界靠色相:②→③ 与 ③→④ 都要肉眼可分的量级。
|
||||
for (i, j) in [(1, 2), (2, 3)] {
|
||||
assert!(
|
||||
(10.0..=40.0).contains(&hue_gap(&CANDIDATES[i], &CANDIDATES[j])),
|
||||
"{}→{} 的色相差应当明显(10°~40°),实测 {}",
|
||||
CANDIDATES[i].tag,
|
||||
CANDIDATES[j].tag,
|
||||
hue_gap(&CANDIDATES[i], &CANDIDATES[j])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_default_is_the_adopted_candidate() {
|
||||
// 用户 2026-11-01 拍板采用 **④ 天蓝**。这条用例就是「默认值 ↔ 候选表」的绑定器:
|
||||
// 谁改了 `DEFAULT_SEED`/`DEFAULT_NEUTRAL_CHROMA` 却没同步这里(或反过来),它会立刻红。
|
||||
// 断言一律比 **hex / 数值**,这样失败信息里直接能读到「期望 ④、实测几」。
|
||||
let adopted = &CANDIDATES[3];
|
||||
assert_eq!(
|
||||
DEFAULT_SEED.to_hex(),
|
||||
"#3C8DBC",
|
||||
"DEFAULT_SEED 必须仍是已采用的 ④ 天蓝:期望 #3C8DBC(④),实测 {}({})",
|
||||
DEFAULT_SEED.to_hex(),
|
||||
if DEFAULT_SEED.to_hex() == CANDIDATES[2].seed_hex {
|
||||
"③ 浅冷蓝——要改回 ③ 请连偏离登记与 pin 一起改"
|
||||
} else {
|
||||
"不是候选表里的任何一个种子"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
DEFAULT_SEED.to_hex(),
|
||||
adopted.seed_hex,
|
||||
"DEFAULT_SEED({})与已采用档 {} 的种子({})不一致",
|
||||
DEFAULT_SEED.to_hex(),
|
||||
adopted.tag,
|
||||
adopted.seed_hex
|
||||
);
|
||||
assert_eq!(
|
||||
DEFAULT_NEUTRAL_CHROMA, 12.0,
|
||||
"DEFAULT_NEUTRAL_CHROMA 必须仍是已采用的 12,实测 {}",
|
||||
DEFAULT_NEUTRAL_CHROMA
|
||||
);
|
||||
assert_eq!(
|
||||
DEFAULT_NEUTRAL_CHROMA, adopted.neutral_chroma,
|
||||
"DEFAULT_NEUTRAL_CHROMA({})与已采用档 {} 的中性彩度({})不一致",
|
||||
DEFAULT_NEUTRAL_CHROMA, adopted.tag, adopted.neutral_chroma
|
||||
);
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let product = Scheme::baseline(flavor);
|
||||
let expected = scheme(adopted, flavor);
|
||||
for (role, got) in product.iter() {
|
||||
assert_eq!(
|
||||
got.to_hex(),
|
||||
expected.get(role).to_hex(),
|
||||
"产品默认与已采用档 {} 的 {}({} 模式)不一致",
|
||||
adopted.tag,
|
||||
role.name(),
|
||||
flavor.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_tint_stays_a_tint() {
|
||||
// 门禁:中性彩度提高是「淡蓝调」不是「把窗口染成蓝纸」。surface 的 HCT 彩度必须
|
||||
// 明显低于 primary 的 36,且 surface 的 Y 仍 > 0.85(浅色模式还是浅底)。
|
||||
for candidate in &CANDIDATES {
|
||||
let light = scheme(candidate, SchemeFlavor::Light);
|
||||
let surface = light.get(Role::Surface);
|
||||
let chroma = Hct::from_color(surface).chroma;
|
||||
assert!(
|
||||
chroma <= 1.2 * candidate.neutral_chroma,
|
||||
"{} 的 surface 彩度 {chroma} 超过中性彩度入参的两倍",
|
||||
candidate.tag
|
||||
);
|
||||
assert!(surface.relative_luminance() > 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dump_candidates_for_the_color_card() {
|
||||
// 色卡脚本读的就是这两行格式(`SEED|` / `ROLE|`)。跑法:
|
||||
// cargo test -p unlucky-design --test skyseed_candidates -- --nocapture
|
||||
for candidate in &CANDIDATES {
|
||||
let hct = seed_hct(candidate);
|
||||
println!(
|
||||
"SEED|{}|{}|{}|{}|hue={:.2}|chroma={:.2}",
|
||||
candidate.tag,
|
||||
candidate.title,
|
||||
candidate.seed_hex,
|
||||
candidate.neutral_chroma,
|
||||
hct.hue,
|
||||
hct.chroma
|
||||
);
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let s = scheme(candidate, flavor);
|
||||
for role in Role::ALL {
|
||||
println!(
|
||||
"ROLE|{}|{}|{}|{}",
|
||||
candidate.tag,
|
||||
flavor.name(),
|
||||
role.name(),
|
||||
s.get(role).to_hex().trim_start_matches('#')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! 令牌契约测试:从**消费方视角**(只用公开 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() {
|
||||
// 派生式(**这条测试钉的就是它**):`stroke = 1.5 × 24 / render_px`,1.5 是视觉线宽、24 是 viewBox。
|
||||
//
|
||||
// 两个锚点,一个不随本次收敛变化、一个随令牌取值:
|
||||
// - 20px(`IconSize::Nav`,本轮没动)→ 1.5 × 24 / 20 = **1.8**
|
||||
// - 18px(`IconSize::Button`,M86 由 16 收敛到官方 18)→ 1.5 × 24 / 18 = **2.0**
|
||||
//
|
||||
// ⚠️ **这条只保证「烘焙路径」**(`crates/app-iced/src/icons.rs::view_at`:按盒尺寸烘焙 SVG,
|
||||
// 于是一个 viewBox 单位的 stroke 落到盒子后正好是 1.5 视觉像素)。
|
||||
// 它**不**保证「24 烘焙 + 布局缩到 18」那条路径——那条会得到 `1.5 × 18/24 = 1.125px`,
|
||||
// 正是复审 r1 P2-2 实测到的 1.04~1.25px 缺陷。所以组件侧凡是盒 ≠ `RENDER_PX`(24) 的调用点
|
||||
// 都必须走 `view_at`(见 `icons.rs::tests::view_at_bakes_the_requested_size` 与
|
||||
// `crates/app-iced/src/components/gate.rs` 的 Q9 扫描)。
|
||||
//
|
||||
// 之所以写死字面量而不写成 `stroke_width_for(IconSize::Nav.px(), 1.5)`:后者等于拿实现算实现,
|
||||
// 派生式一旦被改坏(例如把 ×24 写成 ×20)它照样绿。写死才会红。
|
||||
// Slint 口径(绝对 1.5px,不乘 viewBox)在这两档都会得到 1.5 —— 那正是这条要防的错。
|
||||
let nav_width = IconKind::Settings.icon().stroke_width(IconSize::Nav.px());
|
||||
assert!(
|
||||
(nav_width - 1.8).abs() < 1.0e-6,
|
||||
"20px 渲染应取 1.8 个 viewBox 单位(锚点,不随 IconSize 收敛变化)"
|
||||
);
|
||||
let button_width = IconKind::Settings
|
||||
.icon()
|
||||
.stroke_width(IconSize::Button.px());
|
||||
assert!(
|
||||
(button_width - 2.0).abs() < 1.0e-6,
|
||||
"18px 渲染应取 2.0 个 viewBox 单位(M86:Button 由 16→18,旧期望 2.25 是 16px 的派生值)"
|
||||
);
|
||||
// 视觉宽度的往返一致性(这条是「别照抄 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() {
|
||||
// M69(G9)在末尾追加 Checkbox/Radio/Search/NavigationDrawer/Divider:20 → 25。
|
||||
assert_eq!(Component::ALL.len(), 25);
|
||||
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);
|
||||
// 末尾追加的 5 个不许改动既有次序。
|
||||
assert_eq!(Component::ALL[0], Component::TopAppBar);
|
||||
assert_eq!(Component::ALL[19], Component::EmptyState);
|
||||
}
|
||||
|
||||
#[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, 36.0);
|
||||
for tone in [20.0_f64, 40.0, 60.0, 80.0] {
|
||||
let color = palette.tone(tone);
|
||||
assert!(
|
||||
(f64::from(color.lstar()) - tone).abs() <= 1.0,
|
||||
"tone {tone} 实际 {}",
|
||||
color.lstar()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! 官方数值钉死(二):**色彩 tone 表 49 条**、7 条 easing 曲线、16 档时长、28 个图标下标、
|
||||
//! 形状阶梯,以及「同族容器不撞色」的回归(reviewer P3-2)。
|
||||
//!
|
||||
//! 期望值全部独立于实现重写;实现改一个数、表不改,这里就红。
|
||||
|
||||
use unlucky_design::color::{Role, Scheme, SchemeFlavor};
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::motion::{DurationToken, Easing};
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
|
||||
fn expect_tone(role: Role, light: f64, dark: f64) {
|
||||
assert_eq!(
|
||||
Scheme::tone_of(role, SchemeFlavor::Light),
|
||||
light,
|
||||
"{} 的 light tone 被改动了",
|
||||
role.name()
|
||||
);
|
||||
assert_eq!(
|
||||
Scheme::tone_of(role, SchemeFlavor::Dark),
|
||||
dark,
|
||||
"{} 的 dark tone 被改动了",
|
||||
role.name()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_forty_nine_tones_are_pinned() {
|
||||
expect_tone(Role::Primary, 40.0, 80.0);
|
||||
expect_tone(Role::OnPrimary, 100.0, 20.0);
|
||||
expect_tone(Role::PrimaryContainer, 90.0, 30.0);
|
||||
expect_tone(Role::OnPrimaryContainer, 30.0, 90.0);
|
||||
expect_tone(Role::Secondary, 40.0, 80.0);
|
||||
expect_tone(Role::OnSecondary, 100.0, 20.0);
|
||||
expect_tone(Role::SecondaryContainer, 90.0, 30.0);
|
||||
expect_tone(Role::OnSecondaryContainer, 30.0, 90.0);
|
||||
expect_tone(Role::Tertiary, 40.0, 80.0);
|
||||
expect_tone(Role::OnTertiary, 100.0, 20.0);
|
||||
expect_tone(Role::TertiaryContainer, 90.0, 30.0);
|
||||
expect_tone(Role::OnTertiaryContainer, 30.0, 90.0);
|
||||
expect_tone(Role::Error, 40.0, 80.0);
|
||||
expect_tone(Role::OnError, 100.0, 20.0);
|
||||
expect_tone(Role::ErrorContainer, 90.0, 30.0);
|
||||
expect_tone(Role::OnErrorContainer, 30.0, 90.0);
|
||||
expect_tone(Role::Surface, 98.0, 6.0);
|
||||
expect_tone(Role::OnSurface, 10.0, 90.0);
|
||||
expect_tone(Role::SurfaceVariant, 90.0, 30.0);
|
||||
expect_tone(Role::OnSurfaceVariant, 30.0, 80.0);
|
||||
expect_tone(Role::Background, 98.0, 6.0);
|
||||
expect_tone(Role::OnBackground, 10.0, 90.0);
|
||||
expect_tone(Role::SurfaceDim, 87.0, 6.0);
|
||||
expect_tone(Role::SurfaceBright, 98.0, 24.0);
|
||||
expect_tone(Role::SurfaceContainerLowest, 100.0, 4.0);
|
||||
expect_tone(Role::SurfaceContainerLow, 96.0, 10.0);
|
||||
expect_tone(Role::SurfaceContainer, 94.0, 12.0);
|
||||
expect_tone(Role::SurfaceContainerHigh, 92.0, 17.0);
|
||||
expect_tone(Role::SurfaceContainerHighest, 90.0, 22.0);
|
||||
expect_tone(Role::Outline, 50.0, 60.0);
|
||||
expect_tone(Role::OutlineVariant, 80.0, 30.0);
|
||||
expect_tone(Role::InverseSurface, 20.0, 90.0);
|
||||
expect_tone(Role::InverseOnSurface, 95.0, 20.0);
|
||||
expect_tone(Role::InversePrimary, 80.0, 40.0);
|
||||
expect_tone(Role::Shadow, 0.0, 0.0);
|
||||
expect_tone(Role::Scrim, 0.0, 0.0);
|
||||
expect_tone(Role::SurfaceTint, 40.0, 80.0);
|
||||
expect_tone(Role::PrimaryFixed, 90.0, 90.0);
|
||||
expect_tone(Role::PrimaryFixedDim, 80.0, 80.0);
|
||||
expect_tone(Role::OnPrimaryFixed, 10.0, 10.0);
|
||||
expect_tone(Role::OnPrimaryFixedVariant, 30.0, 30.0);
|
||||
expect_tone(Role::SecondaryFixed, 90.0, 90.0);
|
||||
expect_tone(Role::SecondaryFixedDim, 80.0, 80.0);
|
||||
expect_tone(Role::OnSecondaryFixed, 10.0, 10.0);
|
||||
expect_tone(Role::OnSecondaryFixedVariant, 30.0, 30.0);
|
||||
expect_tone(Role::TertiaryFixed, 90.0, 90.0);
|
||||
expect_tone(Role::TertiaryFixedDim, 80.0, 80.0);
|
||||
expect_tone(Role::OnTertiaryFixed, 10.0, 10.0);
|
||||
expect_tone(Role::OnTertiaryFixedVariant, 30.0, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn easing_curves_are_pinned() {
|
||||
let cases: [(Easing, f32, f32, f32, f32); 7] = [
|
||||
(Easing::Linear, 0.0, 0.0, 1.0, 1.0),
|
||||
(Easing::Standard, 0.2, 0.0, 0.0, 1.0),
|
||||
(Easing::StandardDecelerate, 0.0, 0.0, 0.0, 1.0),
|
||||
(Easing::StandardAccelerate, 0.3, 0.0, 1.0, 1.0),
|
||||
(Easing::Emphasized, 0.2, 0.0, 0.0, 1.0),
|
||||
(Easing::EmphasizedDecelerate, 0.05, 0.7, 0.1, 1.0),
|
||||
(Easing::EmphasizedAccelerate, 0.3, 0.0, 0.8, 0.15),
|
||||
];
|
||||
for (easing, x1, y1, x2, y2) in cases {
|
||||
let curve = easing.curve();
|
||||
assert_eq!(
|
||||
(curve.x1, curve.y1, curve.x2, curve.y2),
|
||||
(x1, y1, x2, y2),
|
||||
"{easing:?} 的曲线参数被改动了"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_are_pinned() {
|
||||
let cases: [(DurationToken, u32); 16] = [
|
||||
(DurationToken::Short1, 50),
|
||||
(DurationToken::Short2, 100),
|
||||
(DurationToken::Short3, 150),
|
||||
(DurationToken::Short4, 200),
|
||||
(DurationToken::Medium1, 250),
|
||||
(DurationToken::Medium2, 300),
|
||||
(DurationToken::Medium3, 350),
|
||||
(DurationToken::Medium4, 400),
|
||||
(DurationToken::Long1, 450),
|
||||
(DurationToken::Long2, 500),
|
||||
(DurationToken::Long3, 550),
|
||||
(DurationToken::Long4, 600),
|
||||
(DurationToken::ExtraLong1, 700),
|
||||
(DurationToken::ExtraLong2, 800),
|
||||
(DurationToken::ExtraLong3, 900),
|
||||
(DurationToken::ExtraLong4, 1000),
|
||||
];
|
||||
for (token, ms) in cases {
|
||||
assert_eq!(token.ms(), ms, "{} 的毫秒值被改动了", token.name());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_presets_are_pinned() {
|
||||
use unlucky_design::motion::{
|
||||
spring, MotionSchemeKind, Spring, SpringCategory, SpringSpeed, SPRING_EFFECTS_DEFAULT,
|
||||
SPRING_EFFECTS_FAST, SPRING_EFFECTS_SLOW, SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||
SPRING_EXPRESSIVE_SPATIAL_FAST, SPRING_EXPRESSIVE_SPATIAL_SLOW,
|
||||
SPRING_STANDARD_SPATIAL_DEFAULT, SPRING_STANDARD_SPATIAL_FAST,
|
||||
SPRING_STANDARD_SPATIAL_SLOW,
|
||||
};
|
||||
// A 级:StandardMotionTokens / ExpressiveMotionTokens(damping = 阻尼比 ζ,stiffness = k)。
|
||||
let cases: [(Spring, f32, f32); 9] = [
|
||||
// standard 方案 spatial:ζ=0.9(保守)
|
||||
(SPRING_STANDARD_SPATIAL_FAST, 1400.0, 0.9),
|
||||
(SPRING_STANDARD_SPATIAL_DEFAULT, 700.0, 0.9),
|
||||
(SPRING_STANDARD_SPATIAL_SLOW, 300.0, 0.9),
|
||||
// expressive 方案 spatial:ζ=0.6/0.8(回弹明显)
|
||||
(SPRING_EXPRESSIVE_SPATIAL_FAST, 800.0, 0.6),
|
||||
(SPRING_EXPRESSIVE_SPATIAL_DEFAULT, 380.0, 0.8),
|
||||
(SPRING_EXPRESSIVE_SPATIAL_SLOW, 200.0, 0.8),
|
||||
// effects 两套同值:ζ=1.0(不过冲)
|
||||
(SPRING_EFFECTS_FAST, 3800.0, 1.0),
|
||||
(SPRING_EFFECTS_DEFAULT, 1600.0, 1.0),
|
||||
(SPRING_EFFECTS_SLOW, 800.0, 1.0),
|
||||
];
|
||||
for (entry, stiffness, damping_ratio) in cases {
|
||||
assert_eq!(entry.stiffness, stiffness, "弹簧刚度被改动");
|
||||
assert_eq!(entry.damping_ratio, damping_ratio, "弹簧阻尼比被改动");
|
||||
}
|
||||
// 二维表查询与常量必须一致(改了表结构也别想绕过 pin)。
|
||||
assert_eq!(
|
||||
spring(
|
||||
MotionSchemeKind::Expressive,
|
||||
SpringCategory::Spatial,
|
||||
SpringSpeed::Default
|
||||
),
|
||||
SPRING_EXPRESSIVE_SPATIAL_DEFAULT
|
||||
);
|
||||
assert_eq!(
|
||||
spring(
|
||||
MotionSchemeKind::Standard,
|
||||
SpringCategory::Spatial,
|
||||
SpringSpeed::Fast
|
||||
),
|
||||
SPRING_STANDARD_SPATIAL_FAST
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_indices_are_pinned() {
|
||||
let names = [
|
||||
"instances",
|
||||
"download",
|
||||
"logs",
|
||||
"settings",
|
||||
"ai",
|
||||
"play",
|
||||
"stop",
|
||||
"search",
|
||||
"plus",
|
||||
"close",
|
||||
"minimize",
|
||||
"maximize",
|
||||
"chevron-left",
|
||||
"chevron-right",
|
||||
"chevron-down",
|
||||
"expand",
|
||||
"send",
|
||||
"check",
|
||||
"warn",
|
||||
"error",
|
||||
"more",
|
||||
"sidebar",
|
||||
"restore",
|
||||
"user",
|
||||
"refresh",
|
||||
"filter",
|
||||
"folder",
|
||||
"trash",
|
||||
];
|
||||
assert_eq!(IconKind::ALL.len(), names.len());
|
||||
for (i, kind) in IconKind::ALL.iter().enumerate() {
|
||||
assert_eq!(
|
||||
kind.name(),
|
||||
names[i],
|
||||
"第 {i} 个图标被换位/改名了(序号即持久化 id)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_ladder_is_pinned() {
|
||||
let cases: [(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 cases {
|
||||
assert_eq!(scale.dp(), dp, "{scale:?} 的半径档位被改动了");
|
||||
}
|
||||
assert_eq!(ShapeScale::Full.radius_px(64.0), 32.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_family_containers_do_not_collide() {
|
||||
// P3-2 回归:light 模式下 primary-container / secondary-container 曾经被 gamut 裁剪成同色。
|
||||
let light = Scheme::baseline(SchemeFlavor::Light);
|
||||
let primary = light.get(Role::PrimaryContainer);
|
||||
let secondary = light.get(Role::SecondaryContainer);
|
||||
assert_ne!(
|
||||
primary, secondary,
|
||||
"同族容器撞色(彩度压缩没生效):{primary:?}"
|
||||
);
|
||||
// secondary-container 必须比 primary-container 更灰(M3 的「次级」语义)。
|
||||
let c_primary = unlucky_design::color::Hct::from_color(primary).chroma;
|
||||
let c_secondary = unlucky_design::color::Hct::from_color(secondary).chroma;
|
||||
assert!(
|
||||
c_secondary < c_primary,
|
||||
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
|
||||
);
|
||||
// tertiary 与 primary 也该可区分(不同色相)。
|
||||
assert_ne!(light.get(Role::TertiaryContainer), primary);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! 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]);
|
||||
// 2dp 只能通过带尺寸闸的 `radius_px` 拿到(`dp()` 是 crate 内部口,外部看不见)。
|
||||
assert_eq!(
|
||||
ComponentRadius::Micro.radius_px(20.0),
|
||||
Some(2.0),
|
||||
"官方 Checkbox RadiusSize = 2.dp"
|
||||
);
|
||||
// 尺寸闸:>24dp 一律拿不到微半径(D32 的「只允许 ≤24dp 微控件」)。
|
||||
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);
|
||||
// 边长小于两倍半径时按边长一半折算(2dp 元素不能有 2dp 圆角)。
|
||||
assert_eq!(ComponentRadius::Micro.radius_px(2.0), Some(1.0));
|
||||
}
|
||||
|
||||
#[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");
|
||||
// A 级(§20.4):图标可视左缘 16 = 文本框内容内边距 12 + `SearchBarIconOffsetX = 4`。
|
||||
assert_eq!(cm.pad_start, Space::X3.dp(), "内容内边距 12");
|
||||
assert_eq!(cm.icon_offset, 4.0, "SearchBarIconOffsetX = 4");
|
||||
assert_eq!(cm.pad_start + cm.icon_offset, 16.0, "图标可视左缘必须是 16");
|
||||
// 右侧同式:图标右缘也是 16。
|
||||
assert_eq!(cm.pad_end, Space::X3.dp());
|
||||
assert_eq!(cm.pad_end + cm.icon_offset, 16.0, "图标可视右缘必须是 16");
|
||||
// 守门:不许把 pad 本身写成 16(否则 pad + offset = 20,越过 rubric 的 ±2px)。
|
||||
assert_ne!(cm.pad_start, 16.0);
|
||||
assert_ne!(cm.pad_end, 16.0);
|
||||
// 搜索条圆角 = 高/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());
|
||||
// 与 search bar 同一条公式:图标边 = pad + icon_offset(抽屉的 offset 是 0)。
|
||||
assert_eq!(cm.pad_start + cm.icon_offset, 16.0, "图标起点 = 项内 start");
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! 官方数值钉死(一):**排版两张表**(baseline 15 档 + Expressive 强调 15 档)。
|
||||
//!
|
||||
//! 表的来源是 A 级 `androidx.compose.material3.tokens.TypeScaleTokens`(v0_103)。这里的期望值是
|
||||
//! **独立于实现重写的一份**:任何人改动 `TypeRole::style()` / `emphasized()` 的数值,都必须同时改
|
||||
//! 这张表才会同时绿——这就是「冻结」的含义(reviewer P2-1 的变异测试就是打这里)。
|
||||
|
||||
use unlucky_design::typography::{FontFamily, TypeRole, Weight};
|
||||
|
||||
/// baseline 单档对拍。
|
||||
fn expect_baseline(
|
||||
role: TypeRole,
|
||||
size: f32,
|
||||
line_height: f32,
|
||||
weight: Weight,
|
||||
tracking: f32,
|
||||
family: FontFamily,
|
||||
) {
|
||||
let got = role.style();
|
||||
assert_eq!(
|
||||
(
|
||||
got.size_px,
|
||||
got.line_height_px,
|
||||
got.weight,
|
||||
got.letter_spacing_px,
|
||||
got.family
|
||||
),
|
||||
(size, line_height, weight, tracking, family),
|
||||
"{} 的 baseline 值被改动了(官方 TypeScaleTokens)",
|
||||
role.name()
|
||||
);
|
||||
}
|
||||
|
||||
/// Expressive 强调档单档对拍。
|
||||
fn expect_emphasized(role: TypeRole, size: f32, line_height: f32, weight: Weight, tracking: f32) {
|
||||
let got = role.emphasized();
|
||||
assert_eq!(
|
||||
(
|
||||
got.size_px,
|
||||
got.line_height_px,
|
||||
got.weight,
|
||||
got.letter_spacing_px
|
||||
),
|
||||
(size, line_height, weight, tracking),
|
||||
"{} 的 Expressive 强调档值被改动了(官方 TypeScaleTokens *Emphasized*)",
|
||||
role.name()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_type_scale_matches_official_tokens() {
|
||||
use FontFamily::{Display, Text};
|
||||
use Weight::{Bold, Medium, Regular};
|
||||
expect_baseline(TypeRole::DisplayLarge, 57.0, 64.0, Regular, -0.2, Display);
|
||||
expect_baseline(TypeRole::DisplayMedium, 45.0, 52.0, Regular, 0.0, Display);
|
||||
expect_baseline(TypeRole::DisplaySmall, 36.0, 44.0, Regular, 0.0, Display);
|
||||
expect_baseline(TypeRole::HeadlineLarge, 32.0, 40.0, Regular, 0.0, Display);
|
||||
expect_baseline(TypeRole::HeadlineMedium, 28.0, 36.0, Regular, 0.0, Display);
|
||||
expect_baseline(TypeRole::HeadlineSmall, 24.0, 32.0, Regular, 0.0, Display);
|
||||
expect_baseline(TypeRole::TitleLarge, 22.0, 28.0, Regular, 0.0, Display);
|
||||
expect_baseline(TypeRole::TitleMedium, 16.0, 24.0, Medium, 0.2, Text);
|
||||
expect_baseline(TypeRole::TitleSmall, 14.0, 20.0, Medium, 0.1, Text);
|
||||
expect_baseline(TypeRole::BodyLarge, 16.0, 24.0, Regular, 0.5, Text);
|
||||
expect_baseline(TypeRole::BodyMedium, 14.0, 20.0, Regular, 0.2, Text);
|
||||
expect_baseline(TypeRole::BodySmall, 12.0, 16.0, Regular, 0.4, Text);
|
||||
expect_baseline(TypeRole::LabelLarge, 14.0, 20.0, Medium, 0.1, Text);
|
||||
expect_baseline(TypeRole::LabelMedium, 12.0, 16.0, Medium, 0.5, Text);
|
||||
expect_baseline(TypeRole::LabelSmall, 11.0, 16.0, Medium, 0.5, Text);
|
||||
let _ = Bold;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expressive_emphasis_matches_official_tokens() {
|
||||
use Weight::{Bold, Medium};
|
||||
// 展示/正文类强调 → Medium(500);label 与小标题 → Bold(700)。
|
||||
expect_emphasized(TypeRole::DisplayLarge, 57.0, 64.0, Medium, 0.0);
|
||||
expect_emphasized(TypeRole::DisplayMedium, 45.0, 52.0, Medium, 0.0);
|
||||
expect_emphasized(TypeRole::DisplaySmall, 36.0, 44.0, Medium, 0.0);
|
||||
expect_emphasized(TypeRole::HeadlineLarge, 32.0, 40.0, Medium, 0.0);
|
||||
expect_emphasized(TypeRole::HeadlineMedium, 28.0, 36.0, Medium, 0.0);
|
||||
expect_emphasized(TypeRole::HeadlineSmall, 24.0, 32.0, Medium, 0.0);
|
||||
expect_emphasized(TypeRole::TitleLarge, 22.0, 28.0, Medium, 0.0);
|
||||
expect_emphasized(TypeRole::TitleMedium, 16.0, 24.0, Bold, 0.15);
|
||||
expect_emphasized(TypeRole::TitleSmall, 14.0, 20.0, Bold, 0.1);
|
||||
expect_emphasized(TypeRole::BodyLarge, 16.0, 24.0, Medium, 0.15);
|
||||
expect_emphasized(TypeRole::BodyMedium, 14.0, 20.0, Medium, 0.25);
|
||||
expect_emphasized(TypeRole::BodySmall, 12.0, 16.0, Medium, 0.4);
|
||||
expect_emphasized(TypeRole::LabelLarge, 14.0, 20.0, Bold, 0.1);
|
||||
expect_emphasized(TypeRole::LabelMedium, 12.0, 16.0, Bold, 0.5);
|
||||
expect_emphasized(TypeRole::LabelSmall, 11.0, 16.0, Bold, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_tables_cover_all_fifteen_roles() {
|
||||
// 防止「表里少写一档」:15 个角色都必须被上面两张表摸到。
|
||||
let mut names = std::collections::BTreeSet::new();
|
||||
for role in TypeRole::ALL {
|
||||
names.insert(role.name());
|
||||
assert_ne!(
|
||||
role.style(),
|
||||
role.emphasized(),
|
||||
"{} 强调档等同 baseline",
|
||||
role.name()
|
||||
);
|
||||
}
|
||||
assert_eq!(names.len(), 15);
|
||||
}
|
||||
Reference in new issue
Block a user