//! Runs the `resolved` binary on the example scenes. use std::path::{Path, PathBuf}; use std::process::Command; fn repo() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") } fn out_dir() -> PathBuf { let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("cli"); std::fs::create_dir_all(&dir).unwrap(); dir } /// Runs the binary from the repository root with `ASTROKIT_KERNELS` cleared, so the /// kernel search falls back the way it does for someone who has not set it. fn resolved(args: &[&str]) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_resolved")) .args(args) .current_dir(repo()) .env_remove("ASTROKIT_KERNELS") .output() .unwrap() } /// Renders `scene` on `adapter` to a 16-bit PNG and returns its pixels as DN (the /// file holds 12-bit DN shifted up by 4). fn render(scene: &str, adapter: &str) -> image::ImageBuffer, Vec> { render_at(scene, adapter, "L0") } /// A data directory whose Earth images and Moon map are small and uniform, for tests at /// lighting L1 that do not need the real ones: loading those (16384 x 8192 each) takes /// seconds in a debug build, and they are only there once fetched. fn gray_earth() -> PathBuf { static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); DIR.get_or_init(|| { let dir = out_dir().join("gray-data"); std::fs::create_dir_all(dir.join("earth")).unwrap(); let image = image::RgbImage::from_pixel(64, 32, image::Rgb([128; 3])); for month in 1..=12 { image .save(dir.join(format!("earth/bmng-2004-{month:02}.jpg"))) .unwrap(); } // A uniform Moon: I/F 0.06 (16-bit values of I/F x 100,000). std::fs::create_dir_all(dir.join("moon")).unwrap(); image::ImageBuffer::, _>::from_pixel(64, 32, image::Luma([6000])) .save(dir.join("moon/wac-hapke-643.png")) .unwrap(); dir }) .clone() } /// [`render`] at lighting tier `lighting`, with Earth's images from [`gray_earth`]. fn render_at( scene: &str, adapter: &str, lighting: &str, ) -> image::ImageBuffer, Vec> { let data = gray_earth(); let out = out_dir().join(format!( "{}-{lighting}-{adapter}.png", Path::new(scene).file_stem().unwrap().to_str().unwrap() )); let preview = out.with_extension("preview.png"); let result = resolved(&[ "render", "--scene", scene, "--adapter", adapter, "--lighting", lighting, "--data", data.to_str().unwrap(), "--out", out.to_str().unwrap(), "--preview", preview.to_str().unwrap(), ]); assert!( result.status.success(), "{}", String::from_utf8_lossy(&result.stderr) ); assert!(preview.is_file()); let mut pixels = image::open(&out).unwrap().into_rgb16(); for p in pixels.pixels_mut() { assert!(p.0.iter().all(|v| v & 0xf == 0), "low 4 bits are padding"); p.0 = p.0.map(|v| v >> 4); } pixels } #[test] fn example_scene_on_gpu_and_cpu() { let gpu = render("examples/scenes/boxwing.toml", "gpu"); let cpu = render("examples/scenes/boxwing.toml", "cpu"); assert_eq!(gpu.dimensions(), (1920, 1080)); // The image center is on the sunlit gold bus: brighter in red than in blue. let [r, _, b] = gpu.get_pixel(960, 540).0; assert!(r > 500 && r > 2 * b, "{r} {b}"); // Above the limb is empty space, which reads the 64 DN bias; below it, Earth's // dayside, lit and blue. assert_eq!(gpu.get_pixel(0, 0).0, [64; 3]); let [r, g, b] = gpu.get_pixel(0, 1079).0; assert!(r > 64 && g > r && b > g, "{r} {g} {b}"); let differing = gpu .pixels() .zip(cpu.pixels()) .filter(|(a, b)| a.0.iter().zip(b.0).any(|(x, y)| x.abs_diff(y) > 1)) .count(); // Only pixels on silhouette edges may land differently. assert!( differing < 500, "{differing} pixels differ between GPU and CPU" ); } #[test] fn pretty_previews_lift_the_darks_and_leave_space_black() { let preview = |pretty: bool| { let path = out_dir().join(format!("preview-pretty-{pretty}.png")); let path_str = path.to_str().unwrap(); let mut args = vec![ "render", "--scene", "examples/scenes/boxwing.toml", "--preview", path_str, ]; if pretty { args.push("--pretty"); } let result = resolved(&args); assert!(result.status.success(), "{result:?}"); image::open(&path).unwrap().into_rgb8() }; let (raw, pretty) = (preview(false), preview(true)); // Space is the bias, black either way. assert_eq!(raw.get_pixel(0, 0).0, [0; 3]); assert_eq!(pretty.get_pixel(0, 0).0, [0; 3]); // Earth's dayside below the limb, well short of saturation: brighter when pretty. let (r, p) = (raw.get_pixel(0, 1079).0, pretty.get_pixel(0, 1079).0); assert!((0..3).all(|k| p[k] > r[k]), "raw {r:?}, pretty {p:?}"); } #[cfg(feature = "astrokit")] #[test] fn lunar_eclipse_darkens_the_moon() { // Mid-totality: at L1 the whole Moon is in Earth's umbra and reads the bias. At L0 // there are no eclipses on the bodies and it is a full Moon. let scene = "examples/scenes/lunar-eclipse.toml"; let l0 = render_at(scene, "gpu", "L0"); let l1 = render_at(scene, "gpu", "L1"); assert!( l0.get_pixel(960, 540).0.iter().all(|&v| v > 150), "{:?}", l0.get_pixel(960, 540) ); assert!( l1.pixels().all(|p| p.0 == [64; 3]), "every pixel at the bias" ); } #[cfg(feature = "astrokit")] #[test] fn solar_eclipse_puts_the_umbra_at_greatest_eclipse() { // The camera looks straight down at the point of greatest eclipse from NASA's // bulletin (65.2 N 25.2 W, 17:46 UTC): at L1 it is in the Moon's umbra and reads the // bias, and the image corner, about 3500 km away in the penumbra, is brighter. At L0 // the center is lit. let scene = "examples/scenes/solar-eclipse.toml"; let l0 = render_at(scene, "gpu", "L0"); let l1 = render_at(scene, "gpu", "L1"); assert!( l0.get_pixel(960, 540).0.iter().all(|&v| v > 100), "{:?}", l0.get_pixel(960, 540) ); assert_eq!(l1.get_pixel(960, 540).0, [64; 3]); let corner = l1.get_pixel(40, 1040).0; assert!(corner.iter().all(|&v| v > 70), "{corner:?}"); } #[test] fn lighting_l1_names_a_missing_moon_map() { // Earth's images but no Moon map: the error names the file and the script. let dir = out_dir().join("earth-only"); std::fs::create_dir_all(dir.join("earth")).unwrap(); std::fs::copy( gray_earth().join("earth/bmng-2004-07.jpg"), dir.join("earth/bmng-2004-07.jpg"), ) .unwrap(); let result = resolved(&[ "render", "--scene", "examples/scenes/landsat8.toml", "--lighting", "L1", "--data", dir.to_str().unwrap(), "--preview", "x.png", ]); assert!(!result.status.success()); let stderr = String::from_utf8_lossy(&result.stderr); assert!( stderr.contains("moon/wac-hapke-643.png") && stderr.contains("fetch-moon.py"), "{stderr}" ); } #[test] fn errors_are_reported() { let missing = resolved(&["render", "--scene", "no/such.toml", "--preview", "x.png"]); assert!(!missing.status.success()); assert!(String::from_utf8_lossy(&missing.stderr).contains("no/such.toml")); let idle = resolved(&["render", "--scene", "examples/scenes/boxwing.toml"]); assert!(!idle.status.success()); assert!(String::from_utf8_lossy(&idle.stderr).contains("nothing to do")); } #[cfg(feature = "astrokit")] #[test] fn missing_kernels_say_where_they_looked() { let result = resolved(&[ "render", "--scene", "examples/scenes/full-moon.toml", "--kernels", "no/such/dir", "--preview", "x.png", ]); assert!(!result.status.success()); let stderr = String::from_utf8_lossy(&result.stderr); assert!( stderr.contains("--kernels") && stderr.contains("no/such/dir"), "{stderr}" ); } #[test] fn lighting_l1_says_what_it_is_missing() { // Without an epoch or an earth_month, L1 cannot pick a month: boxwing.toml with its // [appearance] removed. let scene = out_dir().join("no-month.toml"); let models = repo().join("examples/models").canonicalize().unwrap(); let text = std::fs::read_to_string(repo().join("examples/scenes/boxwing.toml")) .unwrap() .replace("../models", models.to_str().unwrap()) .replace("[appearance]\nearth_month = 7\n", ""); assert!(!text.contains("earth_month")); std::fs::write(&scene, text).unwrap(); let no_month = resolved(&[ "render", "--scene", scene.to_str().unwrap(), "--lighting", "L1", "--preview", "x.png", ]); assert!(!no_month.status.success()); let stderr = String::from_utf8_lossy(&no_month.stderr); assert!(stderr.contains("earth_month"), "{stderr}"); // With a month but no data: the error names the file and the fetch script. let no_data = resolved(&[ "render", "--scene", "examples/scenes/boxwing.toml", "--lighting", "L1", "--data", "no/such/dir", "--preview", "x.png", ]); assert!(!no_data.status.success()); let stderr = String::from_utf8_lossy(&no_data.stderr); assert!( stderr.contains("bmng-2004-07.jpg") && stderr.contains("fetch-earth.sh"), "{stderr}" ); let unknown = resolved(&[ "render", "--scene", "examples/scenes/boxwing.toml", "--lighting", "L7", "--preview", "x.png", ]); assert!(!unknown.status.success()); } #[test] fn adapters_lists_something() { let result = resolved(&["adapters"]); assert!(result.status.success()); assert!(!result.stdout.is_empty()); }