Files
photocraft/crates/engine/src/float_cmds/tests.rs
T
2026-10-21 14:25:55 -06:00

289 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use photocraft_geom::Rect;
use serde_json::json;
use super::*;
/// An 80×60 transparent document whose layer is painted red over (10..30)²; (10..30)² selected.
fn session() -> (Session, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 80, "height": 60, "background": "transparent"})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.edit("paint", |doc, _| {
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
(s, id)
}
fn alpha(d: &Document, id: LayerId, x: i32, y: i32) -> f32 {
d.layer(id).unwrap().surface().unwrap().rgba(x, y)[3]
}
#[test]
fn float_moves_a_cut_piece_without_touching_the_document_until_dropped() {
let (mut s, id) = session();
let steps = s.active().unwrap().history.past_len();
assert_eq!(s.execute("select.float", json!({"dx": 15, "dy": 0})).unwrap()["offset"], json!([15, 0]));
// Moving it again doesn't cut anything new.
s.execute("select.float", json!({"dx": 0, "dy": 10})).unwrap();
let st = s.active().unwrap();
assert_eq!(floating(st).unwrap().offset, (15, 10));
assert_eq!(alpha(&st.doc, id, 12, 12), 1.0, "the document is untouched while floating");
assert_eq!(st.history.past_len(), steps);
let shown = displayed(st, (0, 0)).unwrap();
assert!(alpha(&shown, id, 12, 12) == 0.0 && alpha(&shown, id, 27, 22) == 1.0, "shown cut and moved");
// Drop: one history step, pixels and selection where they were shown.
s.execute("select.drop", json!({})).unwrap();
let st = s.active().unwrap();
assert!(floating(st).is_none());
assert_eq!(st.history.past_len(), steps + 1);
for (x, y) in [(12, 12), (27, 22), (44, 39), (40, 40)] {
assert_eq!(alpha(&st.doc, id, x, y), alpha(&shown, id, x, y), "dropped as shown at ({x}, {y})");
}
assert_eq!(st.doc.selection.as_ref().unwrap().content_bounds(), Rect::new(25, 20, 45, 40));
// Undo takes the whole move back.
s.undo();
assert_eq!(alpha(&s.active().unwrap().doc, id, 12, 12), 1.0);
}
#[test]
fn any_other_command_drops_it_and_undo_puts_it_back() {
let (mut s, id) = session();
let steps = s.active().unwrap().history.past_len();
s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap();
// Undo while floating: the piece goes back; nothing in the document or history changed.
assert_eq!(s.execute("edit.undo", json!({})).unwrap()["floating"], json!("returned"));
let st = s.active().unwrap();
assert!(floating(st).is_none() && alpha(&st.doc, id, 12, 12) == 1.0 && st.history.past_len() == steps);
// Float again, then deselect: dropped first (where it was shown), then deselected.
s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let st = s.active().unwrap();
assert!(st.doc.selection.is_none() && floating(st).is_none());
assert!(alpha(&st.doc, id, 12, 12) == 0.0 && alpha(&st.doc, id, 35, 20) == 1.0);
}
/// The History panel's Step Backward / Step Forward treat a floating piece like Edit › Undo /
/// Redo: no Move step is recorded and the redo states survive.
#[test]
fn history_panel_steps_put_the_piece_back_and_keep_the_redo_states() {
for back in ["edit.undo", "history.stepBackward"] {
let (mut s, id) = session();
let steps = s.active().unwrap().history.past_len();
s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap();
assert_eq!(s.execute(back, json!({})).unwrap()["floating"], json!("returned"), "{back}");
let st = s.active().unwrap();
assert!(floating(st).is_none() && alpha(&st.doc, id, 12, 12) == 1.0, "{back}: piece back");
assert_eq!((st.history.past_len(), st.history.can_redo()), (steps, false), "{back}: no Move step");
}
for forward in ["edit.redo", "history.stepForward"] {
let (mut s, id) = session();
// A redo state (the deselection), then a moved piece over the selection undo restored.
s.execute("select.deselect", json!({})).unwrap();
s.undo();
let steps = s.active().unwrap().history.past_len();
s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap();
s.execute(forward, json!({})).unwrap();
let st = s.active().unwrap();
assert!(floating(st).is_none(), "{forward}: piece back");
assert_eq!(alpha(&st.doc, id, 12, 12), 1.0, "{forward}: the move never reached the document");
assert!(st.doc.selection.is_none(), "{forward}: stepped forward to the deselection");
assert_eq!((st.history.past_len(), st.history.can_redo()), (steps + 1, false), "{forward}: no Move step");
// Nothing to step forward to: refused, and the piece keeps floating.
let (mut s, _) = session();
s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap();
let steps = s.active().unwrap().history.past_len();
assert!(s.execute(forward, json!({})).is_err(), "{forward}: nothing to redo");
let st = s.active().unwrap();
assert_eq!(floating(st).map(|f| f.offset), Some((20, 0)), "{forward}: still floating");
assert_eq!(st.history.past_len(), steps);
}
}
#[test]
fn needs_a_selection_and_an_unlocked_pixel_layer() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 20, "height": 20})).unwrap();
assert!(!s.is_enabled("select.float"), "no selection");
s.execute("select.all", json!({})).unwrap();
assert!(s.is_enabled("select.float"), "the Background's selected pixels move");
s.execute("layer.new.layer", json!({})).unwrap();
assert!(s.is_enabled("select.float"));
s.execute("layer.lockLayers", json!({"position": true})).unwrap();
assert!(!s.is_enabled("select.float"), "a position-locked layer");
}
/// On the Background, which can't hold transparency, the hole takes the background colour.
#[test]
fn on_the_background_the_hole_takes_the_background_colour() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 40})).unwrap();
s.tools.background = [0.0, 0.0, 1.0, 1.0];
let id = s.active().unwrap().active_layer.unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
s.execute("select.float", json!({"dx": 15, "dy": 0})).unwrap();
s.execute("select.drop", json!({})).unwrap();
let px = |x, y| s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().rgba(x, y);
assert_eq!(px(12, 12), [0.0, 0.0, 1.0, 1.0], "the hole");
assert_eq!(px(27, 12), [1.0, 1.0, 1.0, 1.0], "the moved pixels");
assert_eq!(px(5, 5), [1.0, 1.0, 1.0, 1.0], "the rest");
}
/// Copying leaves the original pixels, at every depth; the move is one undo step.
#[test]
fn a_copy_leaves_the_original_at_every_depth_in_one_undo_step() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 80, "height": 60, "background": "transparent", "depth": depth})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
let steps = s.active().unwrap().history.past_len();
s.execute("select.float", json!({"dx": 30, "dy": 0, "copy": true})).unwrap();
s.execute("select.drop", json!({})).unwrap();
let st = s.active().unwrap();
assert!(alpha(&st.doc, id, 12, 12) == 1.0 && alpha(&st.doc, id, 42, 12) == 1.0, "{depth}-bit: both");
assert_eq!((st.history.past_len(), st.history.undo_label()), (steps + 1, Some("Move")), "{depth}-bit");
s.undo();
let st = s.active().unwrap();
assert!(alpha(&st.doc, id, 12, 12) == 1.0 && alpha(&st.doc, id, 42, 12) == 0.0, "{depth}-bit: undone");
}
}
/// `cargo test --release -p photocraft-engine float_drag_bench -- --ignored --nocapture`
#[test]
#[ignore]
fn float_drag_bench() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 6000, "height": 4000, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, a| {
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 6000, 4000), &[0.2, 0.4, 0.6, 1.0]);
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 1000, "y": 1000, "width": 500, "height": 400})).unwrap();
let t = std::time::Instant::now();
s.execute("select.float", json!({})).unwrap();
let cut = t.elapsed();
let t = std::time::Instant::now();
for i in 0..20 {
displayed(s.active().unwrap(), (i * 7, i * 3)).unwrap();
}
eprintln!("6000×4000 layer, 500×400 piece: cut {cut:?}, then {:?} per pointer move", t.elapsed() / 20);
}
#[test]
fn copy_floats_a_duplicate_and_leaves_the_original() {
let (mut s, id) = session();
s.execute("select.float", json!({"dx": 30, "dy": 0, "copy": true})).unwrap();
let shown = displayed(s.active().unwrap(), (0, 0)).unwrap();
assert!(alpha(&shown, id, 12, 12) == 1.0 && alpha(&shown, id, 42, 12) == 1.0, "original kept, copy shown");
s.execute("select.drop", json!({})).unwrap();
let doc = &s.active().unwrap().doc;
assert!(alpha(doc, id, 12, 12) == 1.0 && alpha(doc, id, 42, 12) == 1.0, "dropped: both");
}
#[test]
fn float_with_the_mask_target_moves_the_mask_not_the_layer() {
let (mut s, id) = session();
s.edit("mask", |doc, _| {
let l = doc.layer_mut(id).unwrap();
let mut m = photocraft_doc::LayerMask::reveal_all();
m.surface.fill_rect(Rect::new(10, 10, 30, 30), &[0.0]);
l.mask = Some(m);
Ok(())
})
.unwrap();
let mask_at = |s: &Session, x, y| s.active().unwrap().doc.layer(id).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
s.execute("select.float", json!({"dx": 25, "dy": 0, "copy": true, "target": "mask"})).unwrap();
s.execute("select.drop", json!({})).unwrap();
assert_eq!(mask_at(&s, 12, 12), 0.0, "the copy leaves the mask where it was");
assert_eq!(mask_at(&s, 37, 12), 0.0, "the mask's selected part moved");
assert_eq!(mask_at(&s, 60, 12), 1.0);
assert_eq!(alpha(&s.active().unwrap().doc, id, 37, 12), 0.0, "the layer's pixels did not move");
assert_eq!(alpha(&s.active().unwrap().doc, id, 12, 12), 1.0);
}
#[test]
fn cutting_an_untouched_part_of_the_mask_moves_it_and_leaves_the_background_colour() {
let (mut s, id) = session();
s.edit("mask", |doc, _| {
let mut m = photocraft_doc::LayerMask::hide_all();
m.surface.fill_rect(Rect::new(50, 10, 70, 30), &[1.0]);
doc.layer_mut(id).unwrap().mask = Some(m);
Ok(())
})
.unwrap();
s.tools.background = [0.5, 0.5, 0.5, 1.0];
let mask_at = |s: &Session, x, y| s.active().unwrap().doc.layer(id).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
// (10..30)² is the mask's untouched black; it moves over the white at (50..70, 10..30).
s.execute("select.float", json!({"dx": 40, "dy": 0, "target": "mask"})).unwrap();
s.execute("select.drop", json!({})).unwrap();
assert_eq!(mask_at(&s, 55, 12), 0.0, "the untouched black moved");
assert!((mask_at(&s, 12, 12) - 0.5).abs() < 0.01, "the hole takes the background colour");
assert_eq!(mask_at(&s, 40, 12), 0.0);
}
#[test]
fn float_shares_interior_tiles_on_cut_and_place() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap();
s.tools.background = [0.2, 0.4, 0.8, 1.0];
let id = s.active().unwrap().active_layer.unwrap();
// Paint non-uniform pixels so original tiles are distinct.
s.edit("pattern", |doc, _| {
let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap();
for ty in 0..4 {
for tx in 0..4 {
surf.write_pixel(tx * 256 + 5, ty * 256 + 5, &[tx as f32 * 0.25, ty as f32 * 0.25, 0.5, 1.0]);
}
}
Ok(())
})
.unwrap();
// Select (100..900)^2, which fully covers interior tiles (1, 1) and (2, 2).
s.execute("select.rect", json!({"x": 100, "y": 100, "width": 800, "height": 800})).unwrap();
s.execute("select.float", json!({})).unwrap();
let st = s.active().unwrap();
let orig = st.doc.layer(id).unwrap().surface().unwrap();
let parts = &floating(st).unwrap().parts;
let tc1 = photocraft_geom::TileCoord { tx: 1, ty: 1 };
let tc2 = photocraft_geom::TileCoord { tx: 2, ty: 2 };
// Interior tiles of `piece` share the original layer's Arc<Tile>.
assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), parts.piece.tile(tc1).unwrap()));
assert!(Arc::ptr_eq(orig.tile(tc2).unwrap(), parts.piece.tile(tc2).unwrap()));
// Interior tiles of the Background hole in `rest` share one solid fill Arc<Tile>.
assert!(Arc::ptr_eq(parts.rest.tile(tc1).unwrap(), parts.rest.tile(tc2).unwrap()));
// Moving by a whole tile (256, 0) shares the interior tile directly in the preview document.
let shown = displayed(st, (256, 0)).unwrap();
let shown_surf = shown.layer(id).unwrap().surface().unwrap();
let moved_tc = photocraft_geom::TileCoord { tx: 2, ty: 1 };
assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), shown_surf.tile(moved_tc).unwrap()));
}
#[test]
fn float_feathered_selection_preserves_partial_alpha_across_depths() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 120, "height": 80, "background": "transparent", "depth": depth})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.edit("paint", |doc, _| {
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 55, 80), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 30, "height": 30, "feather": 4.0})).unwrap();
s.execute("select.float", json!({"dx": 40, "dy": 0})).unwrap();
s.execute("select.drop", json!({})).unwrap();
let surf = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
// Center of moved piece is opaque red; center of hole is transparent; feathered edge has partial alpha.
assert!(surf.rgba(75, 35)[3] > 0.95, "{depth}-bit moved center");
assert!(surf.rgba(35, 35)[3] < 0.05, "{depth}-bit hole center");
let edge_a = surf.rgba(60, 35)[3];
assert!(edge_a > 0.1 && edge_a < 0.9, "{depth}-bit feathered edge alpha={edge_a}");
}
}