lightcraft/crates/ui-egui/src/tests_masking.rs

694 lines
33 KiB
Rust

//! Headless tests of the Masking and Remove tools on the photo: overlay keys, pins, spot editing.
use std::time::Duration;
use lightcraft_develop::MaskShape;
use serde_json::json;
use crate::headless::Headless;
use crate::state::RightPanel;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn detail(panel: &str) -> Headless {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
let r = h.request("ui.set", json!({"view": "detail"}), T);
assert_eq!(r["ok"], true, "{r}");
let r = h.request("engine.execute", json!({"command": panel}), T);
assert_eq!(r["ok"], true, "{r}");
h
}
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{command}: {r}");
r["result"].clone()
}
fn pointer(h: &mut Headless, events: serde_json::Value) {
let r = h.request("ui.pointer", json!({"events": events}), T);
assert_eq!(r["ok"], true, "{r}");
}
fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings {
let id = h.app.session.active().expect("active photo");
(*h.app.session.develop_of(id).unwrap_or_default()).clone()
}
#[test]
fn mask_overlay_keys_and_pins() {
use lightcraft_pipeline::{MaskView, Overlay};
let mut h = detail("panel.masking");
assert_eq!(h.app.ui.right, RightPanel::Masking);
exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.3, 0.4], "rx": 0.1, "ry": 0.1}));
exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.7, 0.2], "end": [0.7, 0.6]}));
assert_eq!(h.app.session.active_mask, Some(2));
// the loupe asks the renderer for the selected mask's overlay
let d = develop(&h);
let o = crate::panels::detail::view_overlay(&h.app, &d);
assert_eq!(o, Overlay::Mask { id: 2, view: MaskView::Color, color: [230, 30, 40], opacity: 50 });
// O toggles it, Shift+O cycles the colour (and leaves the crop overlay alone)
h.request("ui.key", json!({"key": "o"}), T);
assert!(!h.app.ui.mask_overlay);
assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None);
h.request("ui.key", json!({"key": "o"}), T);
let crop = h.app.ui.crop_overlay;
let colour = h.app.ui.mask_overlay_color;
h.request("ui.key", json!({"key": "o", "shift": true}), T);
assert_ne!(h.app.ui.mask_overlay_color, colour, "the next overlay colour");
assert_eq!(h.app.ui.mask_overlay_mode, "color", "the mode stays");
assert_eq!(h.app.ui.crop_overlay, crop);
exec(&mut h, "view.maskOverlayMode", json!({"mode": "whiteOnBlack"}));
exec(&mut h, "view.maskOverlayColor", json!({"color": "#2870f0", "opacity": 80}));
assert_eq!((h.app.ui.mask_overlay_color, h.app.ui.mask_overlay_opacity), ([0x28, 0x70, 0xf0], 80.0));
let r = h.request("engine.execute", json!({"command": "view.maskOverlayMode", "params": {"mode": "nope"}}), T);
assert_eq!(r["ok"], false, "{r}");
// clicking another mask's pin selects that mask
pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "up", "x": 0.3, "y": 0.4}]));
assert_eq!(h.app.session.active_mask, Some(1));
// dragging a pin moves its component (one undo step)
pointer(
&mut h,
json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "drag", "x": 0.35, "y": 0.45}, {"kind": "drag", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}]),
);
let MaskShape::Radial { center, .. } = develop(&h).masks[0].components[0].shape.clone() else { panic!("radial") };
assert!((center.x - 0.5).abs() < 0.02 && (center.y - 0.6).abs() < 0.02, "{center:?}");
// dragging the linear gradient's pin (a non-selected mask) selects and moves it
pointer(
&mut h,
json!([{"kind": "down", "x": 0.7, "y": 0.4}, {"kind": "drag", "x": 0.72, "y": 0.4}, {"kind": "drag", "x": 0.8, "y": 0.4}, {"kind": "up", "x": 0.8, "y": 0.4}]),
);
assert_eq!(h.app.session.active_mask, Some(2));
let MaskShape::Linear { start, end } = develop(&h).masks[1].components[0].shape.clone() else { panic!("linear") };
assert!((start.x - 0.8).abs() < 0.02 && (end.x - 0.8).abs() < 0.02 && (start.y - 0.2).abs() < 0.02, "{start:?} {end:?}");
// hidden pins can't be grabbed
exec(&mut h, "view.maskPins", json!({"show": false}));
pointer(&mut h, json!([{"kind": "down", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}]));
assert_eq!(h.app.session.active_mask, Some(2));
h.settle(SETTLE);
}
#[test]
fn brush_strokes_carry_auto_mask() {
let mut h = detail("panel.masking");
let r = h.request("ui.set", json!({"brushAutoMask": true}), T);
assert_eq!(r["ok"], true, "{r}");
exec(&mut h, "tool.brush", json!({}));
assert_eq!(h.app.ui.tool, "brush");
pointer(
&mut h,
json!([{"kind": "down", "x": 0.3, "y": 0.5}, {"kind": "drag", "x": 0.4, "y": 0.5}, {"kind": "drag", "x": 0.5, "y": 0.5}, {"kind": "up", "x": 0.5, "y": 0.5}]),
);
let d = develop(&h);
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
assert!(strokes[0].auto_mask && strokes[0].points.len() >= 2, "{strokes:?}");
assert!(!strokes[0].erase);
// holding ⌥ paints an erase stroke without switching the brush to Erase
let r = h.request(
"ui.pointer",
json!({"events": [{"kind": "down", "x": 0.35, "y": 0.5}, {"kind": "drag", "x": 0.45, "y": 0.5}, {"kind": "up", "x": 0.45, "y": 0.5}], "alt": true}),
T,
);
assert_eq!(r["ok"], true, "{r}");
let d = develop(&h);
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
assert_eq!(strokes.len(), 2, "{strokes:?}");
assert!(strokes[1].erase, "⌥ erases");
assert!(!h.app.ui.brush_erase, "the brush mode is unchanged");
h.settle(SETTLE);
}
#[test]
fn remove_spots_by_pointer_and_keyboard() {
let mut h = detail("panel.remove");
assert_eq!(h.app.ui.right, RightPanel::Remove);
let spots = |h: &Headless| develop(h).spots;
// paint a spot: it's added with the brush's size/feather/opacity and selected
let r = h.request("ui.set", json!({"removeFeather": 30.0, "removeOpacity": 80.0}), T);
assert_eq!(r["ok"], true, "{r}");
pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "up", "x": 0.3, "y": 0.6}]));
assert_eq!(spots(&h).len(), 1);
assert_eq!((spots(&h)[0].feather, spots(&h)[0].opacity), (30.0, 80.0));
assert_eq!(h.app.session.active_spot, Some(0));
// [ / ] resize the selected spot, Shift+[ / Shift+] feather it
let s0 = spots(&h)[0].size;
h.request("ui.key", json!({"key": "]"}), T);
assert!(spots(&h)[0].size > s0 * 1.1, "{} vs {s0}", spots(&h)[0].size);
h.request("ui.key", json!({"key": "["}), T);
h.request("ui.key", json!({"key": "["}), T);
assert!(spots(&h)[0].size < s0 * 0.9);
h.request("ui.key", json!({"key": "[", "shift": true}), T);
assert_eq!(spots(&h)[0].feather, 20.0);
h.request("ui.key", json!({"key": "]", "shift": true}), T);
assert_eq!(spots(&h)[0].feather, 30.0);
// / picks another source (and leaves the filmstrip alone)
let (src, film) = (spots(&h)[0].source_offset, h.app.ui.filmstrip);
h.request("ui.key", json!({"key": "/"}), T);
assert_ne!(spots(&h)[0].source_offset, src);
assert_eq!(h.app.ui.filmstrip, film);
// a second spot elsewhere; clicking the first one's pin selects it
pointer(&mut h, json!([{"kind": "down", "x": 0.7, "y": 0.3}, {"kind": "up", "x": 0.7, "y": 0.3}]));
assert_eq!((spots(&h).len(), h.app.session.active_spot), (2, Some(1)));
pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "up", "x": 0.3, "y": 0.6}]));
assert_eq!((spots(&h).len(), h.app.session.active_spot), (2, Some(0)));
// drag its target: it moves, its source offset stays
let src = spots(&h)[0].source_offset;
pointer(
&mut h,
json!([{"kind": "down", "x": 0.3, "y": 0.6}, {"kind": "drag", "x": 0.32, "y": 0.6}, {"kind": "drag", "x": 0.4, "y": 0.6}, {"kind": "up", "x": 0.4, "y": 0.6}]),
);
let sp = spots(&h)[0].clone();
assert!((sp.points[0].x - 0.4).abs() < 0.01 && (sp.points[0].y - 0.6).abs() < 0.01, "{:?}", sp.points);
assert_eq!(sp.source_offset, src);
// drag its source to a fixed place
let o = sp.source_offset.unwrap();
let (sx, sy) = (sp.points[0].x + o.x, sp.points[0].y + o.y);
pointer(
&mut h,
json!([{"kind": "down", "x": sx, "y": sy}, {"kind": "drag", "x": sx + 0.01, "y": sy}, {"kind": "drag", "x": 0.5, "y": 0.8}, {"kind": "up", "x": 0.5, "y": 0.8}]),
);
let sp = spots(&h)[0].clone();
let o = sp.source_offset.unwrap();
assert!((sp.points[0].x + o.x - 0.5).abs() < 0.01 && (sp.points[0].y + o.y - 0.8).abs() < 0.01, "{o:?}");
// ⌫ deletes the selected spot, not the photo
let photos = h.app.session.visible_cloned().len();
h.request("ui.key", json!({"key": "delete"}), T);
assert_eq!((spots(&h).len(), h.app.session.active_spot), (1, None));
assert_eq!(h.app.session.visible_cloned().len(), photos);
h.request("ui.key", json!({"key": "delete"}), T);
assert_eq!(spots(&h).len(), 1, "nothing selected: nothing deleted");
h.settle(SETTLE);
}
/// Masks list: double-click renames in place, the hover eye hides a mask, the overlay colour
/// cycles through the swatches.
#[test]
fn mask_list_rename_hide_and_overlay_colour() {
let mut h = detail("panel.masking");
exec(&mut h, "mask.add", json!({"kind": "linear"}));
exec(&mut h, "mask.add", json!({"kind": "radial"}));
h.settle(SETTLE);
let first = develop(&h).masks[0].id;
let r = h.request("ui.clickWidget", json!({"id": format!("mask:{first}"), "count": 2}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(Duration::from_secs(5));
assert_eq!(h.app.ui.renaming_mask.as_ref().map(|r| r.0), Some(first), "double-click starts renaming");
h.request("ui.key", json!({"key": "A", "cmd": true}), T);
h.request("ui.text", json!({"text": "Sky"}), T);
h.request("ui.key", json!({"key": "Enter"}), T);
h.settle(Duration::from_secs(5));
assert!(h.app.ui.renaming_mask.is_none());
assert_eq!(develop(&h).masks[0].name, "Sky");
// hover the row, then click its eye
let r = h.request("ui.hoverWidget", json!({"id": format!("mask:{first}")}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(Duration::from_secs(5));
let r = h.request("ui.clickWidget", json!({"id": format!("maskVisible:{first}")}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(!develop(&h).masks[0].visible);
// overlay colour: no params = next swatch
let before = h.app.ui.mask_overlay_color;
exec(&mut h, "view.maskOverlayColor", json!({}));
let all = crate::panels::masking::OVERLAY_COLORS;
let i = all.iter().position(|c| *c == before).unwrap();
assert_eq!(h.app.ui.mask_overlay_color, all[(i + 1) % all.len()]);
}
#[test]
fn command_drag_straightens_in_crop() {
let mut h = detail("panel.crop");
let tool = h.app.ui.tool.clone();
let before = develop(&h).crop.geometry.angle;
let r = h.request(
"ui.pointer",
json!({"events": [{"kind": "down", "x": 0.3, "y": 0.5}, {"kind": "drag", "x": 0.45, "y": 0.51}, {"kind": "drag", "x": 0.6, "y": 0.53}, {"kind": "up", "x": 0.6, "y": 0.53}], "cmd": true}),
T,
);
assert_eq!(r["ok"], true, "{r}");
let angle = develop(&h).crop.geometry.angle;
assert!(angle != before && (1.0..15.0).contains(&angle.abs()), "a slightly tilted line straightens: {angle}");
assert_eq!(h.app.ui.tool, tool, "the crop tool stays active");
assert!(h.app.gesture.is_none());
h.settle(SETTLE);
}
#[test]
fn double_click_in_crop_box_applies_the_crop() {
let mut h = detail("panel.crop");
h.settle(SETTLE);
assert_eq!(h.app.ui.right, crate::state::RightPanel::Crop);
let c = h.app.image_rect.expect("image on screen").center();
let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "count": 2}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit, "double-click leaves the crop tool");
}
#[test]
fn option_digit_toggles_keyword_from_set() {
let mut h = detail("panel.keywords");
exec(&mut h, "photo.setMeta", json!({"addKeywords": ["alpha"]}));
exec(&mut h, "photo.setMeta", json!({"removeKeywords": ["alpha"]}));
let has = |h: &Headless| develop_photo_keywords(h).iter().any(|k| k == "alpha");
assert!(!has(&h));
let r = h.request("ui.key", json!({"key": "1", "alt": true}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(has(&h), "⌥1 adds the first recent keyword");
let r = h.request("ui.clickWidget", json!({"id": "kwSet:1"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(!has(&h), "its button removes it again");
h.settle(SETTLE);
}
fn develop_photo_keywords(h: &Headless) -> Vec<String> {
let id = h.app.session.active().expect("active photo");
h.app.session.catalog.photo(id).unwrap().meta.keywords.clone()
}
#[test]
fn smart_album_rule_editor_creates_and_edits() {
let mut h = detail("panel.edit");
exec(&mut h, "dialog.smartAlbum", json!({"name": "Keepers"}));
// the editor starts with Rating ≥ 3; "+" adds a second rule
let r = h.request("ui.clickWidget", json!({"id": "button:ruleAdd-rules-0"}), T);
assert_eq!(r["ok"], true, "{r}");
let Some(crate::state::Dialog::SmartRules { rules, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") };
assert_eq!(rules.rules.len(), 2);
rules.rules[1] = serde_json::from_value(json!({"field": "flag", "op": "isNot", "value": "reject"})).unwrap();
let r = h.request("ui.dialog.confirm", json!({}), T);
assert_eq!(r["ok"], true, "{r}");
let a = h.app.session.catalog.albums().find(|a| a.name == "Keepers").expect("album").clone();
let n = h.app.session.catalog.photos().filter(|p| !p.deleted && p.rating >= 3 && p.flag != lightcraft_catalog::Flag::Reject).count();
assert_eq!(h.app.session.catalog.album_count(a.id), n);
// edit: back to one rule
exec(&mut h, "dialog.smartAlbum", json!({"id": a.id.0}));
let r = h.request("ui.clickWidget", json!({"id": "button:ruleRemove-rules-1"}), T);
assert_eq!(r["ok"], true, "{r}");
let r = h.request("ui.dialog.confirm", json!({}), T);
assert_eq!(r["ok"], true, "{r}");
let n3 = h.app.session.catalog.photos().filter(|p| !p.deleted && p.rating >= 3).count();
assert_eq!(h.app.session.catalog.album_count(a.id), n3);
h.settle(SETTLE);
}
#[test]
fn g_toggles_grids_and_shift_g_starts_guided_upright() {
let mut h = detail("panel.edit");
let key = |h: &mut Headless, shift: bool| {
let r = h.request("ui.key", json!({"key": "g", "shift": shift}), T);
assert_eq!(r["ok"], true, "{r}");
};
key(&mut h, false);
assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid);
key(&mut h, false);
assert_eq!(h.app.ui.view, crate::state::ViewMode::SquareGrid);
key(&mut h, false);
assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid);
key(&mut h, true);
assert_eq!((h.app.ui.view, h.app.ui.right, h.app.ui.tool.as_str()), (crate::state::ViewMode::Detail, RightPanel::Crop, "guidedUpright"));
assert_eq!(develop(&h).geometry.upright, lightcraft_develop::Upright::Guided);
h.settle(SETTLE);
}
#[test]
fn luminance_range_controls_and_map() {
let mut h = detail("panel.masking");
exec(&mut h, "mask.add", json!({"kind": "luminanceRange", "lo": 0.6, "hi": 1.0}));
h.settle(SETTLE);
let lum = |h: &Headless| match &develop(h).masks[0].components[0].shape {
MaskShape::LuminanceRange { lo, hi, lo_feather, .. } => (*lo, *hi, *lo_feather),
s => panic!("{s:?}"),
};
let undo0 = h.app.session.undo.len();
// drag the high handle from the right end to 80 %
let r = h.request("ui.dragWidget", json!({"id": "lumRange:0", "fx": 1.0, "fy": 0.5, "dx": -48.0}), T);
assert_eq!(r["ok"], true, "{r}");
let (lo, hi, _) = lum(&h);
assert_eq!(lo, 0.6, "the nearer handle moves");
assert!(hi < 0.9 && hi > 0.6, "{hi}");
assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step per drag");
// Show Luminance Map: black-and-white overlay, and back
let r = h.request("ui.clickWidget", json!({"id": "check:lumMap0"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.ui.mask_overlay && h.app.ui.mask_overlay_mode == "colorOnBw");
let r = h.request("ui.clickWidget", json!({"id": "check:lumMap0"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_ne!(h.app.ui.mask_overlay_mode, "colorOnBw");
h.settle(SETTLE);
}
#[test]
fn b_adds_to_quick_collection_in_the_grid_and_brushes_in_edit() {
let mut h = detail("panel.edit");
// in the loupe B is the masking brush
let r = h.request("ui.key", json!({"key": "b"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.tool, "brush");
assert!(h.app.session.catalog.quick_collection().is_none());
// in the grid it adds the selection to the Quick Collection
exec(&mut h, "view.photoGrid", json!({}));
let r = h.request("ui.key", json!({"key": "b"}), T);
assert_eq!(r["ok"], true, "{r}");
let q = h.app.session.catalog.quick_collection().expect("quick collection");
assert_eq!(h.app.session.catalog.album_count(q), 1);
h.settle(SETTLE);
}
#[test]
fn local_folder_tree_expands_and_browses() {
let base = std::env::temp_dir().join(format!("lc-ui-tree-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("Trip/Day 1")).unwrap();
std::fs::create_dir_all(base.join(".hidden")).unwrap();
let mut h = detail("panel.edit");
exec(&mut h, "view.leftPanel", json!({"show": true}));
h.hide_home_above(&base);
exec(&mut h, "local.addRoot", json!({"path": base.to_string_lossy()}));
exec(&mut h, "library.browse", json!({"path": base.to_string_lossy()}));
h.settle(SETTLE);
let base_s = base.to_string_lossy().to_string();
let r = h.request("ui.clickWidget", json!({"id": format!("folderToggle:{base_s}")}), T);
assert_eq!(r["ok"], true, "{r}");
let trip = base.join("Trip").to_string_lossy().to_string();
// the folder listing can land a few frames later on a loaded machine (FreeBSD CI): wait for the row
let row = format!("source:local:{trip}");
h.step_until(SETTLE, |h| h.app.widgets.iter().any(|(w, _)| *w == row));
let r = h.request("ui.clickWidget", json!({"id": format!("source:local:{trip}")}), T);
assert_eq!(r["ok"], true, "the subfolder is listed: {r}");
assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(trip.clone()), "clicking it browses it");
let hidden = base.join(".hidden").to_string_lossy().to_string();
let r = h.request("ui.clickWidget", json!({"id": format!("source:local:{hidden}")}), T);
assert_ne!(r["ok"], true, "hidden folders are not listed");
let _ = std::fs::remove_dir_all(&base);
h.settle(SETTLE);
}
#[test]
fn slideshow_advances_pauses_and_ends() {
let mut h = detail("panel.edit");
let first = h.app.session.active();
exec(&mut h, "view.slideshow", json!({"interval": 0.5}));
assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some());
// simulated time runs with the frames
let mut moved = false;
for _ in 0..200 {
h.step();
if h.app.session.active() != first {
moved = true;
break;
}
}
assert!(moved, "the next photo comes up");
// Space pauses: nothing moves
let r = h.request("ui.key", json!({"key": "space"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.ui.slideshow.is_some_and(|s| s.2), "paused");
let held = h.app.session.active();
for _ in 0..120 {
h.step();
}
assert_eq!(h.app.session.active(), held);
// Esc ends it
let r = h.request("ui.key", json!({"key": "escape"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(!h.app.ui.fullscreen && h.app.ui.slideshow.is_none());
h.settle(SETTLE);
}
#[test]
fn geometry_slider_drag_marks_the_grid() {
let mut h = detail("panel.crop");
let spec = lightcraft_develop::controls::find("geometry.vertical").unwrap();
let start = crate::widgets::SliderOut { value: None, drag_started: true, drag_stopped: false, reset: false };
crate::panels::edit::apply_slider_out(&mut h.app, spec, start, |_, _| Ok(serde_json::Value::Null));
assert_eq!(h.app.ui.dragging_control.as_deref(), Some("geometry.vertical"));
h.step();
let stop = crate::widgets::SliderOut { value: None, drag_started: false, drag_stopped: true, reset: false };
crate::panels::edit::apply_slider_out(&mut h.app, spec, stop, |_, _| Ok(serde_json::Value::Null));
assert!(h.app.ui.dragging_control.is_none());
h.settle(SETTLE);
}
#[test]
fn edit_in_external_editor_opens_the_copy() {
let dir = std::env::temp_dir().join(format!("lc-ui-ext-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let opened: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> = Default::default();
let o = opened.clone();
let services = Services {
png: None,
open_with: Some(Box::new(move |path: &str, app: &str| {
o.lock().unwrap().push((path.to_string(), app.to_string()));
Ok(())
})),
..Default::default()
};
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services);
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.app.ui.settings.external_editor = "PhotoCraft".into();
let r = h.request("engine.execute", json!({"command": "photo.editInExternal", "params": {"dir": dir.to_string_lossy()}}), T);
assert_eq!(r["ok"], true, "{r}");
let calls = opened.lock().unwrap().clone();
assert_eq!(calls.len(), 1);
assert!(calls[0].0.ends_with("-Edit.tif") && std::path::Path::new(&calls[0].0).exists(), "{calls:?}");
assert_eq!(calls[0].1, "PhotoCraft");
let _ = std::fs::remove_dir_all(&dir);
h.settle(SETTLE);
}
#[test]
fn second_window_shows_the_active_photo() {
let mut h = detail("panel.edit");
exec(&mut h, "view.photoGrid", json!({}));
exec(&mut h, "view.secondWindow", json!({"show": true}));
h.settle(SETTLE);
// headless has no native windows: it's embedded, and renders the photo for its own slot
let tex = h.app.renderer.textures.get(&crate::render::Slot::Second).map(|t| t.photo);
assert_eq!(tex, h.app.session.active(), "the second window has its own render of the active photo");
let r = h.request("ui.clickWidget", json!({"id": "view:secondWindow"}), T);
assert_eq!(r["ok"], true, "on screen: {r}");
exec(&mut h, "view.secondWindow", json!({}));
assert!(!h.app.ui.second_window);
h.settle(SETTLE);
}
/// Frame time of the photo grid on a 100k-photo library (ignored:
/// `cargo test --release -p lightcraft-ui-egui -- --ignored grid_frame_100k --nocapture`).
#[test]
#[ignore]
fn grid_frame_100k() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
let mut session = lightcraft_engine::Session::new();
let ops = (0..100_000u64)
.map(|i| {
let mut p = Photo::new(
PhotoId(i + 1),
Source::Demo { scene: (i % 20) as u32 },
&format!("IMG_{i:06}.jpg"),
"JPEG",
6000,
4000 - (i % 3) as u32 * 1000,
"2026-01-01T00:00:00",
);
p.captured = Some(format!("20{:02}-{:02}-{:02}T10:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28));
p.meta.keywords = vec![format!("kw{}", i % 300)];
Op::AddPhoto { photo: Box::new(p) }
})
.collect();
session.commit("Add", Op::Batch { ops }).unwrap();
let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1600.0, 1000.0], 1.0);
h.app.ui.view = crate::state::ViewMode::PhotoGrid;
h.app.ui.left_panel = true;
for _ in 0..5 {
h.step();
}
let t = std::time::Instant::now();
let n = 30;
for _ in 0..n {
h.step();
}
let ms = t.elapsed().as_secs_f64() * 1e3 / n as f64;
eprintln!("grid frame at 100k photos: {ms:.1} ms (UI thread, renders excluded)");
}
#[test]
fn keyword_painter_toggles_on_click() {
let mut h = detail("panel.edit");
exec(&mut h, "tool.keywordPainter", json!({"keyword": "harbour"}));
assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid);
h.settle(SETTLE);
let target = h.app.session.visible_cloned()[0];
let has = |h: &Headless| h.app.session.catalog.photo(target).unwrap().meta.keywords.iter().any(|k| k == "harbour");
let sel = h.app.session.selection.clone();
// a click: press and release on separate frames, no movement
let click = |h: &mut Headless| {
let w = h.request("ui.widgets", json!({}), T);
let r = w["result"]
.as_array()
.and_then(|a| a.iter().find(|x| x["id"] == format!("thumb:{}", target.0)))
.map(|x| x["rect"].clone())
.expect("thumb on screen");
let (x, y) = (r[0].as_f64().unwrap() + r[2].as_f64().unwrap() / 2.0, r[1].as_f64().unwrap() + r[3].as_f64().unwrap() / 2.0);
let r = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y, "steps": 2}), T);
assert_eq!(r["ok"], true, "{r}");
};
click(&mut h);
assert!(has(&h), "painted");
assert_eq!(h.app.session.selection, sel, "painting doesn't select");
click(&mut h);
assert!(!has(&h), "a second click takes it away");
h.request("ui.key", json!({"key": "escape"}), T);
assert!(h.app.ui.keyword_painter.is_none());
h.settle(SETTLE);
}
#[test]
fn grid_info_cycles_caption() {
let mut h = detail("panel.edit");
assert_eq!(exec(&mut h, "view.gridInfo", json!({}))["info"], "exposure");
assert_eq!(exec(&mut h, "view.gridInfo", json!({}))["info"], "date");
assert_eq!(exec(&mut h, "view.gridInfo", json!({"info": "filename"}))["info"], "filename");
let r = h.request("engine.execute", json!({"command": "view.gridInfo", "params": {"info": "lens"}}), T);
assert_ne!(r["ok"], true);
exec(&mut h, "view.squareGrid", json!({}));
exec(&mut h, "view.gridInfo", json!({"info": "exposure"}));
h.settle(SETTLE);
}
#[test]
fn reference_view_pins_a_photo_beside_the_active_one() {
let mut h = detail("panel.edit");
let first = h.app.session.active().unwrap();
exec(&mut h, "photo.setReference", json!({}));
let r = h.request("ui.key", json!({"key": "r", "shift": true}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.view, crate::state::ViewMode::Reference);
assert_eq!(h.app.ui.reference, Some(first.0));
let active = h.app.session.active().unwrap();
assert_ne!(active, first, "the next photo is the one being edited");
// edits go to the active photo, the reference stays put
exec(&mut h, "develop.set", json!({"control": "light.exposure", "value": 0.7}));
assert_eq!(h.app.session.develop_of(active).unwrap().light.exposure, 0.7);
assert_ne!(h.app.session.develop_of(first).unwrap().light.exposure, 0.7);
h.settle(SETTLE);
assert!(h.app.renderer.textures.get(&crate::render::Slot::Compare(0)).is_some_and(|t| t.photo == first), "the reference is drawn");
}
#[test]
fn soft_proofing_flags_out_of_gamut_colours_and_makes_proof_copies() {
let mut h = detail("panel.edit");
if h.app.ui.right != RightPanel::Edit {
exec(&mut h, "panel.edit", json!({}));
}
assert_eq!(h.app.ui.right, RightPanel::Edit);
h.app.renderer.keep_pixels = true;
exec(&mut h, "develop.set", json!({"control": "color.saturation", "value": 100}));
exec(&mut h, "develop.set", json!({"control": "color.vibrance", "value": 100}));
let red = |h: &Headless| {
let t = h.app.renderer.textures.get(&crate::render::Slot::Main).expect("loupe render");
t.pixels.as_ref().expect("pixels kept").pixels.iter().filter(|c| c.r() == 255 && c.g() == 0 && c.b() == 0).count()
};
h.settle(SETTLE);
let before = red(&h);
// S in the loupe: soft proofing on; the warning is the proof's own setting
let r = h.request("ui.key", json!({"key": "s"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.ui.soft_proof);
let st = exec(&mut h, "view.softProof", json!({"space": "srgb", "destWarning": true}));
assert_eq!(st, json!({"on": true, "space": "srgb", "destWarning": true, "displayWarning": false}));
h.settle(SETTLE);
assert!(red(&h) > before, "out-of-gamut colours are painted red");
let r = h.request("engine.execute", json!({"command": "view.softProof", "params": {"space": "cmyk"}}), T);
assert_ne!(r["ok"], true);
// Create Proof Copy: a virtual copy named after the proof
let n = h.app.session.catalog.photos().count();
let r = h.request("ui.clickWidget", json!({"id": "button:createProofCopy"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.catalog.photos().count(), n + 1);
let copy = (**h.app.session.catalog.photos().max_by_key(|p| p.id.0).unwrap()).clone();
assert_eq!(copy.copy_name.as_deref(), Some("Proof Copy (sRGB)"));
// S again turns it off; in a grid S is Expand/Collapse Stack (the copy made a stack)
h.request("ui.key", json!({"key": "s"}), T);
assert!(!h.app.ui.soft_proof);
exec(&mut h, "view.photoGrid", json!({}));
let stack = h.app.session.catalog.stack_of(copy.id).cloned().expect("stacked with its original");
h.request("ui.key", json!({"key": "s"}), T);
assert!(!h.app.ui.soft_proof);
assert_ne!(h.app.session.catalog.stack_of(copy.id).unwrap().collapsed, stack.collapsed, "S toggled the stack");
}
/// Object / Describe without the SAM 3 model: the download is offered (never started without a
/// yes), the dialog says why it can't start when no mirror is configured, nothing freezes and no
/// empty mask is left behind.
#[test]
fn ai_masks_without_the_model_offer_the_download() {
use crate::state::Dialog;
use lightcraft_engine::segment::Segmenter;
let mut h = detail("panel.masking");
let dir = std::env::temp_dir().join(format!("lc-ui-no-sam3-{}", std::process::id()));
h.app.session.segmenter.dir = Some(dir.clone());
h.app.session.segmenter.mirrors_file = Some(dir.join("none.txt"));
let t = std::time::Instant::now();
let r = h.request("ui.clickWidget", json!({"id": "maskNew:object"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(develop(&h).masks.is_empty());
if !Segmenter::AVAILABLE {
// a build without AI masks says so (a toast), no dialog
assert_eq!(h.app.ui.dialog, None);
return;
}
assert_eq!(h.app.ui.dialog, Some(Dialog::SamModel { then: Some(("object".into(), "new".into())), error: None }));
assert!(!h.app.session.segmenter.download_status().running, "nothing downloads without a yes");
if h.app.session.segmenter.mirrors().is_empty() {
// no location configured in this build: no Download button (only Close), and an agent
// confirming anyway gets the reason; the dialog stays
h.step();
// …but a way to install it by hand: the guide, and the model folder (created on demand)
assert!(h.app.widgets.iter().any(|(id, _)| id == "link:samHelp"), "no installation guide link");
let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let log = shown.clone();
h.app.services.reveal = Some(Box::new(move |p: &str| {
log.lock().unwrap().push(p.to_string());
Ok(())
}));
h.step();
let r = h.request("ui.clickWidget", json!({"id": "button:samFolder"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(dir.is_dir(), "the model folder is created to be shown");
assert_eq!(*shown.lock().unwrap(), vec![dir.to_string_lossy().to_string()]);
let _ = std::fs::remove_dir_all(&dir);
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], false, "{r}");
let r = h.request("ui.dialog.confirm", json!({}), T);
assert_eq!(r["ok"], false, "{r}");
assert!(r["error"].as_str().unwrap_or_default().contains("LIGHTCRAFT_SAM3_MIRRORS"), "{r}");
assert!(matches!(h.app.ui.dialog, Some(Dialog::SamModel { .. })), "stays open");
} else {
// with a mirror: Download starts it in the background (here it fails: nothing listens)
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], true, "{r}");
}
// (a frame with the message laid out, so the buttons are where they are drawn)
h.step();
h.step();
let r = h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.dialog, None);
// Describe asks too
let r = h.request("ui.clickWidget", json!({"id": "maskNew:prompt"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.dialog, Some(Dialog::SamModel { then: Some(("prompt".into(), "new".into())), error: None }));
assert_eq!(h.app.ui.describe, None);
// a click command from an agent gets the not-installed error at once, and the app offers the model
let e = h.app.run("mask.add", json!({"kind": "prompt", "text": "sky"})).unwrap_err();
assert!(e.starts_with(lightcraft_engine::segment::NOT_INSTALLED), "{e}");
assert!(develop(&h).masks.is_empty());
assert!(t.elapsed() < SETTLE, "{:?}", t.elapsed());
}