diff --git a/crates/design/src/palette/resolve/mod.rs b/crates/design/src/palette/resolve/mod.rs index a09621e..f88ef44 100644 --- a/crates/design/src/palette/resolve/mod.rs +++ b/crates/design/src/palette/resolve/mod.rs @@ -339,4 +339,33 @@ mod tests { Some("palette.hint.background.no-opaque-pixels") ); } + + #[test] + fn a_five_percent_accent_drives_level_one() { + // 端到端反证(与 `palette::seed::tests::end_to_end_picks_the_five_percent_accent` 配对): + // 95% 米白 + 5% 珊瑚的背景图**必须**在一级取色成功,且种子里就是那 5% 的珊瑚。 + // 若 `analyze_rgba` 退化成「不透明像素求算术平均」,这条会变成「回落色卡」而红。 + let beige = Color::rgb(0xF5, 0xF1, 0xEA); + let coral = Color::rgb(0xFF, 0x6B, 0x5A); + let mut pixels = Vec::with_capacity(40 * 40 * 4); + for index in 0..40 * 40 { + let color = if index < 40 * 40 - 80 { beige } else { coral }; + pixels.extend_from_slice(&[color.r, color.g, color.b, color.a]); + } + + let (scheme, provenance) = resolve_source( + Some(ImageInput::pixels(&pixels, 40, 40)), + None, + Some(CardKey::Rose), + SchemeFlavor::Light, + ); + assert_eq!(provenance.level, Level::BackgroundImage, "{provenance:?}"); + assert_eq!(provenance.background(), Outcome::Used); + let seed = provenance.seed.expect("一级成功必须带种子"); + assert_eq!(seed.color, coral, "种子应当是 5% 的珊瑚"); + assert!(provenance.hint_key().is_none()); + // 方案来自珊瑚,而不是「回落到用户选的 Rose 卡」——两者 primary 不同。 + assert_ne!(scheme, CardKey::Rose.card().scheme(SchemeFlavor::Light)); + assert!(crate::palette::wcag::check(&scheme).is_clean()); + } } diff --git a/crates/design/src/palette/seed.rs b/crates/design/src/palette/seed/mod.rs similarity index 64% rename from crates/design/src/palette/seed.rs rename to crates/design/src/palette/seed/mod.rs index a0403c3..f6a3f3d 100644 --- a/crates/design/src/palette/seed.rs +++ b/crates/design/src/palette/seed/mod.rs @@ -217,136 +217,4 @@ fn sample_blocks(pixels: &[u8], width: usize, height: usize) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - - /// 生成 `width × height` 的全 `color` 图。 - fn solid(color: Color, width: u32, height: u32) -> Vec { - let mut out = Vec::with_capacity((width * height * 4) as usize); - for _ in 0..(width * height) { - out.extend_from_slice(&[color.r, color.g, color.b, color.a]); - } - out - } - - #[test] - fn solid_vivid_image_yields_seed() { - let pixels = solid(Color::rgb(0x3C, 0x8D, 0xBC), 4, 4); - let seed = analyze_rgba(&pixels, 4, 4).expect("天蓝纯色图有种子"); - assert_eq!(seed.color, Color::from_hex("#3C8DBC").expect("合法 hex")); - assert!(seed.chroma > MIN_SEED_CHROMA); - assert!((seed.share - 1.0).abs() < 1.0e-6); - assert!(!seed.is_muted()); - } - - #[test] - fn gray_white_image_is_rejected_with_measured_chroma() { - // D40 的反证:灰白壁纸硬取色会得到青色 #006874,这里必须拒绝并报出彩度。 - let pixels = solid(Color::rgb(0xF2, 0xF2, 0xF0), 8, 8); - match analyze_rgba(&pixels, 8, 8) { - Err(Failure::LowChroma { chroma, .. }) => { - assert!(chroma < MIN_SEED_CHROMA, "彩度 {chroma} 不该过门槛"); - } - other => panic!("灰白图必须被拒,实际 {other:?}"), - } - assert_eq!(seed_from_rgba(&pixels, 8, 8), None); - } - - #[test] - fn neutral_gray_hash_rejects_instead_of_inventing_cyan() { - // 与 M70 复审实测的坏味道对齐:#808080 的 CAM16 色相 209.5°,硬取色会得到青色。 - let pixels = solid(Color::rgb(0x80, 0x80, 0x80), 4, 4); - assert_eq!(seed_from_rgba(&pixels, 4, 4), None); - } - - #[test] - fn tiny_images_are_too_small() { - let pixels = solid(Color::rgb(0xFF, 0x00, 0x00), 1, 3); - assert_eq!( - analyze_rgba(&pixels, 1, 3), - Err(Failure::TooSmall { - width: 1, - height: 3 - }) - ); - assert_eq!( - Failure::TooSmall { - width: 1, - height: 3 - } - .code(), - "too-small" - ); - } - - #[test] - fn wrong_buffer_length_is_reported() { - let pixels = solid(Color::rgb(0xFF, 0x00, 0x00), 2, 2); - match analyze_rgba(&pixels[..12], 2, 2) { - Err(Failure::BadDimensions { - expected, actual, .. - }) => { - assert_eq!((expected, actual), (16, 12)); - } - other => panic!("应当报维度错误,实际 {other:?}"), - } - } - - #[test] - fn fully_transparent_image_has_no_seed() { - let pixels = solid(Color::rgba(0x3C, 0x8D, 0xBC, 0), 4, 4); - assert_eq!(analyze_rgba(&pixels, 4, 4), Err(Failure::NoOpaquePixels)); - } - - #[test] - fn large_image_is_downsampled_and_still_deterministic() { - let (width, height) = (512_u32, 512_u32); - let mut pixels = Vec::with_capacity((width * height * 4) as usize); - for y in 0..height { - for x in 0..width { - // 细网格:等距抽样会走样,块平均能得到稳定的底色。 - let checker = (x + y) % 2 == 0; - pixels.extend_from_slice(if checker { - &[0xD2, 0x3B, 0x2A, 0xFF] - } else { - &[0x2A, 0x3B, 0xD2, 0xFF] - }); - } - } - let first = analyze_rgba(&pixels, width, height).expect("大图有种子"); - let second = analyze_rgba(&pixels, width, height).expect("可复现"); - assert_eq!(first, second); - let samples = sample_blocks(&pixels, width as usize, height as usize); - assert!( - samples.len() <= MAX_SAMPLES, - "采样点 {} 超标", - samples.len() - ); - // 块平均把红蓝网格合成偏紫的中间色,而不是撞上纯红或纯蓝之一。 - assert!(first.chroma > MIN_SEED_CHROMA, "{first:?}"); - } - - #[test] - fn block_sampling_averages_instead_of_picking() { - // 2×2 红蓝棋盘,块边长 1(图很小)→ 4 个采样点;只验证采样点数量与顺序稳定。 - let pixels = [ - 0xFF, 0x00, 0x00, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00, - 0x00, 0xFF, - ]; - let samples = sample_blocks(&pixels, 2, 2); - assert_eq!(samples.len(), 4); - assert_eq!(samples[0], Color::rgb(0xFF, 0x00, 0x00)); - assert_eq!(samples[1], Color::rgb(0x00, 0x00, 0xFF)); - } - - #[test] - fn seed_scheme_meets_wcag_on_the_primary_pair() { - let pixels = solid(Color::rgb(0xC0, 0x39, 0x2B), 4, 4); - let seed = analyze_rgba(&pixels, 4, 4).expect("砖红有种子"); - for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { - let scheme = seed.scheme(flavor, 12.0); - let ratio = scheme.contrast(crate::color::Role::OnPrimary, crate::color::Role::Primary); - assert!(ratio >= 4.5, "{flavor:?} 只有 {ratio}"); - } - } -} +mod tests; diff --git a/crates/design/src/palette/seed/tests.rs b/crates/design/src/palette/seed/tests.rs new file mode 100644 index 0000000..cc98f88 --- /dev/null +++ b/crates/design/src/palette/seed/tests.rs @@ -0,0 +1,190 @@ +//! `seed` 模块的单测:五类输入各自的处置 + **端到端反证**(不许退化成平均色)。 +//! +//! 为什么端到端测试必须在这里(而不是只测 `quantize`/`score`):那两条单测手工构造 +//! `Cluster` 直接喂中间层,**绕过了 `analyze_rgba` 的接线**。把 `analyze_rgba` 换成 +//! 「不透明像素求算术平均」时,中间层单测照样绿,只有这里会红。 + +use super::*; + +/// 生成 `width × height` 的全 `color` 图。 +fn solid(color: Color, width: u32, height: u32) -> Vec { + let mut out = Vec::with_capacity((width * height * 4) as usize); + for _ in 0..(width * height) { + out.extend_from_slice(&[color.r, color.g, color.b, color.a]); + } + out +} + +/// 生成「其余是 `base`,`accent_pixels` 个像素是 `accent`」的图(强调色排在末尾)。 +/// +/// 这是**反证「不许取平均色」**的输入构造器:强调色面积小到平均色会被底色吃掉, +/// 但它是人眼会记住、能当强调色用的那一小撮。 +fn two_tone(width: u32, height: u32, base: Color, accent: Color, accent_pixels: usize) -> Vec { + let total = (width * height) as usize; + assert!(accent_pixels <= total); + let mut out = Vec::with_capacity(total * 4); + for index in 0..total { + let color = if index < total - accent_pixels { + base + } else { + accent + }; + out.extend_from_slice(&[color.r, color.g, color.b, color.a]); + } + out +} + +#[test] +fn end_to_end_picks_the_five_percent_accent() { + // ⚠️ 端到端反证:这条必须走 `analyze_rgba` 的**完整管线**(量化 → 打分 → 彩度门槛), + // 不许像 `quantize`/`score` 的单测那样手工构造 `Cluster` 直接喂中间层。 + // 如果 `analyze_rgba` 被改成「不透明像素求算术平均」,平均值会是米白(chroma ≈4)→ + // 返回 `LowChroma` 而不是种子,这条立刻红。 + let beige = Color::from_hex("#F5F1EA").expect("合法 hex"); + let coral = Color::from_hex("#FF6B5A").expect("合法 hex"); + let pixels = two_tone(40, 40, beige, coral, 80); // 95% 米白 + 5% 珊瑚 + + let seed = analyze_rgba(&pixels, 40, 40).expect("5% 的珊瑚应当被选中当种子"); + assert_eq!( + seed.color, + coral, + "种子必须是珊瑚(实际 {});米白占比 95% 但它不是「主色」", + seed.color.to_hex() + ); + assert!(seed.chroma > 40.0, "珊瑚彩度应当很高,实测 {}", seed.chroma); + // 与「平均色」泾渭分明:原图的算术平均值是米白,色相/彩度都差一整个量级。 + assert!(!seed.is_muted()); +} + +#[test] +fn end_to_end_picks_the_three_percent_accent() { + // 面积再降到 3%(48/1600):`Score` 的 1% 人气门槛仍然放行,平均色更不可能靠巧合命中。 + let beige = Color::from_hex("#F5F1EA").expect("合法 hex"); + let orange = Color::from_hex("#FF8800").expect("合法 hex"); + let pixels = two_tone(40, 40, beige, orange, 48); + + let seed = analyze_rgba(&pixels, 40, 40).expect("3% 的橙应当被选中当种子"); + assert_eq!(seed.color, orange, "实际 {}", seed.color.to_hex()); + assert!(seed.chroma > 40.0, "彩度 {}", seed.chroma); +} + +#[test] +fn solid_vivid_image_yields_seed() { + let pixels = solid(Color::rgb(0x3C, 0x8D, 0xBC), 4, 4); + let seed = analyze_rgba(&pixels, 4, 4).expect("天蓝纯色图有种子"); + assert_eq!(seed.color, Color::from_hex("#3C8DBC").expect("合法 hex")); + assert!(seed.chroma > MIN_SEED_CHROMA); + assert!((seed.share - 1.0).abs() < 1.0e-6); + assert!(!seed.is_muted()); +} + +#[test] +fn gray_white_image_is_rejected_with_measured_chroma() { + // D40 的反证:灰白壁纸硬取色会得到青色 #006874,这里必须拒绝并报出彩度。 + let pixels = solid(Color::rgb(0xF2, 0xF2, 0xF0), 8, 8); + match analyze_rgba(&pixels, 8, 8) { + Err(Failure::LowChroma { chroma, .. }) => { + assert!(chroma < MIN_SEED_CHROMA, "彩度 {chroma} 不该过门槛"); + } + other => panic!("灰白图必须被拒,实际 {other:?}"), + } + assert_eq!(seed_from_rgba(&pixels, 8, 8), None); +} + +#[test] +fn neutral_gray_hash_rejects_instead_of_inventing_cyan() { + // 与 M70 复审实测的坏味道对齐:#808080 的 CAM16 色相 209.5°,硬取色会得到青色。 + let pixels = solid(Color::rgb(0x80, 0x80, 0x80), 4, 4); + assert_eq!(seed_from_rgba(&pixels, 4, 4), None); +} + +#[test] +fn tiny_images_are_too_small() { + let pixels = solid(Color::rgb(0xFF, 0x00, 0x00), 1, 3); + assert_eq!( + analyze_rgba(&pixels, 1, 3), + Err(Failure::TooSmall { + width: 1, + height: 3 + }) + ); + assert_eq!( + Failure::TooSmall { + width: 1, + height: 3 + } + .code(), + "too-small" + ); +} + +#[test] +fn wrong_buffer_length_is_reported() { + let pixels = solid(Color::rgb(0xFF, 0x00, 0x00), 2, 2); + match analyze_rgba(&pixels[..12], 2, 2) { + Err(Failure::BadDimensions { + expected, actual, .. + }) => { + assert_eq!((expected, actual), (16, 12)); + } + other => panic!("应当报维度错误,实际 {other:?}"), + } +} + +#[test] +fn fully_transparent_image_has_no_seed() { + let pixels = solid(Color::rgba(0x3C, 0x8D, 0xBC, 0), 4, 4); + assert_eq!(analyze_rgba(&pixels, 4, 4), Err(Failure::NoOpaquePixels)); +} + +#[test] +fn large_image_is_downsampled_and_still_deterministic() { + let (width, height) = (512_u32, 512_u32); + let mut pixels = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + for x in 0..width { + // 细网格:等距抽样会走样,块平均能得到稳定的底色。 + let checker = (x + y) % 2 == 0; + pixels.extend_from_slice(if checker { + &[0xD2, 0x3B, 0x2A, 0xFF] + } else { + &[0x2A, 0x3B, 0xD2, 0xFF] + }); + } + } + let first = analyze_rgba(&pixels, width, height).expect("大图有种子"); + let second = analyze_rgba(&pixels, width, height).expect("可复现"); + assert_eq!(first, second); + let samples = sample_blocks(&pixels, width as usize, height as usize); + assert!( + samples.len() <= MAX_SAMPLES, + "采样点 {} 超标", + samples.len() + ); + // 块平均把红蓝网格合成偏紫的中间色,而不是撞上纯红或纯蓝之一。 + assert!(first.chroma > MIN_SEED_CHROMA, "{first:?}"); +} + +#[test] +fn block_sampling_averages_instead_of_picking() { + // 2×2 红蓝棋盘,块边长 1(图很小)→ 4 个采样点;只验证采样点数量与顺序稳定。 + let pixels = [ + 0xFF, 0x00, 0x00, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00, + 0xFF, + ]; + let samples = sample_blocks(&pixels, 2, 2); + assert_eq!(samples.len(), 4); + assert_eq!(samples[0], Color::rgb(0xFF, 0x00, 0x00)); + assert_eq!(samples[1], Color::rgb(0x00, 0x00, 0xFF)); +} + +#[test] +fn seed_scheme_meets_wcag_on_the_primary_pair() { + let pixels = solid(Color::rgb(0xC0, 0x39, 0x2B), 4, 4); + let seed = analyze_rgba(&pixels, 4, 4).expect("砖红有种子"); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let scheme = seed.scheme(flavor, 12.0); + let ratio = scheme.contrast(crate::color::Role::OnPrimary, crate::color::Role::Primary); + assert!(ratio >= 4.5, "{flavor:?} 只有 {ratio}"); + } +}