13.7 KiB · text History 6811748
//! 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]);
}