diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index 02bc22ae35..45db91789d 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -475,6 +475,8 @@ fn record_keeps_only_replayable_steps() { 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(); @@ -1169,6 +1171,8 @@ fn button_color_is_one_of_the_photoshop_swatches() { 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!({})), diff --git a/crates/engine/src/cmd/atn_cmds/render_tests.rs b/crates/engine/src/cmd/atn_cmds/render_tests.rs index 21f87c286e..edad254518 100644 --- a/crates/engine/src/cmd/atn_cmds/render_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/render_tests.rs @@ -261,30 +261,51 @@ fn render_actions_match_direct_filters_and_undo() { // Just right of the canvas: the dialog's 0..1 range does not clamp a recorded centre. (*b"LnsF", flare(100, [1.4, 0.25], "Zm"), "filter.render.lensFlare", json!({"brightness": 100, "centerX": 1.4, "centerY": 0.25, "lens": "zoom"})), ]; - for mode in ["rgb", "gray"] { - for depth in [8, 16, 32] { - for (event, descriptor, id, params) in &cases { - let setup = || { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 8, "height": 6, "mode": mode, "depth": depth, "background": "transparent"})).unwrap(); - s.execute("edit.fill", json!({"contents": "color", "color": "#304050"})).unwrap(); - s.execute("tools.setColors", json!({"foreground": "#fe1020", "background": "#1050e0"})).unwrap(); - s - }; - let mut action = setup(); - let mut direct = setup(); - let before = action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(); - action.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file(AtnEvent::Code(*event), descriptor.clone()))})).unwrap(); - let played = action.execute("actions.play", json!({"action": "Render"})).unwrap(); - assert!(played.get("failed").is_none(), "{played}"); - direct.execute(id, params.clone()).unwrap(); - let surface = |s: &Session| { - let st = s.active().unwrap(); - st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) - }; - assert_eq!(surface(&action), surface(&direct), "{mode}/{depth}/{id}"); - action.execute("edit.undo", json!({})).unwrap(); - assert_eq!(action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(), before); + for single_state in [true, false] { + for mode in ["rgb", "gray"] { + for depth in [8, 16, 32] { + for (event, descriptor, id, params) in &cases { + let setup = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6, "mode": mode, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#304050"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#fe1020", "background": "#1050e0"})).unwrap(); + s + }; + let mut action = setup(); + let mut direct = setup(); + action.execute("actions.setHistory", json!({"singleState": single_state})).unwrap(); + let before_doc = action.active().unwrap().doc.clone(); + let history = action.active().unwrap().history.past_len(); + let before = action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(); + action + .execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file(AtnEvent::Code(*event), descriptor.clone()))})) + .unwrap(); + let played = action.execute("actions.play", json!({"action": "Render"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + direct.execute(id, params.clone()).unwrap(); + let surface = |s: &Session| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) + }; + assert_eq!(surface(&action), surface(&direct), "{mode}/{depth}/{id}"); + let after_doc = action.active().unwrap().doc.clone(); + let changed = after_doc.as_ref() != before_doc.as_ref(); + let added = usize::from(!single_state || changed); + assert_eq!(action.active().unwrap().history.past_len(), history + added, "{mode}/{depth}/{id}, singleState={single_state}"); + // An unchanged grouped action preserves the setup row instead of adding one. + if added > 0 { + action.execute("edit.undo", json!({})).unwrap(); + } else { + assert!(std::sync::Arc::ptr_eq(&action.active().unwrap().doc, &before_doc)); + } + assert_eq!(action.active().unwrap().doc.as_ref(), before_doc.as_ref(), "{mode}/{depth}/{id} undo"); + assert_eq!(action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(), before); + if added > 0 { + action.execute("edit.redo", json!({})).unwrap(); + assert_eq!(action.active().unwrap().doc.as_ref(), after_doc.as_ref(), "{mode}/{depth}/{id} redo"); + } + } } } } diff --git a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs index 976940ef43..0e8ca94d79 100644 --- a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs @@ -360,7 +360,7 @@ fn exports_materialize_native_defaults_and_reject_unrepresentable_settings() { } } -/// Imported actions produce the same pixels and Undo history as the specified native commands. +/// Imported actions match native pixels with either grouped or per-step Undo and Redo. #[test] fn imported_group_d_playback_matches_direct_commands_across_modes_and_depths() { let items: Vec<_> = fixtures() @@ -368,36 +368,50 @@ fn imported_group_d_playback_matches_direct_commands_across_modes_and_depths() { .map(|f| code_item(f.code, f.changed)) .chain(["FndE", "Slrz", "Fct ", "Frgm"].map(|code| code_item(code, desc("null")))) .collect(); - for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (8, "gray"), (8, "cmyk"), (16, "lab")] { - let mut imported = load_items(items.clone()); - imported.actions.list[0].disabled.insert(10); - imported.actions.list[0].dialogs.insert(0); - let steps = imported.actions.list[0].steps.clone(); - let mut direct = Session::new(); - for s in [&mut imported, &mut direct] { - prepare_pattern(s, depth, mode); - } - let before = pixels(&imported); - let history = imported.active().unwrap().history.past_len(); - let result = imported.execute("actions.play", json!({"action":"A"})).unwrap(); - assert!(result.get("failed").is_none(), "{depth}/{mode}: {result}"); - assert_eq!(result["ran"], 11); - for (index, (id, params)) in steps.into_iter().enumerate() { - if index != 10 { - direct.execute(&id, params).unwrap(); + for single_state in [true, false] { + for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (8, "gray"), (8, "cmyk"), (16, "lab")] { + let mut imported = load_items(items.clone()); + imported.execute("actions.setHistory", json!({"singleState": single_state})).unwrap(); + imported.actions.list[0].disabled.insert(10); + imported.actions.list[0].dialogs.insert(0); + let steps = imported.actions.list[0].steps.clone(); + let mut direct = Session::new(); + for s in [&mut imported, &mut direct] { + prepare_pattern(s, depth, mode); } + let before = pixels(&imported); + let before_doc = imported.active().unwrap().doc.clone(); + let history = imported.active().unwrap().history.past_len(); + let result = imported.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(result.get("failed").is_none(), "{depth}/{mode}: {result}"); + assert_eq!(result["ran"], 11); + for (index, (id, params)) in steps.into_iter().enumerate() { + if index != 10 { + direct.execute(&id, params).unwrap(); + } + } + assert_eq!(pixels(&imported), pixels(&direct), "{depth}/{mode}"); + let after_doc = imported.active().unwrap().doc.clone(); + if single_state { + assert_eq!(imported.active().unwrap().history.past_len(), history + 1); + } else { + assert_eq!(imported.active().unwrap().history.past_len(), direct.active().unwrap().history.past_len()); + } + let added = imported.active().unwrap().history.past_len() - history; + assert!(added > 0); + for _ in 0..added { + imported.execute("edit.undo", json!({})).unwrap(); + } + assert_eq!(pixels(&imported), before, "{depth}/{mode} undo"); + assert_eq!(imported.active().unwrap().doc.as_ref(), before_doc.as_ref(), "{depth}/{mode} undo"); + for _ in 0..added { + imported.execute("edit.redo", json!({})).unwrap(); + } + assert_eq!(imported.active().unwrap().doc.as_ref(), after_doc.as_ref(), "{depth}/{mode} redo"); + let saved = saved_items(&mut imported); + assert!(!saved[10].enabled); + assert!(saved[0].with_dialog); } - assert_eq!(pixels(&imported), pixels(&direct), "{depth}/{mode}"); - assert_eq!(imported.active().unwrap().history.past_len(), direct.active().unwrap().history.past_len()); - let added = imported.active().unwrap().history.past_len() - history; - assert!(added > 0); - for _ in 0..added { - imported.execute("edit.undo", json!({})).unwrap(); - } - assert_eq!(pixels(&imported), before, "{depth}/{mode} undo"); - let saved = saved_items(&mut imported); - assert!(!saved[10].enabled); - assert!(saved[0].with_dialog); } } diff --git a/docs/scorecard.md b/docs/scorecard.md index 32cc47bfd5..a4b9635f0f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 45 | 79 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 41 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 11 of 183 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 11 of 184 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -203,7 +203,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 11 of 183** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 11 of 184** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`.