Files
photocraft/crates/engine/tests/multi_channel_editing.rs
T
rmsff 6106c825e7 Red Eye: name the step Red Eye Removal Tool and record a click that changes nothing
A click that runs but changes no pixel (a blue pupil, a transparent area, an
empty layer, Darken Amount 0) now adds one unchanged history state instead of
the "no red-eye pixels" error, as in Photoshop. Non-RGB/Lab documents, channel
and mask targets, hidden layers and clicks outside the selection or the canvas
keep the error and add no state.
2026-11-10 13:56:00 +03:00

1563 lines
71 KiB
Rust

use photocraft_color::SampleType;
use photocraft_engine::{Session, brush_cmds::LiveStroke, channel_cmds::ChannelTarget, color_channels};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
fn setup(mode: &str, depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":32,"height":24,"mode":mode,"depth":depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"contents":"white"})).unwrap();
s.edit("fixture", |doc, active| {
let r = doc.bounds();
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let n = surf.channels();
let mut data = Vec::new();
for y in 0..24 {
for x in 0..32 {
for k in 0..n - 1 {
data.push(((x * 7 + y * 11 + k * 37) % 221 + 15) as f32 / 255.0);
}
data.push(0.65);
}
}
surf.write_region(r, &data);
Ok(())
})
.unwrap();
s.execute("history.setSource", json!({"state":2})).unwrap();
s
}
fn surface(s: &Session) -> Surface {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone()
}
fn subset(s: &mut Session) {
s.execute("channel.target", json!({"colors":[0,2]})).unwrap();
}
fn unchanged(before: &Surface, after: &Surface) {
let fmt = before.format();
let bytes = fmt.sample.bytes();
let keep: Vec<_> = (0..fmt.channels()).filter(|k| ![0, 2].contains(k)).collect();
for (c, t) in before.tiles() {
let new = after.tile(*c).unwrap();
for (a, b) in t.bytes().chunks_exact(fmt.bytes_per_pixel()).zip(new.bytes().chunks_exact(fmt.bytes_per_pixel())) {
for k in &keep {
assert_eq!(&a[k * bytes..(k + 1) * bytes], &b[k * bytes..(k + 1) * bytes], "component {k}");
}
}
}
}
#[test]
fn shift_selection_is_color_only_and_all_colors_become_composite() {
let mut s = setup("rgb", 8);
s.execute("channel.target", json!({"channel":"red"})).unwrap();
s.execute("channel.target", json!({"channel":"blue","extend":true})).unwrap();
assert_eq!(color_channels::selected(&s).unwrap().indices().collect::<Vec<_>>(), vec![0, 2]);
let view = &s.active().unwrap().channel_view;
assert!(view.color_visible(0) && view.color_visible(2) && !view.color_visible(1));
let revision = s.active().unwrap().revision;
assert!(s.execute("channel.target", json!({"channel":"mask","extend":true})).is_err());
assert_eq!(s.active().unwrap().revision, revision);
s.execute("channel.target", json!({"channel":"green","extend":true})).unwrap();
assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::Composite);
s.execute("channel.new", json!({"name":"saved"})).unwrap();
assert!(s.execute("channel.target", json!({"channel":"saved","extend":true})).is_err());
}
#[test]
fn adjustments_filters_and_drawing_preserve_other_encoded_components() {
let commands = [
("image.adjustments.invert", json!({})),
("image.adjustments.threshold", json!({"level":128})),
("image.adjustments.levels", json!({"gamma":1.7})),
("image.adjustments.curves", json!({"points":[[0,255],[255,0]]})),
("filter.blur.gaussianBlur", json!({"radius":2})),
("paint.stroke", json!({"points":[[8,8],[22,12]],"size":9,"color":"#ffffff"})),
("paint.pencil", json!({"points":[[8,8],[22,12]],"size":9,"color":"#000000"})),
("paint.stroke", json!({"points":[[8,8],[22,12]],"size":9,"erase":true})),
("paint.bucket", json!({"x":10,"y":10,"tolerance":255,"color":"#ffffff"})),
("paint.gradient", json!({"from":[0,0],"to":[31,0],"colors":["#000000","#ffffff"]})),
("paint.dodge", json!({"points":[[8,8],[22,12]],"size":9})),
("paint.burn", json!({"points":[[8,8],[22,12]],"size":9})),
("paint.blur", json!({"points":[[8,8],[22,12]],"size":9})),
("paint.sharpen", json!({"points":[[8,8],[22,12]],"size":9})),
("paint.smudge", json!({"points":[[8,8],[22,12]],"size":9})),
("paint.magicEraser", json!({"x":10,"y":10,"tolerance":255})),
("paint.backgroundEraser", json!({"points":[[8,8],[22,12]],"size":9,"tolerance":100})),
("paint.colorReplacement", json!({"points":[[8,8],[22,12]],"size":9,"tolerance":100,"mode":"luminosity","color":"#ffffff"})),
("paint.cloneStamp", json!({"points":[[8,8],[22,12]],"size":9,"source":[2,2]})),
("paint.historyBrush", json!({"points":[[8,8],[22,12]],"size":9})),
("paint.artHistoryBrush", json!({"points":[[8,8],[22,12]],"size":9,"tolerance":0})),
("paint.mixerBrush", json!({"points":[[8,8],[22,12]],"size":9,"color":"#ffffff"})),
];
for mode in ["rgb", "cmyk", "lab"] {
for depth in [8, 16, 32] {
for (cmd, p) in &commands {
let mut s = setup(mode, depth);
subset(&mut s);
let before = surface(&s);
// Color Replacement refuses 32-bit RGB, as the reference application does (#11923).
if *cmd == "paint.colorReplacement" && mode == "rgb" && depth == 32 {
assert!(s.execute(cmd, p.clone()).is_err(), "{mode} {depth} {cmd} is refused");
assert_eq!(surface(&s), before, "{mode} {depth} {cmd}: nothing changed");
continue;
}
s.execute(cmd, p.clone()).unwrap_or_else(|e| panic!("{mode} {depth} {cmd}: {e}"));
let after = surface(&s);
unchanged(&before, &after);
assert_ne!(before, after, "{mode} {depth} {cmd} must change selected samples");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(surface(&s), before, "undo {cmd}");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(surface(&s), after, "redo {cmd}");
}
}
}
}
#[test]
fn threshold_uses_each_selected_native_plane() {
let mut s = setup("rgb", 16);
s.edit("pixel", |doc, id| {
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.1, 0.95, 0.8, 0.7]);
Ok(())
})
.unwrap();
subset(&mut s);
s.execute("image.adjustments.threshold", json!({"level":128})).unwrap();
let px = surface(&s).pixel(4, 4);
assert_eq!((px[0], px[2]), (0.0, 1.0));
assert!((px[1] - 0.95).abs() < 0.0001 && (px[3] - 0.7).abs() < 0.0001);
}
#[test]
fn explicit_target_wins_and_invalid_sets_are_transactional() {
let mut s = setup("rgb", 32);
s.execute("channel.target", json!({"channel":"green"})).unwrap();
let before = surface(&s);
s.execute("image.adjustments.invert", json!({"target":{"colors":[0,2]}})).unwrap();
unchanged(&before, &surface(&s));
for colors in [json!([]), json!([-1, 2]), json!([0, 3]), json!([0, "red"]), json!(null), json!([u64::MAX]), json!(true)] {
let prior = surface(&s);
assert!(s.execute("image.adjustments.invert", json!({"target":{"colors":colors}})).is_err());
assert_eq!(surface(&s), prior);
}
}
#[test]
fn live_brush_and_eraser_match_commit_at_all_native_depths() {
for mode in ["rgb", "cmyk", "lab"] {
for depth in [8, 16, 32] {
for erase in [false, true] {
let mut s = setup(mode, depth);
subset(&mut s);
let before = surface(&s);
let mut p = json!({"points":[[6,7,1],[23,15,0.7]],"size":9,"color":"#ffffff","erase":erase,"seed":42});
let live = LiveStroke::begin(&s, &p).unwrap();
p["seed"] = json!(live.seed);
let st = s.active().unwrap();
let shown = live.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone();
unchanged(&before, &shown);
s.offer_live_stroke(live).unwrap();
let result = s.execute("paint.stroke", p).unwrap();
assert_eq!(result["fromLive"], true, "reuse {mode} {depth} {erase}");
assert!(
surface(&s) == shown,
"live pixels differ: {mode} {depth} {erase}; shown tiles {}, committed {}",
shown.tile_count(),
surface(&s).tile_count()
);
}
}
}
}
#[test]
fn live_mixer_captures_selected_components_and_matches_commit_and_replay() {
use photocraft_engine::brush_cmds::mixer_live::LiveMixer;
use photocraft_engine::paint::StrokePoint;
for mode in ["rgb", "cmyk", "lab"] {
for depth in [8, 16, 32] {
let mut s = setup(mode, depth);
subset(&mut s);
let before = surface(&s);
let params = json!({"points":[[6,7,1]],"size":9,"color":"#ffffff","seed":42,"sampleAllLayers":true});
let mut live = LiveMixer::begin(&s, &params).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let first = live.doc.layer(id).unwrap().surface().unwrap();
unchanged(&before, first);
assert_ne!(&before, first, "the initial dab paints {mode} {depth}");
live.push(&[StrokePoint::new(23.0, 15.0, 0.7)]).unwrap();
let shown = live.doc.layer(id).unwrap().surface().unwrap().clone();
unchanged(&before, &shown);
// The in-flight stroke keeps its original target even if the panel changes.
s.execute("channel.target", json!({"channel":"composite"})).unwrap();
let params = live.prepare_commit(&mut s).unwrap();
assert_eq!(params["target"], json!({"colors":[0,2]}));
assert_eq!(s.execute("paint.mixerBrush", params.clone()).unwrap()["fromLive"], true);
assert_eq!(surface(&s), shown, "displayed pixels are committed {mode} {depth}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(surface(&s), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(surface(&s), shown);
let mut replay = setup(mode, depth);
replay.execute("paint.mixerBrush", params).unwrap();
assert_eq!(surface(&replay), shown, "replay matches {mode} {depth}");
}
}
}
#[test]
fn unsupported_geometric_edits_and_camera_raw_fail_before_modification() {
let mut s = setup("rgb", 8);
subset(&mut s);
for cmd in [
"layer.translate",
"select.float",
"edit.transform",
"edit.transform.rotate90Cw",
"edit.contentAwareScale",
"edit.puppetWarp",
"edit.perspectiveWarp",
"filter.cameraRaw",
] {
let before = surface(&s);
assert!(!s.is_enabled(cmd), "{cmd}");
assert!(s.execute(cmd, json!({"dx":2,"dy":3})).is_err(), "{cmd}");
assert_eq!(surface(&s), before);
}
}
#[test]
fn native_float_payloads_survive_unselected_component_restoration() {
// Exercise the low-level helper directly with raw HDR, signed zero and NaN payloads.
let fmt = photocraft_color::PixelFormat::RGBA32F;
let mut before = Surface::new(fmt);
before.fill_rect(Rect::new(0, 0, 1, 1), &[2.0, 0.1, -1.0, 0.5]);
let coord = photocraft_geom::TileCoord { tx: 0, ty: 0 };
before.tile_mut(coord).bytes_mut()[4..8].copy_from_slice(&0x7fc0_1234u32.to_ne_bytes());
let mut after = Surface::new(fmt);
after.fill_rect(Rect::new(0, 0, 1, 1), &[0.3, 0.5, 0.7, 0.9]);
let colors = color_channels::ColorChannels::from_indices([0, 2], 3).unwrap();
color_channels::restrict_surface(&before, &mut after, colors).unwrap();
assert_eq!(&before.tile(coord).unwrap().bytes()[4..8], &after.tile(coord).unwrap().bytes()[4..8]);
assert_eq!(after.format().sample, SampleType::F32);
}
#[test]
fn live_retouching_matches_direct_replay_and_preserves_unselected_bytes() {
for mode in ["rgb", "cmyk", "lab"] {
for depth in [8, 16, 32] {
for cmd in ["paint.dodge", "paint.burn", "paint.blur", "paint.sharpen", "paint.smudge", "paint.cloneStamp", "paint.historyBrush"] {
let mut s = setup(mode, depth);
subset(&mut s);
let original = surface(&s);
let p = json!({"points":[[6,7,1],[23,15,0.7]],"size":9,"source":[2,2]});
let shown = if matches!(cmd, "paint.cloneStamp" | "paint.historyBrush") {
let live = photocraft_engine::retouch_cmds::LiveRetouch::begin(&s, cmd, &p).unwrap();
let st = s.active().unwrap();
let shown = live.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone();
s.offer_live_retouch(live).unwrap();
shown
} else {
let live = photocraft_engine::retouch_cmds::LiveDab::begin(&s, cmd, &p).unwrap();
let st = s.active().unwrap();
let shown = live.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone();
s.offer_live_dab(live).unwrap();
shown
};
unchanged(&original, &shown);
s.execute(cmd, p.clone()).unwrap();
let committed = surface(&s);
unchanged(&original, &committed);
let mut replay = setup(mode, depth);
subset(&mut replay);
replay.execute(cmd, p).unwrap();
assert!(committed == surface(&replay), "{mode} {depth} {cmd}: live reuse differs from replay");
}
}
}
}
#[test]
fn copy_cut_paste_operate_on_the_whole_selected_set_in_one_history_step() {
let mut s = setup("rgb", 16);
subset(&mut s);
let original = surface(&s);
s.execute("edit.copy", json!({})).unwrap();
s.execute("image.adjustments.invert", json!({})).unwrap();
let inverted = surface(&s);
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
assert!(surface(&s) == original);
s.execute("edit.undo", json!({})).unwrap();
assert!(surface(&s) == inverted);
s.execute("edit.cut", json!({})).unwrap();
unchanged(&inverted, &surface(&s));
s.execute("edit.undo", json!({})).unwrap();
assert!(surface(&s) == inverted);
}
#[test]
fn explicit_layer_restriction_protects_the_layer_actually_edited() {
let mut s = setup("rgb", 32);
let first = s.active().unwrap().active_layer.unwrap();
let original = surface(&s);
s.execute("layer.new.layer", json!({})).unwrap();
subset(&mut s);
s.execute("paint.stroke", json!({"layer":first.0,"target":{"colors":[0,2]},"points":[[8,8],[23,12]],"size":9,"color":"#ffffff"})).unwrap();
let after = s.active().unwrap().doc.layer(first).unwrap().surface().unwrap();
unchanged(&original, after);
assert!(original != *after);
}
#[test]
fn background_eraser_on_an_opaque_background_edits_only_selected_channels() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap();
s.tools.background = [0.0, 0.0, 0.0, 1.0];
subset(&mut s);
let before = surface(&s);
let id = s.active().unwrap().active_layer.unwrap();
let p = json!({"points":[[12,12]],"size":12,"hardness":1,"tolerance":100});
let live = photocraft_engine::eraser_cmds::live_background_eraser(&s, &p).unwrap();
let shown = live.doc.layer(id).unwrap().surface().unwrap().clone();
s.execute("paint.backgroundEraser", p).unwrap();
let after = surface(&s);
unchanged(&before, &after);
assert!(before != after, "depth {depth}");
assert!(shown == after, "preview depth {depth}");
assert_eq!(before.format(), after.format());
assert!(s.active().unwrap().doc.is_background(id));
}
}
#[test]
fn lab_combined_tone_curve_changes_both_selected_components() {
for command in ["image.adjustments.levels", "image.adjustments.curves"] {
let mut s = setup("lab", 32);
subset(&mut s);
let before = surface(&s).pixel(4, 4);
let p = if command.ends_with("levels") { json!({"gamma":1.8}) } else { json!({"points":[[0,255],[255,0]]}) };
s.execute(command, p).unwrap();
let after = surface(&s).pixel(4, 4);
assert_ne!(before[0], after[0]);
assert_ne!(before[2], after[2]);
assert_eq!(before[1], after[1]);
assert_eq!(before[3], after[3]);
}
}
#[test]
fn lab16_neutral_chroma_survives_inversion_and_off_canvas_samples_are_adjusted() {
let mut s = setup("lab", 16);
s.edit("outside canvas", |doc, id| {
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(-4, -4, 4, 4), &[0.2, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
Ok(())
})
.unwrap();
subset(&mut s);
s.execute("image.adjustments.invert", json!({})).unwrap();
let px = surface(&s).pixel(-2, -2);
assert!((px[0] - 0.8).abs() < 1e-4);
assert!((px[2] - 128.0 / 255.0).abs() < 1e-4);
assert!((px[1] - 128.0 / 255.0).abs() < 1e-4);
}
#[test]
fn explicit_copy_and_singleton_paste_use_the_requested_components() {
let mut s = setup("rgb", 16);
let before = surface(&s);
s.execute("edit.copy", json!({"target":{"colors":[0,2]}})).unwrap();
assert_eq!(s.clipboard.as_ref().unwrap().components, Some(vec![0, 2]));
s.execute("channel.target", json!({"colors":[0,2]})).unwrap();
s.execute("edit.copy", json!({"target":{"colors":[1]}})).unwrap();
assert_eq!(s.clipboard.as_ref().unwrap().surface.format().mode, photocraft_color::ColorMode::Grayscale);
let layers = s.active().unwrap().doc.layers.len();
s.execute("edit.pasteSpecial.pasteInPlace", json!({"target":{"colors":[0]}})).unwrap();
assert_eq!(s.active().unwrap().doc.layers.len(), layers);
let after = surface(&s).pixel(5, 5);
let old = before.pixel(5, 5);
assert_eq!(after[0], old[1]);
assert_eq!(after[1], old[1]);
assert_eq!(after[2], old[2]);
assert_eq!(after[3], old[3]);
}
#[test]
fn paste_into_and_outside_obey_subset_and_pixel_selection() {
for outside in [false, true] {
let mut s = setup("rgb", 16);
subset(&mut s);
s.execute("edit.copy", json!({})).unwrap();
s.execute("image.adjustments.invert", json!({})).unwrap();
let before = surface(&s);
let layers = s.active().unwrap().doc.layers.len();
s.execute("select.rect", json!({"x":0,"y":0,"width":16,"height":24})).unwrap();
let cmd = if outside { "edit.pasteSpecial.pasteOutside" } else { "edit.pasteSpecial.pasteInto" };
s.execute(cmd, json!({"center":[16,12]})).unwrap();
let after = surface(&s);
unchanged(&before, &after);
assert_eq!(s.active().unwrap().doc.layers.len(), layers);
let (changed, kept) = if outside { (24, 8) } else { (8, 24) };
assert_ne!(before.pixel(changed, 10)[0], after.pixel(changed, 10)[0]);
assert_eq!(before.pixel(kept, 10), after.pixel(kept, 10));
}
}
#[test]
fn feathered_pixel_selection_constrains_subset_adjust_filter_and_brush() {
for depth in [8, 16, 32] {
for (command, params) in [
("image.adjustments.invert", json!({})),
("filter.blur.gaussianBlur", json!({"radius":2})),
("paint.stroke", json!({"points":[[0,12],[31,12]],"size":32,"color":"#ffffff","seed":42})),
] {
let mut s = setup("rgb", depth);
s.execute("select.rect", json!({"x":10,"y":7,"width":12,"height":10})).unwrap();
s.execute("select.modify.feather", json!({"radius":2})).unwrap();
let selection = s.active().unwrap().doc.selection.clone().unwrap();
let before = surface(&s);
subset(&mut s);
s.execute(command, params).unwrap();
let after = surface(&s);
unchanged(&before, &after);
assert_ne!(before, after, "{command} @{depth}");
let mut partial = false;
for y in 0..24 {
for x in 0..32 {
let amount = selection.pixel(x, y)[0];
if amount == 0.0 {
assert_eq!(before.pixel(x, y), after.pixel(x, y), "outside {command}");
}
if command == "image.adjustments.invert" && amount > 0.0 && amount < 1.0 {
partial = true;
for ch in [0, 2] {
let old = before.pixel(x, y)[ch];
let expected = old + (1.0 - 2.0 * old) * amount;
let tolerance = if depth == 8 {
2.0 / 255.0
} else if depth == 16 {
2.0 / 65535.0
} else {
1e-6
};
assert!((after.pixel(x, y)[ch] - expected).abs() < tolerance, "feather @{depth}");
}
}
}
}
assert!(command != "image.adjustments.invert" || partial);
}
}
}
#[test]
fn supported_filter_families_and_last_filter_follow_current_set() {
for (command, params) in [
("filter.blur.boxBlur", json!({"radius":2})),
("filter.other.highPass", json!({"radius":2})),
("filter.noise.median", json!({"radius":2})),
("filter.noise.addNoise", json!({"amount":30,"seed":42})),
("filter.stylize.emboss", json!({"height":2})),
("filter.distort.twirl", json!({"angle":60})),
("filter.pixelate.mosaic", json!({"cellSize":4})),
] {
let mut s = setup("rgb", 16);
subset(&mut s);
let before = surface(&s);
s.execute(command, params).unwrap();
unchanged(&before, &surface(&s));
assert_ne!(before, surface(&s), "{command}");
}
let mut s = setup("rgb", 32);
subset(&mut s);
s.execute("filter.noise.addNoise", json!({"amount":30,"seed":42})).unwrap();
s.execute("channel.target", json!({"channel":"green"})).unwrap();
let before = surface(&s);
s.execute("filter.lastFilter", json!({})).unwrap();
let after = surface(&s);
for y in 0..24 {
for x in 0..32 {
for k in [0, 2, 3] {
assert_eq!(before.pixel(x, y)[k].to_bits(), after.pixel(x, y)[k].to_bits(), "last filter component {k}");
}
}
}
assert_ne!(before, after);
}
#[test]
fn explicit_clear_and_singleton_cut_override_saved_channel_selection() {
for command in ["edit.clear", "edit.cut"] {
let mut s = setup("rgb", 32);
s.execute("channel.new", json!({"fill":"white"})).unwrap();
let before = surface(&s);
let alpha = s.active().unwrap().doc.channels[0].surface.clone();
s.execute(command, json!({"target":{"colors":[0]}})).unwrap();
let after = surface(&s);
assert_eq!(s.active().unwrap().doc.channels[0].surface, alpha);
assert_ne!(before, after);
for y in 0..24 {
for x in 0..32 {
for k in [1, 2, 3] {
assert_eq!(before.pixel(x, y)[k].to_bits(), after.pixel(x, y)[k].to_bits());
}
}
}
if command == "edit.cut" {
assert_eq!(s.clipboard.as_ref().unwrap().surface.pixel(8, 8)[0], before.pixel(8, 8)[0]);
}
s.undo();
assert_eq!(surface(&s), before);
}
}
#[test]
fn singleton_component_clip_pastes_into_and_outside_without_luminosity_conversion() {
for command in ["edit.pasteSpecial.pasteInto", "edit.pasteSpecial.pasteOutside"] {
let mut s = setup("rgb", 32);
s.edit("flat", |doc, id| {
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.8, 0.4, 0.2, 0.65]);
Ok(())
})
.unwrap();
s.execute("edit.copy", json!({"target":{"colors":[0]}})).unwrap();
s.execute("channel.target", json!({"channel":"blue"})).unwrap();
s.execute("select.rect", json!({"x":0,"y":0,"width":16,"height":24})).unwrap();
let before = surface(&s);
s.execute(command, json!({"center":[16,12]})).unwrap();
let (edited, kept) = if command.ends_with("pasteInto") { (4, 24) } else { (24, 4) };
assert!((surface(&s).pixel(edited, 12)[2] - 0.8).abs() < 1e-6);
assert_eq!(surface(&s).pixel(kept, 12), before.pixel(kept, 12));
s.undo();
assert_eq!(surface(&s), before);
}
}
#[test]
fn lab_chroma_inversion_materializes_selected_default_pixels_and_preserves_holes() {
for depth in [16, 32] {
for inverse in [false, true] {
let mut s = setup("lab", depth);
s.edit("sparse opaque", |doc, id| {
let surf = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap();
*surf = Surface::with_default(surf.format(), &[0.2, 0.3, 0.4, 1.0]);
let mut mask = Surface::new(photocraft_color::PixelFormat::GRAY8);
if inverse {
mask.fill_rect(doc.bounds(), &[1.0]);
}
mask.fill_rect(Rect::new(8, 6, 20, 16), &[if inverse { 0.0 } else { 1.0 }]);
doc.selection = Some(mask);
Ok(())
})
.unwrap();
let before = surface(&s);
subset(&mut s);
s.execute("image.adjustments.invert", json!({})).unwrap();
let after = surface(&s);
for (x, y) in [(0, 0), (12, 10), (31, 23)] {
let selected = Rect::new(8, 6, 20, 16).contains(x, y) != inverse;
let old = before.pixel(x, y);
let new = after.pixel(x, y);
for ch in [1, 3] {
assert_eq!(old[ch].to_bits(), new[ch].to_bits());
}
if selected {
assert!((new[0] - (1.0 - old[0])).abs() < 2.0 / 65535.0, "depth={depth} inverse={inverse} xy={x},{y} old={old:?} new={new:?}");
assert!((new[2] - (256.0 / 255.0 - old[2])).abs() < 2.0 / 65535.0);
} else {
assert_eq!(old, new);
}
}
s.undo();
assert_eq!(surface(&s), before);
}
}
}
#[test]
fn bucket_and_gradient_edit_the_explicit_layer_and_restrict_that_same_layer() {
for (command, mut params) in [
("paint.bucket", json!({"x":10,"y":10,"tolerance":255,"color":"#ffffff"})),
("paint.gradient", json!({"from":[0,0],"to":[31,0],"colors":["#ffffff","#000000"]})),
] {
let mut s = setup("rgb", 16);
let id = s.active().unwrap().active_layer.unwrap();
let before = surface(&s);
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color":"#336699"})).unwrap();
let active = surface(&s);
subset(&mut s);
params["layer"] = json!(id.0);
s.execute(command, params).unwrap();
assert_eq!(surface(&s), active);
let changed = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
unchanged(&before, changed);
assert_ne!(&before, changed);
s.undo();
assert_eq!(s.active().unwrap().doc.layer(id).unwrap().surface().unwrap(), &before);
}
}
#[test]
fn legacy_cmyk_channel_clips_keep_their_tone_when_pasted_into_a_set_or_single_channel() {
for dest in [json!({"colors":[0,2]}), json!({"channel":{"color":2}})] {
for depth in [8, 16, 32] {
let mut s = setup("cmyk", depth);
s.edit("inks", |doc, id| {
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.2, 0.4, 0.6, 0.1, 0.65]);
Ok(())
})
.unwrap();
s.execute("channel.target", json!({"channel":{"color":0}})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
assert!((s.clipboard.as_ref().unwrap().surface.pixel(4, 4)[0] - 0.8).abs() < 0.005);
s.execute("channel.target", dest.clone()).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert!((surface(&s).pixel(4, 4)[2] - 0.2).abs() < 0.005, "depth={depth} target={dest}");
}
}
}
#[test]
fn cmyk_grayscale_clips_roundtrip_explicit_and_limited_paste_at_every_depth() {
for depth in [8, 16, 32] {
for explicit_copy in [false, true] {
for command in ["edit.paste", "edit.pasteSpecial.pasteInto", "edit.pasteSpecial.pasteOutside"] {
let mut s = setup("cmyk", depth);
s.execute("channel.target", json!({"channel":{"color":0}})).unwrap();
let before = surface(&s);
s.execute("edit.copy", if explicit_copy { json!({"target":{"color":0}}) } else { json!({}) }).unwrap();
s.edit("clear cyan", |doc, id| {
let surf = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap();
let mut raw = surf.read_region(Rect::new(0, 0, 32, 24));
for p in raw.as_chunks_mut::<5>().0 {
p[0] = 0.93;
}
surf.write_region(Rect::new(0, 0, 32, 24), &raw);
Ok(())
})
.unwrap();
if command != "edit.paste" {
s.execute("select.rect", json!({"x":0,"y":0,"width":16,"height":24})).unwrap();
}
s.execute(command, json!({"target":{"color":0},"center":[16,12]})).unwrap();
let after = surface(&s);
let x = if command.ends_with("pasteOutside") { 20 } else { 4 };
assert!((before.pixel(x, 8)[0] - after.pixel(x, 8)[0]).abs() < 2.0 / 255.0, "{depth} {explicit_copy} {command}");
}
}
}
}
#[test]
fn last_filter_rejects_non_object_journal_params_without_panicking() {
for saved in [json!(17), json!(true), json!([]), json!("bad")] {
let mut s = setup("rgb", 16);
s.journal.push(("filter.blur.gaussianBlur".into(), saved));
let before = surface(&s);
let p = json!({"target":{"colors":[0,2]},"layer":s.active().unwrap().active_layer.unwrap().0});
assert!(s.execute("filter.lastFilter", p.clone()).is_err());
let spec = photocraft_engine::command_specs().iter().find(|s| s.id == "filter.lastFilter").unwrap();
assert!((spec.run)(&mut s, &p).is_err());
assert_eq!(surface(&s), before);
}
let mut s = setup("rgb", 16);
s.journal.push(("filter.blur.gaussianBlur".into(), json!(null)));
s.execute("filter.lastFilter", json!({"target":{"color":0}})).unwrap();
}
#[test]
fn first_single_channel_retouch_preview_preserves_every_other_sample() {
for cmd in ["paint.dodge", "paint.burn", "paint.sponge", "paint.blur", "paint.sharpen", "paint.smudge", "paint.colorReplacement"] {
// Color Replacement refuses 32-bit RGB (#11923): it is checked at 16 bits.
let mut s = setup("rgb", if cmd == "paint.colorReplacement" { 16 } else { 32 });
s.execute("channel.target", json!({"channel":{"color":0}})).unwrap();
let before = surface(&s);
let p = json!({"points":[[6,7,1],[23,15,0.7]],"size":9,"color":"#ffffff","tolerance":100});
let live = if cmd == "paint.colorReplacement" {
photocraft_engine::brush_cmds::live_color_replacement(&s, &p).unwrap()
} else {
photocraft_engine::retouch_cmds::LiveDab::begin(&s, cmd, &p).unwrap()
};
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap();
for y in 0..24 {
for x in 0..32 {
for k in [1, 2, 3] {
assert_eq!(before.pixel(x, y)[k].to_bits(), shown.pixel(x, y)[k].to_bits(), "{cmd} {x} {y} {k}");
}
}
}
if cmd != "paint.colorReplacement" {
s.offer_live_dab(live).unwrap();
}
s.execute(cmd, p).unwrap();
}
}
#[test]
fn last_filter_forwards_explicit_target_before_implicit_panel_selection() {
let mut s = setup("rgb", 32);
subset(&mut s);
s.execute("filter.noise.addNoise", json!({"amount":20,"seed":42})).unwrap();
let before = surface(&s);
s.execute("filter.lastFilter", json!({"target":{"colors":[1]}})).unwrap();
let after = surface(&s);
assert_ne!(before, after);
for y in 0..24 {
for x in 0..32 {
for k in [0, 2, 3] {
assert_eq!(before.pixel(x, y)[k].to_bits(), after.pixel(x, y)[k].to_bits());
}
}
}
}
#[test]
fn selected_channel_paste_keeps_source_document_placement_and_centers_across_documents() {
for target in [None, Some(json!({"color":0})), Some(json!({"colors":[0,2]}))] {
let mut s = setup("rgb", 16);
let source = s.active().unwrap().doc.id;
s.execute("channel.target", json!({"channel":"red"})).unwrap();
s.execute("select.rect", json!({"x":2,"y":3,"width":4,"height":4})).unwrap();
let params = target.as_ref().map_or(json!({}), |target| json!({"target":target}));
s.execute("edit.copy", params.clone()).unwrap();
assert_eq!(s.clipboard.as_ref().unwrap().source, Some(source));
s.execute("select.deselect", json!({})).unwrap();
assert_eq!(s.execute("edit.paste", params.clone()).unwrap()["offset"], json!([0, 0]), "same document: {target:?}");
s.execute("file.new", json!({"width":40,"height":32,"depth":16})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("channel.target", json!({"channel":"red"})).unwrap();
assert_ne!(s.active().unwrap().doc.id, source);
assert_eq!(s.execute("edit.paste", params).unwrap()["offset"], json!([16, 11]), "another document: {target:?}");
}
}
#[test]
fn native_plane_writeback_rejects_incompatible_formats_without_editing() {
let mut dest = surface(&setup("rgb", 16));
let before = dest.clone();
for format in [
photocraft_color::PixelFormat::GRAY8,
photocraft_color::PixelFormat::RGBA8,
photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, SampleType::U16, true),
] {
let plane = Surface::new(format);
assert!(color_channels::scatter(&mut dest, &plane, 0, Rect::new(0, 0, 32, 24)).is_err());
assert_eq!(dest, before);
}
}
#[test]
fn lab_single_channel_tone_parameters_round_trip_without_applying_lightness_twice() {
use photocraft_engine::adjust_params;
for (kind, params) in [("levels", json!({"lightness":{"gamma":2.0}})), ("curves", json!({"lightness":[[0,0],[128,200],[255,255]]}))] {
let mut raw = setup("lab", 32);
let mut canonical = setup("lab", 32);
for session in [&mut raw, &mut canonical] {
session.execute("channel.target", json!({"channel":{"color":0}})).unwrap();
}
let serialized = adjust_params::to_params(&adjust_params::from_params(kind, &params, None, photocraft_color::ColorMode::Lab).unwrap());
let command = format!("image.adjustments.{kind}");
raw.execute(&command, params).unwrap();
canonical.execute(&command, serialized).unwrap();
assert_eq!(surface(&raw), surface(&canonical));
}
}
fn flat_rgb() -> Session {
flat_rgb_at(32)
}
fn flat_rgb_at(depth: u32) -> Session {
let mut s = setup("rgb", depth);
s.edit("flat", |doc, id| {
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.25, 0.5, 0.25, 1.0]);
Ok(())
})
.unwrap();
s
}
#[test]
fn noise_monochromatic_and_native_halftone_angles_keep_their_meaning_for_subsets() {
let mut mono = flat_rgb();
let mut color = flat_rgb();
for s in [&mut mono, &mut color] {
subset(s);
}
mono.execute("filter.noise.addNoise", json!({"amount":50,"seed":42,"monochromatic":true})).unwrap();
color.execute("filter.noise.addNoise", json!({"amount":50,"seed":42,"monochromatic":false})).unwrap();
assert_eq!(surface(&mono).pixel(4, 12)[0], surface(&mono).pixel(4, 12)[2]);
assert_ne!(surface(&mono), surface(&color));
assert_ne!(surface(&color).pixel(4, 12)[0], surface(&color).pixel(4, 12)[2]);
let mut a = flat_rgb();
let mut b = flat_rgb();
for s in [&mut a, &mut b] {
subset(s);
}
a.execute("filter.pixelate.colorHalftone", json!({"maxRadius":4,"channel3":0})).unwrap();
b.execute("filter.pixelate.colorHalftone", json!({"maxRadius":4,"channel3":35})).unwrap();
let a = surface(&a);
let b = surface(&b);
assert_ne!(a, b);
for y in 0..24 {
for x in 0..32 {
assert_eq!(a.pixel(x, y)[0].to_bits(), b.pixel(x, y)[0].to_bits());
}
}
}
#[test]
fn blur_reads_opaque_black_pixels_outside_the_canvas() {
let mut composite = flat_rgb();
let mut partial = flat_rgb();
for s in [&mut composite, &mut partial] {
s.edit("opaque black border", |doc, id| {
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(32, 0, 48, 24), &[0.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
}
subset(&mut partial);
let before = surface(&partial);
for s in [&mut composite, &mut partial] {
s.execute("filter.blur.gaussianBlur", json!({"radius":3})).unwrap();
}
let expected = surface(&composite).pixel(31, 12);
let actual = surface(&partial).pixel(31, 12);
assert!((actual[0] - expected[0]).abs() < 1e-6, "{actual:?} != {expected:?}");
assert!((actual[2] - expected[2]).abs() < 1e-6);
unchanged(&before, &surface(&partial));
}
#[test]
fn small_filter_selections_preserve_shared_tiles_outside_the_selection() {
let mut s = setup("rgb", 32);
s.edit("larger flat image", |doc, id| {
doc.size.width = 512;
doc.size.height = 512;
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 512, 512), &[0.25, 0.5, 0.25, 1.0]);
Ok(())
})
.unwrap();
subset(&mut s);
s.execute("select.rect", json!({"x":0,"y":0,"width":16,"height":16})).unwrap();
let before = surface(&s);
s.execute("filter.noise.addNoise", json!({"amount":50,"seed":42})).unwrap();
let after = surface(&s);
let distant = photocraft_geom::TileCoord { tx: 1, ty: 1 };
assert_eq!(before.pixel(300, 300), after.pixel(300, 300));
assert!(std::sync::Arc::ptr_eq(before.tile(distant).unwrap(), after.tile(distant).unwrap()));
}
#[test]
fn rgb_component_exposure_uses_the_document_transfer_at_all_depths() {
for depth in [8, 16, 32] {
for colors in [vec![0usize], vec![0, 2]] {
for selection in [false, true] {
let mut composite = setup("rgb", depth);
let mut partial = setup("rgb", depth);
for s in [&mut composite, &mut partial] {
s.edit("midgray", |doc, id| {
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.5, 0.5, 0.5, 1.0]);
Ok(())
})
.unwrap();
if selection {
s.execute("select.rect", json!({"x":4,"y":4,"width":20,"height":16})).unwrap();
}
}
let before = surface(&partial);
partial.execute("channel.target", json!({"colors":colors})).unwrap();
for s in [&mut composite, &mut partial] {
s.execute("image.adjustments.exposure", json!({"exposure":1})).unwrap();
}
let expected = surface(&composite);
let actual = surface(&partial);
for (x, y) in [(0, 0), (10, 10)] {
for k in 0..4 {
if colors.contains(&k) {
let tolerance = if depth == 8 {
1.1 / 255.0
} else if depth == 16 {
1.1 / 65535.0
} else {
1e-6
};
assert!((actual.pixel(x, y)[k] - expected.pixel(x, y)[k]).abs() < tolerance, "RGB{depth} {colors:?} {selection}");
} else {
assert_eq!(actual.pixel(x, y)[k].to_bits(), before.pixel(x, y)[k].to_bits());
}
}
}
}
}
}
}
#[test]
fn color_replacement_live_preview_forwards_explicit_component_and_pixel_targets() {
for target in [json!({"colors":[1]}), json!("pixels")] {
// 16-bit: Color Replacement refuses 32-bit RGB (#11923).
let mut s = flat_rgb_at(16);
subset(&mut s);
let params = json!({"points":[[8,8],[22,12]],"size":9,"tolerance":100,"mode":"luminosity","color":"#ffffff","seed":42,"target":target});
let live = photocraft_engine::brush_cmds::live_color_replacement(&s, &params).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let preview = live.doc.layer(id).unwrap().surface().unwrap().clone();
s.execute("paint.colorReplacement", params).unwrap();
assert_eq!(surface(&s), preview);
}
}
#[test]
fn symmetry_settings_remain_available_on_non_raster_layers_with_selected_channels() {
let mut s = setup("rgb", 8);
subset(&mut s);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let before = s.active().unwrap().doc.clone();
s.execute("paint.setSymmetry", json!({"mode":"vertical"})).unwrap();
s.execute("paint.symmetryDisable", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("path.set", json!({"name":"work","path":{"subpaths":[{"closed":false,"knots":[[16,0],[16,24]]}]}})).unwrap();
s.execute("paint.symmetryFromPath", json!({"name":"work"})).unwrap();
s.execute("paint.symmetryDisable", json!({})).unwrap();
}
#[test]
fn remove_cannot_bypass_component_protection_by_creating_a_new_layer() {
for colors in [json!([0]), json!([0, 2])] {
let mut s = setup("rgb", 8);
s.execute("channel.target", json!({"colors":colors})).unwrap();
let before = s.active().unwrap().doc.clone();
assert!(s.execute("paint.remove", json!({"points":[[8,8],[22,12]],"size":9,"newLayer":true})).is_err());
assert_eq!(s.active().unwrap().doc, before);
}
}
#[test]
fn hdr_toning_cannot_flatten_a_document_when_colour_components_are_targeted() {
let mut s = setup("rgb", 32);
subset(&mut s);
let before = s.active().unwrap().doc.clone();
assert!(!s.is_enabled("image.adjustments.hdrToning"));
assert!(s.execute("image.adjustments.hdrToning", json!({})).is_err());
assert_eq!(s.active().unwrap().doc, before);
}
#[test]
fn sparse_neighbourhood_filters_keep_output_support_separate_from_sampling_extent() {
let mut s = Session::new();
s.execute("file.new", json!({"width":2048,"height":2048,"depth":32,"background":"transparent"})).unwrap();
s.edit("small opaque patch", |doc, id| {
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 32), &[0.2, 0.4, 0.6, 1.0]);
Ok(())
})
.unwrap();
subset(&mut s);
let before = surface(&s);
s.execute("filter.other.highPass", json!({"radius":2})).unwrap();
let after = surface(&s);
assert!(after.tile_count() <= 4, "materialized {} tiles", after.tile_count());
assert_eq!(after.pixel(1500, 1500), before.pixel(1500, 1500));
unchanged(&before, &after);
assert_ne!(before.pixel(16, 16), after.pixel(16, 16));
}
#[test]
fn singleton_copy_keeps_scalar_tone_for_alpha_and_mask_clipboard_consumers() {
for mode in ["rgb", "cmyk", "lab"] {
for depth in [8, 16, 32] {
for mask in [false, true] {
let mut s = setup(mode, depth);
s.edit("uniform", |doc, id| {
let surf = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap();
let mut value = vec![0.3; surf.channels()];
*value.last_mut().unwrap() = 0.65;
surf.fill_rect(Rect::new(0, 0, 32, 24), &value);
Ok(())
})
.unwrap();
s.execute("edit.copy", json!({"target":{"colors":[0]}})).unwrap();
if mask {
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("channel.target", json!({"channel":"mask"})).unwrap();
} else {
s.execute("channel.new", json!({"fill":"white"})).unwrap();
}
s.execute("edit.paste", json!({})).unwrap();
let st = s.active().unwrap();
let dest = if mask { &st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface } else { &st.doc.channels[0].surface };
let expected = if mode == "cmyk" { 0.7 } else { 0.3 };
assert!((dest.pixel(8, 8)[0] - expected).abs() < 0.005, "{mode}{depth} mask={mask}");
}
}
}
}
#[test]
fn named_layers_resolve_identically_for_painting_and_component_restoration() {
for (command, mut params) in [
("paint.stroke", json!({"points":[[8,8],[22,12]],"size":9,"color":"#ffffff","seed":42})),
("paint.blur", json!({"points":[[8,8],[22,12]],"size":9,"strength":80})),
("filter.blur.gaussianBlur", json!({"radius":2})),
("filter.blur.average", json!({})),
("image.adjustments.equalize", json!({})),
("image.adjustments.shadowsHighlights", json!({"shadowAmount":50})),
("filter.lensCorrection", json!({"distortion":20})),
("paint.magicEraser", json!({"x":10,"y":10,"tolerance":255})),
("paint.backgroundEraser", json!({"points":[[8,8],[22,12]],"size":9,"tolerance":100})),
("path.fill", json!({"color":"#ffffff"})),
("path.stroke", json!({"size":9,"color":"#ffffff"})),
] {
let mut s = setup("rgb", 32);
let id = s.active().unwrap().active_layer.unwrap();
let name = s.active().unwrap().doc.layer(id).unwrap().name.clone();
let before = surface(&s);
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color":"#333333"})).unwrap();
let active = surface(&s);
s.execute("path.set", json!({"path":{"subpaths":[{"closed":true,"knots":[[2,2],[25,2],[25,20],[2,20]]}]}})).unwrap();
subset(&mut s);
params["layer"] = json!(name);
s.execute(command, params).unwrap();
assert!(surface(&s) == active, "{command} must not change the active layer");
let changed = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
unchanged(&before, changed);
assert!(&before != changed, "{command} must change the named layer");
}
}
#[test]
fn patch_and_content_aware_move_previews_protect_the_same_components_as_commit() {
use photocraft_engine::retouch_cmds;
for command in ["paint.patch", "paint.contentAwareMove"] {
let mut s = Session::new();
s.execute("file.new", json!({"width":100,"height":60})).unwrap();
s.execute("paint.pencil", json!({"points":[[68,30],[74,30]],"size":6,"color":"#ff0000"})).unwrap();
s.execute("select.lasso", json!({"points":[[60,20],[84,20],[84,40],[60,40]]})).unwrap();
subset(&mut s);
let id = s.active().unwrap().active_layer.unwrap();
let before = surface(&s);
let params = json!({"offset":[-40,0]});
let preview = if command == "paint.patch" {
retouch_cmds::patch_preview(&s, &params, 16384, 1).unwrap().0
} else {
retouch_cmds::content_aware_move_preview(&s, &params).unwrap().run(&photocraft_engine::jobs::JobCtx::new()).unwrap().0
};
unchanged(&before, preview.layer(id).unwrap().surface().unwrap());
assert_eq!(surface(&s), before, "preview is read-only");
s.execute(command, params).unwrap();
assert_eq!(&surface(&s), preview.layer(id).unwrap().surface().unwrap());
}
}
#[test]
fn lens_blur_keeps_original_transparency_depth_and_component_noise_streams() {
let mut composite = flat_rgb();
let mut partial = flat_rgb();
for s in [&mut composite, &mut partial] {
s.edit("focus alpha", |doc, id| {
let surf = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap();
surf.fill_rect(Rect::new(0, 0, 16, 24), &[0.2, 0.2, 0.2, 128.0 / 255.0]);
surf.fill_rect(Rect::new(16, 0, 32, 24), &[0.8, 0.2, 0.8, 128.0 / 255.0]);
Ok(())
})
.unwrap();
}
subset(&mut partial);
let before = surface(&partial);
for s in [&mut composite, &mut partial] {
s.execute("filter.blur.lensBlur", json!({"radius":8,"depthMap":"transparency","focalDistance":128})).unwrap();
}
let expected = surface(&composite).pixel(15, 12);
let actual = surface(&partial).pixel(15, 12);
for k in [0, 2] {
assert!((expected[k] - actual[k]).abs() < 1e-5, "{expected:?} != {actual:?}");
}
unchanged(&before, &surface(&partial));
let mut mono = flat_rgb();
let mut color = flat_rgb();
for s in [&mut mono, &mut color] {
subset(s);
}
mono.execute("filter.blur.lensBlur", json!({"radius":1,"noise":30,"seed":42,"monochromatic":true})).unwrap();
color.execute("filter.blur.lensBlur", json!({"radius":1,"noise":30,"seed":42,"monochromatic":false})).unwrap();
assert_eq!(surface(&mono).pixel(4, 12)[0], surface(&mono).pixel(4, 12)[2]);
assert_ne!(surface(&color).pixel(4, 12)[0], surface(&color).pixel(4, 12)[2]);
}
#[test]
fn coloured_clouds_and_fibers_keep_native_render_colours_on_selected_channels() {
for command in ["filter.render.clouds", "filter.render.fibers"] {
let mut composite = flat_rgb();
let mut partial = flat_rgb();
for s in [&mut composite, &mut partial] {
s.tools.foreground = [1.0, 0.0, 0.0, 1.0];
s.tools.background = [0.0, 0.0, 1.0, 1.0];
}
subset(&mut partial);
let before = surface(&partial);
for s in [&mut composite, &mut partial] {
s.execute(command, json!({"seed":42})).unwrap();
}
let expected = surface(&composite);
let actual = surface(&partial);
unchanged(&before, &actual);
for y in 0..24 {
for x in 0..32 {
for k in [0, 2] {
assert_eq!(actual.pixel(x, y)[k].to_bits(), expected.pixel(x, y)[k].to_bits(), "{command}");
}
}
}
assert_ne!(actual.pixel(8, 8)[0], actual.pixel(8, 8)[2]);
}
}
#[test]
fn lighting_effects_keep_the_original_alpha_texture_for_colour_targets() {
let mut composite = flat_rgb();
let mut partial = flat_rgb();
for s in [&mut composite, &mut partial] {
s.edit("alpha relief", |doc, id| {
let surf = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap();
surf.fill_rect(Rect::new(0, 0, 16, 24), &[0.5, 0.5, 0.5, 0.2]);
surf.fill_rect(Rect::new(16, 0, 32, 24), &[0.5, 0.5, 0.5, 0.8]);
Ok(())
})
.unwrap();
}
subset(&mut partial);
let before = surface(&partial);
for s in [&mut composite, &mut partial] {
s.execute("filter.render.lightingEffects", json!({"texture":"alpha","height":100,"gloss":-100,"metallic":-100})).unwrap();
}
let expected = surface(&composite);
let actual = surface(&partial);
unchanged(&before, &actual);
for y in 0..24 {
for x in 0..32 {
for k in [0, 2] {
assert_eq!(actual.pixel(x, y)[k].to_bits(), expected.pixel(x, y)[k].to_bits());
}
}
}
}
#[test]
fn mode_conversion_normalizes_a_now_complete_colour_set_to_composite() {
let mut s = setup("cmyk", 8);
s.execute("channel.target", json!({"colors":[0,1,2]})).unwrap();
s.execute("image.mode.rgb", json!({})).unwrap();
assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::Composite);
assert!(color_channels::selected(&s).is_none());
assert!(s.active().unwrap().channel_view.selected_colors.is_empty());
assert!(s.is_enabled("layer.translate"));
assert!(s.is_enabled("filter.cameraRaw"));
}
#[test]
fn colour_only_edits_cannot_create_an_unrestricted_render_layer() {
let mut s = setup("rgb", 8);
subset(&mut s);
let before = s.active().unwrap().doc.clone();
for command in ["paint.remove", "filter.render.tree", "filter.render.flame", "filter.render.pictureFrame"] {
assert!(s.execute(command, json!({"newLayer":true})).is_err(), "{command}");
assert_eq!(&*s.active().unwrap().doc, &*before);
}
}
#[test]
fn selections_with_a_selected_default_and_stored_holes_edit_the_remaining_components() {
for depth in [8, 16, 32] {
let mut s = setup("rgb", depth);
let before = surface(&s);
s.edit("selected default with a hole", |doc, _| {
let mut mask = Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]);
mask.fill_rect(Rect::new(0, 0, 4, 4), &[0.0]);
doc.selection = Some(mask);
Ok(())
})
.unwrap();
subset(&mut s);
s.execute("image.adjustments.invert", json!({})).unwrap();
let after = surface(&s);
unchanged(&before, &after);
assert_eq!(before.pixel(1, 1), after.pixel(1, 1));
for k in [0, 2] {
assert!((after.pixel(10, 10)[k] - (1.0 - before.pixel(10, 10)[k])).abs() < 0.005, "depth {depth} component {k}");
}
}
}
#[test]
fn geometric_filters_on_colour_sets_preserve_background_identity_and_linked_masks() {
for (command, params) in [
("filter.lensCorrection", json!({"distortion":20})),
("filter.liquify", json!({"strokes":[{"tool":"forwardWarp","size":12,"points":[[10,10],[15,13]]}]})),
] {
let mut s = Session::new();
s.execute("file.new", json!({"width":32,"height":24})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.edit("coloured background and linked mask", |doc, _| {
let layer = doc.layer_mut(id).unwrap();
layer.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 12, 24), &[0.2, 0.4, 0.6, 1.0]);
let mut mask = photocraft_doc::LayerMask::reveal_all();
mask.surface.fill_rect(Rect::new(0, 0, 10, 24), &[0.3]);
layer.mask = Some(mask);
Ok(())
})
.unwrap();
let original = s.active().unwrap().doc.layer(id).unwrap().clone();
let before = surface(&s);
subset(&mut s);
s.execute(command, params).unwrap();
let layer = s.active().unwrap().doc.layer(id).unwrap();
assert_eq!(layer.name, original.name);
assert_eq!(layer.locks, original.locks);
assert_eq!(layer.mask, original.mask);
unchanged(&before, layer.surface().unwrap());
assert!(layer.surface().unwrap() != &before, "{command}");
}
}
#[test]
fn colour_adjustments_keep_the_original_cross_component_inputs() {
for mode in ["rgb", "cmyk", "lab"] {
for depth in [8, 16, 32] {
for (cmd, params) in [
("image.adjustments.channelMixer", json!({"red":[0,100,0,0]})),
("image.adjustments.hueSaturation", json!({"saturation":-30})),
("image.adjustments.vibrance", json!({"vibrance":40})),
("image.adjustments.colorBalance", json!({"midtones":[20,-10,30]})),
("image.adjustments.photoFilter", json!({"color":"#ff0000","density":70})),
] {
let mut control = setup(mode, depth);
let before = surface(&control);
control.execute(cmd, params.clone()).unwrap();
let composite = surface(&control);
for indices in [vec![0], vec![0, 2]] {
let mut s = setup(mode, depth);
s.execute("channel.target", json!({"colors":indices})).unwrap();
s.execute(cmd, params.clone()).unwrap();
let mut expected = composite.clone();
let colors = color_channels::ColorChannels::from_indices(indices, before.format().mode.color_channels()).unwrap();
color_channels::restrict_surface(&before, &mut expected, colors).unwrap();
assert!(surface(&s) == expected, "{mode} {depth} {cmd}");
}
}
}
}
}
#[test]
fn named_patch_and_content_aware_move_targets_do_not_edit_the_active_layer() {
for command in ["paint.patch", "paint.contentAwareMove"] {
let mut s = Session::new();
s.execute("file.new", json!({"width":100,"height":60})).unwrap();
s.execute("layer.new.layer", json!({"name":"other"})).unwrap();
s.execute("edit.fill", json!({"color":"#ffffff"})).unwrap();
s.execute("paint.pencil", json!({"points":[[68,30],[74,30]],"size":6,"color":"#ff0000"})).unwrap();
s.execute("select.lasso", json!({"points":[[60,20],[84,20],[84,40],[60,40]]})).unwrap();
subset(&mut s);
let before = surface(&s);
s.execute(command, json!({"offset":[-40,0],"layer":"Background"})).unwrap();
assert!(surface(&s) == before, "{command} must leave the active layer unchanged");
}
}
#[test]
fn colour_only_rendering_needs_an_existing_raster_destination() {
let mut s = setup("rgb", 8);
s.execute("select.deselectLayers", json!({})).unwrap();
subset(&mut s);
let before = s.active().unwrap().doc.clone();
for cmd in ["filter.render.tree", "filter.render.flame", "filter.render.pictureFrame"] {
assert!(!s.is_enabled(cmd));
assert!(s.execute(cmd, json!({})).is_err());
assert!(std::sync::Arc::ptr_eq(&before, &s.active().unwrap().doc));
}
}
#[test]
fn rgb_float_curves_keep_endpoint_extrapolation_in_selected_planes() {
let params = json!({"points":[[0,0],[128,180],[255,255]]});
for value in [-0.2, 1.5, 2.0] {
for selected in [false, true] {
for indices in [vec![0], vec![0, 2]] {
let mut s = setup("rgb", 32);
s.edit("HDR fixture", |doc, id| {
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[value, 0.4, value, 0.7]);
Ok(())
})
.unwrap();
if selected {
s.execute("select.rect", json!({"x":0,"y":0,"width":16,"height":24})).unwrap();
}
let before = surface(&s);
let mut control = Session::new();
control.add_document((*s.active().unwrap().doc).clone(), None);
control.select_layer(s.active().unwrap().active_layer.unwrap()).unwrap();
control.execute("image.adjustments.curves", params.clone()).unwrap();
let expected = surface(&control);
s.execute("channel.target", json!({"colors":indices})).unwrap();
s.execute("image.adjustments.curves", params.clone()).unwrap();
let after = surface(&s);
for (x, y) in [(10, 10), (25, 10)] {
for k in 0..4 {
if indices.contains(&k) {
assert!((after.pixel(x, y)[k] - expected.pixel(x, y)[k]).abs() < 1e-5, "value {value}, selection {selected}, component {k}");
} else {
assert_eq!(after.pixel(x, y)[k].to_bits(), before.pixel(x, y)[k].to_bits());
}
}
}
}
}
}
}
#[test]
fn clear_and_cut_in_mask_view_preserve_pixels_and_clear_only_selected_mask_samples() {
for mode in ["rgb", "cmyk", "lab"] {
for depth in [8, 16, 32] {
for command in ["edit.clear", "edit.cut"] {
let mut s = setup(mode, depth);
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
subset(&mut s);
s.execute("view.layerMask", json!({"mode":"gray"})).unwrap();
s.execute("tools.setColors", json!({"background":"#000000"})).unwrap();
s.edit("Feathered selection", |doc, _| {
let mut mask = Surface::new(photocraft_color::PixelFormat::GRAY8);
mask.fill_rect(Rect::new(4, 4, 12, 12), &[0.5]);
doc.selection = Some(mask);
Ok(())
})
.unwrap();
let before = surface(&s);
let layer = s.active().unwrap().active_layer.unwrap();
let mask = |s: &Session| s.active().unwrap().doc.layer(layer).unwrap().mask.as_ref().unwrap().surface.clone();
let mask_before = mask(&s);
let steps = s.active().unwrap().history.past_len();
s.execute(command, json!({})).unwrap();
assert_eq!(surface(&s), before, "{command} {mode}@{depth}");
let cleared = mask(&s);
assert!((cleared.sample_channel(8, 8, 0) - (1.0 - 128.0 / 255.0)).abs() < 0.005);
assert_eq!(cleared.sample_channel(20, 20, 0), 1.0);
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
if command == "edit.cut" {
let clip = s.clipboard.as_ref().unwrap();
assert_eq!(clip.surface.format().mode, photocraft_doc::ColorMode::Grayscale);
assert_eq!(clip.surface.sample_channel(8, 8, 0), 1.0);
assert!(clip.components.is_none());
}
s.undo();
assert_eq!(mask(&s), mask_before);
assert_eq!(surface(&s), before);
s.redo();
assert_eq!(mask(&s), cleared);
}
}
}
}
#[test]
fn refused_mask_cut_and_clear_leave_the_pixels_mask_and_clipboard_unchanged() {
for command in ["edit.cut", "edit.clear"] {
let mut s = setup("rgb", 16);
let before = surface(&s);
assert!(s.execute(command, json!({"target":"mask"})).is_err());
assert_eq!(surface(&s), before);
s.execute("edit.copy", json!({})).unwrap();
let clipboard = s.clipboard.as_ref().unwrap().surface.clone();
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
subset(&mut s);
s.execute("view.layerMask", json!({"mode":"gray"})).unwrap();
s.edit("Lock layer", |doc, id| {
doc.layer_mut(id.unwrap()).unwrap().locks.all = true;
Ok(())
})
.unwrap();
let doc = s.active().unwrap().doc.clone();
let layer = s.active().unwrap().active_layer.unwrap();
let steps = s.active().unwrap().history.past_len();
assert!(s.execute(command, json!({})).is_err());
assert_eq!(surface(&s), before);
assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().mask, doc.layer(layer).unwrap().mask);
assert_eq!(s.clipboard.as_ref().unwrap().surface, clipboard);
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
}
#[test]
fn desaturate_honours_explicit_native_targets_over_the_panel_selection() {
for mode in ["rgb", "cmyk", "lab"] {
for depth in [8, 16, 32] {
for target in [json!({"color":1}), json!({"colors":[1]}), json!("pixels")] {
let mut s = setup(mode, depth);
let before = surface(&s);
let mut control = Session::new();
let st = s.active().unwrap();
control.add_document((*st.doc).clone(), None);
control.select_layer(st.active_layer.unwrap()).unwrap();
control.execute("image.adjustments.desaturate", json!({})).unwrap();
let expected = surface(&control);
subset(&mut s);
s.execute("image.adjustments.desaturate", json!({"target":target})).unwrap();
let actual = surface(&s);
assert_ne!(actual, before, "{mode}@{depth} {target}");
for y in 0..24 {
for x in 0..32 {
for k in 0..actual.channels() {
let want = if target == "pixels" || k == 1 { &expected } else { &before };
assert_eq!(
actual.sample_channel(x, y, k).to_bits(),
want.sample_channel(x, y, k).to_bits(),
"{mode}@{depth} {target} component {k}"
);
}
}
}
}
}
}
}
#[test]
fn mask_view_routes_implicit_edits_and_tone_editors_to_the_mask_before_colour_sets() {
for mode in ["rgb", "cmyk", "lab"] {
for depth in [8, 16, 32] {
for view in ["gray", "overlay"] {
for colors in [vec![0], vec![0, 2]] {
let mut s = setup(mode, depth);
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("channel.target", json!({"colors":colors})).unwrap();
s.execute("view.layerMask", json!({"mode":view})).unwrap();
let before = surface(&s);
let st = s.active().unwrap();
let layer = st.active_layer.unwrap();
let descriptor = photocraft_engine::channel_cmds::isolated_curve_channel(st).unwrap();
assert_eq!(descriptor.gray, Some(photocraft_engine::channel_cmds::GraySurface::LayerMask));
assert!(descriptor.components.is_none());
s.execute("image.adjustments.invert", json!({})).unwrap();
assert_eq!(surface(&s), before);
let mask = |s: &Session| s.active().unwrap().doc.layer(layer).unwrap().mask.as_ref().unwrap().surface.clone();
assert_eq!(mask(&s).sample_channel(8, 8, 0), 0.0);
s.execute("paint.stroke", json!({"points":[[8,8]],"size":6,"hardness":1.0,"color":"#ffffff"})).unwrap();
assert!(mask(&s).sample_channel(8, 8, 0) > 0.9);
s.execute("filter.blur.gaussianBlur", json!({"radius":2})).unwrap();
s.execute("image.adjustments.desaturate", json!({})).unwrap();
assert_eq!(surface(&s), before);
let mask_before = mask(&s);
// An explicit native target can still override the viewed mask.
s.execute("image.adjustments.invert", json!({"target":{"colors":colors}})).unwrap();
assert_ne!(surface(&s), before);
assert_eq!(mask(&s), mask_before);
s.execute("channel.target", json!({"colors":colors})).unwrap();
assert!(photocraft_engine::mask_view_cmds::current(s.active().unwrap()).is_none(), "selecting colours leaves mask view");
s.execute("view.layerMask", json!({"mode":view})).unwrap();
s.execute("channel.target", json!({"channel":"composite"})).unwrap();
assert!(photocraft_engine::mask_view_cmds::current(s.active().unwrap()).is_none(), "selecting the composite leaves mask view");
}
}
}
}
}
#[test]
fn copy_and_cut_resolve_the_same_layer_for_composite_and_colour_targets() {
for indices in [vec![], vec![0], vec![0, 2]] {
let mut s = Session::new();
s.execute("file.new", json!({"width":8,"height":8,"depth":32})).unwrap();
s.execute("layer.new.layer", json!({"name":"source"})).unwrap();
s.execute("edit.fill", json!({"color":"#804020"})).unwrap();
let source_id = s.active().unwrap().active_layer.unwrap();
let source_before = surface(&s);
s.execute("layer.new.layer", json!({"name":"active"})).unwrap();
s.execute("edit.fill", json!({"color":"#00ff80"})).unwrap();
let active_before = surface(&s);
s.execute("tools.setColors", json!({"background":"#000000"})).unwrap();
if !indices.is_empty() {
s.execute("channel.target", json!({"colors":indices})).unwrap();
}
s.execute("edit.copy", json!({"layer":"source"})).unwrap();
let copied = &s.clipboard.as_ref().unwrap().surface;
let expected = if indices.is_empty() { &active_before } else { &source_before };
assert_eq!(copied.pixel(4, 4)[0], expected.pixel(4, 4)[0], "Copy {indices:?}");
assert_eq!(surface(&s), active_before);
assert_eq!(s.active().unwrap().doc.layer(source_id).unwrap().surface().unwrap(), &source_before);
s.execute("edit.cut", json!({"layer":"source"})).unwrap();
let source_after = s.active().unwrap().doc.layer(source_id).unwrap().surface().unwrap();
let clipboard = &s.clipboard.as_ref().unwrap().surface;
if indices.is_empty() {
assert!(source_after == &source_before);
assert_eq!(surface(&s).pixel(4, 4)[3], 0.0);
assert_eq!(clipboard.pixel(4, 4), active_before.pixel(4, 4));
} else {
assert!(surface(&s) == active_before);
for k in 0..4 {
let expected = if indices.contains(&k) { 0.0 } else { source_before.pixel(4, 4)[k] };
assert_eq!(source_after.pixel(4, 4)[k], expected);
}
assert!((clipboard.pixel(4, 4)[0] - source_before.pixel(4, 4)[0]).abs() < 1e-6);
}
}
}
#[test]
fn named_red_eye_target_does_not_bypass_channel_protection_on_the_active_layer() {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":64,"depth":32})).unwrap();
s.execute("layer.new.layer", json!({"name":"other"})).unwrap();
s.execute("edit.fill", json!({"color":"#404040"})).unwrap();
s.execute("layer.new.layer", json!({"name":"eye"})).unwrap();
s.edit("red pupil", |doc, id| {
let mut values = Vec::new();
for y in 0..64 {
for x in 0..64 {
let distance = ((x as f32 - 32.0).powi(2) + (y as f32 - 32.0).powi(2)).sqrt();
values.extend(if distance <= 1.2 {
[1.0; 4]
} else if distance <= 8.0 {
[0.95, 0.1, 0.1, 1.0]
} else {
[0.38, 0.28, 0.16, 1.0]
});
}
}
doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().write_region(Rect::new(0, 0, 64, 64), &values);
Ok(())
})
.unwrap();
s.execute("channel.target", json!({"colors":[1,2]})).unwrap();
let before = surface(&s);
let result = s.execute("paint.redEye", json!({"x":32,"y":32,"layer":"other"})).unwrap();
assert_eq!(result["pixels"], 0, "{result}");
assert!(surface(&s) == before);
}
#[test]
fn only_pixel_editing_paint_commands_follow_a_colour_set() {
for id in ["paint.patch", "paint.contentAwareMove", "paint.stroke", "paint.magicEraser"] {
assert!(photocraft_engine::channel_cmds::follows_target(id), "{id}");
}
for id in ["paint.mixerLoad", "paint.setSymmetry", "paint.symmetryFromPath", "paint.symmetryDisable"] {
assert!(!photocraft_engine::channel_cmds::follows_target(id), "{id}");
}
// ⌥-click with the Mixer Brush samples a fill layer with a colour set targeted: it edits
// nothing, so it is not refused with "Rasterize the layer".
let mut s = setup("rgb", 8);
s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap();
subset(&mut s);
let loaded = s.execute("paint.mixerLoad", json!({"x": 4.0, "y": 4.0})).unwrap();
assert_eq!(loaded["loaded"], json!(true));
}