mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 17:05:26 +02:00
* fix(doc): variable definitions follow layer id remapping on copy and load A duplicated document (image.duplicate, history.newDocument) or a .pcraft loaded with remapped ids assigned fresh layer ids but cloned VariableDef.layer unchanged, so datasets silently drove no layer (or, after remap, a colliding one). Remap variable targets through the same id map as layer comps and slices; a dangling target keeps a non-colliding placeholder. A merged-only duplicate drops the whole variable binding set, matching how it drops comps. Closes #4969 * style: cargo fmt duplicate_variables test --------- Co-authored-by: highperfocused <highperfocused@pm.me>
147 lines
6.4 KiB
Rust
147 lines
6.4 KiB
Rust
//! Variable definitions must continue targeting their layers after a copied document gets
|
|
//! fresh ids (#4969), and a merged-only copy must not keep bindings to discarded layers.
|
|
|
|
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size, TextLayer};
|
|
use photocraft_engine::Session;
|
|
use serde_json::json;
|
|
|
|
fn scene() -> (Session, LayerId, LayerId, LayerId) {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
|
let ids = s
|
|
.edit("setup", |doc, _| {
|
|
let fmt = doc.pixel_format();
|
|
let mut photo = Layer::raster("photo", fmt);
|
|
photo.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &photocraft_raster::from_rgba(&fmt, [0.5, 0.5, 0.5, 1.0]));
|
|
let badge = Layer::raster("badge", fmt);
|
|
let title = Layer::new("title", LayerContent::Text(TextLayer { text: "Old".into(), ..Default::default() }));
|
|
let (p, b, t) = (photo.id, badge.id, title.id);
|
|
doc.layers.push(photo);
|
|
doc.layers.push(badge);
|
|
doc.layers.push(title);
|
|
Ok((p, b, t))
|
|
})
|
|
.unwrap();
|
|
(s, ids.0, ids.1, ids.2)
|
|
}
|
|
|
|
fn red_png(dir: &str) -> String {
|
|
let doc = Document::with_background(
|
|
"red",
|
|
Size::new(4, 4),
|
|
photocraft_doc::ColorMode::Rgb,
|
|
photocraft_doc::SampleType::U8,
|
|
photocraft_doc::Color::rgb(1.0, 0.0, 0.0),
|
|
);
|
|
let bytes = photocraft_io::export(&doc, "png", &photocraft_io::ExportOptions::default()).unwrap().bytes;
|
|
let path = format!("{dir}/replacement.png");
|
|
std::fs::write(&path, bytes).unwrap();
|
|
path
|
|
}
|
|
|
|
fn copied_layer(s: &Session, name: &str) -> LayerId {
|
|
s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id
|
|
}
|
|
|
|
fn define_all(s: &mut Session, photo: LayerId, badge: LayerId, title: LayerId, png: &str) {
|
|
s.execute(
|
|
"image.variables.define",
|
|
json!({"defs": [
|
|
{"name": "showBadge", "layer": badge.0, "type": "visibility"},
|
|
{"name": "headline", "layer": title.0, "type": "textReplacement"},
|
|
{"name": "photo", "layer": photo.0, "type": "pixelReplacement", "method": "conform"},
|
|
]}),
|
|
)
|
|
.unwrap();
|
|
s.execute(
|
|
"image.variables.dataSets",
|
|
json!({"dataSets": [
|
|
{"name": "A", "values": [
|
|
{"variable": "showBadge", "kind": "visibility", "value": false},
|
|
{"variable": "headline", "kind": "text", "value": "Hello"},
|
|
{"variable": "photo", "kind": "pixels", "value": png},
|
|
]},
|
|
]}),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn duplicated_documents_keep_variables_targeting_the_copied_layers() {
|
|
let dir = std::env::temp_dir().join(format!("pc-dup-vars-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let png = red_png(&dir.to_string_lossy());
|
|
|
|
for command in ["image.duplicate", "history.newDocument"] {
|
|
let (mut s, photo, badge, title) = scene();
|
|
define_all(&mut s, photo, badge, title, &png);
|
|
s.execute(command, json!({})).unwrap();
|
|
|
|
let doc = &s.active().unwrap().doc;
|
|
let new_photo = copied_layer(&s, "photo");
|
|
let new_badge = copied_layer(&s, "badge");
|
|
let new_title = copied_layer(&s, "title");
|
|
assert_ne!(new_photo, photo, "{command}: layers must get new identities");
|
|
let targets: Vec<LayerId> = doc.variables.defs.iter().map(|d| d.layer).collect();
|
|
assert_eq!(targets, vec![new_badge, new_title, new_photo], "{command}: variable defs follow their layers");
|
|
|
|
let r = s.execute("image.applyDataSet", json!({"name": "A"})).unwrap();
|
|
assert_eq!(r["applied"], "A");
|
|
let doc = &s.active().unwrap().doc;
|
|
assert!(!doc.layer(new_badge).unwrap().visible, "{command}: visibility drives the copy");
|
|
match &doc.layer(new_title).unwrap().content {
|
|
LayerContent::Text(t) => assert_eq!(t.text, "Hello", "{command}: text drives the copy"),
|
|
_ => panic!("not a text layer"),
|
|
}
|
|
let px = doc.layer(new_photo).unwrap().surface().unwrap().pixel(0, 0);
|
|
assert!(px[0] > 0.9 && px[1] < 0.1, "{command}: pixel replacement drives the copy: {px:?}");
|
|
|
|
// The source document still targets its own layers and is untouched.
|
|
s.set_active(0);
|
|
let src = &s.active().unwrap().doc;
|
|
let src_targets: Vec<LayerId> = src.variables.defs.iter().map(|d| d.layer).collect();
|
|
assert_eq!(src_targets, vec![badge, title, photo], "{command}: source defs untouched");
|
|
assert!(src.layer(badge).unwrap().visible);
|
|
assert!(src.layer(photo).unwrap().surface().unwrap().pixel(0, 0)[0] < 0.9);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dangling_variable_targets_stay_dangling_after_duplication() {
|
|
let (mut s, photo, badge, title) = scene();
|
|
define_all(&mut s, photo, badge, title, "unused.png");
|
|
// A def may already refer to a deleted layer before duplication.
|
|
let missing = LayerId(u64::MAX);
|
|
s.edit("Break one def", |doc, _| {
|
|
doc.variables.defs.iter_mut().find(|d| d.name == "showBadge").unwrap().layer = missing;
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
s.execute("image.duplicate", json!({})).unwrap();
|
|
|
|
let doc = &s.active().unwrap().doc;
|
|
let def = doc.variables.defs.iter().find(|d| d.name == "showBadge").unwrap();
|
|
assert_eq!(def.layer, missing, "a deleted target must not be rewritten to an unrelated new id");
|
|
let photo_def = doc.variables.defs.iter().find(|d| d.name == "photo").unwrap();
|
|
assert_eq!(photo_def.layer, copied_layer(&s, "photo"), "live targets still remap");
|
|
}
|
|
|
|
#[test]
|
|
fn merged_only_duplicate_does_not_carry_variables_for_removed_layers() {
|
|
let dir = std::env::temp_dir().join(format!("pc-dup-vars-merged-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let png = red_png(&dir.to_string_lossy());
|
|
let (mut s, photo, badge, title) = scene();
|
|
define_all(&mut s, photo, badge, title, &png);
|
|
|
|
s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap();
|
|
let d = &s.active().unwrap().doc;
|
|
assert_eq!(d.layers.len(), 1, "merged-only copy is a single composite layer");
|
|
assert!(d.variables.defs.is_empty(), "old variables cannot address discarded layers");
|
|
assert!(d.variables.data_sets.is_empty());
|
|
assert_eq!(d.variables.active, None);
|
|
|
|
s.set_active(0);
|
|
assert_eq!(s.active().unwrap().doc.variables.defs.len(), 3, "original variables are preserved");
|
|
}
|