test(actions): cover grouped and per-step history

This commit is contained in:
yoriswind committed 2026-11-02 20:08:20 -08:00
1 parent 0738c9bb13
commit 699683d8eb
4 files changed
+94 -55

No files matched your search

@@ -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!({})),
+45 -24
View File
@@ -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");
}
}
}
}
}
+43 -29
View File
@@ -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);
}
}
+2 -2
View File
@@ -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`.