From 78cf0cf91f641b49e4563fdf4b01d983d6972e9a Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:53:47 +0100 Subject: [PATCH] Integration: transform anchor codes, Close save:false, Batch Save As + Close, opacity and thumbnail-click regressions (#10702) - FTcs reference points used a 3x3 grid (Qcs0..Qcs8). The reference application has Qcsa centre, Qcs0..Qcs3 corners clockwise from top-left and Qcs4..Qcs7 the sides top/right/bottom/left (measured: 50 % scale, Qcs6 keeps the bottom centre, Qcs1 the top-right corner). Qcs8 and Qcsi stay kept steps; nothing writes Qcs8. - file.close {"save": false} (used by ui-egui tests) became an error; it discards again and saves as Svng N. - Batch never performs the action's Save As, so the scratch document stayed dirty and the recorded Close (no Svng -> "save":"ask") failed every file of "...; Save As; Close". A Save / Save As save point now marks the document saved. - Adjustment-layer opacity is quantised to 8 bits on load; a recorded 70 % saved back as 70.196 %. The whole percent that quantises to the same level is saved again. - Cmd-clicking a group's thumbnail errored; it loads the whole canvas again as before. A recorded Cmd-click on another layer's thumbnail kept the numeric id and failed strict Save Actions; it records the layer by name (or as the Background). Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd.rs | 6 ++- crates/engine/src/cmd/actions_cmds/tests.rs | 42 +++++++++++++++++ crates/engine/src/cmd/atn_cmds/steps_doc.rs | 1 + crates/engine/src/cmd/atn_cmds/steps_image.rs | 17 +++---- crates/engine/src/cmd/atn_cmds/steps_make.rs | 12 ++++- crates/engine/src/cmd/atn_cmds/tests.rs | 47 +++++++++++++++---- crates/engine/src/cmd/channel_cmds/refs.rs | 1 - crates/engine/src/cmd/file_cmds/batch_run.rs | 10 +++- crates/engine/src/cmd/file_cmds/tests.rs | 22 +++++++++ .../src/cmd/layer_multi_cmds/selection.rs | 23 +++++++++ crates/engine/tests/atn_mapped_identifiers.rs | 42 ++++++++++------- 11 files changed, 184 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 93ccc06d54..00988c6669 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -311,7 +311,7 @@ fn build() -> Vec { "Close", ["File"], Some("Cmd+W"), - r##"{"document":index?,"save":"discard|ask"="discard"} (ask refuses to close a changed document in headless playback)"##, + r##"{"document":index?,"save":"discard|ask"|false="discard"} (false means discard; ask refuses to close a changed document in headless playback)"##, has_doc, |s, p| { let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; @@ -321,7 +321,9 @@ fn build() -> Vec { return Err(bad("file.close", "the document has unsaved changes; save it or explicitly discard them")); } } - Some(v) if v != "discard" => return Err(bad("file.close", "`save` must be ask or discard")), + // `false` (don't save) is the older spelling of discard. + Some(Value::Bool(false)) => {} + Some(v) if v != "discard" => return Err(bad("file.close", "`save` must be ask, discard or false")), _ => {} } s.close(i).ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index caddcaa892..933ae5fe90 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -114,6 +114,48 @@ fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() { 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(); diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 900fb06485..46329f2e1a 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -131,6 +131,7 @@ pub(super) fn close_to(_: &str, p: &Value) -> Option { } match p.get("save").filter(|v| !v.is_null()) { Some(v) if v == "ask" => Some(desc("null")), + Some(Value::Bool(false)) => Some(desc("null").with("Svng", enm("YsN ", "N "))), Some(v) if v != "discard" => None, _ => Some(desc("null").with("Svng", enm("YsN ", "N "))), } diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 7eb9662ac8..29e4b0209c 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -7,18 +7,19 @@ use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; use super::tables::{CHANNELS, INTERPOLATION, RADIAL_METHOD, RADIAL_QUALITY, channel_of, lookup, lookup_back}; use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json}; -/// Photoshop's nine frame anchors and the average/centre anchor. +/// The reference point of a Free Transform (`QCSt`): `Qcsa` the centre (average), `Qcs0`..`Qcs3` +/// the corners clockwise from the top left, `Qcs4`..`Qcs7` the sides top, right, bottom, left. +/// `Qcsi` (independent) and unknown codes have no mapping and stay kept steps. const TRANSFORM_REFERENCE: &[(&str, &str)] = &[ ("Qcsa", "center"), ("Qcs0", "topLeft"), - ("Qcs1", "top"), - ("Qcs2", "topRight"), - ("Qcs3", "left"), - ("Qcs4", "center"), + ("Qcs4", "top"), + ("Qcs1", "topRight"), + ("Qcs7", "left"), ("Qcs5", "right"), - ("Qcs6", "bottomLeft"), - ("Qcs7", "bottom"), - ("Qcs8", "bottomRight"), + ("Qcs3", "bottomLeft"), + ("Qcs6", "bottom"), + ("Qcs2", "bottomRight"), ]; /// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre, diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 6acc3d3249..f6a6312612 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -279,6 +279,16 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { } } +/// The percent to save for an adjustment layer's opacity. Loading quantises a recorded percent +/// to 8 bits (70 % becomes 179/255); a whole percent that quantises to that same level is the +/// one that was recorded, so it is saved back (load then save keeps `70`, not `70.196`). +fn recorded_percent(opacity: f64) -> f64 { + let level = opacity * 255.0; + let percent = opacity * 100.0; + let whole = percent.round(); + if (level - level.round()).abs() < 1e-6 && (whole * 255.0 / 100.0).round() == level.round() { whole } else { percent } +} + /// Write a creation step, refusing native options the target descriptor cannot carry. pub(super) fn make_to(id: &str, p: &Value) -> Option { let with_name = |cls: &str| { @@ -425,7 +435,7 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { }; let mut using = desc("AdjL").with("Type", D::Descriptor(t)); if let Some(opacity) = opacity { - using = using.with("Opct", pct(opacity * 100.0)); + using = using.with("Opct", pct(recorded_percent(opacity))); } desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using)) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 89283ac3c7..22cf7f0092 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3237,14 +3237,13 @@ fn mapped_transform_reference_points_round_trip() { for (code, reference) in [ ("Qcsa", "center"), ("Qcs0", "topLeft"), - ("Qcs1", "top"), - ("Qcs2", "topRight"), - ("Qcs3", "left"), - ("Qcs4", "center"), + ("Qcs4", "top"), + ("Qcs1", "topRight"), + ("Qcs7", "left"), ("Qcs5", "right"), - ("Qcs6", "bottomLeft"), - ("Qcs7", "bottom"), - ("Qcs8", "bottomRight"), + ("Qcs3", "bottomLeft"), + ("Qcs6", "bottom"), + ("Qcs2", "bottomRight"), ] { for target in [target_ref("Lyr "), D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])] { let d = desc("null") @@ -3268,8 +3267,19 @@ fn mapped_transform_reference_points_round_trip() { } } } - let d = desc("null").with("null", target_ref("Lyr ")).with("FTcs", enm("QCSt", "unknown")).with("Wdth", pct(50.0)); - assert_eq!(mapped_recording("Trnf", d).actions[0].steps[0].0, PHOTOSHOP_STEP); + // The reference application never records `Qcs8` or `Qcsi` (independent): kept steps. + for code in ["unknown", "Qcs8", "Qcsi"] { + let d = desc("null").with("null", target_ref("Lyr ")).with("FTcs", enm("QCSt", code)).with("Wdth", pct(50.0)); + assert_eq!(mapped_recording("Trnf", d).actions[0].steps[0].0, PHOTOSHOP_STEP, "{code}"); + } + // A native anchor saves as the reference application's code; nothing writes `Qcs8`. + for (reference, code) in [("top", "Qcs4"), ("topRight", "Qcs1"), ("bottom", "Qcs6"), ("bottomRight", "Qcs2")] { + let native = Action::new("Native", vec![("edit.transform".into(), json!({"scaleX":50,"scaleY":50,"reference":reference}))]); + let (bytes, _) = export("Anchors", &[&native], &[], false).unwrap(); + let saved = atn::parse(&bytes).unwrap(); + let d = saved.actions[0].items[0].descriptor.as_ref().unwrap(); + assert!(matches!(d.get("FTcs"), Some(D::Enumerated { value, .. }) if value.is(code)), "{reference}: {:?}", d.get("FTcs")); + } } /// Alt-clicking an eye twice must isolate and then restore visibility, not just show a layer. @@ -3450,6 +3460,25 @@ fn mapped_close_keeps_ask_and_discard_distinct() { assert!(s.documents().is_empty()); } +/// `"save": false` is the older spelling of discard: it closes a changed document and saves as +/// "don't save" (`Svng N`); `true` and other values stay refused. +#[test] +fn close_save_false_discards_and_saves_as_dont_save() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + for refused in [json!(true), json!("yes"), json!(0)] { + assert!(s.execute("file.close", json!({"save": refused})).is_err(), "{refused}"); + assert_eq!(s.documents().len(), 1); + } + s.execute("file.close", json!({"save": false})).unwrap(); + assert!(s.documents().is_empty()); + let native = Action::new("Close", vec![("file.close".into(), json!({"save": false}))]); + let (bytes, _) = export("Close", &[&native], &[], false).unwrap(); + let d = atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.clone().unwrap(); + assert!(matches!(d.get("Svng"), Some(D::Enumerated { type_id, value }) if type_id.is("YsN ") && value.is("N ")), "{:?}", d.get("Svng")); +} + /// Lab Photo Filter colours use the CMS conversion and remain mapped after saving as RGB. #[test] fn mapped_lab_photo_filter_imports_and_round_trips() { diff --git a/crates/engine/src/cmd/channel_cmds/refs.rs b/crates/engine/src/cmd/channel_cmds/refs.rs index 1bdf587f67..bf12284997 100644 --- a/crates/engine/src/cmd/channel_cmds/refs.rs +++ b/crates/engine/src/cmd/channel_cmds/refs.rs @@ -171,7 +171,6 @@ fn ref_planes(doc: &Document, layer: Option, active: Option, r .map(|p| p[3]) .collect(), ], - LayerContent::Group(_) => return Err(EngineError::Other("group transparency sources are not supported".into())), // Adjustment and fill layers cover the whole canvas. _ => vec![vec![1.0; area.width() as usize * area.height() as usize]], }, diff --git a/crates/engine/src/cmd/file_cmds/batch_run.rs b/crates/engine/src/cmd/file_cmds/batch_run.rs index 0506b9dded..5d500f549f 100644 --- a/crates/engine/src/cmd/file_cmds/batch_run.rs +++ b/crates/engine/src/cmd/file_cmds/batch_run.rs @@ -307,7 +307,15 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { let mut saved: Option> = None; for (id, params) in &steps { if batch_save_point(id) { - saved = Some(scratch.active().ok_or(EngineError::NoDocument)?.doc.clone()); + let st = scratch.active_mut().ok_or(EngineError::NoDocument)?; + saved = Some(st.doc.clone()); + // Batch performs the action's Save / Save As (to its destination), so the + // document is saved there as far as the steps after it see: a Close recorded + // without a save policy (`"save":"ask"`) then closes it. A Save a Copy leaves + // the document as changed as it was. + if id != "file.saveACopy" { + st.saved_revision = st.revision; + } continue; } let r = scratch.execute(id, params.clone())?; diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index d93c15fd82..03e7fabe89 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -1828,6 +1828,28 @@ fn batch_saves_at_the_recorded_save_so_a_close_after_it_keeps_the_result() { assert!(r["errors"][0]["error"].as_str().unwrap().contains("no active document"), "{r}"); } +/// "…; Save As; Close" with the Close recorded on a clean document (no `Svng`, `"save":"ask"`): +/// Batch performs the Save As, so the document is saved when the Close asks, and every file is +/// written. The Close used to refuse the still-changed scratch document and fail each file. +#[test] +fn batch_save_as_then_close_that_asks_processes_every_file() { + let dir = tmp("batch-save-close-ask"); + let input = vec![png(&dir, "red.png", 8, 8, "#ff0000"), png(&dir, "blue.png", 8, 8, "#0000ff")]; + let out = join(&dir, "out"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveAs", {}], ["file.close", {"save": "ask"}]]); + let r = Session::new().execute("file.automate.batch", json!({"input": input, "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}"); + let red = photocraft_io::import("red.png", &std::fs::read(join(&out, "red.png")).unwrap()).unwrap().document; + assert_eq!(photocraft_compose::render(&red, Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 1.0, 1.0], "the inverted red"); + // A Save a Copy doesn't save the document itself: the Close that asks still refuses it. + let out = join(&dir, "copy"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveACopy", {}], ["file.close", {"save": "ask"}]]); + let r = Session::new().execute("file.automate.batch", json!({"input": input, "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["files"], json!([]), "{r}"); + assert_eq!(r["errors"].as_array().unwrap().len(), 2, "{r}"); +} + /// The document a recorded save marked is the result, not the one left active after it closed. /// Here the action saves and closes a second document it made, then inverts the input; the output /// used to be the inverted input, written in place of the result without an error. diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index a0b5f76a77..1a5916cc06 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -159,6 +159,29 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio } return p; } + if id == "select.loadSelection" { + return recorded_source_layer(s, p, layer_id); + } + p +} + +/// ⌘-clicking another layer's thumbnail loads that layer's transparency. The step refers to the +/// layer the way the reference application records it, by name (or as the Background), so it +/// replays in another document and saves to `.atn`. A layer that the name would not find (a +/// same-named layer above it) keeps its id. +fn recorded_source_layer(s: &Session, mut p: Value, id: LayerId) -> Value { + let Some(doc) = s.active().map(|st| &st.doc) else { return p }; + let Some(layer) = doc.layer(id) else { return p }; + let reference = if doc.background().is_some_and(|b| b.id == id) { + json!({"background": true}) + } else if doc.walk().into_iter().rev().find(|(_, _, l)| l.name == layer.name).is_some_and(|(_, _, l)| l.id == id) { + json!({"name": layer.name}) + } else { + return p; + }; + if let Some(fields) = p.as_object_mut() { + fields.insert("layer".into(), reference); + } p } diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index 35cce20f21..7b11aeb500 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -150,7 +150,7 @@ fn review_copy_cut_opacity_is_validated_before_any_step_runs() { fn review_layer_transform_uses_the_recorded_anchor() { for depth in [8, 16, 32] { for (code, fx, fy) in - [("Qcs0", 0, 0), ("Qcs1", 1, 0), ("Qcs2", 2, 0), ("Qcs3", 0, 1), ("Qcs4", 1, 1), ("Qcs5", 2, 1), ("Qcs6", 0, 2), ("Qcs7", 1, 2), ("Qcs8", 2, 2)] + [("Qcs0", 0, 0), ("Qcs4", 1, 0), ("Qcs1", 2, 0), ("Qcs7", 0, 1), ("Qcsa", 1, 1), ("Qcs5", 2, 1), ("Qcs3", 0, 2), ("Qcs6", 1, 2), ("Qcs2", 2, 2)] { let d = Descriptor::new("null") .with("FTcs", enm("QCSt", code)) @@ -176,11 +176,15 @@ fn review_layer_transform_uses_the_recorded_anchor() { .unwrap(); replay_saved(&mut s, &action); let state = s.active().unwrap(); - assert_eq!( - state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(), - photocraft_geom::Rect::new(20 + 5 * fx, 20 + 5 * fy, 30 + 5 * fx, 30 + 5 * fy), - "{code}, depth {depth}" - ); + let bounds = state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(); + assert_eq!(bounds, photocraft_geom::Rect::new(20 + 5 * fx, 20 + 5 * fy, 30 + 5 * fx, 30 + 5 * fy), "{code}, depth {depth}"); + // Measured in the reference application: Qcs6 keeps the bottom centre fixed, Qcs1 the + // top-right corner. + match code { + "Qcs6" => assert_eq!(bounds, photocraft_geom::Rect::new(25, 30, 35, 40), "depth {depth}"), + "Qcs1" => assert_eq!(bounds, photocraft_geom::Rect::new(30, 20, 40, 30), "depth {depth}"), + _ => {} + } s.execute("edit.undo", json!({})).unwrap(); if code == "Qcs0" { s.edit("Mark rotation", |doc, active| { @@ -377,9 +381,10 @@ fn review_10702_transparency_names_resolve_at_playback() { } } -/// A group has no supported transparency plane and must not overwrite a real selection. +/// A group's transparency loads the whole canvas, as ⌘-clicking its thumbnail always did (the +/// group's combined transparency is not composited yet), rather than failing. #[test] -fn group_transparency_sources_fail_without_changing_selection() { +fn group_transparency_sources_load_the_canvas() { use photocraft_doc::LayerId; use photocraft_geom::Rect; @@ -409,19 +414,21 @@ fn group_transparency_sources_fail_without_changing_selection() { ]), ); let action = recorded(AtnEvent::Code(*b"setd"), descriptor.clone()); - let before = s.active().unwrap().doc.clone(); - let revision = s.active().unwrap().revision; - let history = s.active().unwrap().history.past_len(); let (id, params) = &action.steps[0]; let result = s.execute(id, params.clone()); - let alpha = s.active().unwrap().doc.selection.as_ref().map(|mask| mask.read_region(Rect::from_xywh(0, 0, 2, 1))); - assert!(result.is_err(), "group {name:?} returned {result:?} with selection {alpha:?}"); - assert_eq!(s.active().unwrap().doc, before); - assert_eq!(s.active().unwrap().revision, revision); - assert_eq!(s.active().unwrap().history.past_len(), history); + let doc = &s.active().unwrap().doc; + let alpha = doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]); + assert!(result.is_ok(), "group {name:?} returned {result:?}"); + assert_eq!(alpha, vec![1.0, 1.0], "group {name:?}"); + // ⌘-click on the group's thumbnail (by id) loads the same. + s.execute("select.deselect", json!({})).unwrap(); + s.execute("select.loadSelection", json!({"channel":"transparency","layer":group.0})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]), vec![1.0, 1.0]); assert_eq!(saved(&action).descriptor, Some(descriptor)); // The group's explicit mask remains a valid source. + s.execute("select.loadSelection", json!({"channel":"transparency","layer":"Child"})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({"layer":group.0})).unwrap(); assert!(s.active().unwrap().doc.selection.is_none()); s.execute("select.loadSelection", json!({"channel":"mask","layer":params["layer"]})).unwrap(); @@ -557,7 +564,8 @@ fn recorded_adjustment_opacity_keeps_gradient_settings_and_defaults() { } let written = saved(&action).descriptor.unwrap(); let Some(D::Descriptor(using)) = written.get("Usng") else { panic!("missing adjustment metadata") }; - assert!(matches!(using.get("Opct"),Some(D::UnitFloat{unit,value}) if unit==b"#Prc" && (*value-179.0/255.0*100.0).abs()<1e-10)); + // The recorded 70 % saves back as 70 %, not as the 8-bit level 179/255 (70.196 %). + assert!(matches!(using.get("Opct"), Some(D::UnitFloat { unit, value }) if unit == b"#Prc" && *value == 70.0), "{:?}", using.get("Opct")); for depth in [8, 16, 32] { let mut s = Session::new(); s.execute("file.new", json!({"width":4,"height":3,"depth":depth})).unwrap();