mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
1132 lines
56 KiB
Rust
1132 lines
56 KiB
Rust
//! Process-level tests of the `photocraft-cli` binary.
|
||
|
||
use std::io::{BufRead, BufReader, Write};
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Command, Stdio};
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
fn bin() -> Command {
|
||
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
|
||
}
|
||
|
||
fn tmp(name: &str) -> PathBuf {
|
||
let d = std::env::temp_dir().join(format!("pc-cli-{}-{name}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&d);
|
||
std::fs::create_dir_all(&d).unwrap();
|
||
d
|
||
}
|
||
|
||
fn write_png(path: &Path, w: u32, h: u32, seed: u8) {
|
||
let data: Vec<u8> = (0..w * h * 4).map(|i| (i as u8).wrapping_mul(seed).wrapping_add(seed)).collect();
|
||
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data).unwrap();
|
||
std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||
}
|
||
|
||
fn ok(cmd: &mut Command) -> (String, String) {
|
||
let o = cmd.output().unwrap();
|
||
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
|
||
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
|
||
(out, err)
|
||
}
|
||
|
||
#[test]
|
||
fn usage_and_unknown_command() {
|
||
let o = bin().output().unwrap();
|
||
assert_eq!(o.status.code(), Some(2));
|
||
assert!(String::from_utf8_lossy(&o.stderr).contains("USAGE"));
|
||
let o = bin().arg("frobnicate").output().unwrap();
|
||
assert_eq!(o.status.code(), Some(2));
|
||
let (out, _) = ok(bin().arg("--help"));
|
||
assert!(out.contains("convert"));
|
||
let (out, _) = ok(bin().arg("--version"));
|
||
assert!(out.starts_with("photocraft-cli "));
|
||
}
|
||
|
||
/// #423: `<subcommand> --help` and `-h` print usage and return at once (no inputs read, no files
|
||
/// written, no server started), whatever else is on the line.
|
||
#[test]
|
||
fn every_subcommand_answers_help() {
|
||
let d = tmp("help");
|
||
for sub in ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"] {
|
||
for help in ["--help", "-h"] {
|
||
let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap();
|
||
let out = String::from_utf8_lossy(&o.stdout);
|
||
assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr));
|
||
assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}");
|
||
}
|
||
}
|
||
// Help where a flag's value should be is still help, not a missing value or a file named `-h`.
|
||
for args in [&["run", "x.png", "--out", "--help"][..], &["commands", "--filter", "-h"], &["mcp", "--bridge", "-h"], &["serve", "--port", "--help"]] {
|
||
let o = bin().current_dir(&d).args(args).stdin(Stdio::null()).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&o.stderr));
|
||
assert!(String::from_utf8_lossy(&o.stdout).contains("USAGE"), "{args:?}");
|
||
}
|
||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
|
||
}
|
||
|
||
/// #423: a flag the subcommand doesn't take is a usage error naming it, before any work.
|
||
#[test]
|
||
fn unknown_flags_are_usage_errors() {
|
||
let d = tmp("flags");
|
||
let input = d.join("in");
|
||
std::fs::create_dir_all(&input).unwrap();
|
||
write_png(&input.join("a.png"), 8, 4, 3);
|
||
let actions = d.join("actions.json");
|
||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||
let out_dir = d.join("out");
|
||
let fails = |cmd: &mut Command, flag: &str| {
|
||
let o = cmd.output().unwrap();
|
||
let err = String::from_utf8_lossy(&o.stderr);
|
||
assert_eq!(o.status.code(), Some(2), "{err}");
|
||
assert!(err.contains(&format!("unknown flag {flag}")), "{err}");
|
||
};
|
||
for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] {
|
||
fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat");
|
||
assert!(!out_dir.exists(), "nothing written");
|
||
}
|
||
let x = d.join("x.png");
|
||
fails(bin().arg("run").arg(input.join("a.png")).args(["--cmd", "image.adjustments.invert", "--param", "{}", "--out"]).arg(&x), "--param");
|
||
assert!(!x.exists());
|
||
// A flag one subcommand takes is still unknown to another; a bare flag takes no value.
|
||
fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json");
|
||
let o = bin().args(["commands", "--json=yes"]).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(2));
|
||
assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value"));
|
||
// A flag can't be a value: a missing value is reported, not filled with the next flag.
|
||
let o = bin()
|
||
.args(["batch", "--actions"])
|
||
.arg(&actions)
|
||
.arg("--in")
|
||
.arg(&input)
|
||
.arg("--out")
|
||
.arg(&out_dir)
|
||
.args(["--format", "--quality", "80"])
|
||
.output()
|
||
.unwrap();
|
||
assert_eq!(o.status.code(), Some(2));
|
||
assert!(String::from_utf8_lossy(&o.stderr).contains("--format needs a value"));
|
||
assert!(!out_dir.exists(), "nothing written");
|
||
// `--flag=value` still passes a value that starts with `--`.
|
||
ok(bin().args(["commands", "--filter=--json"]));
|
||
// Every flag still works in both forms.
|
||
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
|
||
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
|
||
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
|
||
for needle in ["ellipse", "marquee"] {
|
||
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
|
||
assert!(out.contains("select.rect"), "{needle}: {out}");
|
||
}
|
||
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
|
||
"--format=jpg",
|
||
"--quality",
|
||
"80",
|
||
]));
|
||
assert!(out_dir.join("a.jpg").exists());
|
||
let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact"));
|
||
assert!(!out.trim().contains('\n'), "compact JSON is one line");
|
||
}
|
||
|
||
fn server_usage_error(args: &[&str], expected: &str) {
|
||
let o = bin().args(args).env_remove("PHOTOCRAFT_CONTROL_TOKEN").env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE").stdin(Stdio::null()).output().unwrap();
|
||
let err = String::from_utf8_lossy(&o.stderr);
|
||
assert_eq!(o.status.code(), Some(2), "{args:?}: {err}");
|
||
assert!(o.stdout.is_empty(), "usage errors must leave protocol stdout empty: {args:?}");
|
||
assert!(err.contains(expected), "{args:?}: expected {expected:?} in {err}");
|
||
}
|
||
|
||
#[test]
|
||
fn server_modes_reject_ignored_or_conflicting_options() {
|
||
for sub in ["mcp", "serve"] {
|
||
server_usage_error(&[sub, "stray.psd"], "does not take positional arguments");
|
||
for flag in ["--control-token", "--control-token-file"] {
|
||
server_usage_error(&[sub, flag, "unused"], if sub == "mcp" { "requires --bridge" } else { "requires --port" });
|
||
}
|
||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||
server_usage_error(&[sub, flag, ".", flag, "."], "may only be specified once");
|
||
server_usage_error(&[sub, &format!("{flag}=")], "needs a non-empty value");
|
||
server_usage_error(&[sub, flag, "--automation-write-root", "."], &format!("{flag} needs a value"));
|
||
}
|
||
let transport = if sub == "mcp" { "--bridge" } else { "--port" };
|
||
for flag in [transport, "--control-token", "--control-token-file"] {
|
||
server_usage_error(&[sub, flag, "one", &format!("{flag}=two")], "may only be specified once");
|
||
server_usage_error(&[sub, flag, ""], "needs a non-empty value");
|
||
}
|
||
server_usage_error(
|
||
&[sub, transport, "7878", "--control-token", "unused", "--control-token-file", "unused"],
|
||
"use either --control-token or --control-token-file",
|
||
);
|
||
}
|
||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||
server_usage_error(&["mcp", "--bridge", "127.0.0.1:7878", flag, "."], "configure automation roots on the desktop app");
|
||
}
|
||
}
|
||
|
||
/// Each launch line would, before validation, have started a TCP server and written the token file.
|
||
#[test]
|
||
fn server_validation_does_not_consume_stdio_or_create_files() {
|
||
let d = tmp("server-validation");
|
||
let token = d.join("must-not-be-created.token");
|
||
let other = d.join("other.token");
|
||
let token_flag = format!("--control-token-file={}", token.display());
|
||
let other_flag = format!("--control-token-file={}", other.display());
|
||
for tail in [&["stray"][..], &["--automation-read-root", ".", "--automation-read-root", "."], &["--port", "0"], &[&other_flag]] {
|
||
let mut child = bin()
|
||
.current_dir(&d)
|
||
.args(["serve", "--port", "0", &token_flag])
|
||
.args(tail)
|
||
.env_remove("PHOTOCRAFT_CONTROL_TOKEN")
|
||
.env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE")
|
||
.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.unwrap();
|
||
// Keep stdin open: invalid launch arguments must fail before starting a server.
|
||
let input = child.stdin.take().unwrap();
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||
while child.try_wait().unwrap().is_none() && std::time::Instant::now() < deadline {
|
||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||
}
|
||
if child.try_wait().unwrap().is_none() {
|
||
child.kill().unwrap();
|
||
child.wait().unwrap();
|
||
panic!("{tail:?}: invalid configuration started a server");
|
||
}
|
||
drop(input);
|
||
let o = child.wait_with_output().unwrap();
|
||
assert_eq!(o.status.code(), Some(2), "{tail:?}: {}", String::from_utf8_lossy(&o.stderr));
|
||
assert!(o.stdout.is_empty(), "{tail:?}");
|
||
assert!(!token.exists() && !other.exists(), "{tail:?}: validation must happen before creating credentials");
|
||
}
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn convert_png_pcraft_png_is_lossless() {
|
||
let d = tmp("convert");
|
||
let (a, b, c) = (d.join("a.png"), d.join("b.pcraft"), d.join("c.png"));
|
||
write_png(&a, 37, 23, 7);
|
||
ok(bin().args(["convert"]).arg(&a).arg(&b));
|
||
assert!(photocraft_format::is_pcraft(&std::fs::read(&b).unwrap()));
|
||
ok(bin().args(["convert"]).arg(&b).arg(&c));
|
||
let x = photocraft_codecs::decode(&std::fs::read(&a).unwrap()).unwrap();
|
||
let y = photocraft_codecs::decode(&std::fs::read(&c).unwrap()).unwrap();
|
||
assert_eq!(x.to_rgba8(), y.to_rgba8());
|
||
// Format override and lossy warning path.
|
||
ok(bin().args(["convert"]).arg(&a).arg(d.join("out.bin")).args(["--format", "jpg", "--quality", "60"]));
|
||
assert!(photocraft_codecs::detect(&std::fs::read(d.join("out.bin")).unwrap()) == Some(photocraft_codecs::Format::Jpeg));
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn convert_errors() {
|
||
let d = tmp("converr");
|
||
let o = bin().args(["convert", "/missing/in.png"]).arg(d.join("x.png")).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1));
|
||
let o = bin().args(["convert", "only-one"]).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1));
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn info_prints_layer_tree_json() {
|
||
let d = tmp("info");
|
||
let a = d.join("a.png");
|
||
write_png(&a, 10, 6, 3);
|
||
let (out, _) = ok(bin().arg("info").arg(&a));
|
||
let v: Value = serde_json::from_str(&out).unwrap();
|
||
assert_eq!(v["width"], 10);
|
||
assert_eq!(v["height"], 6);
|
||
// An RGBA PNG opens as a normal "Layer 0", not the locked Background (as in Photoshop).
|
||
assert_eq!(v["layers"][0]["name"], "Layer 0");
|
||
assert!(v.get("history").is_none());
|
||
let (out, _) = ok(bin().arg("info").arg(&a).arg("--compact"));
|
||
assert_eq!(out.trim().lines().count(), 1);
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
/// #518, #523: a truncated JPEG and an animated GIF open, but `info` lists why the image is
|
||
/// incomplete and `convert` prints it on stderr (and still succeeds); complete files stay quiet.
|
||
#[test]
|
||
fn info_and_convert_report_decode_warnings() {
|
||
let d = tmp("decode-warnings");
|
||
let noise: Vec<u8> = (0..64 * 48 * 3).map(|i: u32| (i.wrapping_mul(7919) % 251) as u8).collect();
|
||
let img = photocraft_codecs::Image::from_u8(64, 48, photocraft_codecs::ChannelLayout::Rgb, noise).unwrap();
|
||
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap();
|
||
// Three 1x1 frames.
|
||
let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
|
||
for _ in 0..3 {
|
||
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
|
||
}
|
||
gif.push(0x3B);
|
||
// A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it
|
||
// writes, after the decode warning.
|
||
let cases = [
|
||
("whole.jpg", &jpeg[..], None, None),
|
||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None),
|
||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")),
|
||
];
|
||
for (name, bytes, warning, export) in cases {
|
||
let file = d.join(name);
|
||
std::fs::write(&file, bytes).unwrap();
|
||
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
|
||
let v: Value = serde_json::from_str(&out).unwrap();
|
||
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
|
||
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
|
||
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
|
||
assert_eq!(
|
||
err.trim(),
|
||
expected.join(
|
||
"
|
||
"
|
||
),
|
||
"{name}"
|
||
);
|
||
}
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn run_commands_and_save() {
|
||
let d = tmp("run");
|
||
let out_pc = d.join("r.pcraft");
|
||
let (out, _) = ok(bin()
|
||
.args(["run", "--new", r#"{"width":40,"height":30,"name":"Run"}"#])
|
||
.args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#])
|
||
.args(["--cmd", "paint.stroke", "--params", r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##])
|
||
.args(["--cmd", "document.inspect"])
|
||
.arg("--out")
|
||
.arg(&out_pc));
|
||
let lines: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||
assert_eq!(lines.len(), 3);
|
||
assert_eq!(lines[2]["command"], "document.inspect");
|
||
assert_eq!(lines[2]["result"]["name"], "Run");
|
||
let (info, _) = ok(bin().arg("info").arg(&out_pc));
|
||
let v: Value = serde_json::from_str(&info).unwrap();
|
||
// Saving a native document gives the reopened bundle its output filename.
|
||
assert_eq!(v["name"], "r.pcraft");
|
||
let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
|
||
assert_eq!(names, ["Ink", "Background"]);
|
||
// Continue from the saved file and export PNG.
|
||
let png = d.join("r.png");
|
||
ok(bin().arg("run").arg(&out_pc).args(["--cmd", "layer.new.layer"]).arg("--out").arg(&png));
|
||
assert_eq!(photocraft_codecs::decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (40, 30));
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn run_stops_after_failed_action_without_exporting() {
|
||
let d = tmp("run-failed-action");
|
||
let out = d.join("result.png");
|
||
let result = bin()
|
||
.args(["run", "--new", r#"{"width":8,"height":8}"#])
|
||
.args(["--cmd", "layer.new.layer"])
|
||
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
|
||
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
|
||
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
|
||
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
|
||
.args(["--cmd", "document.inspect", "--out"])
|
||
.arg(&out)
|
||
.output()
|
||
.unwrap();
|
||
let error = String::from_utf8_lossy(&result.stderr);
|
||
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
|
||
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
|
||
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
|
||
assert!(!out.exists(), "a failed action must not export a partial result");
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn run_continues_after_successful_action_and_exports() {
|
||
let d = tmp("run-successful-action");
|
||
let out = d.join("result.png");
|
||
let (stdout, _) = ok(bin()
|
||
.args(["run", "--new", r#"{"width":8,"height":8}"#])
|
||
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
|
||
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
|
||
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
|
||
.args(["--cmd", "document.inspect", "--out"])
|
||
.arg(&out));
|
||
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
|
||
assert_eq!(playback["result"]["ran"], 1);
|
||
assert!(playback["result"].get("failed").is_none());
|
||
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
|
||
assert!(out.is_file());
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn run_errors() {
|
||
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1));
|
||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown command"));
|
||
let o = bin().args(["run", "--new", "{}", "--params", "{}"]).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1));
|
||
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1));
|
||
// Params must be a JSON object, for `--new` and for each `--params`.
|
||
let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1));
|
||
let e = String::from_utf8_lossy(&o.stderr);
|
||
assert!(e.contains("--new") && e.contains("must be a JSON object"), "{e}");
|
||
let o = bin().args(["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "\"x\""]).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1));
|
||
let e = String::from_utf8_lossy(&o.stderr);
|
||
assert!(e.contains("layer.new.layer") && e.contains("must be a JSON object"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn run_rejects_invalid_filter_parameters_without_exporting() {
|
||
let d = tmp("invalid-filter-params");
|
||
let out = d.join("result.png");
|
||
for (params, key) in [(r#"{"radius":"big"}"#, "radius"), (r#"{"radus":3}"#, "radus"), (r#"{"radius":5000}"#, "radius"), (r#"{"radius":-4}"#, "radius")] {
|
||
let result = bin()
|
||
.args(["run", "--new", r#"{"width":8,"height":8}"#, "--cmd", "filter.blur.gaussianBlur", "--params", params, "--out"])
|
||
.arg(&out)
|
||
.output()
|
||
.unwrap();
|
||
assert_eq!(result.status.code(), Some(1));
|
||
let error = String::from_utf8_lossy(&result.stderr);
|
||
assert!(error.contains("filter.blur.gaussianBlur") && error.contains(key), "{error}");
|
||
assert!(!out.exists());
|
||
}
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn batch_applies_actions_to_directory() {
|
||
let d = tmp("batch");
|
||
let (inp, outp) = (d.join("in"), d.join("out"));
|
||
std::fs::create_dir_all(&inp).unwrap();
|
||
write_png(&inp.join("one.png"), 12, 8, 1);
|
||
write_png(&inp.join("two.png"), 9, 9, 2);
|
||
std::fs::write(inp.join("notes.txt"), "ignored").unwrap();
|
||
let actions = d.join("actions.json");
|
||
std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap();
|
||
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"]));
|
||
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
|
||
for n in ["one", "two"] {
|
||
let (info, _) = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft"))));
|
||
let v: Value = serde_json::from_str(&info).unwrap();
|
||
assert_eq!(v["layers"][0]["name"], "Batch");
|
||
}
|
||
// A failing action reports and exits 1.
|
||
std::fs::write(&actions, r#"{"actions":[{"id":"no.such.command"}]}"#).unwrap();
|
||
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1));
|
||
assert!(String::from_utf8_lossy(&o.stderr).contains("FAIL"));
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn batch_skips_recorded_view_and_save_steps() {
|
||
let d = tmp("batch-shell-steps");
|
||
let input = d.join("in");
|
||
std::fs::create_dir_all(&input).unwrap();
|
||
let source = input.join("a.png");
|
||
write_png(&source, 12, 8, 3);
|
||
let original = std::fs::read(&source).unwrap();
|
||
let recorded_save = d.join("recorded-save.psd");
|
||
let mut cases = Vec::new();
|
||
for (name, id) in [("zoom-in", "view.zoomIn"), ("zoom-out", "view.zoomOut"), ("fit", "view.fitOnScreen"), ("actual-pixels", "view.actualPixels")] {
|
||
cases.push((name, json!([[id, {}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||
}
|
||
cases.push(("save", json!([["file.save", {}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||
cases.push(("save-as", json!([["file.saveAs", {"path": recorded_save.to_string_lossy()}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||
|
||
for (name, actions) in cases {
|
||
let o = batch_with(&d, &input, name, &actions, &[]);
|
||
let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||
assert!(o.status.success(), "{name}: {stdout}\n{stderr}");
|
||
assert!(stdout.contains("1 succeeded, 0 failed"), "{name}: {stdout}");
|
||
let output = d.join(name).join("a.png");
|
||
let (info, _) = ok(bin().arg("info").arg(&output));
|
||
let info: Value = serde_json::from_str(&info).unwrap();
|
||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(12)), "{name}: {info}");
|
||
assert_eq!(std::fs::read(&source).unwrap(), original, "{name} changed its input");
|
||
assert!(!recorded_save.exists(), "{name} wrote the recorded Save As path");
|
||
}
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn parse_actions_formats() {
|
||
let a = photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap();
|
||
assert_eq!(a, vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))]);
|
||
assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1);
|
||
assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err());
|
||
assert!(photocraft_cli::parse_actions("7").is_err());
|
||
// #489: the step shapes a recorded action and a droplet store, bare or wrapped.
|
||
let want = vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))];
|
||
for text in [
|
||
r#"[["a",{"x":1}],["b"]]"#,
|
||
r#"[["a",{"x":1}],"b"]"#,
|
||
r#"{"steps":[["a",{"x":1}],["b",{}]]}"#,
|
||
r#"{"photocraftDroplet":1,"action":{"steps":[["a",{"x":1}],{"command":"b"}]}}"#,
|
||
r#"{"actions":[["a",{"x":1}],["b",{}]]}"#,
|
||
] {
|
||
assert_eq!(photocraft_cli::parse_actions(text).unwrap(), want, "{text}");
|
||
}
|
||
for text in ["[42]", "[[]]", "[[7,{}]]", r#"{"name":"x"}"#] {
|
||
assert!(photocraft_cli::parse_actions(text).is_err(), "{text}");
|
||
}
|
||
}
|
||
|
||
/// Runs `batch` on `input` with `actions` (saved as `<name>.json`), writing to the folder `<d>/<name>`.
|
||
fn batch_with(d: &Path, input: &Path, name: &str, actions: &str, extra: &[&str]) -> std::process::Output {
|
||
let file = d.join(format!("{name}.json"));
|
||
std::fs::write(&file, actions).unwrap();
|
||
bin().args(["batch", "--actions"]).arg(&file).arg("--in").arg(input).arg("--out").arg(d.join(name)).args(extra).output().unwrap()
|
||
}
|
||
|
||
/// #489: `batch --actions` takes `[id, params]` steps and a droplet's steps, with the same results
|
||
/// as the object form; a malformed step fails before anything is written.
|
||
#[test]
|
||
fn batch_accepts_recorded_action_steps() {
|
||
let d = tmp("batch-steps");
|
||
let input = d.join("in");
|
||
std::fs::create_dir_all(&input).unwrap();
|
||
write_png(&input.join("a.png"), 8, 4, 3);
|
||
write_png(&input.join("b.png"), 6, 5, 9);
|
||
let shapes = [
|
||
("objects", r#"[{"command":"image.adjustments.invert"}]"#),
|
||
("pairs", r#"[["image.adjustments.invert",{}]]"#),
|
||
("ids", r#"["image.adjustments.invert"]"#),
|
||
("droplet", r#"{"photocraftDroplet":1,"action":{"steps":[["image.adjustments.invert",{}]]}}"#),
|
||
];
|
||
for (name, actions) in shapes {
|
||
let o = batch_with(&d, &input, name, actions, &[]);
|
||
assert!(o.status.success(), "{name}: {}", String::from_utf8_lossy(&o.stderr));
|
||
assert!(String::from_utf8_lossy(&o.stdout).contains("2 succeeded, 0 failed"), "{name}");
|
||
for f in ["a.png", "b.png"] {
|
||
assert_eq!(std::fs::read(d.join(name).join(f)).unwrap(), std::fs::read(d.join("objects").join(f)).unwrap(), "{name}/{f}");
|
||
}
|
||
}
|
||
let o = batch_with(&d, &input, "bad", "[42]", &[]);
|
||
assert_eq!(o.status.code(), Some(1));
|
||
assert!(!d.join("bad").exists(), "nothing written");
|
||
}
|
||
|
||
/// #490: a leading dot on `--format` doesn't double the dot in output names.
|
||
#[test]
|
||
fn batch_format_with_leading_dot() {
|
||
let d = tmp("batch-dot");
|
||
let input = d.join("in");
|
||
std::fs::create_dir_all(&input).unwrap();
|
||
write_png(&input.join("a.png"), 8, 4, 3);
|
||
write_png(&input.join("b.png"), 6, 5, 9);
|
||
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
|
||
for (name, format) in [("dot", ".jpg"), ("plain", "jpg")] {
|
||
let o = batch_with(&d, &input, name, invert, &["--format", format]);
|
||
assert!(o.status.success(), "{format}: {}", String::from_utf8_lossy(&o.stderr));
|
||
let mut names: Vec<String> = std::fs::read_dir(d.join(name)).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||
names.sort();
|
||
assert_eq!(names, ["a.jpg", "b.jpg"], "{format}");
|
||
assert_eq!(photocraft_codecs::detect(&std::fs::read(d.join(name).join("a.jpg")).unwrap()), Some(photocraft_codecs::Format::Jpeg));
|
||
}
|
||
}
|
||
|
||
/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size,
|
||
/// and nothing is written.
|
||
#[test]
|
||
fn quality_outside_1_to_100_is_refused() {
|
||
let d = tmp("quality");
|
||
let input = d.join("in");
|
||
std::fs::create_dir_all(&input).unwrap();
|
||
let a = input.join("a.png");
|
||
write_png(&a, 8, 4, 3);
|
||
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
|
||
for q in ["0", "101", "255", "256", "1000", "-1", "abc", "50.5", ""] {
|
||
let out = d.join("q.jpg");
|
||
let (mut convert, mut run) = (bin(), bin());
|
||
convert.arg("convert").arg(&a).arg(&out).args(["--quality", q]);
|
||
run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]);
|
||
for cmd in [&mut convert, &mut run] {
|
||
let o = cmd.output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1), "--quality {q}");
|
||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}");
|
||
assert!(!out.exists(), "--quality {q} wrote a file");
|
||
}
|
||
let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]);
|
||
assert_eq!(o.status.code(), Some(1), "batch --quality {q}");
|
||
assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder");
|
||
}
|
||
// The documented range still encodes, lower quality making a smaller file.
|
||
let sizes: Vec<usize> = ["1", "50", "100"]
|
||
.iter()
|
||
.map(|q| {
|
||
let out = d.join(format!("q{q}.jpg"));
|
||
ok(bin().arg("convert").arg(&a).arg(&out).args(["--quality", q]));
|
||
std::fs::read(out).unwrap().len()
|
||
})
|
||
.collect();
|
||
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
|
||
}
|
||
|
||
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
|
||
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
|
||
#[test]
|
||
fn dpx_converts_both_ways_with_the_bits_asked_for() {
|
||
let d = tmp("dpx");
|
||
let a = d.join("a.png");
|
||
write_png(&a, 8, 4, 3);
|
||
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
|
||
let out = d.join("a.dpx");
|
||
let mut convert = bin();
|
||
convert.arg("convert").arg(&a).arg(&out);
|
||
if let Some(b) = bits {
|
||
convert.args(["--dpx-bits", b]);
|
||
}
|
||
ok(&mut convert);
|
||
let bytes = std::fs::read(&out).unwrap();
|
||
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
|
||
}
|
||
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
|
||
let v: Value = serde_json::from_str(&info).unwrap();
|
||
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
|
||
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
|
||
let back = d.join("back.png");
|
||
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
|
||
assert!(back.exists());
|
||
for bad in ["24", "0", "abc"] {
|
||
let out = d.join("bad.dpx");
|
||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
|
||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
|
||
assert!(!out.exists(), "--dpx-bits {bad} wrote a file");
|
||
}
|
||
}
|
||
|
||
/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
|
||
/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
|
||
/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
|
||
#[test]
|
||
fn dds_converts_both_ways_with_the_compression_asked_for() {
|
||
let d = tmp("dds");
|
||
let a = d.join("a.png");
|
||
write_png(&a, 8, 4, 3);
|
||
for (args, fourcc, mips) in [
|
||
(&[][..], [0; 4], 1),
|
||
(&["--dds-compression", "bc1"][..], *b"DXT1", 1),
|
||
(&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4),
|
||
(&["--dds-compression", "bc3"][..], *b"DXT5", 1),
|
||
(&["--dds-mipmaps"][..], [0; 4], 4),
|
||
] {
|
||
let out = d.join("a.dds");
|
||
ok(bin().arg("convert").arg(&a).arg(&out).args(args));
|
||
let bytes = std::fs::read(&out).unwrap();
|
||
let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap());
|
||
assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}");
|
||
let back = d.join("back.png");
|
||
ok(bin().arg("convert").arg(&out).arg(&back));
|
||
assert!(back.exists(), "{args:?}");
|
||
}
|
||
for bad in ["bc7", "dxt2", ""] {
|
||
let out = d.join("bad.dds");
|
||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
|
||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
|
||
assert!(!out.exists(), "--dds-compression {bad} wrote a file");
|
||
}
|
||
}
|
||
|
||
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
|
||
/// refused and nothing is written.
|
||
#[test]
|
||
fn tga_bits_choose_the_targa_depth() {
|
||
let d = tmp("tga-bits");
|
||
let a = d.join("a.png");
|
||
write_png(&a, 8, 4, 3);
|
||
for (bits, want) in [(None, 32), (Some("24"), 24), (Some("32"), 32)] {
|
||
let out = d.join("a.tga");
|
||
let mut convert = bin();
|
||
convert.arg("convert").arg(&a).arg(&out);
|
||
if let Some(b) = bits {
|
||
convert.args(["--tga-bits", b]);
|
||
}
|
||
ok(&mut convert);
|
||
assert_eq!(std::fs::read(&out).unwrap()[16], want, "--tga-bits {bits:?}");
|
||
}
|
||
for bad in ["16", "0", "abc", ""] {
|
||
let out = d.join("bad.tga");
|
||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1), "--tga-bits {bad}");
|
||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --tga-bits `{bad}`: expected 24 or 32")), "--tga-bits {bad}");
|
||
assert!(!out.exists(), "--tga-bits {bad} wrote a file");
|
||
}
|
||
}
|
||
|
||
/// #7437: `run <file> --out` to that same file, however `--out` is spelt, is refused before the
|
||
/// file is read or any command runs, unless `--in-place` asks for it (as `batch` does, #492).
|
||
#[test]
|
||
fn run_refuses_to_write_over_its_input() {
|
||
let d = tmp("run-in-place");
|
||
write_png(&d.join("in.png"), 8, 4, 7);
|
||
let before = std::fs::read(d.join("in.png")).unwrap();
|
||
let run = |out: &Path, extra: &[&str]| {
|
||
bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out"]).arg(out).args(extra).output().unwrap()
|
||
};
|
||
let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")];
|
||
std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap();
|
||
outs.push(PathBuf::from("hard.png"));
|
||
#[cfg(unix)]
|
||
{
|
||
std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap();
|
||
outs.push(PathBuf::from("soft.png"));
|
||
}
|
||
for out in &outs {
|
||
for extra in [&[][..], &["--format", "png"][..]] {
|
||
let o = run(out, extra);
|
||
let (stdout, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||
assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display());
|
||
assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}");
|
||
assert!(stdout.is_empty(), "--out {} {extra:?} ran commands: {stdout}", out.display());
|
||
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "--out {} {extra:?} changed the original", out.display());
|
||
}
|
||
}
|
||
// Another file is written as before, leaving the original alone.
|
||
ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "out.png"]));
|
||
assert!(d.join("out.png").is_file());
|
||
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
|
||
// `--in-place` opts in: the inverted result replaces the original, which still opens.
|
||
ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "in.png", "--in-place"]));
|
||
assert_ne!(std::fs::read(d.join("in.png")).unwrap(), before, "--in-place left the original unchanged");
|
||
let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
|
||
let info: Value = serde_json::from_str(&out).unwrap();
|
||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
|
||
}
|
||
|
||
/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it
|
||
/// doesn't name is refused and nothing is written.
|
||
#[test]
|
||
fn jpeg_format_chooses_the_scans() {
|
||
let d = tmp("jpeg-format");
|
||
let a = d.join("a.png");
|
||
write_png(&a, 16, 8, 3);
|
||
let marker = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]);
|
||
for (format, progressive) in [(None, false), (Some("baseline"), false), (Some("optimized"), false), (Some("progressive"), true)] {
|
||
let out = d.join("a.jpg");
|
||
let mut convert = bin();
|
||
convert.arg("convert").arg(&a).arg(&out);
|
||
if let Some(f) = format {
|
||
convert.args(["--jpeg-format", f]);
|
||
}
|
||
ok(&mut convert);
|
||
assert_eq!(marker(&std::fs::read(&out).unwrap(), 0xC2), progressive, "--jpeg-format {format:?}");
|
||
}
|
||
for bad in ["Progressive", "1", ""] {
|
||
let out = d.join("bad.jpg");
|
||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap();
|
||
assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}");
|
||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}");
|
||
assert!(!out.exists(), "--jpeg-format {bad} wrote a file");
|
||
}
|
||
}
|
||
|
||
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
|
||
/// anything is written, unless `--in-place` asks for it.
|
||
#[test]
|
||
fn batch_refuses_to_write_over_its_inputs() {
|
||
let d = tmp("batch-in-place");
|
||
let input = d.join("in");
|
||
std::fs::create_dir_all(&input).unwrap();
|
||
write_png(&input.join("a.png"), 8, 4, 3);
|
||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
|
||
std::fs::write(input.join("a.tif"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &Default::default()).unwrap()).unwrap();
|
||
let actions = d.join("actions.json");
|
||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||
let snapshot = || {
|
||
let mut files: Vec<(PathBuf, Vec<u8>)> =
|
||
std::fs::read_dir(&input).unwrap().map(|e| e.unwrap().path()).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect();
|
||
files.sort();
|
||
files
|
||
};
|
||
let before = snapshot();
|
||
let batch = |out: &Path, extra: &[&str]| {
|
||
bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out"]).arg(out).args(extra).output().unwrap()
|
||
};
|
||
#[cfg(unix)]
|
||
std::os::unix::fs::symlink(&input, d.join("link")).unwrap();
|
||
let link = cfg!(unix).then(|| PathBuf::from("link"));
|
||
for out in [PathBuf::from("in"), PathBuf::from("./in/"), input.clone(), input.join(".")].iter().chain(&link) {
|
||
for extra in [&[][..], &["--format", "tif"][..]] {
|
||
let o = batch(out, extra);
|
||
let err = String::from_utf8_lossy(&o.stderr);
|
||
assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display());
|
||
assert!(err.contains("is the --in folder") && err.contains("--in-place"), "{err}");
|
||
assert_eq!(snapshot(), before, "--out {} {extra:?} changed the originals", out.display());
|
||
}
|
||
}
|
||
// A separate folder runs as before, leaving the originals alone.
|
||
let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"]));
|
||
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
|
||
assert_eq!(snapshot(), before);
|
||
// `--in-place` opts in: each result replaces its original.
|
||
let o = batch(Path::new("in"), &["--in-place"]);
|
||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||
let after = snapshot();
|
||
assert_eq!(after.len(), 2);
|
||
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
|
||
}
|
||
|
||
/// #7437: `convert` to its own input file, however the output is spelt, is refused before
|
||
/// anything is read or written, unless `--in-place` asks for it (as `batch` does, #492).
|
||
#[test]
|
||
fn convert_refuses_to_write_over_its_input() {
|
||
let d = tmp("convert-in-place");
|
||
write_png(&d.join("in.png"), 8, 4, 5);
|
||
let before = std::fs::read(d.join("in.png")).unwrap();
|
||
let convert = |out: &Path, extra: &[&str]| bin().current_dir(&d).args(["convert", "in.png"]).arg(out).args(extra).output().unwrap();
|
||
let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")];
|
||
std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap();
|
||
outs.push(PathBuf::from("hard.png"));
|
||
#[cfg(unix)]
|
||
{
|
||
std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap();
|
||
outs.push(PathBuf::from("soft.png"));
|
||
}
|
||
for out in &outs {
|
||
for extra in [&[][..], &["--format", "png"][..], &["--quality", "80"][..]] {
|
||
let o = convert(out, extra);
|
||
let err = String::from_utf8_lossy(&o.stderr);
|
||
assert_eq!(o.status.code(), Some(1), "{} {extra:?}: {err}", out.display());
|
||
assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}");
|
||
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "{} {extra:?} changed the original", out.display());
|
||
}
|
||
}
|
||
// Another file converts as before, leaving the original alone.
|
||
ok(bin().current_dir(&d).args(["convert", "in.png", "out.jpg"]));
|
||
assert!(d.join("out.jpg").is_file());
|
||
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
|
||
// `--in-place` opts in: the result replaces the original, which still opens.
|
||
ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"]));
|
||
let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
|
||
let info: Value = serde_json::from_str(&out).unwrap();
|
||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
|
||
}
|
||
|
||
#[test]
|
||
fn commands_listing() {
|
||
let (out, _) = ok(bin().arg("commands"));
|
||
assert!(out.contains("file.new"));
|
||
let (out, _) = ok(bin().args(["commands", "--json", "--filter", "layer.new"]));
|
||
let v: Value = serde_json::from_str(&out).unwrap();
|
||
assert!(
|
||
v.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.all(|c| { c["id"].as_str().unwrap().contains("layer.new") || c["label"].as_str().unwrap().to_lowercase().contains("layer.new") })
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn in_process_run_matches_binary() {
|
||
let mut out = Vec::new();
|
||
let mut err = Vec::new();
|
||
let code = photocraft_cli::run(&["commands".into(), "--filter".into(), "file.new".into()], &mut out, &mut err);
|
||
assert_eq!(code, 0);
|
||
assert!(String::from_utf8(out).unwrap().contains("file.new"));
|
||
}
|
||
|
||
#[test]
|
||
fn mcp_stdio_handshake_and_tool_call() {
|
||
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||
let mut stdin = child.stdin.take().unwrap();
|
||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||
let mut send = |v: Value| {
|
||
writeln!(stdin, "{v}").unwrap();
|
||
stdin.flush().unwrap();
|
||
};
|
||
let mut recv = |id: u64| -> Value {
|
||
loop {
|
||
let mut line = String::new();
|
||
assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed");
|
||
let v: Value = serde_json::from_str(&line).unwrap();
|
||
if v["id"] == id {
|
||
return v;
|
||
}
|
||
}
|
||
};
|
||
send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}));
|
||
let init = recv(1);
|
||
assert_eq!(init["result"]["serverInfo"]["name"], "photocraft", "{init}");
|
||
send(json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
|
||
send(json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}));
|
||
let tools = recv(2);
|
||
let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().filter_map(|t| t["name"].as_str()).collect();
|
||
assert!(names.contains(&"command_run") && names.contains(&"doc_render_preview"), "{names:?}");
|
||
send(json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"doc_new","arguments":{"width":8,"height":8}}}));
|
||
let r = recv(3);
|
||
assert_ne!(r["result"]["isError"], true, "{r}");
|
||
send(json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"doc_render_preview","arguments":{}}}));
|
||
let r = recv(4);
|
||
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
|
||
let _ = child.kill();
|
||
let _ = child.wait();
|
||
}
|
||
|
||
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
|
||
#[test]
|
||
fn batch_fails_inputs_that_share_an_output_name() {
|
||
let d = tmp("batch-collide");
|
||
let input = d.join("in");
|
||
std::fs::create_dir_all(&input).unwrap();
|
||
write_png(&input.join("a.png"), 8, 4, 3);
|
||
// Same stem in other formats; `A.tif` differs only in case.
|
||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
|
||
for (name, format) in [("a.bmp", photocraft_codecs::Format::Bmp), ("A.tif", photocraft_codecs::Format::Tiff)] {
|
||
std::fs::write(input.join(name), photocraft_codecs::encode(&img, format, &Default::default()).unwrap()).unwrap();
|
||
}
|
||
let actions = d.join("actions.json");
|
||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||
let out_dir = d.join("out");
|
||
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap();
|
||
let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||
assert!(
|
||
!o.status.success(),
|
||
"a lost result must fail the run
|
||
{out}
|
||
{err}"
|
||
);
|
||
assert!(out.contains("1 succeeded, 2 failed"), "{out}");
|
||
assert_eq!(err.matches("not written").count(), 2, "{err}");
|
||
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
|
||
// Keeping each file's format, every input gets its own output.
|
||
let out_dir = d.join("same");
|
||
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir));
|
||
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
|
||
}
|
||
|
||
#[test]
|
||
fn droplet_runs_an_action_on_files() {
|
||
let d = tmp("droplet");
|
||
write_png(&d.join("a.png"), 8, 4, 3);
|
||
write_png(&d.join("b.png"), 6, 2, 5);
|
||
let droplet = d.join("rot.pcdroplet");
|
||
std::fs::write(
|
||
&droplet,
|
||
json!({"photocraftDroplet": 1, "name": "rot", "action": {"steps": [["image.imageRotation.90cw", {}]]}, "options": {"format": "png"}}).to_string(),
|
||
)
|
||
.unwrap();
|
||
let out_dir = d.join("out");
|
||
let (out, _) = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir));
|
||
assert_eq!(out.lines().filter(|l| l.starts_with("ok")).count(), 2, "{out}");
|
||
let img = photocraft_codecs::decode(&std::fs::read(out_dir.join("a.png")).unwrap()).unwrap();
|
||
assert_eq!(img.dimensions(), (4, 8));
|
||
let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap();
|
||
assert!(!o.status.success(), "not a droplet");
|
||
}
|
||
|
||
#[test]
|
||
fn tiff_output_is_flat_unless_tiff_layers_is_given() {
|
||
let d = tmp("tiff-layers");
|
||
let layer_count = |path: &std::path::Path| -> usize {
|
||
let (info, _) = ok(bin().arg("info").arg(path));
|
||
let v: Value = serde_json::from_str(&info).unwrap();
|
||
v["layers"].as_array().unwrap().len()
|
||
};
|
||
for (name, flag, layers) in [("flat.tif", None, 1), ("layered.tif", Some("--tiff-layers"), 2)] {
|
||
let out = d.join(name);
|
||
let mut cmd = bin();
|
||
cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]).args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]);
|
||
if let Some(flag) = flag {
|
||
cmd.arg(flag);
|
||
}
|
||
ok(cmd.arg("--out").arg(&out));
|
||
assert_eq!(layer_count(&out), layers, "{name}");
|
||
}
|
||
// convert honours the same flag.
|
||
let src = d.join("layered.tif");
|
||
let flat = d.join("converted.tif");
|
||
ok(bin().arg("convert").arg(&src).arg(&flat));
|
||
assert_eq!(layer_count(&flat), 1);
|
||
let kept = d.join("kept.tif");
|
||
ok(bin().arg("convert").arg(&src).arg(&kept).arg("--tiff-layers"));
|
||
assert_eq!(layer_count(&kept), 2);
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|
||
|
||
/// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in
|
||
/// `std::env::args()` before anything was printed. It is a usage error naming the argument.
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
|
||
use std::os::unix::ffi::OsStrExt;
|
||
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png");
|
||
for args in [vec![std::ffi::OsStr::new("info"), name], vec![std::ffi::OsStr::new("convert"), name, std::ffi::OsStr::new("out.png")]] {
|
||
let o = bin().args(&args).output().unwrap();
|
||
let err = String::from_utf8_lossy(&o.stderr);
|
||
assert_eq!(o.status.code(), Some(2), "{err}");
|
||
assert!(err.contains("argument 2 is not valid Unicode") && err.contains("caf\u{fffd}.png"), "{err}");
|
||
assert!(!err.contains("panicked"), "{err}");
|
||
}
|
||
}
|
||
|
||
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
|
||
/// PhotoCraft can't play are warned about up front.
|
||
#[test]
|
||
fn batch_plays_a_photoshop_actions_file() {
|
||
use photocraft_engine::actions_cmds::Action;
|
||
let d = tmp("atn");
|
||
let input = d.join("in");
|
||
std::fs::create_dir_all(&input).unwrap();
|
||
write_png(&input.join("a.png"), 8, 4, 3);
|
||
let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]);
|
||
let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]);
|
||
let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap();
|
||
assert!(skipped.is_empty());
|
||
let atn = d.join("Set.atn");
|
||
std::fs::write(&atn, bytes).unwrap();
|
||
let out_dir = d.join("out");
|
||
// Two actions: the set needs --action.
|
||
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
|
||
assert!(!o.status.success());
|
||
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
|
||
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
|
||
assert!(out_dir.join("a.png").exists());
|
||
assert!(!err.contains("warning"), "{err}");
|
||
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
|
||
let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap();
|
||
let err = String::from_utf8_lossy(&o.stderr);
|
||
assert!(err.contains("Generative Fill"), "{err}");
|
||
assert!(!o.status.success());
|
||
}
|
||
|
||
/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`.
|
||
fn invoke(args: &[&str]) -> (i32, String, String) {
|
||
let (mut out, mut err) = (Vec::new(), Vec::new());
|
||
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||
let code = photocraft_cli::run(&args, &mut out, &mut err);
|
||
(code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap())
|
||
}
|
||
|
||
const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
|
||
|
||
/// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr.
|
||
#[test]
|
||
fn top_level_help_version_and_no_arguments() {
|
||
let (code, out, err) = invoke(&[]);
|
||
assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false));
|
||
for help in ["--help", "-h", "help"] {
|
||
let (code, out, err) = invoke(&[help]);
|
||
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}");
|
||
}
|
||
for version in ["--version", "version"] {
|
||
let (code, out, err) = invoke(&[version]);
|
||
assert_eq!((code, err.as_str()), (0, ""), "{version}");
|
||
assert!(out.starts_with("photocraft-cli "), "{version}: {out}");
|
||
}
|
||
let (code, out, err) = invoke(&["frobnicate"]);
|
||
assert_eq!((code, out.is_empty()), (2, true));
|
||
assert!(err.contains("frobnicate"), "{err}");
|
||
}
|
||
|
||
/// `<sub> --help` / `-h` prints help on stdout and returns without running the subcommand
|
||
/// (`mcp` and `serve` would otherwise block on stdin).
|
||
#[test]
|
||
fn every_subcommand_help_returns_on_stdout() {
|
||
for sub in SUBCOMMANDS {
|
||
for help in ["--help", "-h"] {
|
||
let (code, out, err) = invoke(&[sub, help]);
|
||
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}");
|
||
// `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't.
|
||
assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Every subcommand refuses an unknown flag (bare or `=value`) with exit 2, naming it on stderr.
|
||
#[test]
|
||
fn every_subcommand_rejects_an_unknown_flag() {
|
||
for sub in SUBCOMMANDS {
|
||
for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] {
|
||
let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect();
|
||
let (code, out, err) = invoke(&args);
|
||
assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}");
|
||
assert!(err.contains("--fromat"), "{args:?}: {err}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `--flag=value`, a bare flag given a value, a value flag without one, and repeated flags.
|
||
#[test]
|
||
fn flag_value_forms_and_repeats() {
|
||
let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::<Vec<_>>();
|
||
let (code, eq, err) = invoke(&["commands", "--filter=file.new"]);
|
||
assert_eq!(code, 0, "{err}");
|
||
let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]);
|
||
assert_eq!(eq, spaced);
|
||
assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}");
|
||
// The last of a repeated value flag wins, whichever order they come in.
|
||
let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]);
|
||
assert_eq!(code, 0, "{err}");
|
||
assert_eq!(out, eq);
|
||
let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]);
|
||
assert_eq!(code, 0, "{err}");
|
||
assert!(out.is_empty(), "{out}");
|
||
// A bare flag takes no value; a value flag needs one.
|
||
let (code, out, err) = invoke(&["commands", "--json=1"]);
|
||
assert_eq!((code, out.is_empty()), (2, true));
|
||
assert!(err.contains("--json"), "{err}");
|
||
let (code, out, err) = invoke(&["commands", "--filter"]);
|
||
assert_eq!((code, out.is_empty()), (2, true));
|
||
assert!(err.contains("--filter"), "{err}");
|
||
}
|
||
|
||
/// Each `--params` applies to the `--cmd` before it; a `--cmd` without one gets `{}`; a
|
||
/// `--params` ahead of every `--cmd` fails naming `--params`.
|
||
#[test]
|
||
fn run_pairs_params_with_the_preceding_cmd() {
|
||
let (code, out, err) = invoke(&[
|
||
"run",
|
||
"--new",
|
||
r#"{"width":8,"height":8}"#,
|
||
"--cmd",
|
||
"layer.new.layer",
|
||
"--params",
|
||
r#"{"name":"First"}"#,
|
||
"--cmd",
|
||
"layer.new.layer",
|
||
"--cmd",
|
||
"layer.new.layer",
|
||
"--params",
|
||
r#"{"name":"Third"}"#,
|
||
"--cmd",
|
||
"document.inspect",
|
||
]);
|
||
assert_eq!(code, 0, "{err}");
|
||
let rows: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||
assert_eq!(rows.len(), 4, "{out}");
|
||
let names: Vec<&str> = rows[3]["result"]["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
|
||
let count = |n: &str| names.iter().filter(|x| **x == n).count();
|
||
assert_eq!((count("First"), count("Third")), (1, 1), "each --params applies to its own --cmd only: {names:?}");
|
||
// Three new layers plus the background; the middle one (no --params) has a default name.
|
||
assert_eq!(names.len(), 4, "{names:?}");
|
||
// Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params
|
||
// with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead.
|
||
assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}");
|
||
let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
|
||
assert_ne!(code, 0);
|
||
assert!(err.contains("--params"), "{err}");
|
||
}
|
||
|
||
/// #7789: `convert` opens a developed raw at Raw Defaults › Resolution (default 300 ppi), as
|
||
/// `run`, `info` and the app do; it wrote 72.
|
||
#[test]
|
||
fn convert_opens_a_raw_at_the_raw_defaults_resolution() {
|
||
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
|
||
let d = tmp("convert-raw");
|
||
let (w, h) = (24, 16);
|
||
let (raw, psd) = (d.join("IMG_0001.dng"), d.join("IMG_0001.psd"));
|
||
std::fs::write(&raw, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap();
|
||
ok(bin().args(["convert"]).arg(&raw).arg(&psd));
|
||
let doc = photocraft_io::import("IMG_0001.psd", &std::fs::read(&psd).unwrap()).unwrap().document;
|
||
assert_eq!(doc.resolution_dpi, 300.0);
|
||
std::fs::remove_dir_all(d).unwrap();
|
||
}
|