mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 20:17:18 -06:00
324 lines
15 KiB
Rust
324 lines
15 KiB
Rust
//! Remove tool commands: add (automatic source), select, move target/source, edit, refresh, delete.
|
|
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
|
|
/// `library.up` / `library.down` (#1171): move by the given number of photos, clamped at the ends.
|
|
#[test]
|
|
fn up_and_down_move_by_rows_and_stop_at_the_ends() {
|
|
let mut s = Session::with_demo();
|
|
let vis = s.visible_cloned();
|
|
assert!(vis.len() >= 6);
|
|
s.selection = crate::Selection::single(vis[0]);
|
|
s.execute("library.down", &json!({"rows": 4})).unwrap();
|
|
assert_eq!(s.selection.active, Some(vis[4]));
|
|
s.execute("library.up", &json!({})).unwrap();
|
|
assert_eq!(s.selection.active, Some(vis[3]), "default is one photo (the filmstrip)");
|
|
s.execute("library.up", &json!({"rows": 99})).unwrap();
|
|
assert_eq!(s.selection.active, Some(vis[0]), "clamped at the first photo");
|
|
s.execute("library.down", &json!({"rows": u64::MAX})).unwrap();
|
|
assert_eq!(s.selection.active, vis.last().copied(), "hostile counts clamp, never overflow");
|
|
}
|
|
|
|
fn spots(s: &Session) -> Vec<lightcraft_develop::Spot> {
|
|
s.develop_of(s.active().unwrap()).unwrap().spots.clone()
|
|
}
|
|
|
|
#[test]
|
|
fn spots_select_move_edit_refresh_and_delete() {
|
|
let mut s = Session::with_demo();
|
|
// a remove spot gets a resolved source (a pin to show) and is selected
|
|
let r = s.execute("spot.add", &json!({"points": [[0.4, 0.5]], "size": 0.02})).unwrap();
|
|
assert_eq!(r["index"], 0);
|
|
assert_eq!(s.active_spot, Some(0));
|
|
let src0 = spots(&s)[0].source_offset.expect("automatic source resolved");
|
|
assert!(src0.x.hypot(src0.y) > 0.02, "the source is outside the spot: {src0:?}");
|
|
s.execute("spot.add", &json!({"mode": "clone", "points": [[0.7, 0.3]], "source": [0.1, 0.0]})).unwrap();
|
|
assert_eq!(s.active_spot, Some(1));
|
|
assert_eq!(s.execute("library.state", &json!({})).unwrap()["activeSpot"], 1);
|
|
// select
|
|
s.execute("spot.select", &json!({"index": 0})).unwrap();
|
|
assert_eq!(s.active_spot, Some(0));
|
|
assert!(s.execute("spot.select", &json!({"index": 9})).is_err());
|
|
// move the target (the source follows: its offset is relative), then the source alone
|
|
s.execute("spot.update", &json!({"move": [0.05, -0.1]})).unwrap();
|
|
let sp = &spots(&s)[0];
|
|
assert!((sp.points[0].x - 0.45).abs() < 1e-9 && (sp.points[0].y - 0.4).abs() < 1e-9);
|
|
assert_eq!(sp.source_offset, Some(src0));
|
|
s.execute("spot.update", &json!({"moveSource": [0.01, 0.02]})).unwrap();
|
|
let o = spots(&s)[0].source_offset.unwrap();
|
|
assert!((o.x - src0.x - 0.01).abs() < 1e-9 && (o.y - src0.y - 0.02).abs() < 1e-9);
|
|
// size / feather / opacity of the selected spot (clamped), one undo step each
|
|
s.execute("spot.update", &json!({"size": 0.03, "feather": 20, "opacity": 150})).unwrap();
|
|
let sp = &spots(&s)[0];
|
|
assert_eq!((sp.size, sp.feather, sp.opacity), (0.03, 20.0, 100.0));
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(spots(&s)[0].size, 0.02);
|
|
// refresh: a different source, at least a spot radius away
|
|
let before = spots(&s)[0].source_offset.unwrap();
|
|
let r = s.execute("spot.refreshSource", &json!({})).unwrap();
|
|
let after = spots(&s)[0].source_offset.unwrap();
|
|
assert!((after.x - before.x).hypot(after.y - before.y) > 0.01, "{r}");
|
|
// delete the selected spot: the selection clears; deleting before a selected one shifts it
|
|
s.execute("spot.delete", &json!({})).unwrap();
|
|
assert_eq!((spots(&s).len(), s.active_spot), (1, None));
|
|
assert_eq!(spots(&s)[0].mode, lightcraft_develop::SpotMode::Clone);
|
|
assert!(s.execute("spot.delete", &json!({})).is_err(), "nothing selected");
|
|
s.execute("spot.add", &json!({"points": [[0.2, 0.2]], "source": [0.05, 0.0]})).unwrap();
|
|
s.execute("spot.delete", &json!({"index": 0})).unwrap();
|
|
assert_eq!(s.active_spot, Some(0));
|
|
// another photo: no selection
|
|
s.execute("library.next", &json!({})).unwrap();
|
|
assert_eq!(s.active_spot, None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_content_aware_spot_has_no_source() {
|
|
let mut s = Session::with_demo();
|
|
s.execute("spot.add", &json!({"mode": "fill", "points": [[0.4, 0.5]], "size": 0.03})).unwrap();
|
|
let sp = &spots(&s)[0];
|
|
assert_eq!((sp.mode, sp.source_offset), (lightcraft_develop::SpotMode::Fill, None));
|
|
assert_eq!(serde_json::to_value(sp).unwrap()["mode"], "fill");
|
|
assert!(s.execute("spot.update", &json!({"mode": "nonsense"})).is_err());
|
|
}
|
|
|
|
fn target_session() -> Session {
|
|
let mut s = Session::with_demo();
|
|
s.execute("mask.add", &json!({"kind": "colorRange"})).unwrap();
|
|
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
|
|
s.execute("mask.addComponent", &json!({"kind": "colorRange"})).unwrap();
|
|
s.execute("mask.saveEffect", &json!({"id": 1, "name": "Existing"})).unwrap();
|
|
s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap();
|
|
for x in [0.3, 0.7] {
|
|
s.execute("spot.add", &json!({"points": [[x, 0.5]], "source": [0.1, 0.0]})).unwrap();
|
|
}
|
|
// A rejected command must preserve a redo branch as well as existing undo steps.
|
|
s.execute("mask.rename", &json!({"name": "Temporary"})).unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn target_state(s: &Session) -> serde_json::Value {
|
|
let photo = s.catalog.photo(s.active().unwrap()).unwrap();
|
|
json!({
|
|
"develop": photo.develop,
|
|
"history": photo.history,
|
|
"selection": s.selection,
|
|
"activeMask": s.active_mask,
|
|
"activeSpot": s.active_spot,
|
|
"undo": format!("{:?}", s.undo),
|
|
"redo": format!("{:?}", s.redo),
|
|
"commits": s.commits(),
|
|
"effects": s.mask_effects,
|
|
"rememberedEffects": s.mask_tool_effects,
|
|
"journal": s.journal,
|
|
"revision": s.catalog.revision,
|
|
})
|
|
}
|
|
|
|
fn rejects_target(s: &mut Session, command: &str, params: &serde_json::Value) {
|
|
let before = target_state(s);
|
|
let result = s.execute(command, params);
|
|
let after = target_state(s);
|
|
assert!(result.is_err(), "{command} {params} returned {result:?}; state unchanged: {}", before == after);
|
|
assert_eq!(after, before, "{command} {params} changed edits, selection or history on rejection");
|
|
for field in ["id", "component", "index"] {
|
|
if let Some(value) = params.get(field)
|
|
&& !value.is_null()
|
|
&& value.as_u64().is_none()
|
|
{
|
|
assert!(
|
|
result.unwrap_err().to_string().contains(&format!("`{field}`")),
|
|
"{command} must reject the malformed {field} before doing other work"
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn malformed_targets() -> Vec<serde_json::Value> {
|
|
vec![json!(-1), json!(0.5), json!("1"), json!(true), json!([]), json!({}), json!(u64::MAX)]
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_mask_ids_are_rejected_without_changes() {
|
|
let mut s = target_session();
|
|
let commands = [
|
|
("mask.delete", json!({})),
|
|
("mask.rename", json!({"name": "Wrong mask"})),
|
|
("mask.invert", json!({})),
|
|
("mask.visible", json!({"visible": false})),
|
|
("mask.duplicate", json!({})),
|
|
("mask.move", json!({"to": 0})),
|
|
("mask.adjust", json!({"values": {"exposure": 1.0}})),
|
|
("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})),
|
|
("mask.addComponent", json!({"kind": "brush"})),
|
|
("mask.component", json!({"component": 0, "action": "invert"})),
|
|
("mask.brushStroke", json!({"points": [[0.5, 0.5]]})),
|
|
("mask.sampleColor", json!({"x": 0.5, "y": 0.5})),
|
|
("mask.objectPoint", json!({"x": 0.5, "y": 0.5})),
|
|
("mask.refineDetail", json!({})),
|
|
];
|
|
for (command, mut params) in commands {
|
|
for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) {
|
|
params["id"] = target;
|
|
rejects_target(&mut s, command, ¶ms);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_mask_selection_is_rejected_without_changes() {
|
|
let mut s = target_session();
|
|
for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_297_u64)]) {
|
|
rejects_target(&mut s, "mask.select", &json!({"id": target}));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn overflowing_mask_id_does_not_wrap_to_an_existing_mask() {
|
|
let mut s = target_session();
|
|
rejects_target(&mut s, "mask.delete", &json!({"id": 4_294_967_297_u64}));
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_mask_effect_targets_are_rejected_without_changes() {
|
|
let mut s = target_session();
|
|
for (command, mut params) in [
|
|
("mask.saveEffect", json!({"name": "Wrong effect"})),
|
|
("mask.updateEffect", json!({"name": "Existing"})),
|
|
("mask.applyEffect", json!({"name": "Existing"})),
|
|
] {
|
|
for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) {
|
|
params["id"] = target;
|
|
rejects_target(&mut s, command, ¶ms);
|
|
}
|
|
}
|
|
for target in malformed_targets().into_iter().chain([json!([1, -1]), json!([1, null]), json!([1, 4_294_967_297_u64])]) {
|
|
rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": target}));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mask_effect_targets_preserve_explicit_defaults_and_lists() {
|
|
let mut s = target_session();
|
|
s.execute("mask.saveEffect", &json!({"id": 1, "name": "Explicit"})).unwrap();
|
|
assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 0.0);
|
|
for (name, target) in [("Omitted", json!({})), ("Null", json!({"id": null}))] {
|
|
let mut params = target;
|
|
params["name"] = json!(name);
|
|
s.execute("mask.saveEffect", ¶ms).unwrap();
|
|
assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 1.0);
|
|
params["name"] = json!("Existing");
|
|
s.execute("mask.updateEffect", ¶ms).unwrap();
|
|
assert_eq!(s.mask_effects[0].adjust.exposure, 1.0);
|
|
}
|
|
s.execute("mask.updateEffect", &json!({"id": 1, "name": "Existing"})).unwrap();
|
|
assert_eq!(s.mask_effects[0].adjust.exposure, 0.0);
|
|
for mut params in [json!({}), json!({"id": null}), json!({"ids": null})] {
|
|
s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap();
|
|
params["name"] = json!("Existing");
|
|
s.execute("mask.applyEffect", ¶ms).unwrap();
|
|
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].adjust.exposure, 0.0);
|
|
}
|
|
s.execute("mask.adjust", &json!({"id": 1, "values": {"exposure": 1.0}})).unwrap();
|
|
s.execute("mask.adjust", &json!({"id": 2, "values": {"exposure": 2.0}})).unwrap();
|
|
s.execute("mask.applyEffect", &json!({"name": "Existing", "ids": [1, 2]})).unwrap();
|
|
assert!(s.develop_of(s.active().unwrap()).unwrap().masks.iter().all(|m| m.adjust.exposure == 0.0));
|
|
rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": []}));
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_mask_components_are_rejected_without_changes() {
|
|
let mut s = target_session();
|
|
for (command, mut params) in [
|
|
("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})),
|
|
("mask.component", json!({"action": "invert"})),
|
|
("mask.sampleColor", json!({"x": 0.5, "y": 0.5})),
|
|
("mask.refineDetail", json!({})),
|
|
] {
|
|
for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) {
|
|
params["component"] = target;
|
|
rejects_target(&mut s, command, ¶ms);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_spot_indices_are_rejected_without_changes() {
|
|
let mut s = target_session();
|
|
for (command, mut params) in [("spot.delete", json!({})), ("spot.update", json!({"size": 0.1})), ("spot.refreshSource", json!({}))] {
|
|
for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) {
|
|
params["index"] = target;
|
|
rejects_target(&mut s, command, ¶ms);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_spot_selection_is_rejected_without_changes() {
|
|
let mut s = target_session();
|
|
for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_296_u64)]) {
|
|
rejects_target(&mut s, "spot.select", &json!({"index": target}));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mask_and_spot_targets_preserve_explicit_defaults_and_selection_clearing() {
|
|
use lightcraft_develop::MaskShape;
|
|
let mut s = target_session();
|
|
for target in [json!({}), json!({"id": null})] {
|
|
let mut params = target;
|
|
params["name"] = json!("Selected mask");
|
|
s.execute("mask.rename", ¶ms).unwrap();
|
|
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].name, "Selected mask");
|
|
}
|
|
s.execute("mask.rename", &json!({"id": 1, "name": "Explicit mask"})).unwrap();
|
|
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[0].name, "Explicit mask");
|
|
assert_eq!(s.active_mask, Some(2));
|
|
for params in [json!({"shape": {"kind": "brush", "strokes": []}}), json!({"component": null, "shape": {"kind": "brush", "strokes": []}})] {
|
|
s.execute("mask.update", ¶ms).unwrap();
|
|
let d = s.develop_of(s.active().unwrap()).unwrap();
|
|
assert!(matches!(d.masks[1].components[0].shape, MaskShape::Brush { .. }));
|
|
assert!(matches!(d.masks[1].components[1].shape, MaskShape::ColorRange { .. }));
|
|
}
|
|
s.execute("mask.update", &json!({"id": 1, "component": 0, "shape": {"kind": "sky"}})).unwrap();
|
|
assert!(matches!(s.develop_of(s.active().unwrap()).unwrap().masks[0].components[0].shape, MaskShape::Sky));
|
|
for params in [json!({"points": [[0.2, 0.2]]}), json!({"id": null, "points": [[0.4, 0.4]]})] {
|
|
s.execute("mask.brushStroke", ¶ms).unwrap();
|
|
}
|
|
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 2);
|
|
for params in [json!({"x": 0.5, "y": 0.5}), json!({"component": null, "x": 0.5, "y": 0.5})] {
|
|
s.execute("mask.sampleColor", ¶ms).unwrap();
|
|
let d = s.develop_of(s.active().unwrap()).unwrap();
|
|
let MaskShape::ColorRange { samples, .. } = &d.masks[1].components[1].shape else { panic!("color range") };
|
|
assert_eq!(samples.len(), 1);
|
|
}
|
|
for params in [json!({"size": 0.04}), json!({"index": null, "size": 0.04})] {
|
|
s.execute("spot.update", ¶ms).unwrap();
|
|
assert_eq!(spots(&s)[1].size, 0.04);
|
|
}
|
|
s.execute("spot.update", &json!({"index": 0, "size": 0.03})).unwrap();
|
|
assert_eq!(spots(&s)[0].size, 0.03);
|
|
assert_eq!(s.active_spot, Some(1));
|
|
rejects_target(&mut s, "mask.component", &json!({"component": null, "action": "delete"}));
|
|
for params in [json!({"id": null}), json!({})] {
|
|
s.execute("mask.select", &json!({"id": 1})).unwrap();
|
|
assert_eq!(s.active_mask, Some(1));
|
|
s.execute("mask.select", ¶ms).unwrap();
|
|
assert_eq!(s.active_mask, None);
|
|
}
|
|
for params in [json!({"index": null}), json!({})] {
|
|
s.execute("spot.select", &json!({"index": 0})).unwrap();
|
|
assert_eq!(s.active_spot, Some(0));
|
|
s.execute("spot.select", ¶ms).unwrap();
|
|
assert_eq!(s.active_spot, None);
|
|
}
|
|
rejects_target(&mut s, "mask.delete", &json!({}));
|
|
rejects_target(&mut s, "spot.delete", &json!({}));
|
|
// An omitted mask still creates a brush mask when no mask is selected.
|
|
s.execute("mask.brushStroke", &json!({"points": [[0.5, 0.5]]})).unwrap();
|
|
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 3);
|
|
}
|