test(design): 补「先量化再打分」的端到端反证(复审 r1 p1)

reviewer 实测变异:把 analyze_rgba 改成不透明像素算术平均,150 个测试全绿——
因为反证只测了 quantize/score 中间层,接线没人覆盖。新增:
- seed::tests::end_to_end_picks_the_five_percent_accent(95% 米白 + 5% 珊瑚 → #FF6B5A)
- seed::tests::end_to_end_picks_the_three_percent_accent(97/3 → #FF8800)
- resolve::tests::a_five_percent_accent_drives_level_one(一级成功、种子是珊瑚、≠ 回落色卡)
顺带把 seed 的单测拆到 seed/tests.rs(410 行 → 220 + 190,恢复 ≤400 纪律)
This commit is contained in:
AskaEth committed 2026-11-02 11:04:58 +08:00
1 parent 80314f4830
commit 36c1cce00d
3 files changed
+220 -133

No files matched your search

+29
View File
@@ -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());
}
}
@@ -217,136 +217,4 @@ fn sample_blocks(pixels: &[u8], width: usize, height: usize) -> Vec<Color> {
}
#[cfg(test)]
mod tests {
use super::*;
/// 生成 `width × height` 的全 `color` 图。
fn solid(color: Color, width: u32, height: u32) -> Vec<u8> {
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;
+190
View File
@@ -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<u8> {
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<u8> {
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}");
}
}