//! 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()
}
}
/// Ink bounds of a foreground: (x0, y0, x1, y1).
fn ink(pm: &tiny_skia::Pixmap) -> (u32, u32, u32, u32) {
let w = pm.width();
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
for (i, p) in pm.pixels().iter().enumerate() {
if p.alpha() > 128 {
let (x, y) = (i as u32 % w, i as u32 / w);
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
(x0, y0, x1, y1)
}
fn centre(r: (u32, u32, u32, u32)) -> (f32, f32) {
((r.0 + r.2) as f32 / 2.0, (r.1 + r.3) as f32 / 2.0)
}
/// 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 stacked_layers_share_the_centre_and_the_overall_scale() {
use icon_studio::spec::{Foreground, Layer};
// A filled circle alone, then the same circle at half size as the only
// layer over an empty text mark: the layer must land on the same centre,
// and at the size it asks for relative to the overall scale.
let circle = '\u{ef4a}'; // circle
let big = IconSpec {
foreground: Foreground::Icon(circle),
scale: 0.8,
filled: true,
..casabeza_split()
};
let small = IconSpec {
foreground: Foreground::Text(String::new()),
layers: vec![Layer {
glyph: circle,
color: None,
size: 0.5,
rotation: 0.0,
offset: [0.0, 0.0],
filled: true,
}],
..big.clone()
};
let (b, s) = (
ink(&render::render_foreground(&big, 432)),
ink(&render::render_foreground(&small, 432)),
);
let (bc, sc) = (centre(b), centre(s));
assert!(
(bc.0 - sc.0).abs() <= 1.0 && (bc.1 - sc.1).abs() <= 1.0,
"{bc:?} vs {sc:?}"
);
let ratio = (s.2 - s.0) as f32 / (b.2 - b.0) as f32;
assert!((ratio - 0.5).abs() < 0.03, "width ratio {ratio}");
// And stacking both keeps everything the big one had.
let both = IconSpec {
layers: small.layers.clone(),
..big.clone()
};
assert_eq!(ink(&render::render_foreground(&both, 432)), b);
// The overall scale resizes the layers with it: halving it halves the
// layer.
let shrunk = IconSpec {
scale: 0.4,
..small.clone()
};
let h = ink(&render::render_foreground(&shrunk, 432));
let ratio = (h.2 - h.0) as f32 / (s.2 - s.0) as f32;
assert!((ratio - 0.5).abs() < 0.03, "width ratio {ratio}");
let hc = centre(h);
assert!(
(hc.0 - sc.0).abs() <= 1.0 && (hc.1 - sc.1).abs() <= 1.0,
"{hc:?} vs {sc:?}"
);
// The main mark's own size is the same control as a layer's: a half-size
// mark matches a half-size layer exactly.
let half_mark = IconSpec {
mark_size: 0.5,
..big.clone()
};
assert_eq!(ink(&render::render_foreground(&half_mark, 432)), s);
}
#[test]
fn each_layer_rotates_moves_and_colours_on_its_own() {
use icon_studio::spec::{Foreground, Layer};
const RED: [u8; 3] = [0xE0, 0x20, 0x20];
let bar = '\u{e15b}'; // remove: a wide, flat bar
let flat = IconSpec {
foreground: Foreground::Text(String::new()),
scale: 0.6,
layers: vec![Layer {
glyph: bar,
color: Some(NamedColor {
role: "error",
rgb: RED,
}),
size: 1.0,
rotation: 0.0,
offset: [0.0, 0.0],
filled: true,
}],
..casabeza_split()
};
let f = ink(&render::render_foreground(&flat, 432));
assert!(f.2 - f.0 > 4 * (f.3 - f.1), "expected a flat bar: {f:?}");
// Rotation turns it on its own centre: a quarter turn swaps its extent.
let mut turned = flat.clone();
turned.layers[0].rotation = 90.0;
let t = ink(&render::render_foreground(&turned, 432));
assert!(
((t.3 - t.1) as i32 - (f.2 - f.0) as i32).abs() <= 2,
"{t:?} vs {f:?}"
);
assert!(
((t.2 - t.0) as i32 - (f.3 - f.1) as i32).abs() <= 2,
"{t:?} vs {f:?}"
);
let (fc, tc) = (centre(f), centre(t));
assert!((fc.0 - tc.0).abs() <= 1.0 && (fc.1 - tc.1).abs() <= 1.0);
// Offsets are in units of the overall scale: 0.5 here is 0.5 * 0.6 * 432.
let mut moved = flat.clone();
moved.layers[0].offset = [0.5, -0.25];
let mc = centre(ink(&render::render_foreground(&moved, 432)));
let unit = 0.6 * 432.0;
assert!((mc.0 - fc.0 - 0.5 * unit).abs() <= 1.0, "{mc:?}");
assert!((mc.1 - fc.1 + 0.25 * unit).abs() <= 1.0, "{mc:?}");
// The layer draws in its own colour, not the foreground's.
let pm = render::render_foreground(&flat, 432);
let solid: Vec<[u8; 3]> = pm
.pixels()
.iter()
.filter(|p| p.alpha() == 255)
.map(|p| [p.red(), p.green(), p.blue()])
.collect();
assert!(!solid.is_empty());
assert!(solid.iter().all(|c| *c == RED), "layer should be red");
// And the main mark's rotation leaves the layer alone.
let mark_turned = IconSpec {
mark_rotation: 90.0,
..flat.clone()
};
assert_eq!(ink(&render::render_foreground(&mark_turned, 432)), f);
// Unset, it follows the foreground — including a foreground changed after
// the layer was made.
let mut follows = flat.clone();
follows.layers[0].color = None;
follows.fg_color = NamedColor {
role: "base-100",
rgb: [0x10, 0x60, 0xF0],
};
let pm = render::render_foreground(&follows, 432);
assert!(pm.pixels().iter().filter(|p| p.alpha() == 255).all(|p| [
p.red(),
p.green(),
p.blue()
] == [0x10, 0x60, 0xF0]));
}
#[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.
13.7 KiB · text
History
6811748