//! 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");
}
}
neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
8.3 KiB · text
History
aca7335