//! Checks the reconstructed renderer against what the original studio shipped.
//!
//! The studio's source was lost; its output was not. `cbzauth_background.png`
//! is the real 432px background from the installed cbzauth APK, and is the
//! only ground truth available for how the tool actually drew a diagonal
//! split. If this test fails, the reconstruction has drifted from the thing
//! it is meant to reproduce.
use icon_studio::palette::NamedColor;
use icon_studio::render;
use icon_studio::spec::{Background, IconSpec};
const TEAL: [u8; 3] = [0x00, 0xC9, 0xD4]; // Casabeza ACCENT
const GREEN: [u8; 3] = [0xAA, 0xD4, 0x00]; // Casabeza LOGO_GREEN
fn casabeza_split() -> IconSpec {
IconSpec {
bg_a: NamedColor {
role: "primary",
rgb: TEAL,
},
bg_b: NamedColor {
role: "accent",
rgb: GREEN,
},
background: Background::DiagonalSplit,
angle: 45.0,
..Default::default()
}
}
/// Counts of each fully-opaque colour in the image.
fn histogram(px: &[u8]) -> std::collections::HashMap<[u8; 3], usize> {
let mut h = std::collections::HashMap::new();
for c in px.chunks(4) {
*h.entry([c[0], c[1], c[2]]).or_insert(0) += 1;
}
h
}
#[test]
fn diagonal_split_matches_the_shipped_background() {
let shipped = image::open("tests/fixtures/cbzauth_background.png")
.expect("fixture present")
.to_rgba8();
assert_eq!(
shipped.dimensions(),
(432, 432),
"fixture is the xxxhdpi asset"
);
let ours = render::render_background(&casabeza_split(), 432);
let shipped_hist = histogram(shipped.as_raw());
let ours_hist = histogram(
&ours
.pixels()
.iter()
.flat_map(|p| [p.red(), p.green(), p.blue(), p.alpha()])
.collect::<Vec<u8>>(),
);
// The shipped icon is an even two-tone split: both brand colours cover
// the same area, with only the boundary blended.
let s_teal = shipped_hist.get(&TEAL).copied().unwrap_or(0);
let s_green = shipped_hist.get(&GREEN).copied().unwrap_or(0);
let o_teal = ours_hist.get(&TEAL).copied().unwrap_or(0);
let o_green = ours_hist.get(&GREEN).copied().unwrap_or(0);
assert!(s_teal > 0 && s_green > 0, "fixture should be two-tone");
let total = 432.0 * 432.0;
for (name, teal, green) in [("shipped", s_teal, s_green), ("ours", o_teal, o_green)] {
let share = (teal + green) as f64 / total;
assert!(
share > 0.99,
"{name}: brand colours should cover the canvas, got {share:.4}"
);
let balance = (teal as f64 - green as f64).abs() / total;
assert!(
balance < 0.01,
"{name}: halves should be even, off by {balance:.4}"
);
}
}
#[test]
fn split_boundary_is_hard_not_a_gradient() {
// The shipped background has ~one blended pixel per row: a crisp edge, not
// a two-stop gradient. This is the detail that distinguishes the attested
// "diagonal split" from the reconstructed "linear gradient".
let ours = render::render_background(&casabeza_split(), 432);
let px: Vec<u8> = ours
.pixels()
.iter()
.flat_map(|p| [p.red(), p.green(), p.blue(), p.alpha()])
.collect();
let hist = histogram(&px);
let pure = hist.get(&TEAL).copied().unwrap_or(0) + hist.get(&GREEN).copied().unwrap_or(0);
let blended = (432 * 432) as usize - pure;
assert!(
blended < 432 * 4,
"edge should be hairline; {blended} blended pixels looks like a gradient"
);
}
#[test]
fn solid_background_ignores_the_second_colour() {
let spec = IconSpec {
background: Background::Solid,
..casabeza_split()
};
let pm = render::render_background(&spec, 64);
assert!(pm
.pixels()
.iter()
.all(|p| [p.red(), p.green(), p.blue()] == TEAL));
}
#[test]
fn every_background_fills_the_canvas() {
// A generator that leaves transparent gaps would show the launcher's
// backdrop through the icon.
for bg in Background::ALL {
let spec = IconSpec {
background: *bg,
..casabeza_split()
};
let pm = render::render_background(&spec, 128);
let clear = pm.pixels().iter().filter(|p| p.alpha() < 255).count();
assert_eq!(clear, 0, "{:?} left {clear} transparent pixels", bg);
}
}
#[test]
fn foreground_is_transparent_outside_the_mark() {
// The foreground layer must stay transparent so Android can composite and
// parallax it over the background.
let pm = render::render_foreground(&casabeza_split(), 432);
let opaque = pm.pixels().iter().filter(|p| p.alpha() > 0).count();
assert!(opaque > 0, "glyph should render");
assert!(
(opaque as f64) < 432.0 * 432.0 * 0.5,
"foreground should be mostly transparent"
);
}
#[test]
fn export_writes_every_density_and_layer() {
let dir = std::env::temp_dir().join(format!("icon-studio-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let files = icon_studio::export::export_mipmaps(&casabeza_split(), &dir, "ic_icon")
.expect("export succeeds");
// Three PNGs per density, plus the one adaptive-icon descriptor that
// binds the background and foreground layers together.
assert_eq!(files.len(), icon_studio::export::DENSITIES.len() * 3 + 1);
for (bucket, size) in icon_studio::export::DENSITIES {
for suffix in ["", "_background", "_foreground"] {
let p = dir
.join(format!("mipmap-{bucket}"))
.join(format!("ic_icon{suffix}.png"));
assert!(p.exists(), "{} missing", p.display());
let img = image::open(&p).expect("valid png");
// The flat icon is 48dp; only the adaptive layers are 108dp.
let expected = if suffix.is_empty() {
icon_studio::export::legacy_size(*size)
} else {
*size
};
assert_eq!(img.width(), expected, "{} wrong size", p.display());
}
}
// The sizes the shipped cbz icons use at xxxhdpi.
assert_eq!(
image::open(dir.join("mipmap-xxxhdpi/ic_icon.png"))
.unwrap()
.width(),
192
);
assert_eq!(
image::open(dir.join("mipmap-xxxhdpi/ic_icon_foreground.png"))
.unwrap()
.width(),
432
);
// Without this Android ignores the layers and falls back to the flat PNG.
let xml = dir.join("mipmap-anydpi-v26/ic_icon.xml");
let body = std::fs::read_to_string(&xml).expect("adaptive icon descriptor");
assert!(body.contains(r#"@mipmap/ic_icon_background"#), "{body}");
assert!(body.contains(r#"@mipmap/ic_icon_foreground"#), "{body}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_flat_icon_is_masked_and_the_layers_are_not() {
// A full-bleed square here is what makes an icon show up as a hard-edged
// tile in a launcher or a repository index, next to everyone else's
// rounded ones. The adaptive layers must stay full-bleed, because the
// launcher applies its own mask to those.
let spec = casabeza_split();
let flat = render::render_legacy(&spec, 192);
let corner = flat.pixel(2, 2).unwrap();
assert_eq!(corner.alpha(), 0, "flat icon corners should be cut away");
let clear = flat.pixels().iter().filter(|p| p.alpha() == 0).count();
let share = clear as f64 / (192.0 * 192.0);
assert!(
(0.05..0.08).contains(&share),
"corner radius looks wrong: {share:.3} of the icon is transparent"
);
assert!(
flat.pixel(96, 96).unwrap().alpha() == 255,
"the middle must stay opaque"
);
for layer in [
render::render_background(&spec, 432),
render::render_composed(&spec, 432),
] {
assert_eq!(
layer.pixel(2, 2).unwrap().alpha(),
255,
"adaptive layers are full-bleed"
);
}
}
#[test]
fn legacy_sizes_are_exact_at_every_density() {
use icon_studio::export::{legacy_size, DENSITIES};
let got: Vec<u32> = DENSITIES.iter().map(|(_, a)| legacy_size(*a)).collect();
// What the shipped cbz icons use.
assert_eq!(got, vec![48, 72, 96, 144, 192]);
}
neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
8.2 KiB · text
History
6ed9d8a