8.3 KiB · text History aca7335
//! Choosing where icons go, and bundling them up.

use std::fs;
use std::path::{Path, PathBuf};

use icon_studio::spec::IconSpec;
use icon_studio::target::{self, Kind};

/// A scratch directory that cleans itself up.
struct Tmp(PathBuf);

impl Tmp {
    fn new(tag: &str) -> Tmp {
        let p = std::env::temp_dir().join(format!(
            "icon-studio-{tag}-{}-{:?}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        fs::create_dir_all(&p).unwrap();
        Tmp(p)
    }
    fn path(&self) -> &Path {
        &self.0
    }
    fn mkdir(&self, rel: &str) -> PathBuf {
        let p = self.0.join(rel);
        fs::create_dir_all(&p).unwrap();
        p
    }
}

impl Drop for Tmp {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.0);
    }
}

#[test]
fn an_android_project_root_resolves_to_its_res_directory() {
    // The mistake this exists to prevent: picking the project root and
    // getting mipmap directories dropped beside build.gradle.kts.
    let t = Tmp::new("proj");
    t.mkdir("app/src/main/res/values");
    fs::write(t.path().join("build.gradle.kts"), "").unwrap();

    let r = target::resolve(t.path());
    assert_eq!(r.kind, Kind::AndroidProject);
    assert_eq!(r.root, t.path().join("app/src/main/res"));
    assert!(r.note.contains("app/src/main/res"), "{}", r.note);
}

#[test]
fn a_module_root_resolves_too() {
    let t = Tmp::new("module");
    t.mkdir("src/main/res/values");
    let r = target::resolve(t.path());
    assert_eq!(r.kind, Kind::AndroidProject);
    assert_eq!(r.root, t.path().join("src/main/res"));
}

#[test]
fn a_res_directory_is_used_as_is() {
    let t = Tmp::new("res");
    let res = t.mkdir("res");
    t.mkdir("res/values");
    let r = target::resolve(&res);
    assert_eq!(r.kind, Kind::AndroidRes);
    assert_eq!(r.root, res);
}

#[test]
fn a_res_directory_is_recognised_by_content_not_only_by_name() {
    // Resource directories are not always called `res`.
    let t = Tmp::new("byname");
    let odd = t.mkdir("resources");
    t.mkdir("resources/mipmap-xxxhdpi");
    let r = target::resolve(&odd);
    assert_eq!(r.kind, Kind::AndroidRes, "{}", r.note);
}

#[test]
fn picking_a_density_bucket_steps_up_to_the_res_directory() {
    // Browsing in to look at the current icons and stopping there would
    // otherwise nest mipmap-xxxhdpi/mipmap-xxxhdpi.
    let t = Tmp::new("bucket");
    t.mkdir("res/values");
    let bucket = t.mkdir("res/mipmap-xxxhdpi");

    let r = target::resolve(&bucket);
    assert_eq!(r.kind, Kind::AndroidRes);
    assert_eq!(r.root, t.path().join("res"));
    assert!(r.note.contains("parent res"), "{}", r.note);
}

#[test]
fn a_lone_bucket_outside_a_res_tree_is_left_alone() {
    // Only step up when the parent really is a resource directory.
    let t = Tmp::new("lonebucket");
    let bucket = t.mkdir("mipmap-xxxhdpi");
    let r = target::resolve(&bucket);
    assert_eq!(r.kind, Kind::Plain);
    assert_eq!(r.root, bucket);
}

#[test]
fn an_ordinary_folder_is_written_to_literally() {
    let t = Tmp::new("plain");
    let r = target::resolve(t.path());
    assert_eq!(r.kind, Kind::Plain);
    assert_eq!(r.root, t.path());
    assert!(
        r.note.to_lowercase().contains("not an android"),
        "{}",
        r.note
    );
}

#[test]
fn the_plan_lists_every_file_and_flags_replacements() {
    let t = Tmp::new("plan");

    let fresh = target::plan(t.path(), "ic_icon");
    assert_eq!(fresh.len(), icon_studio::export::DENSITIES.len() * 3 + 1);
    assert!(fresh.iter().all(|f| !f.overwrites));
    assert!(target::summarise(&fresh).contains("none existing"));

    // Put one of them in place; it should come back flagged.
    let victim = &fresh[0].path;
    fs::create_dir_all(victim.parent().unwrap()).unwrap();
    fs::write(victim, b"x").unwrap();

    let again = target::plan(t.path(), "ic_icon");
    assert_eq!(again.iter().filter(|f| f.overwrites).count(), 1);
    assert!(target::summarise(&again).contains("1 replaced"));
}

