Files
photocraft/crates/engine/tests/smart_duplicates.rs
8d2806f66c Expose shared Smart Object instances in inspection and replacement results (#4832)
* Smart Objects: inspect and Replace Contents show shared instances (#4075)

Placed layers that share a source have imported as one set of contents
since #2222, and Replace Contents changes every instance, but nothing
reported it: `linkGroup` is Link Layers, and `smartSource` can't tell
copies of one contents from independent copies of the same file.

document.inspect now gives each smart object `smartContents`: the
contents `id` its copies share and how many `instances` there are.
Replace Contents, Relink to File and Convert to Embedded/Linked list the
`instances` they changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test rendered caches after shared Smart Object replacement

---------

Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:46:04 -04:00

299 lines
16 KiB
Rust

//! Duplicated smart objects share their editable contents, but not instance settings (#2163).
use photocraft_doc::{Layer, LayerContent, LayerId, SmartObject};
use photocraft_engine::Session;
use photocraft_engine::smart_cmds::{decode_source, source_bytes};
use photocraft_geom::Rect;
use serde_json::{Value, json};
fn session(depth: u64) -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
paint(&mut s, [0.0, 0.0, 1.0, 1.0]);
let original = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap();
let duplicate = s.execute("layer.duplicate", json!({"layer": original})).unwrap()["layer"].as_u64().unwrap();
(s, original, duplicate)
}
fn paint(s: &mut Session, rgba: [f32; 4]) {
s.edit("paint", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 6), &rgba);
Ok(())
})
.unwrap();
}
fn smart(s: &Session, id: u64) -> &SmartObject {
let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content else { panic!("smart object") };
sm
}
fn contents_pixel(s: &Session, id: u64) -> [f32; 4] {
let (name, bytes) = source_bytes(&s.active().unwrap().doc.metadata, &smart(s, id).source).unwrap();
photocraft_compose::flatten(&decode_source(&name, &bytes).unwrap()).px[0]
}
#[test]
fn editing_original_updates_duplicate_contents() {
for depth in [8, 16, 32] {
let (mut s, original, duplicate) = session(depth);
// The sibling may be nested and have its own placement and smart filters.
s.execute("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap();
s.execute("edit.transform", json!({"layer": duplicate, "matrix": [1, 0, 0, 1, 2, 1]})).unwrap();
let instance = smart(&s, duplicate).clone();
s.edit("group", |doc, _| {
let copy = doc.remove(LayerId(duplicate)).unwrap();
doc.layers.push(Layer::group("Copies", vec![copy]));
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
let child = s.execute("layer.smartObjects.editContents", json!({"layer": original})).unwrap()["document"].as_u64().unwrap();
paint(&mut s, [1.0, 0.0, 0.0, 1.0]);
s.execute("layer.smartObjects.saveContents", json!({})).unwrap();
assert_eq!(s.active_index(), Some(child as usize));
assert!(!s.active().unwrap().is_dirty());
s.set_active(0);
for id in [original, duplicate] {
assert_eq!(contents_pixel(&s, id), [1.0, 0.0, 0.0, 1.0]);
let cache = smart(&s, id).cache.as_ref().unwrap();
let alpha = if id == duplicate { instance.cache.as_ref().unwrap().rgba(4, 3)[3] } else { 1.0 };
assert_eq!(cache.rgba(4, 3), [1.0, 0.0, 0.0, alpha], "re-rendered contents retain the instance's blurred edge");
}
assert_eq!(smart(&s, duplicate).transform, instance.transform);
assert_eq!(smart(&s, duplicate).smart_filters, instance.smart_filters);
assert!(smart(&s, original).smart_filters.is_empty());
let after = s.active().unwrap().doc.clone();
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before, "all instances revert in one undo step");
assert!(s.redo());
assert_eq!(s.active().unwrap().doc, after);
// Entering from the duplicate reuses the same editor, and close commits back to both.
let open = s.documents().len();
assert_eq!(s.execute("layer.smartObjects.editContents", json!({"layer": duplicate})).unwrap()["document"], child);
assert_eq!(s.documents().len(), open);
paint(&mut s, [0.0, 1.0, 0.0, 1.0]);
s.execute("file.close", json!({})).unwrap();
assert_eq!(contents_pixel(&s, original), [0.0, 1.0, 0.0, 1.0]);
assert_eq!(contents_pixel(&s, duplicate), [0.0, 1.0, 0.0, 1.0]);
}
}
#[test]
fn surviving_duplicate_receives_edits_after_originating_layer_is_deleted() {
let (mut s, original, duplicate) = session(16);
let child = s.execute("layer.smartObjects.editContents", json!({"layer": original})).unwrap()["document"].as_u64().unwrap() as usize;
s.set_active(0);
s.execute("layer.delete", json!({"layer": original})).unwrap();
s.set_active(child);
paint(&mut s, [1.0, 0.0, 0.0, 1.0]);
s.execute("layer.smartObjects.saveContents", json!({})).unwrap();
s.set_active(0);
assert_eq!(contents_pixel(&s, duplicate), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn shared_and_independent_contents_survive_native_psd_and_psb() {
for format in ["pcraft", "psd", "psb"] {
let (mut s, original, duplicate) = session(16);
let independent = s.execute("layer.smartObjects.newSmartObjectViaCopy", json!({"layer": original})).unwrap()["layer"].as_u64().unwrap();
s.edit("name", |doc, _| {
for (id, name) in [(original, "original"), (duplicate, "duplicate"), (independent, "independent")] {
doc.layer_mut(LayerId(id)).unwrap().name = name.into();
}
Ok(())
})
.unwrap();
// Initially byte-identical independent copies must not be merged by blob deduplication.
let doc = &s.active().unwrap().doc;
let back = if format == "pcraft" {
let bytes = photocraft_format::save_to_bytes(doc, &Default::default()).unwrap();
photocraft_format::load_from_bytes(&bytes).unwrap()
} else {
let out = photocraft_io::export(doc, format, &Default::default()).unwrap();
assert!(out.warnings.is_empty(), "{format}: {:?}", out.warnings);
photocraft_io::import(&format!("duplicates.{format}"), &out.bytes).unwrap().document
};
let named = |name: &str| back.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id.0;
let (original, duplicate, independent) = (named("original"), named("duplicate"), named("independent"));
let mut s = Session::new();
s.add_document(back, None);
s.execute("layer.smartObjects.editContents", json!({"layer": duplicate})).unwrap();
paint(&mut s, [1.0, 0.0, 0.0, 1.0]);
s.execute("layer.smartObjects.saveContents", json!({})).unwrap();
s.set_active(0);
assert_eq!(contents_pixel(&s, original), [1.0, 0.0, 0.0, 1.0], "{format}");
assert_eq!(contents_pixel(&s, duplicate), [1.0, 0.0, 0.0, 1.0], "{format}");
assert_eq!(contents_pixel(&s, independent), [0.0, 0.0, 1.0, 1.0], "{format}");
s.execute("layer.smartObjects.editContents", json!({"layer": independent})).unwrap();
assert_eq!(s.documents().len(), 3, "independent contents need their own editor");
paint(&mut s, [0.0, 1.0, 0.0, 1.0]);
s.execute("layer.smartObjects.saveContents", json!({})).unwrap();
s.set_active(0);
assert_eq!(contents_pixel(&s, original), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(contents_pixel(&s, independent), [0.0, 1.0, 0.0, 1.0]);
}
}
#[test]
fn copying_a_batch_between_documents_preserves_only_its_internal_sharing() {
let (mut s, original, duplicate) = session(16);
let independent = s.execute("layer.smartObjects.newSmartObjectViaCopy", json!({"layer": original})).unwrap()["layer"].as_u64().unwrap();
let source_names = [original, duplicate, independent].map(|id| s.active().unwrap().doc.layer(LayerId(id)).unwrap().name.clone());
// Another opening of the same document has the same persisted contents IDs.
let target = (*s.active().unwrap().doc).clone();
s.add_document(target, None);
s.set_active(0);
let result = s.execute("layer.copyToDocument", json!({"document": 1, "layers": [original, duplicate, independent]})).unwrap();
// The command returns stacking order, which differs from the requested order after via-copy.
let copied = source_names.map(|name| {
result["layers"]
.as_array()
.unwrap()
.iter()
.map(|id| id.as_u64().unwrap())
.find(|id| s.active().unwrap().doc.layer(LayerId(*id)).unwrap().name == name)
.unwrap()
});
assert_eq!(smart(&s, copied[0]).contents_id, smart(&s, copied[1]).contents_id);
assert_ne!(smart(&s, copied[0]).contents_id, smart(&s, copied[2]).contents_id);
assert_ne!(smart(&s, copied[0]).contents_id, smart(&s, original).contents_id);
s.execute("layer.smartObjects.editContents", json!({"layer": copied[0]})).unwrap();
paint(&mut s, [1.0, 0.0, 0.0, 1.0]);
s.execute("layer.smartObjects.saveContents", json!({})).unwrap();
s.set_active(1);
for id in &copied[..2] {
assert_eq!(contents_pixel(&s, *id), [1.0, 0.0, 0.0, 1.0]);
}
for id in [original, duplicate, independent, copied[2]] {
assert_eq!(contents_pixel(&s, id), [0.0, 0.0, 1.0, 1.0]);
}
s.set_active(0);
assert_eq!(contents_pixel(&s, original), [0.0, 0.0, 1.0, 1.0]);
}
#[test]
fn replacing_shared_contents_does_not_reuse_or_commit_a_stale_editor() {
let (mut s, original, duplicate) = session(16);
let child = s.execute("layer.smartObjects.editContents", json!({"layer": original})).unwrap()["document"].as_u64().unwrap() as usize;
paint(&mut s, [0.0, 1.0, 0.0, 1.0]);
let path = std::env::temp_dir().join(format!("photocraft-shared-contents-{}-{original}.pcraft", std::process::id()));
std::fs::write(&path, photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap()).unwrap();
paint(&mut s, [1.0, 0.0, 0.0, 1.0]);
s.set_active(0);
let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": duplicate, "path": path}));
std::fs::remove_file(path).unwrap();
replaced.unwrap();
for id in [original, duplicate] {
assert_eq!(contents_pixel(&s, id), [0.0, 1.0, 0.0, 1.0]);
}
s.set_active(child);
assert!(!s.is_enabled("layer.smartObjects.saveContents"));
s.execute("file.close", json!({})).unwrap();
assert_eq!(contents_pixel(&s, original), [0.0, 1.0, 0.0, 1.0]);
s.execute("layer.smartObjects.editContents", json!({"layer": original})).unwrap();
assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [0.0, 1.0, 0.0, 1.0]);
}
/// A `.pcraft` file whose contents are one solid colour, for Replace Contents.
fn contents_file(rgba: [f32; 4], tag: &str) -> std::path::PathBuf {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 6})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
paint(&mut s, rgba);
let path = std::env::temp_dir().join(format!("photocraft-{tag}-{}.pcraft", std::process::id()));
std::fs::write(&path, photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap()).unwrap();
path
}
/// The node named `name` in `document.inspect`'s layer tree, searched through groups.
fn inspected<'a>(layers: &'a Value, name: &str) -> Option<&'a Value> {
layers.as_array()?.iter().find_map(|l| if l["name"] == name { Some(l) } else { inspected(&l["children"], name) })
}
/// Placed layers sharing a source (a PSD's copies of one smart object) share their contents, so
/// one Replace Contents updates every copy. Nothing showed it: `linkGroup` is Link Layers, which
/// stays unset (#4075). Inspect gives each smart object its contents identity and instance count,
/// and Replace Contents lists every layer it changed.
#[test]
fn inspect_and_replace_contents_report_shared_instances() {
let (mut s, original, duplicate) = session(8);
let independent = s.execute("layer.smartObjects.newSmartObjectViaCopy", json!({"layer": original})).unwrap()["layer"].as_u64().unwrap();
s.edit("name and group", |doc, _| {
for (id, name) in [(original, "original"), (duplicate, "duplicate"), (independent, "independent")] {
doc.layer_mut(LayerId(id)).unwrap().name = name.into();
}
// An instance inside a group counts too.
let copy = doc.remove(LayerId(duplicate)).unwrap();
doc.layers.push(Layer::group("Copies", vec![copy]));
Ok(())
})
.unwrap();
let out = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap();
let mut s = Session::new();
s.add_document(photocraft_io::import("copies.psd", &out.bytes).unwrap().document, None);
let tree = s.execute("document.inspect", json!({})).unwrap();
let [o, d, i] = ["original", "duplicate", "independent"].map(|name| inspected(&tree["layers"], name).unwrap().clone());
assert_eq!(o["smartContents"]["id"], d["smartContents"]["id"]);
assert_ne!(o["smartContents"]["id"], i["smartContents"]["id"]);
assert_eq!([&o, &d, &i].map(|l| l["smartContents"]["instances"].as_u64()), [Some(2), Some(2), Some(1)]);
assert!([&o, &d, &i].iter().all(|l| l["linkGroup"].is_null()), "shared contents don't link the layers");
let [o, d, i] = [&o, &d, &i].map(|l| l["id"].as_u64().unwrap());
let path = contents_file([0.0, 1.0, 0.0, 1.0], "replace-instances");
let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": d, "path": path}));
std::fs::remove_file(path).unwrap();
let mut changed: Vec<u64> = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
changed.sort_unstable();
assert_eq!(changed, if o < d { [o, d] } else { [d, o] });
assert_eq!([o, d, i].map(|id| contents_pixel(&s, id)), [[0.0, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]]);
for (id, expected) in [(o, [0.0, 1.0, 0.0, 1.0]), (d, [0.0, 1.0, 0.0, 1.0]), (i, [0.0, 0.0, 1.0, 1.0])] {
assert_eq!(smart(&s, id).cache.as_ref().unwrap().rgba(4, 3), expected, "instance {id} must display its current contents");
}
}
/// The same with the placed layer Photoshop wrote, twice: Photoshop's copies of a smart object
/// each carry a `SoLd` naming the one embedded file (`Idnt`), so they import as one contents.
#[cfg(feature = "corpus")]
#[test]
fn photoshop_placed_copies_import_as_shared_instances() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/smart-filters/sf-none-plain.psd");
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display()));
let mut file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap();
let placed = file.layers().iter().find(|r| r.block(b"SoLd").is_some()).expect("a placed layer").clone();
file.layers_mut().push(placed);
let mut s = Session::new();
s.add_document(photocraft_io::psd_to_document(&file).0, None);
let tree = s.execute("document.inspect", json!({})).unwrap();
let copies: Vec<&Value> = tree["layers"].as_array().unwrap().iter().filter(|l| l["kind"] == "Smart Object").collect();
assert_eq!(copies.len(), 2);
assert_eq!(copies[0]["smartContents"], copies[1]["smartContents"]);
assert_eq!(copies[0]["smartContents"]["instances"], 2);
for copy in &copies {
assert_eq!(copy["smartSource"]["kind"], "embedded");
assert!(copy["smartSource"]["uuid"].as_str().is_some());
}
let mut ids: Vec<u64> = copies.iter().map(|l| l["id"].as_u64().unwrap()).collect();
let path = contents_file([0.0, 1.0, 0.0, 1.0], "photoshop-copies");
let replaced = s.execute("layer.smartObjects.replaceContents", json!({"layer": ids[0], "path": path}));
std::fs::remove_file(path).unwrap();
let mut changed: Vec<u64> = replaced.unwrap()["instances"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
changed.sort_unstable();
ids.sort_unstable();
assert_eq!(changed, ids);
for id in ids {
assert_eq!(contents_pixel(&s, id), [0.0, 1.0, 0.0, 1.0]);
let cache = smart(&s, id).cache.as_ref().unwrap();
let bounds = cache.content_bounds();
assert!(!bounds.is_empty(), "instance {id} must have rendered pixels");
assert_eq!(
cache.rgba(bounds.x0 + bounds.width() as i32 / 2, bounds.y0 + bounds.height() as i32 / 2),
[0.0, 1.0, 0.0, 1.0],
"instance {id} must refresh its imported cache"
);
}
}