Files
ea4112a4ce Integration: group-end layer index fails, ordinal precondition resolves once (#10753)
A `{"layer":{"index":n}}` that lands on a group end made explicit_layer
return None, so layer.translate and the other callers acted on the
selected layers instead (e.g. layer.translate {"layer":{"index":3}} with
L1+L2 selected moved both). It now fails with "a group end is not a
layer"; layer.select still treats a group end as a no-op.

explicit_layer_enabled resolved a relative ordinal (Frwr/Bckw), made that
layer active, then ran the precondition with the unresolved ordinal, which
resolved again one layer further. With only Red targeted and A (active),
B, C (type) bottom to top, edit.clear {"layer":{"ordinal":"Frwr"}} was
refused with "Rasterize the layer" although it acts on raster B. The
precondition now sees the resolved layer id.

Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 02:17:31 +01:00

512 lines
25 KiB
Rust

use photocraft_engine::{Session, actions_cmds::Action, atn_cmds};
use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem};
use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D};
use serde_json::{Value, json};
fn class(name: &str) -> Class {
Class { name: UnicodeString::new_nul(""), class_id: Id::new(name) }
}
fn ordinal(name: &str) -> ReferenceItem {
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new(name) }
}
fn load(s: &mut Session, reference: ReferenceItem) {
let descriptor = Descriptor::new("null").with("null", D::Reference(vec![reference]));
s.actions.list = vec![imported(AtnEvent::Code(*b"slct"), descriptor)];
}
fn bytes(event: AtnEvent, descriptor: Descriptor) -> Vec<u8> {
let file = AtnFile {
name: UnicodeString::new_nul("References"),
actions: vec![AtnAction {
name: UnicodeString::new_nul("Select"),
items: vec![AtnItem { event, descriptor: Some(descriptor), ..Default::default() }],
..Default::default()
}],
..Default::default()
};
atn::write(&file).unwrap()
}
fn imported(event: AtnEvent, descriptor: Descriptor) -> Action {
let mut result = atn_cmds::import(&bytes(event, descriptor)).unwrap();
assert!(result.unsupported.is_empty(), "{:?}", result.unsupported);
result.actions.remove(0)
}
/// Check actual saved reference kinds/codes, not only a matching import/export implementation.
fn roundtrip(action: &Action, reference: &ReferenceItem, target: Option<&ReferenceItem>) {
let (data, skipped) = atn_cmds::export("References", &[action], &[], false).unwrap();
assert!(skipped.is_empty());
let file = atn::parse(&data).unwrap();
let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap();
assert_eq!(descriptor.get("null"), Some(&D::Reference(vec![reference.clone()])));
if let Some(target) = target {
assert_eq!(descriptor.get("T "), Some(&D::Reference(vec![target.clone()])));
}
assert_eq!(atn_cmds::import(&data).unwrap().actions[0].steps, action.steps);
}
fn replay(s: &mut Session, action: &Action) {
s.actions.list = vec![action.clone()];
let result = s.execute("actions.play", json!({"action":"Select"})).unwrap();
assert_eq!(result["ran"], 1, "{result}");
assert!(result.get("failed").is_none(), "{result}");
}
fn layer(s: &Session, name: &str) -> u64 {
s.active().unwrap().doc.walk().into_iter().find(|(_, _, layer)| layer.name == name).unwrap().2.id.0
}
fn selection(s: &Session) -> Value {
let st = s.active().unwrap();
json!({"ids":st.selected_layers().iter().map(|id|id.0).collect::<Vec<_>>(),"active":st.active_layer.map(|id|id.0),"anchor":st.layer_anchor.map(|id|id.0),"revision":st.revision})
}
/// The measured stack has a collapsed group and an optional structural Background.
fn scene(background: bool) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4,"background":if background {"white"} else {"transparent"}})).unwrap();
s.execute("layer.renameLayer", json!({"name":"Base"})).unwrap();
for name in ["L1", "L2", "Gchild"] {
s.execute("layer.new.layer", json!({"name":name})).unwrap();
}
s.execute("layer.groupLayers", json!({"name":"G"})).unwrap();
s.execute("layer.new.layer", json!({"name":"L3"})).unwrap();
assert_eq!(s.active().unwrap().doc.background().is_some(), background);
s
}
fn index(class_name: &str, index: u32) -> ReferenceItem {
ReferenceItem::Index { class: class(class_name), index }
}
fn select(reference: ReferenceItem) -> Action {
imported(AtnEvent::Code(*b"slct"), Descriptor::new("null").with("null", D::Reference(vec![reference])))
}
fn moved(reference: ReferenceItem, target: ReferenceItem) -> Action {
imported(AtnEvent::Code(*b"move"), Descriptor::new("null").with("null", D::Reference(vec![reference])).with("T ", D::Reference(vec![target])))
}
fn fixture() -> (Session, u64, u64, u64) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
s.execute("layer.new.layer", json!({"name": "L1"})).unwrap();
let l2 = s.execute("layer.new.layer", json!({"name": "L2"})).unwrap()["layer"].as_u64().unwrap();
let child = s.execute("layer.new.layer", json!({"name": "Gchild"})).unwrap()["layer"].as_u64().unwrap();
let group = s.execute("layer.groupLayers", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.new.layer", json!({"name": "L3"})).unwrap();
(s, l2, child, group)
}
/// Recorded navigation enters collapsed groups; native keyboard navigation follows visible rows.
#[test]
fn recorded_forward_enters_a_collapsed_group() {
let (mut s, l2, child, group) = fixture();
s.execute("layer.select", json!({"layer": l2})).unwrap();
s.execute("layer.selectAbove", json!({})).unwrap();
assert_eq!(s.active().unwrap().active_layer.unwrap().0, group);
s.execute("layer.select", json!({"layer": l2})).unwrap();
load(&mut s, ordinal("Frwr"));
s.execute("actions.play", json!({"action": "Select"})).unwrap();
assert_eq!(s.active().unwrap().active_layer.unwrap().0, child);
}
/// Indexes count group ends; recorded navigation visits collapsed children and retains its form.
#[test]
fn indexes_and_ordinals_use_the_recorded_stack_with_or_without_background() {
for background in [false, true] {
let mut s = scene(background);
let first = u32::from(!background);
let expected = [Some("Base"), Some("L1"), Some("L2"), None, Some("Gchild"), Some("G"), Some("L3")];
for (offset, name) in expected.into_iter().enumerate() {
s.execute("layer.select", json!({"layer":layer(&s,"L2")})).unwrap();
let reference = index("Lyr ", first + offset as u32);
let action = select(reference.clone());
roundtrip(&action, &reference, None);
replay(&mut s, &action);
assert_eq!(s.active().unwrap().active_layer.unwrap().0, layer(&s, name.unwrap_or("L2")));
}
for (code, expected) in [("Trgt", "L2"), ("Frwr", "Gchild"), ("Bckw", "L1"), ("Frnt", "L3"), ("Back", "Base")] {
s.execute("layer.select", json!({"layer":layer(&s,"L2")})).unwrap();
let reference = ordinal(code);
let action = select(reference.clone());
roundtrip(&action, &reference, None);
replay(&mut s, &action);
assert_eq!(s.active().unwrap().active_layer.unwrap().0, layer(&s, expected));
}
for invalid in [first + 7, u32::MAX].into_iter().chain((!background).then_some(0)) {
let action = select(index("Lyr ", invalid));
let before = selection(&s);
assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err());
assert_eq!(selection(&s), before);
}
}
}
/// An end marker is a true no-op, including multi-selection, anchor and add/remove modifiers.
#[test]
fn group_end_does_not_replace_the_current_selection() {
let mut s = scene(true);
s.execute("layer.select", json!({"layer":layer(&s,"L1")})).unwrap();
s.execute("layer.select", json!({"layer":layer(&s,"L2"),"mode":"add"})).unwrap();
for modifier in [None, Some("addToSelection"), Some("removeFromSelection"), Some("addToSelectionContinuous")] {
let mut d = Descriptor::new("null").with("null", D::Reference(vec![index("Lyr ", 3)]));
if let Some(modifier) = modifier {
d = d.with("selectionModifier", D::Enumerated { type_id: Id::new("selectionModifierType"), value: Id::new(modifier) });
}
let action = imported(AtnEvent::Code(*b"slct"), d);
let before = selection(&s);
replay(&mut s, &action);
assert_eq!(selection(&s), before);
}
for extras in [json!({"name":"L1"}), json!({"occurrence":0}), json!({"mode":"invalid"})] {
let mut params = json!({"layer":{"index":3}});
params.as_object_mut().unwrap().extend(extras.as_object().unwrap().clone());
let before = selection(&s);
assert!(s.execute("layer.select", params).is_err());
assert_eq!(selection(&s), before);
}
}
/// Move indexes name pre-move boundaries; the two self boundaries preserve selection/history.
#[test]
fn indexed_moves_follow_the_measured_boundary_table() {
for target in 0..=6 {
let mut s = scene(true);
let reference = index("Lyr ", 2);
let destination = index("Lyr ", target);
let action = moved(reference.clone(), destination.clone());
roundtrip(&action, &reference, Some(&destination));
let before = selection(&s);
if target == 0 {
let doc = s.active().unwrap().doc.clone();
assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err());
assert_eq!(s.active().unwrap().doc, doc);
assert_eq!(selection(&s), before);
continue;
}
replay(&mut s, &action);
let expected = match target {
1 => vec!["Base", "L2", "L1", "G", "L3"],
2 | 3 => vec!["Base", "L1", "L2", "G", "L3"],
4 | 5 => vec!["Base", "L1", "G", "L3"],
_ => vec!["Base", "L1", "G", "L2", "L3"],
};
let doc = &s.active().unwrap().doc;
assert_eq!(doc.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), expected, "target {target}");
let children = doc.layer(photocraft_doc::LayerId(layer(&s, "G"))).unwrap().children().unwrap();
assert_eq!(
children.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(),
match target {
4 => vec!["L2", "Gchild"],
5 => vec!["Gchild", "L2"],
_ => vec!["Gchild"],
}
);
if matches!(target, 2 | 3) {
assert_eq!(selection(&s), before);
}
}
let mut s = scene(false);
let action = moved(index("Lyr ", 3), index("Lyr ", 0));
replay(&mut s, &action);
assert_eq!(s.active().unwrap().doc.layers[0].name, "L2");
let mut s = scene(true);
for invalid in [7, u32::MAX] {
let action = moved(index("Lyr ", 2), index("Lyr ", invalid));
let doc = s.active().unwrap().doc.clone();
let state = selection(&s);
assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err());
assert_eq!(s.active().unwrap().doc, doc);
assert_eq!(selection(&s), state);
}
}
/// A target-layer move keeps the complete selected source set and remains one undoable edit.
#[test]
fn indexed_move_preserves_multiple_selected_sources() {
let mut s = scene(true);
s.execute("layer.select", json!({"layer":layer(&s,"L1")})).unwrap();
s.execute("layer.select", json!({"layer":layer(&s,"L2"),"mode":"add"})).unwrap();
let before = selection(&s);
let action = moved(ordinal("Trgt"), index("Lyr ", 6));
roundtrip(&action, &ordinal("Trgt"), Some(&index("Lyr ", 6)));
replay(&mut s, &action);
let after = selection(&s);
for key in ["ids", "active", "anchor"] {
assert_eq!(after[key], before[key], "{key}");
}
assert_eq!(s.active().unwrap().doc.layers.iter().map(|layer| layer.name.as_str()).collect::<Vec<_>>(), ["Base", "G", "L1", "L2", "L3"]);
assert!(s.undo());
assert_eq!(s.active().unwrap().doc.layers.iter().map(|layer| layer.name.as_str()).collect::<Vec<_>>(), ["Base", "L1", "L2", "G", "L3"]);
}
/// Nested empty groups have distinct end/header boundaries, and cycle refusals are atomic.
#[test]
fn nested_empty_group_boundaries_reparent_at_the_correct_level() {
for target in [1, 2, 3] {
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap();
s.execute("layer.new.group", json!({"name":"Inner"})).unwrap();
s.execute("layer.groupLayers", json!({"name":"Outer"})).unwrap();
s.execute("layer.new.layer", json!({"name":"Top"})).unwrap();
let outer = photocraft_doc::LayerId(layer(&s, "Outer"));
let inner = photocraft_doc::LayerId(layer(&s, "Inner"));
let action = moved(index("Lyr ", 5), index("Lyr ", target));
replay(&mut s, &action);
let doc = &s.active().unwrap().doc;
let parent = doc.path_of(photocraft_doc::LayerId(layer(&s, "Top"))).unwrap();
assert_eq!(
parent.len(),
match target {
1 => 1,
2 => 2,
_ => 3,
}
);
assert_eq!(doc.layer(outer).unwrap().children().unwrap().len(), if target == 2 { 2 } else { 1 });
assert_eq!(doc.layer(inner).unwrap().children().unwrap().len(), usize::from(target == 3));
let action = moved(index("Lyr ", 5), index("Lyr ", 4));
let before = doc.clone();
assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err());
assert_eq!(s.active().unwrap().doc, before);
}
}
/// Typed document indexes use opening order while numeric document indexes still address tabs.
#[test]
fn document_indexes_survive_tab_moves_close_and_reopen_with_old_ids() {
let mut s = Session::new();
for name in ["A", "B", "C"] {
s.execute("file.new", json!({"width":2,"height":2,"name":name})).unwrap();
}
let reopened = (*s.documents()[0].doc).clone();
s.execute("document.move", json!({"document":2,"to":0})).unwrap();
s.execute("document.activate", json!({"document":0})).unwrap();
assert_eq!(s.active().unwrap().doc.name, "C");
let reference = index("Dcmn", 2);
let action = select(reference.clone());
roundtrip(&action, &reference, None);
replay(&mut s, &action);
assert_eq!(s.active().unwrap().doc.name, "B");
s.execute("file.close", json!({"document":1})).unwrap();
s.add_document(reopened, None);
for (n, expected) in [(1, "B"), (2, "C"), (3, "A")] {
replay(&mut s, &select(index("Dcmn", n)));
assert_eq!(s.active().unwrap().doc.name, expected);
}
for invalid in [0, u32::MAX, 4] {
let before = s.active_index();
let action = select(index("Dcmn", invalid));
assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err());
assert_eq!(s.active_index(), before);
}
}
fn comp_scene() -> (Session, u32, u32) {
let mut s = Session::new();
s.execute("file.new", json!({"width":2,"height":2})).unwrap();
let dead = s.execute("layerComp.new", json!({"name":"Deleted"})).unwrap()["comp"].as_u64().unwrap();
s.execute("layer.hideLayers", json!({})).unwrap();
let first = s.execute("layerComp.new", json!({"name":"First"})).unwrap()["comp"].as_u64().unwrap() as u32;
s.execute("layer.showLayers", json!({})).unwrap();
let second = s.execute("layerComp.new", json!({"name":"Second"})).unwrap()["comp"].as_u64().unwrap() as u32;
s.execute("layerComp.delete", json!({"comp":dead})).unwrap();
(s, first, second)
}
/// Comp ids, panel indexes and unusual measured ordinals stay distinct after reordering.
#[test]
fn comp_forms_keep_their_wire_kind_and_resolve_at_playback() {
let (mut s, first, second) = comp_scene();
for reordered in [false, true] {
if reordered {
s.execute("layerComp.reorder", json!({"comp":second,"index":0})).unwrap();
}
let panel_first = if reordered { second } else { first };
let panel_second = if reordered { first } else { second };
let current = panel_second;
let mut cases = vec![
(index("compsClass", 1), panel_first),
(index("compsClass", 2), panel_second),
(ReferenceItem::Identifier { class: class("compsClass"), id: first }, first),
];
for (code, expected) in [("Trgt", current), ("Nxt ", current), ("Prvs", current), ("Frst", panel_first), ("Lst ", panel_first), ("Al ", panel_first)] {
cases.push((
ReferenceItem::Enumerated { class: class("compsClass"), type_id: Id::new("Ordn"), value: Id::Code(code.as_bytes().try_into().unwrap()) },
expected,
));
}
for (reference, expected) in cases {
s.execute("layerComp.apply", json!({"comp":current})).unwrap();
s.execute(if current == second { "layer.hideLayers" } else { "layer.showLayers" }, json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.last_applied_comp, Some(current));
let action = imported(AtnEvent::name("applyComp"), Descriptor::new("null").with("null", D::Reference(vec![reference.clone()])));
roundtrip(&action, &reference, None);
replay(&mut s, &action);
assert_eq!(s.active().unwrap().doc.last_applied_comp, Some(expected));
assert_eq!(s.active().unwrap().doc.layers[0].visible, expected == second);
}
}
}
/// Invalid comp indexes succeed without edits; unmeasured layer/comp reference forms stay kept.
#[test]
fn invalid_comp_indexes_and_unsupported_references_preserve_state() {
let (mut s, _, _) = comp_scene();
for invalid in [0, 3, u32::MAX] {
let reference = index("compsClass", invalid);
let action = imported(AtnEvent::name("applyComp"), Descriptor::new("null").with("null", D::Reference(vec![reference.clone()])));
roundtrip(&action, &reference, None);
let before = s.active().unwrap().doc.clone();
let state = selection(&s);
replay(&mut s, &action);
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(selection(&s), state);
}
let before = s.active().unwrap().doc.clone();
s.execute("layerComp.apply", json!({"comp":{"index":-1}})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
assert!(s.execute("layerComp.apply", json!({"comp":{"id":u32::MAX}})).is_err());
assert_eq!(s.active().unwrap().doc, before);
let negative = Action::new("Select", vec![("layerComp.apply".into(), json!({"comp":{"index":-1}}))]);
let (saved, _) = atn_cmds::export("References", &[&negative], &[], false).unwrap();
assert_eq!(
atn::parse(&saved).unwrap().actions[0].items[0].descriptor.as_ref().unwrap().get("null"),
Some(&D::Reference(vec![index("compsClass", u32::MAX)]))
);
for (event, reference) in [
(AtnEvent::Code(*b"slct"), ReferenceItem::Identifier { class: class("Lyr "), id: 2 }),
(AtnEvent::Code(*b"slct"), ReferenceItem::Offset { class: class("Lyr "), offset: 1 }),
(AtnEvent::name("applyComp"), ReferenceItem::Offset { class: class("compsClass"), offset: 0 }),
(AtnEvent::name("applyComp"), ReferenceItem::Enumerated { class: class("compsClass"), type_id: Id::new("wrong"), value: Id::new("Trgt") }),
(AtnEvent::Code(*b"slct"), ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("wrong"), value: Id::new("Frwr") }),
] {
let d = Descriptor::new("null").with("null", D::Reference(vec![reference]));
let data = bytes(event, d.clone());
let result = atn_cmds::import(&data).unwrap();
assert_eq!(result.unsupported.len(), 1);
assert_eq!(result.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP);
let (saved, _) = atn_cmds::export("References", &[&result.actions[0]], &[], false).unwrap();
assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0].descriptor, Some(d));
}
for offset in [-1, 0, 1] {
let d = Descriptor::new("null")
.with("null", D::Reference(vec![ordinal("Trgt")]))
.with("T ", D::Reference(vec![ReferenceItem::Offset { class: class("Lyr "), offset }]));
assert_eq!(atn_cmds::import(&bytes(AtnEvent::Code(*b"move"), d)).unwrap().unsupported.len(), 1);
}
}
/// Reject integers outside the ATN reference format before they can enter a recording.
#[test]
fn unrepresentable_comp_indexes_are_rejected_before_recording() {
let (mut s, _, _) = comp_scene();
for valid in [json!(i32::MIN), json!(u32::MAX)] {
s.execute("layerComp.apply", json!({"comp":{"index":valid}})).unwrap();
}
let before = s.active().unwrap().doc.clone();
let state = selection(&s);
let history = s.active().unwrap().history.past_len();
s.execute("actions.record", json!({"name":"Index bounds"})).unwrap();
let mut accepted = Vec::new();
for invalid in [json!(u64::from(u32::MAX) + 1), json!(u64::MAX), json!(i64::from(i32::MIN) - 1), json!(i64::MIN)] {
if s.execute("layerComp.apply", json!({"comp":{"index":invalid}})).is_ok() {
accepted.push(invalid);
}
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(selection(&s), state);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
s.execute("actions.stop", json!({})).unwrap();
assert!(accepted.is_empty(), "unrepresentable indexes were accepted: {accepted:?}");
assert!(s.actions.list.last().unwrap().steps.is_empty());
}
/// Valid extreme indexes survive recording and export as ATN Index references.
#[test]
fn accepted_comp_boundary_indexes_record_and_export() {
let (mut s, _, _) = comp_scene();
s.execute("actions.record", json!({"name":"Boundary indexes"})).unwrap();
for valid in [json!(i32::MIN), json!(u32::MAX)] {
s.execute("layerComp.apply", json!({"comp":{"index":valid}})).unwrap();
}
s.execute("actions.stop", json!({})).unwrap();
let action = s.actions.list.last().unwrap();
assert_eq!(action.steps.len(), 2);
let (saved, skipped) = atn_cmds::export("References", &[action], &[], false).unwrap();
assert!(skipped.is_empty());
let parsed = atn::parse(&saved).unwrap();
assert_eq!(parsed.actions[0].items.len(), 2);
for (item, expected) in parsed.actions[0].items.iter().zip([i32::MIN as u32, u32::MAX]) {
assert_eq!(item.descriptor.as_ref().unwrap().get("null"), Some(&D::Reference(vec![index("compsClass", expected)])));
}
}
/// A recorded invalid index is a no-op even with zero comps; ordinary Apply stays disabled.
#[test]
fn empty_comp_list_index_is_noop() {
let mut s = Session::new();
assert!(!s.is_enabled_with("layerComp.apply", &json!({"comp":{"index":0}})));
s.execute("file.new", json!({"width":2,"height":2})).unwrap();
assert!(s.active().unwrap().doc.layer_comps.is_empty());
assert!(!s.is_enabled("layerComp.apply"));
for invalid in [0, 1, u32::MAX] {
let reference = index("compsClass", invalid);
let action = imported(AtnEvent::name("applyComp"), Descriptor::new("null").with("null", D::Reference(vec![reference.clone()])));
roundtrip(&action, &reference, None);
let before = s.active().unwrap().doc.clone();
let state = selection(&s);
replay(&mut s, &action);
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(selection(&s), state);
}
s.execute("layerComp.apply", json!({"comp":{"index":-1}})).unwrap();
assert!(!s.is_enabled("layerComp.apply"));
for params in [json!({}), json!({"comp":{"id":1}}), json!({"comp":{"index":false}})] {
assert!(s.execute("layerComp.apply", params).is_err());
}
}
/// A group-end index names no layer, so a command acting on a layer fails and changes nothing
/// rather than falling back to the selected layers.
#[test]
fn group_end_index_never_targets_the_selection() {
let mut s = scene(true);
s.execute("layer.select", json!({"layer":layer(&s,"L1")})).unwrap();
s.execute("layer.select", json!({"layer":layer(&s,"L2"),"mode":"add"})).unwrap();
let past = s.active().unwrap().history.past_len();
let doc = s.active().unwrap().doc.clone();
assert!(s.execute("layer.translate", json!({"layer":{"index":3},"dx":1,"dy":0})).is_err());
let offset = Descriptor::new("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: 1.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: 0.0 });
let action =
imported(AtnEvent::Code(*b"move"), Descriptor::new("null").with("null", D::Reference(vec![index("Lyr ", 3)])).with("T ", D::Descriptor(offset)));
s.actions.list = vec![action];
let result = s.execute("actions.play", json!({"action":"Select"})).unwrap();
assert!(result.get("failed").is_some(), "{result}");
assert_eq!(s.active().unwrap().history.past_len(), past);
assert_eq!(s.active().unwrap().doc, doc);
}
/// A relative ordinal is resolved once: the precondition checked on the named layer sees that
/// layer, not the one past it (here a type layer that would refuse a colour-channel edit).
#[test]
fn forward_ordinal_precondition_checks_the_resolved_layer() {
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4,"background":"transparent"})).unwrap();
s.execute("layer.renameLayer", json!({"name":"A"})).unwrap();
s.execute("layer.new.layer", json!({"name":"B"})).unwrap();
s.execute("edit.fill", json!({"contents":"color","color":"#000000"})).unwrap();
s.execute("type.create", json!({"text":"C","size":2})).unwrap();
s.execute("layer.renameLayer", json!({"name":"C"})).unwrap();
s.execute("layer.select", json!({"layer":layer(&s,"A")})).unwrap();
s.execute("channel.target", json!({"colors":[0]})).unwrap();
let b = photocraft_doc::LayerId(layer(&s, "B"));
let before = s.active().unwrap().doc.layer(b).cloned();
s.execute("edit.clear", json!({"layer":{"ordinal":"Frwr"}})).unwrap();
assert_ne!(s.active().unwrap().doc.layer(b).cloned(), before);
}