Files
resolved/crates/resolved-cli/tests/cli.rs
T
2026-10-15 12:33:40 -04:00

312 lines
10 KiB
Rust

//! 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<image::Rgb<u16>, Vec<u16>> {
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<PathBuf> = 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::<image::Luma<u16>, _>::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<image::Rgb<u16>, Vec<u16>> {
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());
}