#[test]
fn writability_is_checked_before_writing() {
    assert!(target::writable(Path::new("")).is_err(), "empty path");

    let t = Tmp::new("writable");
    assert!(target::writable(t.path()).is_ok());

    // A destination that no longer exists must be caught here, not on the
    // fifteenth file. This is the case that actually happens: a remembered
    // folder that has since been deleted or unmounted.
    assert!(
        target::writable(&t.path().join("since-deleted")).is_err(),
        "missing directory"
    );
    assert!(
        target::writable(Path::new("/nonexistent-root-xyz/sub")).is_err(),
        "unreachable path"
    );

    // A file is not a destination.
    let f = t.path().join("a-file");
    fs::write(&f, b"x").unwrap();
    assert!(target::writable(&f).is_err(), "regular file");
}

#[test]
fn the_zip_bundle_carries_the_images_and_the_document() {
    let t = Tmp::new("zip");
    let out = t.path().join("bundle.zip");

    let names = icon_studio::export::export_zip(
        &IconSpec::default(),
        &out,
        "ic_icon",
        Some(("ic_icon.toml", "version = 1\n")),
    )
    .expect("bundle written");

    // Images, the adaptive-icon descriptor, and the document.
    assert_eq!(names.len(), icon_studio::export::DENSITIES.len() * 3 + 2);

    let f = fs::File::open(&out).unwrap();
    let mut zip = zip::ZipArchive::new(f).expect("valid zip");
    let entries: Vec<String> = (0..zip.len())
        .map(|i| zip.by_index(i).unwrap().name().unwrap().into_owned())
        .collect();

    assert!(entries.contains(&"mipmap-xxxhdpi/ic_icon.png".to_string()));
    assert!(entries.contains(&"mipmap-mdpi/ic_icon_foreground.png".to_string()));
    assert!(
        entries.contains(&"mipmap-anydpi-v26/ic_icon.xml".to_string()),
        "a bundle of layers without the descriptor binding them is not usable"
    );
    assert!(
        entries.contains(&"ic_icon.toml".to_string()),
        "the bundle should be reproducible, not just a pile of PNGs"
    );

    // And the PNGs are real PNGs at the right size.
    let mut img = zip.by_name("mipmap-xxxhdpi/ic_icon.png").unwrap();
    let mut bytes = Vec::new();
    std::io::Read::read_to_end(&mut img, &mut bytes).unwrap();
    let decoded = image::load_from_memory(&bytes).expect("decodable png");
    // The flat icon is 48dp, so 192px at xxxhdpi — not the 432px the adaptive
    // layers use.
    assert_eq!(decoded.width(), 192);
}

#[test]
fn the_zip_bundle_reads_back_as_it_was_written() {
    let t = Tmp::new("zip-roundtrip");
    let out = t.path().join("bundle.zip");
    let document = "version = 1\n# a comment that must survive the trip\n";

    let names = icon_studio::export::export_zip(
        &IconSpec::default(),
        &out,
        "ic_icon",
        Some(("ic_icon.toml", document)),
    )
    .expect("bundle written");

    let f = fs::File::open(&out).unwrap();
    let mut zip = zip::ZipArchive::new(f).expect("valid zip");

    // Exactly what export_zip says it wrote, in that order: nothing
    // dropped, nothing extra.
    let entries: Vec<String> = (0..zip.len())
        .map(|i| zip.by_index(i).unwrap().name().unwrap().into_owned())
        .collect();
    assert_eq!(entries, names);

    let read = |zip: &mut zip::ZipArchive<fs::File>, name: &str| {
        let mut entry = zip.by_name(name).unwrap();
        assert_eq!(
            entry.compression(),
            zip::CompressionMethod::Deflated,
            "{name} should be deflated"
        );
        let mut bytes = Vec::new();
        std::io::Read::read_to_end(&mut entry, &mut bytes).unwrap();
        bytes
    };

    // The text entries come back byte for byte.
    assert_eq!(read(&mut zip, "ic_icon.toml"), document.as_bytes());
    assert_eq!(
        read(&mut zip, "mipmap-anydpi-v26/ic_icon.xml"),
        icon_studio::export::adaptive_icon_xml("ic_icon").as_bytes()
    );

    // And every image is one the renderer would have produced: the
    // archive is a container, not a re-encoding.
    let spec = IconSpec::default();
    for (bucket, size) in icon_studio::export::DENSITIES {
        let fg = read(&mut zip, &format!("mipmap-{bucket}/ic_icon_foreground.png"));
        let expected = icon_studio::render::render_foreground(&spec, *size)
            .encode_png()
            .unwrap();
        assert_eq!(fg, expected, "{bucket} foreground");
    }
}