Files
photocraft/crates/engine/src/cmd/actions_cmds/tests.rs
T
Hartmut Nörenberg 5c0bec3036 fix(engine): grey out Straighten Layer where the reference does
Straighten Layer was offered wherever a ruler line existed, so its button was
live for every target and the command turned whatever it was pointed at: it
rotated and cropped the canvas on the Background, turned several selected
layers or a group, failed on a locked layer with `layer "..." is locked`, and
rotated pixels nobody could see on a hidden layer. The reference application
(#12033) keeps the button off for the Background, a multi-layer selection, a
group, Lock All and Lock Position, and answers a hidden target layer with its
alert and no change at all. The command's own `enabled` check now says the
first set, so the options bar, the CLI, the control channel and an action step
all agree about what is offered; the hidden layer stays enabled and is refused
by the command, before it reads anything for the edit, so a refused click
leaves the document, its History and the ruler line exactly as they were. The
states the ticket marks *not measured* keep today's behaviour.
2026-11-07 19:36:53 +01:00

1611 lines
89 KiB
Rust

use super::*;
use serde_json::json;
fn pixel(s: &Session, layer: usize, x: i32, y: i32) -> Vec<f32> {
s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y)
}
/// CopyCat's shape: Edit Contents, change the contents, Save. The Save step has no file to
/// write: it updates the smart object, headless too.
#[test]
fn save_in_edit_contents_updates_the_smart_object() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("layer.smartObjects.editContents", json!({})).unwrap();
let steps = vec![("edit.fill".to_string(), json!({"contents": "color", "color": "#0000ff"})), ("file.save".to_string(), json!({}))];
s.actions.list.push(Action::new("Edit and save", steps));
let played = s.execute("actions.play", json!({"action": "Edit and save"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
s.set_active(0);
let doc = &s.active().unwrap().doc;
let flat = crate::file_cmds::flattened(doc, doc.pixel_format());
let p = flat.pixel(2, 2);
assert!(p[0] < 0.01 && p[2] > 0.99, "the smart object shows the saved contents: {p:?}");
// With no Edit Contents document, Save stays the shell's (it needs the document's file).
s.actions.list.push(Action::new("Save", vec![("file.save".to_string(), json!({}))]));
assert!(s.execute("actions.play", json!({"action": "Save"})).unwrap().get("failed").is_some());
}
#[test]
fn deselect_with_nothing_selected_is_a_noop_in_playback() {
// #6744: imported actions often start with Deselect; with no selection Photoshop plays on.
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let before = s.active().unwrap().history.past_len();
// Outside playback the command stays disabled, like Photoshop's greyed menu item.
assert!(s.execute("select.deselect", json!({})).is_err());
assert!(s.active().unwrap().doc.selection.is_none());
assert_eq!(s.active().unwrap().history.past_len(), before, "rejection adds no history step");
let deselect = crate::commands::find("select.deselect").unwrap();
assert_eq!((deselect.run)(&mut s, &json!({})).unwrap(), Value::Null);
assert_eq!(s.active().unwrap().history.past_len(), before, "no history step");
let steps = vec![("select.deselect".to_string(), json!({})), ("layer.new.layer".to_string(), json!({"name": "A"}))];
s.actions.list.push(Action::new("Clear", steps));
let played = s.execute("actions.play", json!({"action": 0})).unwrap();
assert_eq!(played["ran"], 2, "{played}");
assert_eq!(s.active().unwrap().doc.layers.last().unwrap().name, "A");
}
#[test]
fn unchecked_steps_are_skipped_and_follow_move_and_delete() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let step = |name: &str| ("layer.new.layer".to_string(), json!({"name": name}));
s.actions.list.push(Action::new("Three", vec![step("A"), step("B"), step("C")]));
let names = |s: &Session| s.active().unwrap().doc.layers.iter().skip(1).map(|l| l.name.clone()).collect::<Vec<_>>();
assert_eq!(s.execute("actions.toggle", json!({"action": "Three", "step": 1})).unwrap()["disabled"], json!([1]));
let played = s.execute("actions.play", json!({"action": 0})).unwrap();
assert_eq!(played["ran"], 2);
assert_eq!(names(&s), ["A", "C"], "the unchecked step is skipped");
assert_eq!(s.execute("actions.get", json!({"action": 0})).unwrap()["disabled"], json!([1]));
// Moving step 3 to the top keeps B (now step 2) unchecked; deleting the moved step shifts it to 1.
s.execute("actions.move", json!({"action": 0, "step": 2, "to": 0})).unwrap();
let ids: Vec<_> = s.actions.list[0].steps.iter().map(|(_, p)| p["name"].as_str().unwrap()).collect();
assert_eq!((ids, s.actions.list[0].disabled.iter().copied().collect::<Vec<_>>()), (vec!["C", "A", "B"], vec![2]));
s.execute("actions.delete", json!({"action": 0, "step": 0})).unwrap();
assert_eq!(s.actions.list[0].steps, [step("A"), step("B")], "deleting removes only that step");
assert_eq!(s.actions.list[0].disabled.iter().copied().collect::<Vec<_>>(), [1]);
// The action's own check: all on unless all already are.
s.execute("actions.toggle", json!({"action": 0})).unwrap();
assert!(s.actions.list[0].disabled.is_empty());
s.execute("actions.toggle", json!({"action": 0})).unwrap();
assert_eq!(s.actions.list[0].disabled.len(), 2);
s.execute("actions.toggle", json!({"action": 0, "step": 0, "enabled": true})).unwrap();
assert_eq!(s.actions.list[0].disabled.iter().copied().collect::<Vec<_>>(), [1]);
for bad in [
json!({"action": 0, "step": 2}),
json!({"action": 0, "step": -1}),
json!({"action": 0, "step": "x"}),
json!({"action": 0, "step": 0, "enabled": "yes"}),
json!({"action": 7, "step": 0}),
json!({}),
] {
assert!(s.execute("actions.toggle", bad.clone()).is_err(), "{bad}");
}
assert_eq!(s.actions.list[0].disabled.iter().copied().collect::<Vec<_>>(), [1], "rejected toggles change nothing");
}
#[test]
fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "Same"})).unwrap();
let hidden = s.execute("layer.new.layer", json!({"name": "Same"})).unwrap()["layer"].as_u64().unwrap();
s.execute("actions.record", json!({"name": "Hide"})).unwrap();
s.execute("layer.setProps", json!({"layer": hidden, "visible": false, "coalesce": "eye-sweep"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let keep = s.execute("layer.new.layer", json!({"name": "Same"})).unwrap()["layer"].as_u64().unwrap();
let target = s.execute("layer.new.layer", json!({"name": "Same"})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.deselectLayers", json!({})).unwrap();
let result = s.execute("actions.play", json!({"action": "Hide"})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(keep)).unwrap().visible);
assert!(!s.active().unwrap().doc.layer(photocraft_doc::LayerId(target)).unwrap().visible);
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(target)).unwrap().visible);
}
/// ⌘-clicking a layer's thumbnail that is not the selected layer records the layer by name (the
/// Background as the Background), so the step saves in strict Save Actions and replays on the
/// same-named layer of another document.
#[test]
fn recorded_thumbnail_click_on_another_layer_saves_and_replays_by_name() {
use photocraft_geom::Rect;
let fixture = |s: &mut Session| {
s.execute("file.new", json!({"width": 4, "height": 1})).unwrap();
let source = s.execute("layer.new.layer", json!({"name": "Source"})).unwrap()["layer"].as_u64().unwrap();
s.edit("Pixels", |doc, _| {
doc.layer_mut(photocraft_doc::LayerId(source)).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("layer.new.layer", json!({"name": "Active"})).unwrap();
source
};
let mut s = Session::new();
let source = fixture(&mut s);
let background = s.active().unwrap().doc.background().unwrap().id.0;
s.execute("actions.record", json!({"name": "Load"})).unwrap();
s.execute("select.loadSelection", json!({"channel": "transparency", "layer": source, "operation": "new"})).unwrap();
s.execute("select.loadSelection", json!({"channel": "transparency", "layer": background, "operation": "new"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let action = s.actions.list.iter().find(|a| a.name == "Load").unwrap().clone();
assert_eq!(
action.steps,
vec![
("select.loadSelection".into(), json!({"channel": "transparency", "layer": {"name": "Source"}, "operation": "new"})),
("select.loadSelection".into(), json!({"channel": "transparency", "layer": {"background": true}, "operation": "new"})),
]
);
crate::atn_cmds::export("Set", &[&action], &[], false).unwrap();
// Replayed in another document, the first step loads its Source layer.
let mut other = Session::new();
fixture(&mut other);
let (id, params) = &action.steps[0];
other.execute(id, params.clone()).unwrap();
let doc = &other.active().unwrap().doc;
assert_eq!(doc.selection.as_ref().unwrap().read_region(Rect::new(0, 0, 4, 1)), vec![1.0, 0.0, 0.0, 0.0]);
}
#[test]
fn rename_keeps_calls_and_function_key_and_rejects_bad_names() {
let mut s = Session::new();
s.actions.list.push(Action {
name: "Action 1".into(),
steps: vec![("layer.new.layer".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
s.actions.list.push(Action {
name: "Caller".into(),
steps: vec![("actions.play".into(), json!({"action": "Action 1"}))],
disabled: Default::default(),
..Default::default()
});
s.edit_prefs(|p| p.shortcuts.insert(format!("{PLAY_SHORTCUT_PREFIX}Action 1"), "F2".into()));
assert_eq!(s.execute("actions.rename", json!({"action": 0, "name": "Sepia"})).unwrap()["action"], "Sepia");
assert_eq!(s.execute("actions.list", json!({})).unwrap()["actions"][0]["name"], "Sepia");
assert_eq!(s.actions.list[1].steps[0].1["action"], "Sepia");
assert_eq!(s.prefs().shortcuts.get("actions.play:Sepia").map(String::as_str), Some("F2"));
assert!(!s.prefs().shortcuts.contains_key("actions.play:Action 1"));
for bad in [
json!({"action": 0, "name": ""}),
json!({"action": 0, "name": " "}),
json!({"action": 0, "name": "Caller"}),
json!({"action": 0, "name": 7}),
json!({"action": 9, "name": "X"}),
json!({"action": 0}),
] {
assert!(s.execute("actions.rename", bad.clone()).is_err(), "{bad}");
}
assert_eq!(s.actions.list[0].name, "Sepia");
}
#[test]
fn rename_follows_conditional_branches() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
for name in ["B", "Other"] {
s.actions.list.push(Action { name: name.into(), steps: vec![("layer.new.layer".into(), json!({}))], ..Default::default() });
}
s.actions.list.push(Action {
name: "Main".into(),
steps: vec![
("actions.conditional".into(), json!({"if": "always", "then": "B", "else": "Other"})),
("actions.conditional".into(), json!({"if": "never", "then": "Other", "else": "B"})),
],
..Default::default()
});
s.execute("actions.rename", json!({"action": "B", "name": "C"})).unwrap();
let steps = &s.actions.list[2].steps;
assert_eq!(steps[0].1, json!({"if": "always", "then": "C", "else": "Other"}));
assert_eq!(steps[1].1, json!({"if": "never", "then": "Other", "else": "C"}));
let r = s.execute("actions.play", json!({"action": "Main"})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
}
#[test]
fn delete_action_drops_its_function_key_but_deleting_a_step_keeps_it() {
let mut s = Session::new();
let key = format!("{PLAY_SHORTCUT_PREFIX}Action 1");
s.actions.list.push(Action::new("Action 1", vec![("layer.new.layer".into(), json!({})), ("layer.new.layer".into(), json!({}))]));
s.edit_prefs(|p| p.shortcuts.insert(key.clone(), "F2".into()));
s.execute("actions.delete", json!({"action": 0, "step": 0})).unwrap();
assert_eq!(s.prefs().shortcuts.get(&key).map(String::as_str), Some("F2"), "deleting a step keeps the key");
assert_eq!(s.execute("actions.delete", json!({"action": 0})).unwrap()["deleted"], "Action 1");
assert!(!s.prefs().shortcuts.contains_key(&key), "the deleted action's function key is gone");
s.execute("actions.record", json!({})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].name, "Action 1");
assert!(!s.prefs().shortcuts.contains_key(&key), "a new action with the same name starts unbound");
}
fn record_red(s: &mut Session, depth: u32) {
s.execute("file.new", json!({"width": 40, "height": 40, "depth": depth})).unwrap();
s.execute("actions.record", json!({"name": "Red"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
let _ = s.execute("document.pixel", json!({"x": 1, "y": 1}));
let _ = s.execute("actions.list", json!({}));
s.execute("actions.stop", json!({})).unwrap();
}
fn translated_square(s: &mut Session, depth: u32) {
s.execute("file.new", json!({"width": 20, "height": 8, "depth": depth, "background": "transparent"})).unwrap();
s.edit("red square", |doc, _| {
doc.layers[0].surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 4, 0], "interpolation": "nearest"})).unwrap();
assert_eq!(pixel(s, 0, 4, 2), vec![1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn record_transform_again_replays_one_concrete_transform_at_each_depth() {
for depth in [8, 16, 32] {
let mut s = Session::new();
translated_square(&mut s, depth);
let from = s.journal.len();
s.execute("actions.record", json!({"name": "Again"})).unwrap();
s.execute("edit.transform.again", json!({})).unwrap();
let ids: Vec<&str> = s.journal.iter().skip(from).map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, ["edit.transform", "edit.transform.again"], "both commands stay in the journal during recording");
let live_count = s.journal.iter().skip(from).filter(|(id, _)| replayable(id)).count();
s.execute("actions.stop", json!({})).unwrap();
let recorded = s.execute("actions.get", json!({"action": "Again"})).unwrap();
let steps = recorded["steps"].as_array().unwrap();
assert_eq!(steps.len(), 1, "depth {depth}: record the concrete transform once");
assert_eq!(live_count, 1, "depth {depth}: the live Actions count agrees");
assert_eq!(steps[0][0], "edit.transform");
assert_eq!(steps[0][1]["matrix"], json!([1, 0, 0, 1, 4, 0]));
assert_eq!(steps[0][1]["interpolation"], "nearest");
translated_square(&mut s, depth);
let before = s.active().unwrap().history.past_len();
let played = s.execute("actions.play", json!({"action": "Again"})).unwrap();
assert!(played.get("failed").is_none(), "depth {depth}: {played}");
assert_eq!(played["ran"], 1, "depth {depth}: apply the recorded transform once");
assert_eq!(s.active().unwrap().history.past_len(), before + 1, "depth {depth}: one undo step");
assert_eq!(pixel(&s, 0, 8, 2), vec![1.0, 0.0, 0.0, 1.0], "depth {depth}: moved four pixels");
assert_eq!(pixel(&s, 0, 12, 2), vec![0.0; 4], "depth {depth}: no second transform");
assert!(s.undo());
assert_eq!(pixel(&s, 0, 4, 2), vec![1.0, 0.0, 0.0, 1.0]);
assert_eq!(pixel(&s, 0, 8, 2), vec![0.0; 4]);
assert!(s.redo());
assert_eq!(pixel(&s, 0, 8, 2), vec![1.0, 0.0, 0.0, 1.0]);
assert_eq!(pixel(&s, 0, 4, 2), vec![0.0; 4]);
}
}
#[test]
fn record_transform_again_on_a_copy_replays_in_a_session_without_a_transform() {
for depth in [8, 16] {
let mut s = Session::new();
translated_square(&mut s, depth);
s.execute("actions.record", json!({"name": "Step"})).unwrap();
s.execute("edit.transform.againCopy", json!({})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let got = s.execute("actions.get", json!({"action": "Step"})).unwrap();
let ids: Vec<&str> = got["steps"].as_array().unwrap().iter().map(|st| st[0].as_str().unwrap()).collect();
assert_eq!(ids, ["layer.duplicate", "edit.transform"], "depth {depth}: the concrete steps, not the wrapper");
let action = s.actions.list[0].clone();
// A new session has no transform to repeat: the concrete steps still play.
let mut fresh = Session::new();
fresh.execute("file.new", json!({"width": 20, "height": 8, "depth": depth, "background": "transparent"})).unwrap();
fresh
.edit("red square", |doc, _| {
doc.layers[0].surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(4, 2, 6, 4), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
fresh.actions.list.push(action);
let played = fresh.execute("actions.play", json!({"action": "Step"})).unwrap();
assert!(played.get("failed").is_none(), "depth {depth}: {played}");
assert_eq!(played["ran"], 2, "depth {depth}");
assert_eq!(fresh.active().unwrap().doc.layers.len(), 2, "depth {depth}: one copy");
assert_eq!(pixel(&fresh, 0, 4, 2), vec![1.0, 0.0, 0.0, 1.0], "depth {depth}: the original stays");
assert_eq!(pixel(&fresh, 1, 8, 2), vec![1.0, 0.0, 0.0, 1.0], "depth {depth}: the copy moved four pixels");
assert_eq!(pixel(&fresh, 1, 4, 2), vec![0.0; 4], "depth {depth}: moved once");
}
}
#[test]
fn record_drops_the_steps_of_a_command_that_failed_and_was_taken_back() {
// Transform Again on a Copy of a position-locked layer duplicates it, fails to move the
// copy and takes the copy back: nothing is left to record.
let mut s = Session::new();
translated_square(&mut s, 8);
let id = s.active().unwrap().active_layer.unwrap();
s.execute("layer.setProps", json!({"layer": id.0, "locks": {"position": true}})).unwrap();
s.execute("actions.record", json!({"name": "Locked"})).unwrap();
assert!(s.execute("edit.transform.againCopy", json!({})).is_err());
assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no Duplicate step for a copy that was taken back");
// Straighten rolls back when its crop is refused after the rotation ran.
ruled_layer(&mut s, 8);
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
s.authorize = Some(|id, _| if id == "image.crop" { Err(EngineError::Other("refused".into())) } else { Ok(()) });
s.execute("actions.record", json!({"name": "Refused"})).unwrap();
assert!(s.execute("image.analysis.straightenLayer", json!({"crop": true})).is_err());
assert_eq!(s.active().unwrap().doc.size.width, 80, "the rotation was taken back");
assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 0, "no rotation step for a rotation that was taken back");
}
#[test]
fn record_script_that_plays_an_action_keeps_each_step_once() {
let mut s = Session::new();
translated_square(&mut s, 8);
s.execute("actions.record", json!({"name": "Inner"})).unwrap();
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 1, 0], "interpolation": "nearest"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
// Browse records the concrete steps it ran, not itself (#3057): the script's transform and
// the action call, each once, in order.
let script = json!({"steps": [["edit.transform", {"matrix": [1, 0, 0, 1, 2, 0], "interpolation": "nearest"}], ["actions.play", {"action": "Inner"}]]});
s.execute("actions.record", json!({"name": "Outer"})).unwrap();
s.execute("file.scripts.browse", script.clone()).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let got = s.execute("actions.get", json!({"action": "Outer"})).unwrap();
assert_eq!(got["steps"], script["steps"]);
}
#[test]
fn record_explicit_transform_keeps_one_replayable_step() {
let mut s = Session::new();
translated_square(&mut s, 8);
s.execute("actions.record", json!({"name": "Move"})).unwrap();
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 4, 0], "interpolation": "nearest"})).unwrap();
assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 1);
let recorded = s.execute("actions.get", json!({"action": "Move"})).unwrap();
assert_eq!(recorded["steps"][0][0], "edit.transform");
translated_square(&mut s, 8);
let before = s.active().unwrap().history.past_len();
let played = s.execute("actions.play", json!({"action": "Move"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert_eq!(played["ran"], 1);
assert_eq!(s.active().unwrap().history.past_len(), before + 1);
assert_eq!(pixel(&s, 0, 8, 2), vec![1.0, 0.0, 0.0, 1.0]);
assert_eq!(pixel(&s, 0, 12, 2), vec![0.0; 4]);
}
#[test]
fn recording_folds_one_run_of_move_tool_nudges() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Target"})).unwrap();
s.execute("actions.record", json!({"name": "Nudge"})).unwrap();
for (dx, dy, run) in [(1, 0, "nudge:1"), (1, 0, "nudge:1"), (1, 10, "nudge:1"), (0, 1, "nudge:2")] {
s.execute("layer.translate", json!({"dx": dx, "dy": dy, "coalesce": run})).unwrap();
}
s.execute("actions.stop", json!({})).unwrap();
let steps = &s.actions.list[0].steps;
assert_eq!(steps, &[("layer.translate".into(), json!({"dx": 3.0, "dy": 10.0})), ("layer.translate".into(), json!({"dx": 0.0, "dy": 1.0}))]);
}
/// Each nudge moves by whole pixels, so two 0.6 px steps fold into the 2 px they moved, not 1.2.
#[test]
fn folded_nudges_add_whole_pixel_steps() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Target"})).unwrap();
s.execute("actions.record", json!({"name": "Nudge"})).unwrap();
for _ in 0..2 {
s.execute("layer.translate", json!({"dx": 0.6, "dy": 0.0, "coalesce": "nudge:1"})).unwrap();
}
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps, [("layer.translate".into(), json!({"dx": 2.0, "dy": 0.0}))]);
}
#[test]
fn recording_document_activation_uses_opening_order_offset() {
let mut s = Session::new();
for name in ["First", "Second"] {
s.execute("file.new", json!({"width": 8, "height": 8, "name": name})).unwrap();
}
s.execute("actions.record", json!({"name": "Switch"})).unwrap();
s.execute("document.activate", json!({"document": 0})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps, [("document.activate".into(), json!({"offset": -1}))]);
}
#[test]
fn recorded_point_text_position_scales_with_the_playback_canvas() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100, "background": "transparent"})).unwrap();
s.execute("actions.record", json!({"name": "Caption"})).unwrap();
s.execute("type.create", json!({"x": 20, "y": 30, "text": "Hi", "size": 12})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1["xPercent"], json!(20.0));
assert_eq!(s.actions.list[0].steps[0].1["yPercent"], json!(30.0));
s.execute("file.new", json!({"width": 200, "height": 300, "background": "transparent"})).unwrap();
let played = s.execute("actions.play", json!({"action": "Caption"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let info = s.execute("type.info", json!({})).unwrap();
assert_eq!(info["transform"][4], json!(40.0));
assert_eq!(info["transform"][5], json!(90.0));
}
#[test]
fn recorded_point_text_rejects_invalid_relative_positions() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
for params in [
json!({"xPercent": 20}),
json!({"xPercent": "20", "yPercent": 30}),
json!({"xPercent": 20, "yPercent": 30, "x": 20}),
json!({"xPercent": 20, "yPercent": 30, "box": [0, 0, 10, 10]}),
json!({"xPercent": 1e100, "yPercent": 30}),
] {
assert!(playback_params(&s, "type.create", &params).is_err(), "{params}");
}
}
#[test]
fn recorded_single_layer_transform_replays_after_layer_ids_shift() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap();
let source = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap();
s.edit("source pixel", |doc, _| {
doc.layer_mut(photocraft_doc::LayerId(source)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("actions.record", json!({"name": "Move target"})).unwrap();
s.execute("edit.transform", json!({"layer": source, "dx": 4, "interpolation": "nearest"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let recorded = s.execute("actions.get", json!({"action": "Move target"})).unwrap();
assert_eq!(recorded["steps"][0][1], json!({"dx": 4, "interpolation": "nearest"}));
// Add a layer before the target, shifting its id; playback must resolve the current
// selection rather than keep the source document's numeric layer id.
s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Inserted"})).unwrap();
let target = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap();
assert_ne!(source, target);
s.edit("target pixel", |doc, _| {
doc.layer_mut(photocraft_doc::LayerId(target)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
let target_index = s.active().unwrap().doc.layers.iter().position(|layer| layer.id.0 == target).unwrap();
let before = s.active().unwrap().history.past_len();
let played = s.execute("actions.play", json!({"action": "Move target"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert_eq!(s.active().unwrap().history.past_len(), before + 1);
assert_eq!(pixel(&s, target_index, 4, 2), vec![1.0, 0.0, 0.0, 1.0]);
assert_eq!(pixel(&s, target_index, 0, 2), vec![0.0; 4]);
}
/// A Background with a red bar and a slanted ruler line, ready for Straighten.
fn ruled_background(s: &mut Session, depth: u32) {
s.execute("file.new", json!({"width": 80, "height": 60, "depth": depth})).unwrap();
s.edit("red bar", |doc, _| {
doc.layers[0].surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(10, 20, 60, 8), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
}
/// The same, with a pixel layer on top: since #12033 the Background alone greys Straighten Layer
/// out, so the tests that run the command target a layer and pass `crop` explicitly.
fn ruled_layer(s: &mut Session, depth: u32) {
ruled_background(s, depth);
s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap();
}
#[test]
fn record_command_that_runs_commands_replays_it_once() {
// Straighten Layer runs Image Rotation and Crop itself: only its own call is recorded,
// so playback rotates once, not three times (rotate, crop, then straighten again).
for depth in [8, 16] {
let mut s = Session::new();
ruled_layer(&mut s, depth);
s.execute("actions.record", json!({"name": "Level"})).unwrap();
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
// #12033: the crop is asked for, as automation does; the Background it used to default to
// now greys the command out.
s.execute("image.analysis.straightenLayer", json!({"crop": true})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let got = s.execute("actions.get", json!({"action": "Level"})).unwrap();
let ids: Vec<&str> = got["steps"].as_array().unwrap().iter().map(|st| st[0].as_str().unwrap()).collect();
assert_eq!(ids, ["image.analysis.rulerTool", "image.analysis.straightenLayer"], "depth {depth}");
let direct = s.active().unwrap().doc.clone();
ruled_layer(&mut s, depth);
let before = s.active().unwrap().history.past_len();
let played = s.execute("actions.play", json!({"action": "Level"})).unwrap();
assert!(played.get("failed").is_none(), "depth {depth}: {played}");
assert_eq!(played["ran"], 2, "depth {depth}");
assert_eq!(s.active().unwrap().history.past_len(), before + 1, "depth {depth}: one Straighten step (the ruler is no step)");
let replayed = s.active().unwrap().doc.clone();
assert_eq!(replayed.size, direct.size, "depth {depth}: rotated and cropped once");
let r = photocraft_geom::Rect::new(0, 0, direct.size.width as i32, direct.size.height as i32);
let px = |d: &photocraft_doc::Document| d.layers[0].surface().unwrap().read_region(r);
assert_eq!(px(&replayed), px(&direct), "depth {depth}: same pixels as the direct run");
}
}
#[test]
fn record_keeps_only_replayable_steps() {
let mut s = Session::new();
record_red(&mut s, 8);
let got = s.execute("actions.get", json!({"action": "Red"})).unwrap();
let ids: Vec<&str> = got["steps"].as_array().unwrap().iter().map(|st| st[0].as_str().unwrap()).collect();
assert_eq!(ids, ["layer.new.layer", "select.rect", "edit.fill"]);
assert!(s.journal.iter().all(|(id, _)| !id.starts_with("actions.")));
let listed = s.execute("actions.list", json!({})).unwrap();
assert_eq!(listed["actions"][0]["steps"], 3);
assert!(listed["recording"].is_null());
}
#[test]
/// Playback on a different document must match native edits at every depth, independent of history grouping.
fn play_on_another_document_matches_pixels_at_each_depth() {
for depth in [8, 16, 32] {
let mut s = Session::new();
// This control checks the optional, per-step history behavior.
s.execute("actions.setHistory", json!({"singleState": false})).unwrap();
record_red(&mut s, depth);
s.execute("file.new", json!({"width": 40, "height": 40, "depth": depth})).unwrap();
let before = s.active().unwrap().history.past_len();
let r = s.execute("actions.play", json!({"action": 0})).unwrap();
assert!(r.get("failed").is_none(), "depth {depth}: {r}");
assert_eq!(r["ran"], 3, "depth {depth}");
assert_eq!(s.active().unwrap().doc.layers.len(), 2, "depth {depth}");
assert_eq!(s.active().unwrap().history.past_len(), before + 3, "depth {depth}: one history step per played step");
let px = pixel(&s, 1, 5, 5);
assert!(px.iter().zip([1.0, 0.0, 0.0, 1.0]).all(|(a, b)| (a - b).abs() < 1.0e-5), "depth {depth}: {px:?}");
assert_eq!(s.actions.playing, 0);
}
}
#[test]
fn recorded_layer_selection_replays_by_name_on_another_document() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
let original = s.active().unwrap().active_layer.unwrap();
s.execute("layer.setProps", json!({"name": "Photo"})).unwrap();
s.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap();
s.execute("actions.record", json!({"name": "Duplicate photo"})).unwrap();
s.execute("layer.select", json!({"layer": original.0, "mode": "replace"})).unwrap();
// The target name must be captured now, not when recording stops.
s.execute("layer.setProps", json!({"name": "Retouched"})).unwrap();
s.execute("layer.duplicate", json!({})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo", "mode":"replace"}));
// Persisted actions must work in an entirely different session too.
let saved = serde_json::to_string(&s.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
replay.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
replay.execute("layer.setProps", json!({"name": "Photo"})).unwrap();
let target = replay.active().unwrap().active_layer.unwrap();
assert_ne!(original, target);
replay.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap();
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
assert_eq!(result["ran"], 3);
assert_eq!(replay.active().unwrap().doc.layer(target).unwrap().name, "Retouched");
let active = replay.active().unwrap().active_layer.unwrap();
assert_eq!(replay.active().unwrap().doc.layer(active).unwrap().name, "Retouched copy");
}
}
/// #7845: dragging a layer in the Layers panel while recording keeps the drop target by name,
/// so the action plays on another document (whose layer ids differ) instead of failing with
/// "no such layer". The reporter's steps, replayed on a new document.
/// #10485: a mask thumbnail dropped on another layer while recording keeps its destination by
/// name, so the action moves the mask to the same layer in another document (whose ids differ)
/// rather than to whichever layer has the recorded id there.
#[test]
fn recorded_mask_drop_replays_on_another_document() {
let masked = |s: &mut Session, name: &str| {
let id = s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.rect", json!({"x": 1, "y": 1, "width": 4, "height": 4})).unwrap();
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
id
};
let has_mask = |s: &Session, name: &str| s.active().unwrap().doc.walk().iter().any(|(_, _, l)| l.name == name && l.mask.is_some());
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let photo = masked(&mut s, "Photo");
let shadow = s.execute("layer.new.layer", json!({"name": "Shadow"})).unwrap()["layer"].as_u64().unwrap();
s.execute("actions.record", json!({"name": "Mask to shadow"})).unwrap();
s.execute("layer.layerMask.moveTo", json!({"layer": photo, "to": shadow, "copy": false})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"layer": "Photo", "to": "Shadow", "copy": false}));
// Another document: an extra layer between them, so the recorded id is a different layer.
let saved = serde_json::to_string(&s.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
replay.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
masked(&mut replay, "Photo");
replay.execute("layer.new.layer", json!({"name": "Extra"})).unwrap();
replay.execute("layer.new.layer", json!({"name": "Shadow"})).unwrap();
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
assert!(has_mask(&replay, "Shadow"), "the mask went to Shadow");
assert!(!has_mask(&replay, "Photo") && !has_mask(&replay, "Extra"), "and nowhere else");
}
/// A mask dropped on one of several same-named layers records which of them it is, so replay
/// picks the same one (not the topmost of that name), and an explicit name needs the occurrence.
#[test]
fn recorded_mask_drop_onto_a_duplicate_name_replays_on_the_same_layer() {
let build = |s: &mut Session, extra: bool| {
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
if extra {
s.execute("layer.new.layer", json!({"name": "Extra"})).unwrap();
}
let photo = s.execute("layer.new.layer", json!({"name": "Photo"})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.rect", json!({"x": 1, "y": 1, "width": 4, "height": 4})).unwrap();
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
let lower = s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap();
let upper = s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap();
(photo, lower, upper)
};
let mask_on = |s: &Session, id: u64| s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().mask.is_some();
let mut s = Session::new();
let (photo, lower, upper) = build(&mut s, false);
s.execute("actions.record", json!({"name": "Copy mask"})).unwrap();
s.execute("layer.layerMask.moveTo", json!({"layer": photo, "to": lower, "copy": true})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"layer": "Photo", "to": "Twin", "toOccurrence": 0, "copy": true}));
assert!(mask_on(&s, lower) && !mask_on(&s, upper));
let saved = serde_json::to_string(&s.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
let (photo2, lower2, upper2) = build(&mut replay, true);
assert_ne!((photo, lower, upper), (photo2, lower2, upper2), "the ids differ");
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
assert!(mask_on(&replay, photo2), "a copy: the original stays");
assert!(mask_on(&replay, lower2), "the copy went to the lower Twin");
assert!(!mask_on(&replay, upper2), "not to the upper one");
// Explicit calls: a name shared by several layers needs `toOccurrence`; ids still work.
let err = replay.execute("layer.layerMask.moveTo", json!({"layer": photo2, "to": "Twin", "copy": true})).unwrap_err().to_string();
assert!(err.contains("toOccurrence"), "{err}");
replay.execute("layer.layerMask.moveTo", json!({"layer": photo2, "to": "Twin", "toOccurrence": 1, "copy": true})).unwrap();
assert!(mask_on(&replay, upper2), "occurrence 1 is the upper Twin");
replay.execute("layer.layerMask.delete", json!({"layer": upper2})).unwrap();
replay.execute("layer.layerMask.moveTo", json!({"layer": photo2, "to": upper2})).unwrap();
assert!(mask_on(&replay, upper2) && !mask_on(&replay, photo2), "a numeric id still names the layer");
}
#[test]
fn recorded_layer_drag_replays_on_another_document() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap();
s.execute("actions.record", json!({"name": "Retouch"})).unwrap();
s.execute("layer.stampVisible", json!({})).unwrap();
let layer1 = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
let layer2 = s.active().unwrap().active_layer.unwrap();
s.execute("layer.select", json!({"layer": layer1.0, "mode": "replace"})).unwrap();
s.execute("filter.noise.median", json!({"radius": 6})).unwrap();
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
let copy = s.active().unwrap().active_layer.unwrap();
s.execute("layer.moveTo", json!({"layer": copy.0, "target": layer2.0, "position": "above"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let (id, step) = s.actions.list[0].steps.last().unwrap();
assert_eq!((id.as_str(), step), ("layer.moveTo", &json!({"target": "Layer 1 copy", "position": "above"})));
let names = |s: &Session| s.active().unwrap().doc.layers.iter().rev().map(|l| l.name.clone()).collect::<Vec<_>>();
let recorded = names(&s);
let saved = serde_json::to_string(&s.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
replay.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap();
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{depth}: {result}");
assert_eq!(names(&replay), recorded, "{depth}: the same stack");
}
// Several layers of the drop target's name: the recorded occurrence picks the right one.
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
let upper = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "Mover"})).unwrap();
let mover = s.active().unwrap().active_layer.unwrap();
s.execute("actions.record", json!({"name": "Drop"})).unwrap();
s.execute("layer.moveTo", json!({"layer": mover.0, "target": upper.0, "position": "below"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"target": "Twin", "targetOccurrence": 1, "position": "below"}));
// The moved layer shares the target's name: the occurrence counts the other "Twin"s only,
// so it means the same layer before the move (on replay) as after it (when recorded).
let build = |s: &mut Session| {
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
for _ in 0..3 {
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
}
s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::<Vec<_>>()
};
let mut s = Session::new();
let ids = build(&mut s);
let (lowest, top) = (ids[1], ids[3]);
s.execute("actions.record", json!({"name": "Twins"})).unwrap();
s.execute("layer.moveTo", json!({"layer": top.0, "target": lowest.0, "position": "below"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"target": "Twin", "targetOccurrence": 0, "position": "below"}));
let order = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::<Vec<_>>();
let want: Vec<_> = order(&s).iter().map(|id| ids.iter().position(|x| x == id).unwrap()).collect();
let saved = serde_json::to_string(&s.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
let ids2 = build(&mut replay);
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
let got: Vec<_> = order(&replay).iter().map(|id| ids2.iter().position(|x| x == id).unwrap()).collect();
assert_eq!(got, want, "the same layer became the lowest");
// A moved group holding a same-named layer: its contents don't count either, or moving the
// group past another "Twin" would shift the occurrence between recording and replay.
let build_group = |s: &mut Session| {
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
let group = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
let top = s.active().unwrap().active_layer.unwrap();
s.execute("layer.select", json!({"layer": group.0, "mode": "replace"})).unwrap();
(group, top)
};
let mut g = Session::new();
let (group, top) = build_group(&mut g);
g.execute("actions.record", json!({"name": "Group"})).unwrap();
g.execute("layer.moveTo", json!({"layer": group.0, "target": top.0, "position": "above"})).unwrap();
g.execute("actions.stop", json!({})).unwrap();
let recorded = g.actions.list[0].steps[0].1.clone();
let saved = serde_json::to_string(&g.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
let (group2, _) = build_group(&mut replay);
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{recorded}: {result}");
let top_layer = |s: &Session| s.active().unwrap().doc.layers.last().map(|l| l.id);
assert_eq!(top_layer(&replay), Some(group2), "{recorded}: the group went to the top");
// Several moved layers named like the target are left out too, so the name is unambiguous.
let mut m = Session::new();
m.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let a = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
let b = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
let target = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
m.execute("layer.moveTo", json!({"layers": [a.0, b.0], "target": "Twin", "position": "above"})).unwrap();
assert_eq!(m.active().unwrap().doc.layers.last().map(|l| l.id), Some(b), "both moved above the third Twin");
assert_eq!(m.active().unwrap().doc.layers.iter().rev().nth(2).map(|l| l.id), Some(target));
// An ⌥-drag copy of the selected layer beside itself replays by name too.
let mut c = Session::new();
c.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let src = photocraft_doc::LayerId(c.execute("layer.new.layer", json!({"name": "Solo"})).unwrap()["layer"].as_u64().unwrap());
c.execute("actions.record", json!({"name": "Copy"})).unwrap();
c.execute("layer.moveTo", json!({"layer": src.0, "target": src.0, "position": "above", "copy": true})).unwrap();
c.execute("actions.stop", json!({})).unwrap();
assert_eq!(c.actions.list[0].steps[0].1, json!({"target": "Solo", "position": "above", "copy": true}));
let saved = serde_json::to_string(&c.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
replay.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
replay.execute("layer.new.layer", json!({"name": "Solo"})).unwrap();
let before = replay.active().unwrap().doc.layers.len();
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
assert_eq!(replay.active().unwrap().doc.layers.len(), before + 1, "one copy");
// Copying a group holding a layer named like the target: the new copy's contents (made
// before the step is recorded) don't count, so the occurrence matches the replay.
let build_copy = |s: &mut Session| {
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
let group = s.active().unwrap().active_layer.unwrap();
let a = photocraft_doc::LayerId(s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
let b = photocraft_doc::LayerId(s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.select", json!({"layer": group.0, "mode": "replace"})).unwrap();
(group, a, b)
};
let mut gc = Session::new();
let (group, a, _) = build_copy(&mut gc);
gc.execute("actions.record", json!({"name": "Copy group"})).unwrap();
// Below `a`: the copy's "Twin" then comes before `a` in document order.
gc.execute("layer.moveTo", json!({"layer": group.0, "target": a.0, "position": "below", "copy": true})).unwrap();
gc.execute("actions.stop", json!({})).unwrap();
let saved = serde_json::to_string(&gc.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
let (_, a2, _) = build_copy(&mut replay);
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
let layers = &replay.active().unwrap().doc.layers;
let at = layers.iter().position(|l| l.id == a2).unwrap();
let below = at.checked_sub(1).and_then(|i| layers.get(i)).map(|l| l.name.as_str());
assert_eq!(below, Some("Group 1 copy"), "the copy landed below the same Twin");
// A drag of several layers keeps its ids (not covered by name).
s.execute("actions.record", json!({"name": "Several"})).unwrap();
s.execute("layer.moveTo", json!({"layers": [ids[2].0, ids[3].0], "target": ids[1].0, "position": "above"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[1].steps[0].1["target"], json!(ids[1].0));
// Bad targets are errors, not panics.
for bad in [
json!({"target": "Nobody"}),
json!({"target": "Twin"}),
json!({"target": "Twin", "targetOccurrence": 9}),
json!({"target": "Twin", "targetOccurrence": -1}),
json!({"target": true}),
] {
assert!(s.execute("layer.moveTo", bad.clone()).is_err(), "{bad}");
}
}
/// An Alt-drag copy records `inPlace` (#7200), which only matters for an artboard: a plain
/// layer's recorded copy must still export to .atn, while an artboard's must be refused rather
/// than exported as a copy beside the original.
#[test]
fn recorded_in_place_duplicate_keeps_in_place_only_for_artboards() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("actions.record", json!({"name": "Layer copy"})).unwrap();
s.execute("layer.duplicate", json!({"inPlace": true})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
s.execute("layer.new.artboard", json!({"name": "Board", "rect": [0, 0, 8, 8]})).unwrap();
s.execute("actions.record", json!({"name": "Board copy"})).unwrap();
s.execute("layer.duplicate", json!({"inPlace": true})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps, vec![("layer.duplicate".to_string(), json!({}))]);
assert_eq!(s.actions.list[1].steps, vec![("layer.duplicate".to_string(), json!({"inPlace": true}))]);
assert!(crate::atn_cmds::export("Audit", &[&s.actions.list[0]], &[], false).is_ok());
assert!(crate::atn_cmds::export("Audit", &[&s.actions.list[1]], &[], false).is_err());
// Several selected layers are copied together: one artboard among them keeps `inPlace`.
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let photo = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "Logo"})).unwrap();
s.execute("layer.select", json!({"layer": photo.0, "mode": "add"})).unwrap();
s.execute("actions.record", json!({"name": "Layers copy"})).unwrap();
s.execute("layer.duplicate", json!({"inPlace": true})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.active().unwrap().selected_layers().len(), 2, "both layers were copied");
s.execute("layer.new.artboard", json!({"name": "Board", "rect": [0, 0, 8, 8]})).unwrap();
s.execute("layer.select", json!({"layer": photo.0, "mode": "add"})).unwrap();
s.execute("actions.record", json!({"name": "Mixed copy"})).unwrap();
s.execute("layer.duplicate", json!({"inPlace": true})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps, vec![("layer.duplicate".to_string(), json!({}))]);
assert_eq!(s.actions.list[1].steps, vec![("layer.duplicate".to_string(), json!({"inPlace": true}))]);
}
#[test]
fn named_selection_preserves_modes_and_duplicate_occurrences() {
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
s.execute("layer.setProps", json!({"name":"Photo"})).unwrap();
let first = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.group", json!({"name":"Group"})).unwrap();
// A duplicate nested in a group must be found even when that group is collapsed.
let second = s
.edit("child", |doc, active| {
let child = photocraft_doc::Layer::raster("Photo", doc.pixel_format());
let id = child.id;
if let photocraft_doc::LayerContent::Group(group) = &mut doc.layer_mut(active.unwrap()).unwrap().content {
group.children.push(child);
group.expanded = false;
}
Ok(id)
})
.unwrap();
s.execute("actions.record", json!({"name":"Select duplicates"})).unwrap();
s.execute("layer.select", json!({"layer":first.0})).unwrap();
s.execute("layer.select", json!({"layer":second.0,"mode":"add"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo","occurrence":0}));
assert_eq!(s.actions.list[0].steps[1].1, json!({"name":"Photo","occurrence":1,"mode":"add"}));
s.execute("layer.select", json!({"layer":first.0})).unwrap();
let r = s.execute("actions.play", json!({"action":0})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
assert_eq!(s.active().unwrap().selected_layers(), vec![first, second]);
s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"toggle"})).unwrap();
assert_eq!(s.active().unwrap().selected_layers(), vec![first]);
s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"range"})).unwrap();
assert!(s.active().unwrap().selected_layers().contains(&second));
// Main's imported .atn steps use a string-valued `layer` and choose the topmost match.
s.execute("layer.select", json!({"layer":"Photo"})).unwrap();
assert_eq!(s.active().unwrap().selected_layers(), vec![second]);
s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"add"})).unwrap();
s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"remove"})).unwrap();
assert_eq!(s.active().unwrap().selected_layers(), vec![first]);
// Removing an unselected layer does not add it; removing the last target keeps it.
s.execute("layer.select", json!({"layer":"Photo","mode":"remove"})).unwrap();
s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"remove"})).unwrap();
assert_eq!(s.active().unwrap().selected_layers(), vec![first]);
for p in [
json!({"name":"absent"}),
json!({"name":"Photo"}),
json!({"name":1}),
json!({"name":"Photo","occurrence":-1}),
json!({"name":"Photo","occurrence":1.5}),
json!({"name":"Photo","occurrence":99}),
json!({"name":"Photo","layer":first.0}),
json!({"layer":first.0,"occurrence":0}),
] {
let before = s.active().unwrap().selected_layers();
assert!(s.execute("layer.select", p.clone()).is_err(), "{p}");
assert_eq!(s.active().unwrap().selected_layers(), before);
}
s.actions.list.push(Action {
name: "Missing".into(),
steps: vec![("layer.select".into(), json!({"name":"absent"})), ("layer.duplicate".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
let before = s.active().unwrap().doc.walk().len();
let result = s.execute("actions.play", json!({"action":"Missing"})).unwrap();
assert_eq!(result["ran"], 0);
assert!(result["failed"]["error"].as_str().unwrap().contains("absent"));
assert_eq!(s.active().unwrap().doc.walk().len(), before);
}
#[test]
fn play_stops_at_the_failing_step_and_reports_it() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
s.execute("actions.record", json!({})).unwrap();
s.execute("layer.new.layer", json!({"name": "One"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
s.actions.list[0].steps.push(("not.a.command".into(), json!({})));
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
let r = s.execute("actions.play", json!({"action": "Action 1"})).unwrap();
assert_eq!(r["ran"], 1);
assert_eq!(r["failed"]["step"], 1);
assert_eq!(r["failed"]["id"], "not.a.command");
assert!(r["failed"]["error"].as_str().unwrap().contains("unknown command"));
assert_eq!(s.active().unwrap().doc.layers.len(), 2, "the step before the failure is kept");
assert_eq!(s.actions.playing, 0);
// A later play still runs.
assert_eq!(s.execute("actions.play", json!({"action": 0, "from": 1})).unwrap()["ran"], 0);
}
#[test]
fn play_refuses_recursion() {
let mut s = Session::new();
s.actions.list.push(Action {
name: "Loop".into(),
steps: vec![("actions.play".into(), json!({"action": "Loop"}))],
disabled: Default::default(),
..Default::default()
});
let r = s.execute("actions.play", json!({"action": "Loop"})).unwrap();
assert_eq!(r["ran"], 0);
assert_eq!(r["failed"]["id"], "actions.play");
assert!(r["failed"]["error"].as_str().unwrap().contains("recursive action"), "{r}");
assert_eq!(s.actions.playing, 0);
}
#[test]
fn play_checks_each_step_with_the_authorize_hook() {
fn deny_file(id: &str, _: &Value) -> Result<()> {
if id.starts_with("file.") { Err(EngineError::Other(format!("automation command `{id}` is disabled"))) } else { Ok(()) }
}
let mut s = Session::new();
s.authorize = Some(deny_file);
s.actions.list.push(Action {
name: "Open".into(),
steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
let r = s.execute("actions.play", json!({"action": "Open"})).unwrap();
assert_eq!(r["ran"], 0);
assert_eq!(r["failed"]["step"], 0);
assert_eq!(r["failed"]["id"], "file.open");
assert!(r["failed"]["error"].as_str().unwrap().contains("disabled"), "{r}");
assert!(s.documents().is_empty(), "the refused step does not open a document");
}
#[test]
fn append_delete_and_from_step() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
let started = s.execute("actions.record", json!({"name": "Grow"})).unwrap();
assert_eq!(started["index"], 0);
s.execute("layer.new.layer", json!({"name": "One"})).unwrap();
assert!(s.execute("actions.record", json!({})).is_err(), "already recording");
assert!(s.execute("actions.delete", json!({"action": 0})).is_err(), "delete while recording");
let stopped = s.execute("actions.stop", json!({})).unwrap();
assert_eq!(stopped["steps"], 1);
s.execute("actions.record", json!({"action": "Grow"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Two"})).unwrap();
assert_eq!(s.execute("actions.stop", json!({})).unwrap()["steps"], 2);
assert!(s.execute("actions.stop", json!({})).is_err(), "not recording");
let before = s.active().unwrap().doc.layers.len();
let r = s.execute("actions.play", json!({"action": "Grow", "from": 1})).unwrap();
assert_eq!(r["ran"], 1);
assert_eq!(s.active().unwrap().doc.layers.len(), before + 1);
assert_eq!(s.execute("actions.delete", json!({"action": "Grow"})).unwrap()["deleted"], "Grow");
assert!(s.actions.list.is_empty());
assert!(s.execute("actions.get", json!({"action": "Grow"})).is_err());
}
#[test]
fn delete_one_step_while_recording_keeps_journal_and_remaining_steps() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("actions.record", json!({"name": "Edit"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Kept"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Removed"})).unwrap();
let journal = s.journal.clone();
let rev = s.actions.rev;
let before = s.actions.clone();
for step in [json!(-1), json!(2), json!(0.5), json!(null), json!("0"), json!(u64::MAX)] {
assert!(s.execute("actions.delete", json!({"action": 0, "step": step})).is_err());
assert_eq!(s.actions, before, "invalid deletion is atomic");
}
assert_eq!(pending_steps(&s, 0).count(), 2);
let result = s.execute("actions.delete", json!({"action": 0, "step": 1})).unwrap();
assert_eq!(result["steps"], 1);
assert_eq!(s.journal, journal, "deleting a recording does not erase executed commands");
assert_eq!(s.active().unwrap().doc.layers.len(), 3, "it does not undo document edits");
assert!(s.actions.recording.is_some());
assert!(s.actions.rev > rev);
s.execute("layer.new.layer", json!({"name": "After"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps.iter().map(|(_, p)| p["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Kept", "After"]);
s.execute("actions.delete", json!({"action": "Edit", "step": 0})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1["name"], "After");
let saved = serde_json::to_string(&s.actions.list).unwrap();
s.actions.list = serde_json::from_str(&saved).unwrap();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
assert_eq!(s.execute("actions.play", json!({"action": 0})).unwrap()["ran"], 1);
assert_eq!(s.active().unwrap().doc.layers[1].name, "After");
s.execute("actions.delete", json!({"action": 0, "step": 0})).unwrap();
assert_eq!(s.actions.list.len(), 1, "an empty action still exists");
assert!(s.actions.list[0].steps.is_empty());
}
#[test]
fn nested_call_records_one_named_step_and_tracks_child_edits() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
s.actions.list.push(Action {
name: "Child".into(),
steps: vec![("layer.new.layer".into(), json!({"name": "Child layer"}))],
disabled: Default::default(),
..Default::default()
});
s.execute("actions.record", json!({"name": "Parent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Before"})).unwrap();
s.execute("actions.play", json!({"action": 0})).unwrap();
assert_eq!(pending_steps(&s, 1).cloned().collect::<Vec<_>>(), [("actions.play".into(), json!({"action": "Child"}))]);
s.execute("layer.new.layer", json!({"name": "After"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[1].steps.iter().map(|st| st.0.as_str()).collect::<Vec<_>>(), ["layer.new.layer", "actions.play", "layer.new.layer"]);
s.actions.list[0].steps.push(("layer.new.layer".into(), json!({"name": "Added later"})));
s.execute("actions.move", json!({"action": "Child", "to": 1})).unwrap();
let saved = serde_json::to_string(&s.actions.list).unwrap();
s.actions.list = serde_json::from_str(&saved).unwrap();
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
let result = s.execute("actions.play", json!({"action": "Parent"})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
assert_eq!(s.active().unwrap().doc.layers.iter().skip(1).map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Before", "Child layer", "Added later", "After"]);
assert!(s.actions.playback_stack.is_empty());
}
#[test]
fn nested_failure_cycles_depth_and_authorization_stop_parent() {
let mut s = Session::new();
s.actions.list.push(Action {
name: "A".into(),
steps: vec![("actions.play".into(), json!({"action": "B"})), ("file.new".into(), json!({"width": 2,"height": 2}))],
disabled: Default::default(),
..Default::default()
});
s.actions.list.push(Action {
name: "B".into(),
steps: vec![("actions.play".into(), json!({"action": "A"}))],
disabled: Default::default(),
..Default::default()
});
let r = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(r["failed"]["error"].as_str().unwrap().contains("recursive"));
assert!(s.documents().is_empty());
s.actions.list[1].steps = vec![("file.new".into(), json!({"width": 2,"height": 2}))];
s.authorize = Some(|id, _| if id == "file.new" { Err(EngineError::Other("denied nested file".into())) } else { Ok(()) });
let r = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(r["failed"]["error"].as_str().unwrap().contains("denied nested file"));
assert!(s.documents().is_empty());
s.authorize = None;
s.actions.list.clear();
for i in 0..17 {
s.actions.list.push(Action {
name: format!("N{i}"),
steps: if i < 16 { vec![("actions.play".into(), json!({"action":format!("N{}", i + 1)}))] } else { vec![] },
disabled: Default::default(),
..Default::default()
});
}
let r = s.execute("actions.play", json!({"action": "N0"})).unwrap();
assert!(r["failed"]["error"].as_str().unwrap().contains("16 levels"));
assert_eq!(s.actions.playing, 0);
assert!(s.actions.playback_stack.is_empty());
s.actions.list[15].steps.clear();
assert!(s.execute("actions.play", json!({"action": "N0"})).unwrap().get("failed").is_none());
}
#[test]
fn move_pending_steps_is_atomic_persists_and_changes_playback_order() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
s.execute("actions.record", json!({"name": "Order"})).unwrap();
for name in ["One", "Two", "Three"] {
s.execute("layer.new.layer", json!({"name": name})).unwrap();
}
let before = s.actions.clone();
for params in [
json!({"action":0,"step":0,"to":3}),
json!({"action":0,"step":99,"to":0}),
json!({"action":0,"step":0.5,"to":0}),
json!({"action":0,"to":-1}),
json!({"action":0,"to":null}),
] {
assert!(s.execute("actions.move", params).is_err());
assert_eq!(s.actions, before);
}
let journal = s.journal.clone();
s.execute("actions.move", json!({"action":0,"step":0,"to":2})).unwrap();
s.execute("actions.move", json!({"action":0,"step":1,"to":0})).unwrap();
assert_eq!(s.journal, journal);
s.execute("layer.new.layer", json!({"name":"Four"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps.iter().map(|st| st.1["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Three", "Two", "One", "Four"]);
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
s.execute("actions.play", json!({"action":0})).unwrap();
assert_eq!(s.active().unwrap().doc.layers.iter().skip(1).map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Three", "Two", "One", "Four"]);
}
#[test]
fn new_names_do_not_collide_with_named_calls() {
let mut s = Session::new();
s.actions.list.push(Action { name: "Action 2".into(), steps: vec![], disabled: Default::default(), ..Default::default() });
assert_eq!(s.execute("actions.record", json!({})).unwrap()["action"], "Action 3");
s.execute("actions.stop", json!({})).unwrap();
assert!(s.execute("actions.record", json!({"name":"Action 2"})).is_err());
assert!(s.actions.recording.is_none());
}
#[test]
fn moving_action_keeps_recording_attached_to_it() {
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
for name in ["A", "B", "C"] {
s.actions.list.push(Action { name: name.into(), steps: vec![], disabled: Default::default(), ..Default::default() });
}
s.execute("actions.record", json!({"action":"B"})).unwrap();
s.execute("layer.new.layer", json!({"name":"before"})).unwrap();
s.execute("actions.move", json!({"action":"B","to":2})).unwrap();
s.execute("actions.move", json!({"action":"A","to":2})).unwrap();
s.execute("layer.new.layer", json!({"name":"after"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[1].name, "B");
assert_eq!(s.actions.list[1].steps.len(), 2);
}
#[test]
fn bad_params_do_not_panic() {
let mut s = Session::new();
let junk = [
Value::Null,
json!([]),
json!("x"),
json!(1),
json!({"action": true}),
json!({"action": -1}),
json!({"action": 99}),
json!({"action": ""}),
json!({"name": 1}),
json!({"name": ""}),
json!({"from": "start"}),
json!({"from": -3}),
json!({"action": "missing", "from": 4}),
];
for id in ["actions.list", "actions.get", "actions.record", "actions.stop", "actions.play", "actions.delete", "actions.move"] {
for p in &junk {
let _ = s.execute(id, p.clone());
}
}
assert_eq!(s.actions.playing, 0);
assert!(crate::commands::command_specs().iter().any(|c| c.id == "actions.play" && !c.journal && c.menu.is_empty()));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn actions_persist_with_the_preset_store() {
let dir = std::env::temp_dir().join(format!("pc-actions-{}-persist", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::new();
let warnings = s.attach_preset_store(crate::preset_store::open_dir(&dir));
assert!(warnings.iter().all(|w| !w.contains("actions")), "{warnings:?}");
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
s.execute("actions.record", json!({"name": "Kept"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
drop(s);
let mut t = Session::new();
t.attach_preset_store(crate::preset_store::open_dir(&dir));
let listed = t.execute("actions.list", json!({})).unwrap();
assert_eq!(listed["actions"][0]["name"], "Kept");
assert_eq!(listed["actions"][0]["steps"], 1);
assert_eq!(t.execute("actions.get", json!({"action": "Kept"})).unwrap()["steps"][0][0], "layer.new.layer");
// An action created before the store finishes loading is kept, and a different one on
// disk is merged in.
let mut early = Session::new();
early.execute("actions.record", json!({"name": "Early"})).unwrap();
early.execute("actions.stop", json!({})).unwrap();
early.attach_preset_store(crate::preset_store::open_dir(&dir));
let names: Vec<String> = early.actions.list.iter().map(|a| a.name.clone()).collect();
assert!(names.contains(&"Early".into()) && names.contains(&"Kept".into()), "{names:?}");
std::fs::write(dir.join("actions.json"), b"{").unwrap();
let mut bad = Session::new();
let warnings = bad.attach_preset_store(crate::preset_store::open_dir(&dir));
assert!(warnings.iter().any(|w| w.contains("actions")), "{warnings:?}");
assert!(bad.actions.list.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
/// AUTO-219-4: a recording made on one document replays on a second one — a crop, a filter,
/// a selection fill and styled text all act on the document the replay runs in.
#[test]
fn a_recorded_edit_run_replays_on_a_second_document() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
s.execute("actions.record", json!({"name": "Studio"})).unwrap();
s.execute("image.crop", json!({"x": 8, "y": 8, "width": 48, "height": 48})).unwrap();
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 12, "height": 12})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("type.create", json!({"x": 10, "y": 36, "text": "Hi", "size": 12, "color": "#000000"})).unwrap();
s.execute("type.setStyle", json!({"fauxBold": true})).unwrap();
let stopped = s.execute("actions.stop", json!({})).unwrap();
let steps = stopped["steps"].as_u64().unwrap();
assert!(steps >= 7, "the recording keeps every family: {stopped}");
// The second document: a different size, background and name. The replay must act here.
s.execute("file.new", json!({"width": 100, "height": 80, "background": "black", "name": "Second"})).unwrap();
let played = s.execute("actions.play", json!({"action": "Studio"})).unwrap();
assert!(played.get("failed").is_none(), "replay: {played}");
assert_eq!(played["ran"].as_u64(), Some(steps), "every step ran: {played}");
let d = &s.active().unwrap().doc;
assert_eq!((d.size.width, d.size.height), (48, 48), "the crop replayed onto the second document");
assert_eq!(pixel(&s, 0, 16, 16), vec![1.0, 0.0, 0.0, 1.0], "the selection fill replayed");
assert_eq!(pixel(&s, 0, 2, 2), vec![0.0, 0.0, 0.0, 1.0], "outside the selection stays black");
let last = d.layers.last().unwrap();
match &last.content {
photocraft_doc::LayerContent::Text(t) => {
assert_eq!(t.text, "Hi", "the styled text replayed");
assert!(t.char_runs().iter().any(|r| r.style.faux_bold), "the bold run replayed");
}
other => panic!("expected the recorded type layer, got {other:?}"),
}
// The first document is untouched.
let first = &s.documents()[0].doc;
assert_eq!((first.size.width, first.size.height), (48, 48), "the recording document keeps its own crop");
}
#[test]
fn button_color_is_one_of_the_photoshop_swatches() {
let mut s = Session::new();
s.execute("actions.record", json!({"name": "Print"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let set = s.execute("actions.setColor", json!({"action": "Print", "color": "blue"})).unwrap();
assert_eq!(set["color"], "blue");
assert_eq!(s.actions.list[0].color, "blue");
assert_eq!(s.execute("actions.get", json!({"action": 0})).unwrap()["color"], "blue");
let saved = serde_json::to_string(&s.actions.list).unwrap();
assert!(saved.contains("blue"));
s.execute("actions.setColor", json!({"action": 0, "color": ""})).unwrap();
assert!(s.actions.list[0].color.is_empty());
assert!(!serde_json::to_string(&s.actions.list).unwrap().contains("color"), "an empty colour is omitted");
for bad in [json!({"action": 0, "color": "pink"}), json!({"action": 0}), json!({"action": 4, "color": "red"}), json!({"color": "red"})] {
assert!(s.execute("actions.setColor", bad).is_err());
}
assert!(s.actions.list[0].color.is_empty(), "rejected colours change nothing");
}
#[test]
/// An arrangement that cannot move the layer must not create a grouped history entry.
fn arrange_that_cannot_move_is_a_noop_in_playback() {
// #8453: imported actions make an adjustment layer, then Bring it to Front, where it already is.
let mut s = Session::new();
// Keep individual rows visible so grouping cannot hide an extra no-op record.
s.execute("actions.setHistory", json!({"singleState": false})).unwrap();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let steps = vec![
("layer.newAdjustmentLayer.curves".to_string(), json!({})),
("layer.arrange.bringToFront".to_string(), json!({})),
("layer.new.layer".to_string(), json!({"name": "After"})),
];
s.actions.list.push(Action::new("Repro", steps));
let before = s.active().unwrap().history.past_len();
let played = s.execute("actions.play", json!({"action": 0})).unwrap();
assert_eq!(played["ran"], 3, "{played}");
assert!(played.get("failed").is_none(), "{played}");
assert_eq!(s.active().unwrap().history.past_len(), before + 2, "the no-op adds no history step");
// The predicate identifies no movement for every layer: top level, above the Background,
// and inside a group.
let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].clone();
s.execute("layer.select", json!({"layer": a})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
s.execute("layer.new.layer", json!({"name": "B"})).unwrap();
let cmds = [("layer.arrange.bringForward", 1), ("layer.arrange.bringToFront", 1), ("layer.arrange.sendBackward", -1), ("layer.arrange.sendToBack", -1)];
let ids: Vec<_> = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
let bg = crate::background_cmds::background_id(&s.active().unwrap().doc);
for id in ids.into_iter().filter(|id| Some(*id) != bg) {
for (cmd, dir) in cmds {
let at_end = super::super::ops::arrange_at_end(&s.active().unwrap().doc, id, dir);
let history = s.active().unwrap().history.past_len();
let ran = s.execute(cmd, json!({"layer": id.0}));
if at_end {
if matches!(cmd, "layer.arrange.bringToFront" | "layer.arrange.sendToBack") {
assert_eq!(ran.unwrap(), json!({"moved": false}), "{cmd} on {id:?}");
} else {
assert!(ran.is_err(), "{cmd} on {id:?}: {ran:?}");
}
assert_eq!(s.active().unwrap().history.past_len(), history, "a no-op must not add history");
} else {
ran.unwrap();
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
s.execute("edit.undo", json!({})).unwrap();
}
}
}
// Arranging the Background still stops playback: Photoshop refuses it too.
let bg = bg.unwrap();
s.actions.list.push(Action::new("Bg", vec![("layer.arrange.bringToFront".to_string(), json!({"layer": bg.0}))]));
let played = s.execute("actions.play", json!({"action": 1})).unwrap();
assert_eq!(played["ran"], 0, "{played}");
assert!(played["failed"].is_object(), "{played}");
}
/// A deep route cannot consume the only visit to a dependency reachable near the root.
#[test]
fn called_actions_keeps_dependencies_reachable_by_a_shallow_route() {
let mut list = vec![Action::new("Leaf", Vec::new()), Action::new("Shared", vec![("actions.play".into(), json!({"action": "Leaf"}))])];
for i in 0..15 {
let next = if i == 14 { "Shared".to_string() } else { format!("D{}", i + 1) };
list.push(Action::new(format!("D{i}"), vec![("actions.play".into(), json!({"action": next}))]));
}
let mut steps = vec![("file.automate.batch".into(), json!({"action": "Shared"})), ("actions.play".into(), json!({"action": "D0"}))];
let called = called_actions(&list, &mut steps);
assert!(called.iter().any(|a| a.name == "Leaf"));
assert_eq!(called.len(), list.len());
}
/// Extra source copies cannot turn deferred missing indices into valid action calls.
#[test]
fn scoped_source_slots_do_not_bind_previously_missing_references() {
let list = vec![
Action::new("Leaf", Vec::new()),
Action::new("Shared", vec![("actions.play".into(), json!({"action": 0}))]),
Action::new("Bad", vec![("actions.play".into(), json!({"action": 3}))]),
];
let mut steps = vec![
("actions.play".into(), json!({"action": "Shared"})),
("file.automate.batch".into(), json!({"action": "Shared", "calledActions": []})),
("actions.play".into(), json!({"action": "Bad"})),
];
for id in ["actions.play", "file.automate.batch"] {
for action in [json!(3), json!(3.25), json!(-1), json!("Missing"), json!(null)] {
steps.push((id.into(), json!({"action": action})));
if id == "file.automate.batch" {
steps.push((id.into(), json!({"action": action, "calledActions": []})));
}
}
}
let called = called_actions(&list, &mut steps);
assert_eq!(called.len(), 4);
assert_eq!(called.last(), list.get(1), "the raw source keeps its own indices");
for (id, params) in &steps[3..] {
assert!(resolve(&called, params, id).is_err(), "{id}: {params}");
}
let bad = called.iter().find(|a| a.name == "Bad").unwrap();
assert!(resolve(&called, &bad.steps[0].1, "actions.play").is_err());
// A scoped source runs its raw references in the embedded namespace. An index beyond
// the caller's library can therefore be valid there, even after capture adds a source copy.
let list =
vec![Action::new("Source", vec![("actions.play".into(), json!({"action": 3}))]), Action::new("Other", Vec::new()), Action::new("Last", Vec::new())];
let embedded = vec![
Action::new("Gap0", Vec::new()),
Action::new("Gap1", Vec::new()),
Action::new("Gap2", Vec::new()),
Action::new("Child", vec![("layer.new.layer".into(), json!({"name": "embedded child"}))]),
];
let mut steps = vec![
("actions.play".into(), json!({"action": 0})),
("actions.play".into(), json!({"action": 1})),
("actions.play".into(), json!({"action": 2})),
("file.automate.batch".into(), json!({"source": "open", "action": "Source", "calledActions": embedded})),
];
let called = called_actions(&list, &mut steps);
assert_eq!(called.len(), 4);
assert_eq!(called.last(), list.first(), "the scoped source keeps its embedded index");
assert!(resolve(&called, &called[0].steps[0].1, "actions.play").is_err(), "the ordinary source still has a missing target");
let mut session = Session::new();
session.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
session.actions.list = called;
let (id, params) = steps.last().unwrap();
let result = session.execute(id, params.clone()).unwrap();
assert_eq!(result["errors"], json!([]), "{result}");
assert!(session.active().unwrap().doc.layers.iter().any(|layer| layer.name == "embedded child"));
}
/// #10459 review: recording into an existing set keeps that set's exact name, even one with
/// surrounding spaces (from an older actions.json); a new set name is trimmed.
#[test]
fn record_keeps_an_existing_sets_exact_name() {
let mut s = Session::new();
s.actions.list.push(Action { set: Some(" Web ".into()), ..Action::new("Old", Vec::new()) });
s.execute("actions.record", json!({"name": "New", "set": " Web "})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let set_of = |s: &Session, n: &str| s.actions.list.iter().find(|a| a.name == n).and_then(|a| a.set.clone());
assert_eq!(set_of(&s, "New").as_deref(), Some(" Web "), "the existing set");
s.execute("actions.record", json!({"name": "Fresh", "set": " Print "})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(set_of(&s, "Fresh").as_deref(), Some("Print"), "a new set is trimmed");
}
/// CodeRabbit review on #10459: an empty set loaded from a `.atn` (or made by New Set) keeps its
/// exact name too, so the first action recorded into it lands in that folder and saves with it.
#[test]
fn record_keeps_an_empty_loaded_sets_exact_name() {
let mut src = Session::new();
src.actions.sets.push(" Web ".into());
let saved = src.execute("actions.save", json!({"set": " Web "})).unwrap();
let mut s = Session::new();
assert_eq!(s.execute("actions.load", json!({"data": saved["data"]})).unwrap()["set"], " Web ");
s.execute("actions.record", json!({"name": "First", "set": " Web "})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let set = s.actions.list.iter().find(|a| a.name == "First").and_then(|a| a.set.clone());
assert_eq!(set.as_deref(), Some(" Web "));
assert_eq!(crate::actions_edit_cmds::set_names(&s), [" Web "], "one folder, not a trimmed second one");
assert_eq!(s.execute("actions.save", json!({"set": " Web "})).unwrap()["actions"], json!(["First"]));
}
/// A droplet capturing two actions of one name (sets A and B) keeps each call on the action it
/// reached: an index or set-qualified call to B's action still reaches B's copy, a name-only call
/// still reaches A's, and a call to a uniquely named action stays a name.
#[test]
fn captured_calls_keep_their_target_among_same_named_actions() {
let mut list = Vec::new();
for (set, step) in [("A", "image.adjustments.invert"), ("B", "image.imageRotation.180")] {
let mut a = Action::new("Retouch", vec![(step.into(), json!({}))]);
a.set = Some(set.into());
list.push(a);
}
list.push(Action::new("Other", vec![("image.imageRotation.90cw".into(), json!({}))]));
let mut steps: Vec<(String, Value)> = vec![
("actions.play".into(), json!({"action": 1})),
("actions.play".into(), json!({"action": "Retouch"})),
("file.automate.batch".into(), json!({"action": "Retouch", "set": "B", "input": [], "output": "/out"})),
("actions.play".into(), json!({"action": 2})),
];
let called = crate::actions_cmds::called_actions(&list, &mut steps);
assert_eq!(called.len(), 3, "{called:?}");
let set_of = |i: usize| called[i].set.clone();
assert_eq!(set_of(resolve(&called, &steps[0].1, "t").unwrap()), Some("B".into()), "{}", steps[0].1);
assert_eq!(set_of(resolve(&called, &steps[1].1, "t").unwrap()), Some("A".into()), "{}", steps[1].1);
assert_eq!(set_of(crate::file_cmds::batch_action(&called, &steps[2].1).unwrap()), Some("B".into()), "{}", steps[2].1);
assert_eq!(steps[3].1["action"], "Other", "a unique name stays a name");
}
/// Two actions of one name in different sets (a loaded library): renaming the second by index
/// leaves the unqualified calls, which reach the first, and a Batch with explicit steps alone;
/// only the Batch qualified with the second's set follows. Renaming the first moves them all.
#[test]
fn rename_leaves_calls_that_reach_a_same_named_action_in_another_set() {
let calls = || {
vec![
("file.automate.batch".into(), json!({"action": "Target", "input": [], "output": "/out"})),
("file.automate.batch".into(), json!({"action": "Target", "set": "B", "input": [], "output": "/out"})),
("file.automate.batch".into(), json!({"action": "Target", "set": "B", "steps": [["image.adjustments.invert", {}]], "input": [], "output": "/out"})),
("actions.play".into(), json!({"action": "Target"})),
("actions.conditional".into(), json!({"if": "documentIsOpen", "then": "Target", "else": "Target"})),
]
};
let session = || {
let mut s = Session::new();
for set in ["A", "B"] {
let mut target = Action::new("Target", vec![("image.adjustments.invert".into(), json!({}))]);
target.set = Some(set.into());
s.actions.list.push(target);
}
s.actions.list.push(Action::new("Caller", calls()));
s
};
let mut s = session();
s.execute("actions.rename", json!({"action": 1, "name": "Renamed"})).unwrap();
let caller = &s.actions.list[2].steps;
assert_eq!(caller[0].1["action"], "Target", "an unqualified Batch reaches set A's action");
assert_eq!(caller[1].1["action"], "Renamed", "the Batch qualified with set B follows");
assert_eq!(caller[2].1["action"], "Target", "explicit steps are left alone");
assert_eq!(caller[3].1["action"], "Target", "a nested Play reaches set A's action");
assert_eq!((&caller[4].1["then"], &caller[4].1["else"]), (&json!("Target"), &json!("Target")));
assert_eq!(crate::file_cmds::batch_action(&s.actions.list, &caller[0].1).unwrap(), 0);
assert_eq!(crate::file_cmds::batch_action(&s.actions.list, &caller[1].1).unwrap(), 1);
let mut s = session();
s.execute("actions.rename", json!({"action": 0, "name": "Renamed"})).unwrap();
let caller = &s.actions.list[2].steps;
assert_eq!(caller[0].1["action"], "Renamed");
assert_eq!(caller[1].1["action"], "Target", "the Batch qualified with set B still names B's action");
assert_eq!(caller[3].1["action"], "Renamed");
assert_eq!((&caller[4].1["then"], &caller[4].1["else"]), (&json!("Renamed"), &json!("Renamed")));
}
/// A saved named Batch step follows a renamed action, by name alone or within its own set;
/// a Batch naming the same action in another set and an inline Batch stay as they were.
#[test]
fn rename_follows_named_batch_steps_in_their_set() {
let mut s = Session::new();
let mut target = Action::new("Target", vec![("image.adjustments.invert".into(), json!({}))]);
target.set = Some("S".into());
s.actions.list.push(target);
let steps = vec![
("file.automate.batch".into(), json!({"action": "Target", "input": [], "output": "/out"})),
("file.automate.batch".into(), json!({"action": "Target", "set": "S", "input": [], "output": "/out"})),
("file.automate.batch".into(), json!({"action": "Target", "set": null, "input": [], "output": "/out"})),
("file.automate.batch".into(), json!({"action": "Target", "set": "Other", "input": [], "output": "/out"})),
("file.automate.batch".into(), json!({"action": [["actions.play", {"action": "Target"}]], "input": [], "output": "/out"})),
];
s.actions.list.push(Action::new("Caller", steps));
s.execute("actions.rename", json!({"action": "Target", "name": "Renamed"})).unwrap();
let caller = &s.actions.list[1].steps;
for step in &caller[..3] {
assert_eq!(step.1["action"], "Renamed", "{}", step.1);
assert_eq!(crate::file_cmds::batch_action(&s.actions.list, &step.1).unwrap(), 0, "{}", step.1);
}
assert_eq!(caller[1].1["set"], "S");
assert_eq!(caller[3].1["action"], "Target", "a Batch in another set names a different action");
assert!(caller[4].1["action"].is_array());
}
/// All Batch files saved, but the requested error log could not be written: the calling action
/// stops at that step instead of going on as if the Batch had finished.
#[test]
fn playback_stops_when_a_nested_batch_cannot_write_its_error_log() {
let dir = std::env::temp_dir().join(format!("photocraft-actions-batch-log-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dir = dir.to_string_lossy().into_owned();
let mut s = Session::new();
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
// A folder as the log path: the log write fails while every file succeeds.
let batch = json!({"source": "open", "steps": [], "output": format!("{dir}/out"), "format": "png", "errorLog": dir});
s.actions.list.push(Action::new("Run", vec![("file.automate.batch".into(), batch), ("layer.new.layer".into(), json!({}))]));
let layers = s.active().unwrap().doc.layers.len();
let played = s.execute("actions.play", json!({"action": "Run"})).unwrap();
let failed = &played["failed"];
assert_eq!(failed["step"], 0, "{played}");
assert!(failed["error"].as_str().unwrap().contains("error log"), "{played}");
assert!(failed["result"]["errorLogError"].is_string(), "{played}");
assert_eq!(s.active().unwrap().doc.layers.len(), layers, "the step after the Batch must not run");
let _ = std::fs::remove_dir_all(&dir);
}