use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn kalo() -> Command {
Command::new(env!("CARGO_BIN_EXE_kalo"))
}
fn run(args: &[&str]) -> Output {
kalo().args(args).output().expect("kalo should start")
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn chart() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/testchart.png")
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("kalo-it-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch directory");
dir
}
fn contrast(output: &str) -> f64 {
let line = output
.lines()
.find(|line| line.trim_start().starts_with("contrast"))
.expect("the report must contain a contrast line");
let tail = line.split('×').nth(1).expect("a × factor");
tail.split_whitespace()
.next()
.expect("a number after ×")
.parse()
.expect("the factor must parse")
}
#[test]
fn stocks_lists_every_builtin() {
let output = run(&["stocks"]);
assert!(output.status.success(), "stderr: {}", stderr(&output));
let text = stdout(&output);
for id in [
"kodak_ultramax400",
"fuji_superia_xtra400",
"kodak_tmax100",
"portra_endura",
"supra_endura",
"endura_premier_metallic",
"fuji_crystal_archive_pearl",
"polymax_ii_rc",
] {
assert!(text.contains(id), "{id} missing from:\n{text}");
}
}
#[test]
fn longer_development_raises_contrast() {
let short = stdout(&run(&["curve", "--dev", "3"]));
let long = stdout(&run(&["curve", "--dev", "12"]));
assert!(
contrast(&long) > contrast(&short),
"12 min ({}) should out-contrast 3 min ({})",
contrast(&long),
contrast(&short)
);
}
#[test]
fn temperature_trades_against_time() {
let warm_short = stdout(&run(&["curve", "--dev", "3.8235", "--temp", "30"]));
let cool_normal = stdout(&run(&["curve", "--dev", "6.5", "--temp", "20"]));
let delta = (contrast(&warm_short) - contrast(&cool_normal)).abs();
assert!(delta < 1e-3, "expected parity, got delta {delta}");
}
#[test]
fn grain_is_deterministic_in_its_seed() {
let dir = scratch("grain");
let render = |name: &str, seed: &str| {
let out = dir.join(name);
let output = kalo()
.arg("render")
.arg(chart())
.arg("-o")
.arg(&out)
.args(["--dev", "9", "--grain", "--frame-mm", "3.6"])
.args(["--grain-seed", seed])
.output()
.expect("kalo should start");
assert!(output.status.success(), "stderr: {}", stderr(&output));
std::fs::read(&out).expect("the output file was written")
};
let first = render("a.png", "0");
let again = render("b.png", "0");
let other = render("c.png", "1");
assert_eq!(first, again, "the same seed must reproduce the same image");
assert_ne!(first, other, "a different seed must change the grain");
}
fn flat_frame(dir: &Path, name: &str, linear: f64) -> PathBuf {
let path = dir.join(name);
let image = kallo_io::Image {
width: 8,
height: 8,
pixels: vec![[linear; 3]; 64],
};
kallo_io::save(&path, &image).expect("write the frame");
path
}
fn printed_ev(report: &str) -> String {
let line = report
.lines()
.find(|line| line.trim_start().starts_with("ev "))
.expect("the report must contain an ev line");
line.split_whitespace()
.find(|word| word.parse::<f64>().is_ok())
.expect("the ev line must carry a number")
.to_owned()
}
fn ev(report: &str) -> f64 {
printed_ev(report).parse().expect("a number of stops")
}
#[test]
fn render_writes_an_image_and_creates_directories() {
let dir = scratch("render");
let out = dir.join("nested").join("out.png");
let output = kalo()
.arg("render")
.arg(chart())
.arg("-o")
.arg(&out)
.arg("--dev")
.arg("9")
.output()
.expect("kalo should start");
assert!(output.status.success(), "stderr: {}", stderr(&output));
assert!(out.exists(), "the output file was not written");
let image = kallo_io::load(&out, kallo_io::InputEncoding::Srgb).expect("a readable PNG");
assert_eq!((image.width, image.height), (1200, 800));
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn auto_exposure_places_the_frames_mean_on_mid_grey() {
let dir = scratch("auto-ev");
let input = flat_frame(&dir, "dark.png", 0.045);
let render = |stops: &str, name: &str| {
let out = dir.join(name);
let output = kalo()
.arg("render")
.arg(&input)
.arg("-o")
.arg(&out)
.args(["--ev", stops])
.output()
.expect("kalo should start");
assert!(output.status.success(), "stderr: {}", stderr(&output));
(stdout(&output), out)
};
let (report, auto_out) = render("auto", "auto.png");
let solved = ev(&report);
assert!(
(solved - 2.0).abs() < 0.05,
"a frame of 0.045 should be metered to +2 stops, got {solved:+.3}\n{report}"
);
assert!(report.contains("auto — frame mean 0.045"), "{report}");
let (_, manual_out) = render(&printed_ev(&report), "manual.png");
let codepoints = |path: &Path| -> Vec<u8> {
kallo_io::load(path, kallo_io::InputEncoding::Srgb)
.expect("a rendered frame")
.pixels
.iter()
.flatten()
.map(|value| (kallo_core::linear_to_srgb(*value) * 255.0).round() as u8)
.collect()
};
let (metered, hand) = (codepoints(&auto_out), codepoints(&manual_out));
let worst = metered
.iter()
.zip(&hand)
.map(|(a, b)| a.abs_diff(*b))
.max()
.expect("a frame");
assert!(
worst <= 1,
"the printed offset must reproduce the metered frame, worst {worst} code values"
);
let (_, zero_out) = render("0", "zero.png");
assert_ne!(
std::fs::read(&auto_out).expect("the metered frame"),
std::fs::read(&zero_out).expect("the unmetered frame")
);
}
#[test]
fn a_black_frame_is_not_lifted_by_a_meter_that_cannot_read_it() {
let dir = scratch("black-ev");
let input = flat_frame(&dir, "black.png", 0.0);
let out = dir.join("out.png");
let output = kalo()
.arg("render")
.arg(&input)
.arg("-o")
.arg(&out)
.args(["--ev", "auto"])
.output()
.expect("kalo should start");
assert!(output.status.success(), "stderr: {}", stderr(&output));
assert!(ev(&stdout(&output)).abs() < 1e-9, "{}", stdout(&output));
}
#[test]
fn an_exposure_that_is_neither_a_number_nor_auto_is_refused() {
let output = run(&[
"render",
chart().to_str().expect("a utf-8 path"),
"-o",
"out/never.png",
"--ev",
"auto2",
]);
assert!(!output.status.success(), "{}", stdout(&output));
let message = stderr(&output);
assert!(
message.contains("expected a number of stops or `auto`"),
"{message}"
);
assert!(message.contains("auto2"), "{message}");
}
#[test]
fn bake_writes_a_table_and_render_uses_it() {
let dir = scratch("bake");
let table = dir.join("model.klut");
let baked = kalo()
.arg("bake")
.arg("-o")
.arg(&table)
.args(["--size", "5"])
.output()
.expect("kalo should start");
assert!(baked.status.success(), "stderr: {}", stderr(&baked));
assert!(table.exists(), "the table was not written");
let out = dir.join("out.png");
let rendered = kalo()
.arg("render")
.arg(chart())
.arg("-o")
.arg(&out)
.arg("--lut")
.arg(&table)
.output()
.expect("kalo should start");
assert!(rendered.status.success(), "stderr: {}", stderr(&rendered));
assert!(out.exists());
assert!(
stdout(&rendered).contains("spectral, baked table"),
"{}",
stdout(&rendered)
);
let bytes = std::fs::read(&table).expect("table");
let text = String::from_utf8_lossy(&bytes[14..80]).to_string();
assert!(
text.contains("camera="),
"no provenance in the header: {text}"
);
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn a_truncated_table_is_rejected() {
let dir = scratch("badlut");
let table = dir.join("broken.klut");
std::fs::write(&table, b"KLUT").expect("write");
let output = kalo()
.arg("render")
.arg(chart())
.arg("-o")
.arg(dir.join("out.png"))
.arg("--lut")
.arg(&table)
.output()
.expect("kalo should start");
assert!(!output.status.success());
assert!(
stderr(&output).contains("lookup table"),
"{}",
stderr(&output)
);
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn unknown_film_is_reported_by_name() {
let output = run(&["curve", "--film", "portra400"]);
assert!(!output.status.success(), "an unknown stock must fail");
assert!(
stderr(&output).contains("unknown film stock 'portra400'"),
"stderr: {}",
stderr(&output)
);
}
#[test]
fn non_positive_development_time_is_rejected() {
let output = run(&["curve", "--dev", "0"]);
assert!(!output.status.success());
assert!(
stderr(&output).contains("--dev must be"),
"stderr: {}",
stderr(&output)
);
}
#[cfg(not(feature = "gpu"))]
#[test]
fn a_build_without_the_gpu_renders_on_the_cpu() {
let dir = scratch("gpu-off");
let output = kalo()
.arg("render")
.arg(chart())
.arg("-o")
.arg(dir.join("out.png"))
.output()
.expect("kalo should start");
assert!(output.status.success(), "stderr: {}", stderr(&output));
let report = stdout(&output);
let line = report
.lines()
.find(|line| line.trim_start().starts_with("backend"))
.expect("the report must name the backend");
assert!(line.contains("cpu"), "unexpected backend line: {line}");
}
#[cfg(feature = "gpu")]
#[test]
fn the_default_backend_is_the_gpu_or_says_why_it_is_not() {
let dir = scratch("gpu-on");
let out = dir.join("out.png");
let output = kalo()
.arg("render")
.arg(chart())
.arg("-o")
.arg(&out)
.args(["--dev", "9"])
.output()
.expect("kalo should start");
assert!(output.status.success(), "stderr: {}", stderr(&output));
assert!(out.exists(), "no image was written");
let report = stdout(&output);
let line = report
.lines()
.find(|line| line.trim_start().starts_with("backend"))
.expect("the report must name the backend");
assert!(
line.contains("gpu") || line.contains("cpu"),
"unexpected backend line: {line}"
);
}
#[cfg(feature = "gpu")]
#[test]
fn the_gpu_renders_the_effects_to_the_same_pixels_as_the_cpu() {
let dir = scratch("gpu-spectral");
let on_cpu = dir.join("cpu.png");
let on_gpu = dir.join("gpu.png");
let stages = [
"--spectral",
"--spectral-print",
"--spectral-scan",
"--grain",
];
let input = dir.join("input.png");
const WIDTH: u32 = 48;
const HEIGHT: u32 = 32;
let mut pixels = Vec::with_capacity((WIDTH * HEIGHT) as usize);
for y in 0..HEIGHT {
for x in 0..WIDTH {
let a = f64::from(x) / f64::from(WIDTH - 1);
let b = f64::from(y) / f64::from(HEIGHT - 1);
pixels.push([a, b, (a * b).sqrt()]);
}
}
kallo_io::save(
&input,
&kallo_io::Image {
width: WIDTH,
height: HEIGHT,
pixels,
},
)
.expect("the test frame");
let cpu = kalo()
.arg("render")
.arg(&input)
.arg("-o")
.arg(&on_cpu)
.arg("--no-gpu")
.args(stages)
.output()
.expect("kalo should start");
assert!(cpu.status.success(), "stderr: {}", stderr(&cpu));
let gpu = kalo()
.arg("render")
.arg(&input)
.arg("-o")
.arg(&on_gpu)
.args(stages)
.output()
.expect("kalo should start");
assert!(gpu.status.success(), "stderr: {}", stderr(&gpu));
let report = stdout(&gpu);
let backend = report
.lines()
.find(|line| line.trim_start().starts_with("backend"))
.expect("the report must name the backend")
.to_owned();
if backend.contains("gpu —") {
let decoded_cpu =
kallo_io::load(&on_cpu, kallo_io::InputEncoding::Srgb).expect("the cpu render");
let decoded_gpu =
kallo_io::load(&on_gpu, kallo_io::InputEncoding::Srgb).expect("the gpu render");
assert_eq!(decoded_cpu.pixels.len(), decoded_gpu.pixels.len());
let mut worst = 0_i32;
let mut differing = 0_usize;
for (cpu, gpu) in decoded_cpu.pixels.iter().zip(&decoded_gpu.pixels) {
let mut moved = false;
for channel in 0..3 {
let left = (kallo_core::linear_to_srgb(cpu[channel]) * 255.0).round() as i32;
let right = (kallo_core::linear_to_srgb(gpu[channel]) * 255.0).round() as i32;
worst = worst.max((left - right).abs());
moved |= left != right;
}
if moved {
differing += 1;
}
}
assert!(
worst <= 1,
"the backends differ by {worst} levels on {differing} pixels: {backend}"
);
} else {
assert!(
stderr(&gpu).contains("warning"),
"a fallback has to say so: {backend}"
);
}
}
#[cfg(feature = "gpu")]
#[test]
fn the_cpu_can_be_forced() {
let dir = scratch("cpu-forced");
let output = kalo()
.arg("render")
.arg(chart())
.arg("-o")
.arg(dir.join("out.png"))
.args(["--spectral", "--no-gpu"])
.output()
.expect("kalo should start");
assert!(output.status.success(), "stderr: {}", stderr(&output));
let report = stdout(&output);
let line = report
.lines()
.find(|line| line.trim_start().starts_with("backend"))
.expect("the report must name the backend");
assert!(line.contains("cpu"), "unexpected backend line: {line}");
}
#[test]
fn contact_prints_one_sheet_and_a_record_of_where_the_frames_are() {
let dir = scratch("contact");
let sheet = dir.join("proof.png");
let record = dir.join("proof.json");
let output = run(&[
"contact",
chart().to_str().expect("a path"),
"-o",
sheet.to_str().expect("a path"),
"--record",
record.to_str().expect("a path"),
"--film",
"kodak_portra_400",
"--paper",
"portra_endura",
"--dev",
"3,6,9,12,15",
"--dpi",
"150",
]);
assert!(output.status.success(), "stderr: {}", stderr(&output));
assert!(sheet.is_file(), "the sheet was not written");
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&record).expect("the record"))
.expect("the record is json");
let frames = json["frames"].as_array().expect("frames");
assert_eq!(frames.len(), 5);
assert_eq!(frames[0]["minutes"], 3.0);
assert_eq!(frames[4]["minutes"], 15.0);
assert!(
frames[0]["x"].as_u64() < frames[1]["x"].as_u64(),
"a row of strips runs left to right"
);
let image =
kallo_io::load(&sheet, kallo_io::InputEncoding::Srgb).expect("the sheet reads back");
assert_eq!(
json["width"].as_u64().expect("width"),
u64::from(image.width)
);
assert_eq!(
json["height"].as_u64().expect("height"),
u64::from(image.height)
);
assert!(image.width > image.height);
}
#[test]
fn contact_refuses_a_reversal() {
let dir = scratch("contact-reversal");
let output = run(&[
"contact",
chart().to_str().expect("a path"),
"-o",
dir.join("proof.png").to_str().expect("a path"),
"--film",
"provia_100f",
"--dev",
"6",
]);
assert!(!output.status.success());
assert!(
stderr(&output).contains("reversal"),
"an error a person can act on: {}",
stderr(&output)
);
}