Files
lightcraft/crates/ui-egui/src/headless.rs
T
Nathaniel LedfordandClaude Sonnet 5.5 0b5ef3fe11 Import review: choose what happens to files in Recently Deleted (#298)
Files whose photo is in the trash stay unchecked until Restore them or Import as
new is chosen; the toast and undo label say what was restored.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 11:39:28 -04:00

1917 lines
106 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Headless UI rendering: run the app's UI in an offscreen [`egui::Context`] and rasterize it on
//! the CPU ([`crate::softpaint`]) — no window, no GPU, no compositor.
//!
//! Two users:
//! - [`Headless`]: a complete windowless app session (`lightcraft-cli snapshot`, tests). It plays
//! the role eframe plays for the desktop app: builds [`egui::RawInput`], runs `logic` + `ui`,
//! keeps a CPU mirror of the textures, executes viewport commands (`Screenshot` is answered with
//! a CPU-rendered frame, `InnerSize` resizes, `Close` quits). Control-protocol requests go
//! through the very same handler as the desktop app's control server.
//! - [`HeadlessView`] inside the desktop app: `ui.screenshot {"headless": true}` (and the
//! automatic fallback when the compositor delivers no frame, e.g. while the display sleeps)
//! draws the app's UI into a shadow context from `logic`, which keeps ticking when the window
//! is occluded.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::mpsc::Sender;
use std::time::{Duration, Instant};
use egui::{Color32, ColorImage, RawInput, TextureId, ViewportCommand, ViewportId};
use serde_json::{Value, json};
use crate::softpaint::{self, CpuTexture, Layered, TextureStore};
use crate::{ControlRequest, LightcraftApp};
/// Background behind the panels (eframe's default clear colour, made opaque).
const CLEAR: Color32 = Color32::from_rgb(12, 12, 12);
/// Simulated frame interval (deterministic animation time).
const FRAME_DT: f64 = 1.0 / 60.0;
/// An offscreen egui context with our fonts and theme, and a CPU mirror of its textures.
pub struct HeadlessView {
pub ctx: egui::Context,
pub textures: TextureStore,
shapes: Vec<egui::epaint::ClippedShape>,
pixels_per_point: f32,
size: egui::Vec2,
frames: u64,
}
impl Default for HeadlessView {
fn default() -> Self {
Self::new()
}
}
impl HeadlessView {
pub fn new() -> Self {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx);
HeadlessView { ctx, textures: TextureStore::default(), shapes: vec![], pixels_per_point: 1.0, size: egui::vec2(1600.0, 1000.0), frames: 0 }
}
/// Input for one frame of a `size` (points) viewport at `pixels_per_point`.
pub fn raw_input(size: egui::Vec2, pixels_per_point: f32, time: f64, events: Vec<egui::Event>) -> RawInput {
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
let mut raw = RawInput {
screen_rect: Some(rect),
time: Some(time),
predicted_dt: FRAME_DT as f32,
focused: true,
events,
max_texture_side: Some(16384),
..Default::default()
};
let info = raw.viewports.entry(ViewportId::ROOT).or_default();
info.native_pixels_per_point = Some(pixels_per_point);
info.inner_rect = Some(rect);
info.outer_rect = Some(rect);
info.focused = Some(true);
raw
}
/// Run one frame; keeps the shapes for [`Self::paint`]. Returns the root viewport's commands.
pub fn run(&mut self, raw: RawInput, run_ui: impl FnMut(&mut egui::Ui)) -> Vec<ViewportCommand> {
if let Some(r) = raw.screen_rect {
self.size = r.size();
}
let mut out = self.ctx.run_ui(raw, run_ui);
self.frames += 1;
self.textures.apply(std::mem::take(&mut out.textures_delta));
self.shapes = std::mem::take(&mut out.shapes);
self.pixels_per_point = out.pixels_per_point;
out.viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()
}
/// Frames run so far.
pub fn frames(&self) -> u64 {
self.frames
}
/// Size in pixels of the last frame.
pub fn size_px(&self) -> [usize; 2] {
[(self.size.x * self.pixels_per_point).round() as usize, (self.size.y * self.pixels_per_point).round() as usize]
}
/// Rasterize the last frame. `extra` textures (by id) take precedence over the context's own.
pub fn paint(&self, extra: &HashMap<TextureId, CpuTexture>) -> ColorImage {
let prims = self.ctx.tessellate(self.shapes.clone(), self.pixels_per_point);
softpaint::paint(&prims, &Layered { over: extra, base: &self.textures }, self.size_px(), self.pixels_per_point, CLEAR)
}
}
/// A windowless app session: the app's `logic` + `ui` driven frame by frame into a
/// [`HeadlessView`], with control-protocol requests answered by the shared handler.
pub struct Headless {
pub app: LightcraftApp,
pub view: HeadlessView,
/// Logical size (points) and scale.
pub size: egui::Vec2,
pub pixels_per_point: f32,
time: f64,
frames: u64,
events: Vec<egui::Event>,
control: Sender<ControlRequest>,
quit: bool,
}
impl Headless {
/// Wrap `app` (its control channel is replaced by the driver's).
pub fn new(app: LightcraftApp, size: [f32; 2], pixels_per_point: f32) -> Self {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = app.with_control(rx);
app.headless_host = true;
Headless {
app,
view: HeadlessView::new(),
size: egui::vec2(size[0], size[1]),
pixels_per_point,
time: 0.0,
frames: 0,
events: vec![],
control: tx,
quit: false,
}
}
pub fn frames(&self) -> u64 {
self.frames
}
/// `app.quit` was requested.
pub fn quit_requested(&self) -> bool {
self.quit
}
/// Run one frame.
pub fn step(&mut self) {
let mut raw = HeadlessView::raw_input(self.size, self.pixels_per_point, self.time, std::mem::take(&mut self.events));
self.app.raw_input_hook(&mut raw);
let app = &mut self.app;
let commands = self.view.run(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
self.time += FRAME_DT;
self.frames += 1;
for c in commands {
match c {
ViewportCommand::Screenshot(user_data) => {
let image = Arc::new(self.paint());
self.events.push(egui::Event::Screenshot { viewport_id: ViewportId::ROOT, user_data, image });
}
ViewportCommand::InnerSize(s) if s.x >= 1.0 && s.y >= 1.0 => self.size = s,
ViewportCommand::Close => self.quit = true,
_ => {}
}
}
}
/// Is anything still in progress (renders, file-system checks the sidebar waits for, queued
/// input)?
pub fn busy(&self) -> bool {
self.app.renderer.in_flight() > 0
|| crate::panels::left::fs_cached_running(&self.view.ctx) > 0
|| self.app.merge.busy()
|| self.app.scan.is_some()
|| self.app.import.is_some()
|| self.app.export.is_some()
|| !self.app.tasks.is_empty()
|| !self.app.synthetic.is_empty()
|| !self.events.is_empty()
}
/// Run frames until nothing is pending (renders finished, input consumed) for a few frames in
/// a row, or `timeout` passes. Returns whether it settled.
pub fn settle(&mut self, timeout: Duration) -> bool {
let t0 = Instant::now();
let mut quiet = 0;
loop {
self.step();
if self.busy() {
quiet = 0;
std::thread::sleep(Duration::from_millis(1));
} else {
quiet += 1;
}
// a few quiet frames let layout and short UI animations finish
if quiet >= 12 {
return true;
}
if t0.elapsed() > timeout {
return false;
}
}
}
/// Run frames until `done` holds or `timeout` passes (a render that finishes on a busy machine
/// after a quiet spell, where [`Self::settle`] alone would stop too early). Returns `done`.
pub fn step_until(&mut self, timeout: Duration, done: impl Fn(&Self) -> bool) -> bool {
let t0 = Instant::now();
while !done(self) {
if t0.elapsed() > timeout {
return false;
}
self.step();
std::thread::sleep(Duration::from_millis(1));
}
true
}
/// Rasterize the last frame (with the photo textures).
pub fn paint(&self) -> ColorImage {
self.view.paint(&HashMap::new())
}
/// Settle, then render the current UI.
pub fn snapshot(&mut self, timeout: Duration) -> ColorImage {
self.settle(timeout);
self.paint()
}
/// Tests that browse a folder in the temp directory expect it to be a Local location of its
/// own. Where the temp directory lies inside the home folder (Windows), the sidebar lists it
/// inside Home's tree instead, so hide Home for the test; elsewhere this does nothing.
#[cfg(test)]
pub(crate) fn hide_home_above(&mut self, path: &std::path::Path) {
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
if !home.is_empty() && lightcraft_catalog::query::folder_within(&path.to_string_lossy(), &home) {
let r = self.request("engine.execute", json!({"command": "local.hide", "params": {"path": home}}), Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
}
}
/// Send a control-protocol request (see [`crate::control`]) and run frames until it is
/// answered, then until its input has been consumed. Returns `{"ok": …, "result"|"error": …}`.
pub fn request(&mut self, method: &str, params: Value, timeout: Duration) -> Value {
let (req, rx) = ControlRequest::new(method, params);
if self.control.send(req).is_err() {
return json!({"ok": false, "error": "control channel closed"});
}
let t0 = Instant::now();
let reply = loop {
self.step();
if let Ok(v) = rx.try_recv() {
break v;
}
if t0.elapsed() > timeout {
return json!({"ok": false, "error": "timeout"});
}
if self.busy() {
std::thread::sleep(Duration::from_millis(1));
}
};
// let injected input (clicks, keys, drags) play out before the next request
let t1 = Instant::now();
while (!self.app.synthetic.is_empty() || !self.events.is_empty()) && t1.elapsed() < timeout {
self.step();
}
self.step();
reply
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Generous: renders are slow when the machine is loaded (parallel builds), and a timed-out
/// settle would show a half-rendered UI.
const SETTLE: Duration = Duration::from_secs(120);
fn demo(size: [f32; 2]) -> Headless {
let services = crate::Services { png: None, ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
Headless::new(app, size, 1.0)
}
#[test]
fn demo_grid_snapshot_has_ui_pixels() {
let t0 = Instant::now();
let mut h = demo([1200.0, 760.0]);
// settle() can see a quiet spell before the first thumbnails land on a loaded machine
h.step_until(SETTLE, |h| h.app.renderer.thumb_textures() > 0);
let img = h.snapshot(SETTLE);
eprintln!("headless snapshot: {:?} in {:?} ({} frames)", img.size, t0.elapsed(), h.frames());
assert_eq!(img.size, [1200, 760]);
// not blank: many distinct colours
let mut colours: Vec<u32> = img.pixels.iter().map(|c| u32::from_le_bytes(c.to_array())).collect();
colours.sort_unstable();
colours.dedup();
assert!(colours.len() > 200, "only {} colours", colours.len());
// text in the top bar: bright pixels on the dark chrome
let top_h = crate::theme::Tokens::default().top_bar_h as usize;
let bright = img.pixels[..top_h * 1200].iter().filter(|c| c.r() > 150 && c.g() > 150 && c.b() > 150).count();
assert!(bright > 30, "no text in the top bar ({bright} bright px)");
// thumbnails arrived (photo textures were drawn)
assert!(h.app.renderer.thumb_textures() > 0);
}
#[test]
fn snapshots_are_deterministic_and_control_requests_work() {
let shot = || {
let mut h = demo([900.0, 600.0]);
let r = h.request("ui.set", json!({"view": "detail"}), Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
// Compare only fully settled frames: a timed-out settle would compare half-rendered
// pictures and fail as a misleading pixel diff (seen on loaded CI machines).
assert!(h.settle(Duration::from_secs(300)), "the demo detail view did not settle within 300 s");
h.paint()
};
let (a, b) = (shot(), shot());
assert_eq!(a.size, b.size);
// The photo may come from the GPU in one run and the CPU in the other (the GPU warms up in
// the background), which differ by ≤ 1–2 LSB; anything more is a real difference.
let diff = a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.to_array().iter().zip(y.to_array()).any(|(p, q)| p.abs_diff(q) > 2)).count();
assert_eq!(diff, 0, "{diff} pixels differ between two runs");
}
/// Keyboard culling: Compare (rating keys hit the candidate, arrows move it, auto-advance),
/// Survey (keys hit the active photo only), and auto-advance in Detail.
#[test]
fn compare_survey_and_auto_advance_by_keyboard() {
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().rating;
let flag = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().flag;
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]]}}), t);
let r = h.request("engine.execute", json!({"command": "view.compare"}), t);
assert_eq!(r["result"], json!({"select": vis[0], "candidate": vis[1]}), "{r}");
assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare);
// rating applies to the candidate (active) only
let before = rating(&h, vis[0]);
h.request("ui.key", json!({"key": "2"}), t);
assert_eq!((rating(&h, vis[0]), rating(&h, vis[1])), (before, 2));
// arrows move the candidate; the select stays
h.request("ui.key", json!({"key": "right"}), t);
assert_eq!(h.app.ui.compare, Some((vis[0], vis[2])));
h.request("ui.key", json!({"key": "left"}), t);
assert_eq!(h.app.ui.compare, Some((vis[0], vis[1])));
// Shift+X: reject and advance to the next candidate
h.request("ui.key", json!({"key": "x", "shift": true}), t);
assert_eq!(flag(&h, vis[1]), lightcraft_catalog::Flag::Reject);
assert_eq!(h.app.ui.compare, Some((vis[0], vis[2])));
h.request("engine.execute", json!({"command": "compare.swap"}), t);
assert_eq!(h.app.ui.compare, Some((vis[2], vis[0])));
h.request("engine.execute", json!({"command": "compare.makeSelect"}), t);
assert_eq!(h.app.ui.compare.map(|c| c.0), Some(vis[0]));
// Survey: the selection tiled; keys hit the active photo, auto-advance walks the survey
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[3], vis[4], vis[5]], "active": vis[3]}}), t);
let r = h.request("engine.execute", json!({"command": "view.survey"}), t);
assert_eq!(r["result"]["photos"], 3);
h.request("engine.execute", json!({"command": "view.autoAdvance"}), t);
assert!(h.app.ui.auto_advance);
h.request("ui.key", json!({"key": "5"}), t);
h.request("ui.key", json!({"key": "p"}), t);
assert_eq!((rating(&h, vis[3]), flag(&h, vis[4])), (5, lightcraft_catalog::Flag::Pick));
assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[5]));
assert_eq!(h.app.session.selection.ids.len(), 3, "the survey keeps its selection");
let img = h.snapshot(SETTLE);
assert_eq!(img.size, [1000, 700]);
// Detail: auto-advance moves to the next photo
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[6]]}}), t);
h.request("ui.set", json!({"view": "detail"}), t);
h.request("ui.key", json!({"key": "u"}), t);
assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[7]));
// Escape leaves the culling views for Detail
h.request("ui.set", json!({"view": "survey"}), t);
h.request("ui.key", json!({"key": "escape"}), t);
assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail);
// let in-flight renders finish: worker threads must not outlive the test process' TLS
h.settle(SETTLE);
}
/// Profile browser: live variant thumbnails, hover previews in the loupe without touching the
/// photo or its history, click applies, the star toggles the favourite.
#[test]
fn profile_browser_previews_on_hover_and_applies_on_click() {
let mut h = demo([1400.0, 900.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail"}), t);
let r = h.request("engine.execute", json!({"command": "panel.profiles"}), t);
assert_eq!(r["result"]["open"], true, "{r}");
h.settle(SETTLE);
let id = h.app.session.active().unwrap();
let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone();
let before = photo(&h);
h.step_until(SETTLE, |h| h.app.renderer.variant_textures() >= 6);
assert!(h.app.renderer.variant_textures() >= 6, "variant thumbnails rendered: {}", h.app.renderer.variant_textures());
// hover: the loupe shows the look, nothing is committed
let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
// the hover render can land after a quiet spell on a loaded machine (FreeBSD CI): wait for it
h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover"));
assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Profile: Vivid"));
assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover"));
let hover = h.app.renderer.textures.get(&crate::render::Slot::Hover).expect("hover render");
assert_eq!(hover.photo, id);
let after = photo(&h);
assert_eq!((after.develop.clone(), after.history.len()), (before.develop.clone(), before.history.len()), "hover leaves the photo alone");
assert_eq!(h.app.session.undo.len(), 0);
// moving away ends the preview
h.request("ui.move", json!({"x": 5, "y": 500}), t);
h.step();
assert!(h.app.hover_preview.is_none());
// click applies (one history step); the star toggles the favourite
h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t);
assert_eq!(photo(&h).develop.profile.id, "lc.vivid");
assert_eq!(photo(&h).history.len(), before.history.len() + 1);
h.request("ui.clickWidget", json!({"id": "profileStar:lc.portrait"}), t);
assert_eq!(h.app.session.profile_favorites, ["lc.portrait"]);
assert_eq!(photo(&h).develop.profile.id, "lc.vivid", "the star doesn't apply");
h.settle(SETTLE);
}
/// Presets column: resting on a preset previews it in the loupe without a history entry;
/// thumbnails are variant renders; Create Preset includes only the checked groups.
#[test]
fn preset_hover_previews_and_create_preset_groups() {
let mut h = demo([1400.0, 900.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail"}), t);
h.request("engine.execute", json!({"command": "panel.presets"}), t);
h.settle(SETTLE);
let id = h.app.session.active().unwrap();
let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone();
let before = photo(&h);
h.request("ui.hoverWidget", json!({"id": "preset:lc.bw-high-contrast"}), t);
h.settle(SETTLE);
h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover"));
assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W"));
assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover"));
let after = photo(&h);
assert_eq!(after.develop, before.develop);
assert_eq!(after.history.len(), before.history.len(), "no history entry while hovering");
assert!(h.app.session.undo.is_empty());
// hovering down across group headers within the presets list retains the preview (no flicker)
h.request("ui.hoverWidget", json!({"id": "presetGroup:Creative"}), t);
h.step();
assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W"));
// hovering another preset switches smoothly to it
h.request("ui.hoverWidget", json!({"id": "preset:lc.warm-glow"}), t);
h.settle(SETTLE);
h.step();
assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: Warm Glow"));
// thumbnails: variant textures for the visible presets
h.app.ui.preset_thumbs = true;
h.request("ui.move", json!({"x": 5, "y": 500}), t);
h.settle(SETTLE);
h.step();
assert!(h.app.hover_preview.is_none());
assert!(h.app.renderer.variant_textures() >= 5, "{}", h.app.renderer.variant_textures());
// Create Preset with a group checklist: untick Light, keep Effects
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t);
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "effects.clarity", "value": 25}}), t);
h.app.ui.dialog = Some(crate::state::Dialog::create_preset());
h.step();
h.step();
let r = h.request("ui.clickWidget", json!({"id": "presetInclude:light"}), t);
assert_eq!(r["ok"], true, "{r}");
match &h.app.ui.dialog {
Some(crate::state::Dialog::CreatePreset { groups, .. }) => {
assert!(!groups.iter().any(|g| g == "light") && groups.iter().any(|g| g == "effects"), "{groups:?}");
assert!(!groups.iter().any(|g| g == "crop" || g == "masks"), "crop and masks off by default");
}
d => panic!("dialog: {d:?}"),
}
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
let p = h.app.session.presets.iter().find(|p| !p.builtin).expect("created").clone();
assert!(p.settings.get("light").is_none() && p.settings["effects"]["clarity"] == 25.0, "{}", p.settings);
h.settle(SETTLE);
}
/// The filter bar drives `library.filter` and saves the view as a smart album.
#[test]
fn filter_bar_filters_and_saves_a_smart_album() {
let mut h = demo([1400.0, 800.0]);
let t = Duration::from_secs(10);
let all = h.app.session.visible_cloned().len();
h.request("engine.execute", json!({"command": "view.filterBar"}), t);
assert!(h.app.ui.filter_bar);
for w in ["filter:star3", "filter:pick", "filter:label-red", "filter:label-red"] {
let r = h.request("ui.clickWidget", json!({"id": w}), t);
assert_eq!(r["ok"], true, "{w}: {r}");
}
let f = h.app.session.filter.clone();
assert_eq!((f.rating, f.flag, f.label), (3, Some(lightcraft_catalog::Flag::Pick), None), "a second click clears the label");
let n = h.app.session.visible_cloned().len();
assert!(n > 0 && n < all);
h.request("ui.clickWidget", json!({"id": "button:filterSave"}), t);
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::NewSmartAlbum { .. })));
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
let smart = h.app.session.catalog.albums().find(|a| a.is_smart()).expect("smart album").id;
assert_eq!(h.app.session.catalog.album_count(smart), n);
h.request("ui.clickWidget", json!({"id": "button:filterClear"}), t);
assert_eq!(h.app.session.filter, Default::default());
assert_eq!(h.app.session.visible_cloned().len(), all);
h.settle(SETTLE);
}
/// The photo grid shows date headers (registered as `group:<date>` widgets); clicking one
/// selects that day's photos; month headers when zoomed out; none when grouping is off.
#[test]
fn grid_groups_by_capture_date() {
let mut h = demo([1300.0, 800.0]);
let t = Duration::from_secs(10);
h.settle(SETTLE);
let groups = h.app.session.execute("library.groups", &json!({})).unwrap();
let first = groups[0].clone();
let key = first["key"].as_str().unwrap().to_string();
let r = h.request("ui.clickWidget", json!({"id": format!("group:{key}")}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.selection.ids.len() as u64, first["count"].as_u64().unwrap());
// zoomed out: month headers
h.app.ui.thumb_size = 120.0;
h.settle(SETTLE);
let vis = h.app.session.visible_cloned();
let month = h.app.session.catalog.date_runs(&vis, h.app.session.sort.key, lightcraft_catalog::GroupBy::Month);
let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t);
assert_eq!(r["ok"], true, "{r}");
h.request("engine.execute", json!({"command": "library.sort", "params": {"group": "none"}}), t);
let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t);
assert_eq!(r["ok"], false, "no headers: {r}");
h.settle(SETTLE);
}
/// Keywords: the left-panel tree filters (children included), opens levels, the rename dialog
/// renames library-wide, and the Keywords panel adds a suggestion.
#[test]
fn keyword_list_filters_renames_and_suggests() {
// tall: the demo library's own keywords come first in the list
let mut h = demo([1300.0, 1800.0]);
// Host folders arrive asynchronously above Keywords. Keep this keyword fixture's
// geometry independent of their existence and the background stat timing.
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
h.app.ui.hidden_locations.extend(
["Pictures", "Desktop", "Downloads", ""]
.map(|sub| if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }),
);
}
let t = Duration::from_secs(10);
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
let ex = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), Duration::from_secs(10));
ex(&mut h, "photo.setMeta", json!({"ids": [vis[0], vis[1]], "addKeywords": ["travel|italy"]}));
ex(&mut h, "photo.setMeta", json!({"ids": [vis[2]], "addKeywords": ["travel|france"]}));
h.request("ui.set", json!({"leftPanel": true}), t);
// clicks land on last frame's layout: let the keyword list settle first (on a loaded machine a
// row could still move, and the click then hit its neighbour, e.g. "sunrise")
h.settle(SETTLE);
let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel"));
assert_eq!(h.app.session.visible_cloned().len(), 3);
// open the level, filter by the child
h.request("ui.clickWidget", json!({"id": "keywordToggle:travel"}), t);
h.settle(SETTLE);
let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel|italy"}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.visible_cloned().len(), 2);
h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "travel".into(), to: "trips".into() });
h.settle(SETTLE);
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.filter.keyword.as_deref(), Some("trips|italy"));
assert_eq!(h.app.session.visible_cloned().len(), 2);
// Keywords panel: suggestions co-occurring with the photo's keywords
h.request("engine.execute", json!({"command": "library.clearFilter"}), t);
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[1]]}}), t);
ex(&mut h, "photo.setMeta", json!({"ids": [vis[0]], "addKeywords": ["gelato"]}));
h.request("ui.set", json!({"right": "keywords"}), t);
assert!(h.settle(SETTLE), "keyword suggestions did not settle");
let r = h.request("ui.clickWidget", json!({"id": "kwSuggest:gelato"}), t);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(vis[1])).unwrap().meta.keywords.contains(&"gelato".to_string()));
h.settle(SETTLE);
}
/// The sidebar's file-system checks run on worker threads and add rows when they land, which
/// moves every row below them: `busy()` counts them, so `settle` waits for them before a test
/// reads widget positions (a click aimed at a stale rect hits the neighbouring row).
#[test]
fn settle_waits_for_file_system_checks() {
use std::sync::atomic::{AtomicBool, Ordering};
static RELEASE: AtomicBool = AtomicBool::new(false);
fn slow_check(_: &str) -> bool {
let t0 = Instant::now();
while !RELEASE.load(Ordering::Acquire) && t0.elapsed() < Duration::from_secs(60) {
std::thread::sleep(Duration::from_millis(1));
}
true
}
let mut h = demo([800.0, 600.0]);
h.settle(SETTLE);
let check = |h: &mut Headless| {
let raw = HeadlessView::raw_input(h.size, 1.0, 0.0, vec![]);
let mut got = None;
h.view.run(raw, |ui| got = crate::panels::left::fs_cached(ui, "test-slow", "x", f64::INFINITY, slow_check));
got
};
assert_eq!(check(&mut h), None, "the answer is worked out off the UI thread");
assert!(h.busy(), "a pending file-system check is pending work");
RELEASE.store(true, Ordering::Release);
assert!(h.settle(SETTLE));
assert!(!h.busy());
assert_eq!(check(&mut h), Some(true));
}
/// Photo > Rename Photos…: the dialog previews and renames the selection.
#[test]
fn rename_dialog_renames_the_selection() {
let mut h = demo([1200.0, 800.0]);
let t = Duration::from_secs(10);
let vis: Vec<u64> = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": vis}}), t);
let tree = h.request("ui.menu.tree", json!({}), t);
assert!(tree.to_string().contains("Rename 2 Photos…"), "live menu label");
h.request("engine.execute", json!({"command": "dialog.rename", "params": {"template": "Trip-{seq:2}", "start": 5}}), t);
h.settle(SETTLE);
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
let name = |id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().file_name.clone();
assert!(name(vis[0]).starts_with("Trip-05."), "{}", name(vis[0]));
assert!(name(vis[1]).starts_with("Trip-06."), "{}", name(vis[1]));
h.settle(SETTLE);
}
/// The Export dialog hands its batch to a worker thread: the UI keeps drawing frames, shows
/// progress, and reports the result when the files are written.
#[test]
fn export_dialog_keeps_its_width() {
// A dialog row sized as "available width − a guessed button width" made the auto-sized
// window grow a little every frame when the real button was wider (issue #8, Linux).
let mut h = demo([1400.0, 900.0]);
let t = Duration::from_secs(10);
h.app.services.pick_folder = Some(Box::new(|| None));
h.app.services.pick_files = Some(Box::new(Vec::new));
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t);
// the widest variant: watermark graphic and every optional row
if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog {
let mut wm = lightcraft_engine::export::Watermark { image: "logo.png".into(), ..Default::default() };
wm.text.clear();
opts.watermark = Some(wm);
}
let width = |h: &mut Headless| {
let r = h.request("ui.widgets", json!({}), t);
r["result"]
.as_array()
.and_then(|a| a.iter().find(|w| w["id"] == "dialog:window"))
.map(|w| w["rect"][2].as_f64().unwrap())
.expect("dialog on screen")
};
for _ in 0..5 {
h.step();
}
let w0 = width(&mut h);
for _ in 0..60 {
h.step();
}
let w1 = width(&mut h);
assert!((w1 - w0).abs() < 0.5, "the export dialog grew from {w0} to {w1}");
assert!(w1 < 700.0, "{w1}");
}
#[test]
fn export_dialog_runs_in_the_background() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let w = written.clone();
h.app.services.write_shared = Some(std::sync::Arc::new(move |p: &str, _: &[u8]| {
std::thread::sleep(Duration::from_millis(150)); // a slow disk: the UI must not wait for it
w.lock().unwrap().push(p.to_string());
Ok(())
}));
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect();
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t);
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t);
h.settle(SETTLE);
if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog {
*full_size = false;
*resize = lightcraft_engine::export::Resize::long_edge(64);
*dir = "/lc-test-out".into();
}
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.export.is_some(), "running in the background");
let running = h.request("ui.inspect", json!({}), t);
assert_eq!(running["result"]["export"]["running"]["total"], 3, "{}", running["result"]["export"]);
let t0 = Instant::now();
while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) {
h.step();
}
assert!(h.app.export.is_none(), "finished");
let w = written.lock().unwrap().clone();
assert_eq!(w.len(), 3, "{w:?}");
assert!(w.iter().all(|p| p.starts_with("/lc-test-out/")));
let last = h.request("ui.inspect", json!({}), t)["result"]["export"]["last"].clone();
assert_eq!(last["files"].as_array().map(Vec::len), Some(3), "{last}");
assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64));
}
/// Dragging grid photos onto an album row adds them to the album.
#[test]
fn drag_photos_onto_an_album() {
let mut h = demo([1300.0, 900.0]);
let t = Duration::from_secs(10);
h.request("engine.execute", json!({"command": "view.leftPanel"}), t);
let r = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Dropped"}}), t);
let album = r["result"]["id"].as_u64().unwrap_or_else(|| panic!("{r}"));
h.settle(SETTLE);
let rect = |h: &mut Headless, id: &str| {
let w = h.request("ui.widgets", json!({"filter": id}), t);
let r = w["result"]
.as_array()
.and_then(|a| a.iter().find(|x| x["id"] == id))
.map(|x| x["rect"].clone())
.unwrap_or_else(|| panic!("{id}: {w}"));
let f = |i: usize| r[i].as_f64().unwrap();
(f(0) + f(2) / 2.0, f(1) + f(3) / 2.0)
};
let first = h.app.session.visible_cloned()[0].0;
let (x, y) = rect(&mut h, &format!("thumb:{first}"));
let (tx, ty) = rect(&mut h, &format!("source:album:{album}"));
let r = h.request("ui.drag", json!({"x": x, "y": y, "toX": tx, "toY": ty, "steps": 12}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
let members = h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.clone();
assert_eq!(members, vec![lightcraft_catalog::PhotoId(first)]);
assert!(h.app.ui.dragging_photos.is_none(), "the drag ended");
}
/// Issue #10: a raw shown from its embedded JPEG (an undecodable raw variant) says so — a badge
/// on its grid thumbnail, a pill on the loupe, a notice in Edit and Info — and other photos
/// show none of it.
#[test]
fn preview_only_raw_shows_badge_and_notices() {
let mut h = demo([1300.0, 900.0]);
let t = Duration::from_secs(10);
let vis = h.app.session.visible_cloned();
let (po, other) = (vis[0], vis[1]);
let ph = h.app.session.catalog.photo(po).unwrap().clone();
h.app
.session
.catalog
.apply(lightcraft_catalog::Op::SetContent {
id: po,
width: ph.width,
height: ph.height,
file_size: ph.file_size,
content_hash: ph.content_hash.clone(),
preview_only: Some("Nikon Huffman-compressed NEF (no clean-room description available)".into()),
})
.unwrap();
h.settle(SETTLE);
let ids = |h: &mut Headless, filter: &str| -> Vec<String> {
let w = h.request("ui.widgets", json!({"filter": filter}), t);
w["result"].as_array().unwrap_or_else(|| panic!("{w}")).iter().filter_map(|x| x["id"].as_str().map(str::to_string)).collect()
};
let badges = ids(&mut h, "badge:previewOnly:");
assert_eq!(badges, vec![format!("badge:previewOnly:{}", po.0)], "only the preview-only photo has the badge");
// the loupe and the Edit panel
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [po.0]}}), t);
h.request("ui.set", json!({"view": "detail"}), t);
h.app.ui.right = crate::state::RightPanel::Edit;
h.settle(SETTLE);
let n = ids(&mut h, "notice:previewOnly:");
assert!(n.contains(&"notice:previewOnly:loupe".to_string()) && n.contains(&"notice:previewOnly:edit".to_string()), "{n:?}");
// Info
h.app.ui.right = crate::state::RightPanel::Info;
h.settle(SETTLE);
assert!(ids(&mut h, "notice:previewOnly:").contains(&"notice:previewOnly:info".to_string()));
// a regular photo: no notice
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other.0]}}), t);
h.app.ui.right = crate::state::RightPanel::Edit;
h.settle(SETTLE);
assert!(ids(&mut h, "notice:previewOnly:").is_empty());
}
/// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect.
#[test]
fn crop_keys() {
let mut h = demo([1200.0, 800.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail"}), t);
h.request("engine.execute", json!({"command": "panel.crop"}), t);
assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Thirds);
let mask_overlay = h.app.ui.mask_overlay;
h.request("ui.key", json!({"key": "O"}), t);
assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Grid);
h.request("ui.key", json!({"key": "O", "shift": true}), t);
assert_eq!(h.app.ui.crop_overlay_orient, 1);
assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping");
let locked = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().crop.aspect.is_some();
assert!(!locked(&h));
h.request("ui.key", json!({"key": "A"}), t);
assert!(locked(&h));
h.request("ui.key", json!({"key": "A"}), t);
assert!(!locked(&h));
assert!(!h.app.ui.visualize_spots);
}
/// Info panel: typing into a field survives frames and is saved when it loses focus; the new
/// accessibility and place fields reach the photo.
#[test]
fn info_fields_keep_typing_and_save() {
let mut h = demo([1300.0, 1000.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
h.settle(SETTLE);
for (key, text) in [("altText", "A lake at dawn"), ("usageTerms", "Editorial use only"), ("city", "Zermatt")] {
let r = h.request("ui.clickWidget", json!({"id": format!("field:{key}")}), t);
assert_eq!(r["ok"], true, "{r}");
h.request("ui.key", json!({"key": "A", "cmd": true}), t);
h.request("ui.text", json!({"text": text}), t);
h.step();
h.step();
// still being edited: not saved yet, not lost either
h.request("ui.key", json!({"key": "Tab"}), t);
h.settle(Duration::from_secs(5));
}
let m = &h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta;
assert_eq!((m.alt_text.as_str(), m.city.as_str()), ("A lake at dawn", "Zermatt"));
assert_eq!(m.usage_terms, "Editorial use only");
// the copyright status picker is on the panel too
let w = h.request("ui.widgets", json!({"filter": "copyrightStatus"}), t);
assert!(w["result"].to_string().contains("field:copyrightStatus"), "{w}");
}
/// Local: a folder's photos show without joining the library; the breadcrumb, Include
/// subfolders and Add to My Photos work from the grid header.
#[test]
fn browse_a_local_folder() {
let mut h = demo([1300.0, 900.0]);
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-ui-browse-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("inner")).unwrap();
let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() };
for (i, p) in [dir.join("a.png"), dir.join("inner/b.png")].iter().enumerate() {
let mut img = img.clone();
img.data[0][0] = i as u8; // different bytes per file
std::fs::write(p, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap();
}
let library_before = h.app.session.catalog.photos().filter(|p| !p.local).count();
let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t);
// the folder is read in the background: the view switches at once, the photos follow
assert_eq!(r["result"]["scanning"], true, "{r}");
assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::Folder);
h.settle(SETTLE);
assert!(h.app.scan.is_none() && h.app.import.is_none());
assert_eq!(h.app.session.visible_cloned().len(), 1);
assert!(h.settle(SETTLE), "a thumbnail that can't load must not keep the renderer busy");
// this test session has no file hooks: the thumbnail fails once, is remembered, and isn't retried
let id = h.app.session.visible_cloned()[0];
assert!(h.app.renderer.failure(crate::render::Slot::Thumb(id)).is_some());
let done = h.app.renderer.completed;
for _ in 0..30 {
h.step();
}
assert_eq!(h.app.renderer.completed, done, "no retry loop");
let w = h.request("ui.widgets", json!({"filter": "crumb:"}), t);
assert!(w["result"].as_array().is_some_and(|a| !a.is_empty()), "breadcrumb: {w}");
assert_eq!(h.request("ui.clickWidget", json!({"id": "check:includeSubfolders"}), t)["ok"], true);
h.settle(SETTLE);
assert_eq!(h.app.session.visible_cloned().len(), 2);
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:addToLibrary"}), t)["ok"], true);
h.settle(SETTLE);
assert_eq!(h.app.session.catalog.photos().filter(|p| !p.local).count(), library_before + 2);
let _ = std::fs::remove_dir_all(&dir);
}
/// Local: Remove from Local hides a sidebar location (nothing on disk changes) and the
/// "Show hidden locations" row puts it back. The folder is browsed and kept the way Browse
/// Folder… does it, in the temporary folder — on Windows beneath Home, whose tree must not
/// open down to the hidden folder and push the restore row out of view.
#[test]
fn local_location_can_be_hidden_and_restored() {
let size = [1300.0, 900.0];
let mut h = demo(size);
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-ui-hide-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.to_string_lossy().to_string();
let rects = |h: &mut Headless| -> Vec<(String, Vec<f64>)> {
let w = h.request("ui.widgets", json!({"filter": "source:local:"}), t);
w["result"]
.as_array()
.unwrap()
.iter()
.map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"].as_array().unwrap().iter().filter_map(|v| v.as_f64()).collect()))
.collect()
};
let ids = |h: &mut Headless| -> Vec<String> { rects(h).into_iter().map(|(id, _)| id).collect() };
h.request("ui.set", json!({"leftPanel": true}), t);
h.hide_home_above(&dir);
// Browse Folder… browses the picked folder and keeps it in Local within one frame; a
// request runs frames, so keep it first (no frame sees it browsed but not yet kept)
let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": path}}), t);
assert_eq!(r["ok"], true, "{r}");
let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
let row = format!("source:local:{path}");
assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the browsed folder is listed: {:?}", ids(&mut h));
// the "Show hidden locations" row appears only while something is hidden
let hidden_before = !h.app.ui.hidden_locations.is_empty();
assert_eq!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), hidden_before);
// hide it while it is still being browsed
let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
// give any listing a reveal would wait for time to arrive
for _ in 0..20 {
h.step();
std::thread::sleep(Duration::from_millis(5));
}
h.settle(SETTLE);
let after = rects(&mut h);
let after_ids: Vec<&str> = after.iter().map(|(id, _)| id.as_str()).collect();
assert!(!after_ids.contains(&format!("source:local:{path}").as_str()), "{after_ids:?}");
let restore = after.iter().find(|(id, _)| id == "source:local:restoreHidden").map(|(_, r)| r.clone());
let restore = restore.unwrap_or_else(|| panic!("no restore row: {after_ids:?}"));
assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}");
assert!(h.app.session.browse.is_some(), "hiding does not stop browsing");
assert!(dir.is_dir(), "the folder itself is untouched");
assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true);
h.settle(SETTLE);
assert!(h.app.ui.hidden_locations.is_empty());
assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore");
if !hidden_before {
// (with Home hidden for this test, the folder is back inside Home's tree instead)
assert!(ids(&mut h).contains(&format!("source:local:{path}")));
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Local: a kept folder stays a root while its subfolders (and other locations) are
/// browsed — the subfolder is highlighted inside its tree, its siblings stay listed — and
/// the kept folders survive a save/load of the UI state (a restart).
#[test]
fn kept_local_root_stays_while_browsing_below_and_elsewhere() {
let mut h = demo([1300.0, 1400.0]);
let t = Duration::from_secs(10);
let base = std::env::temp_dir().join(format!("lc-ui-roots-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["Photos/2026/20260101", "Photos/2026/20260114", "Other"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
// joined by component: the sidebar's ids spell paths with the platform's separator
let year = base.join("Photos").join("2026");
let (photos, day1, day2, other) = (s(base.join("Photos")), s(year.join("20260101")), s(year.join("20260114")), s(base.join("Other")));
h.hide_home_above(&base);
let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t);
let rects = |h: &mut Headless| -> std::collections::HashMap<String, f64> {
let w = h.request("ui.widgets", json!({"filter": "lc-ui-roots-"}), t);
w["result"].as_array().unwrap().iter().map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"][0].as_f64().unwrap_or(0.0))).collect()
};
h.request("ui.set", json!({"leftPanel": true}), t);
// Home may also own this temporary directory. Keep the fixture tree
// independent of the user's real folders and their async listings.
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
assert_eq!(exec(&mut h, "local.hide", json!({"path": home}))["ok"], true);
}
assert_eq!(exec(&mut h, "local.addRoot", json!({"path": photos}))["ok"], true);
assert_eq!(exec(&mut h, "local.addRoot", json!({"path": format!("{photos}/")}))["result"]["roots"].as_array().map(Vec::len), Some(1));
exec(&mut h, "library.browse", json!({"path": day1}));
h.settle(SETTLE);
h.step();
let row = format!("source:local:{day2}");
assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the sibling listing did not arrive");
let r = rects(&mut h);
assert!(r.contains_key(&format!("source:local:{photos}")), "the kept root stays: {r:?}");
assert!(r.contains_key(&format!("source:local:{day2}")), "the sibling stays reachable: {r:?}");
let (root_x, child_x) = (r[&format!("folderToggle:{photos}")], r[&format!("folderToggle:{day1}")]);
assert!(child_x > root_x, "the browsed folder is inside the root's tree, not a root of its own ({child_x} vs {root_x})");
assert_eq!(h.request("ui.clickWidget", json!({"id": format!("source:local:{day2}")}), t)["ok"], true);
h.settle(SETTLE);
assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(day2.clone()), "the sibling is browsed");
// another location: the kept root stays listed
assert_eq!(exec(&mut h, "library.browse", json!({"path": other}))["ok"], true);
h.settle(SETTLE);
h.step();
let row = format!("source:local:{other}");
assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the other location did not arrive");
let r = rects(&mut h);
assert!(r.contains_key(&format!("source:local:{photos}")) && r.contains_key(&format!("source:local:{other}")), "{r:?}");
// restart: the kept roots come back with the saved UI state
let saved = serde_json::to_string(&h.app.ui).unwrap();
let back: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(back.local_roots, vec![photos.clone()]);
let _ = std::fs::remove_dir_all(&base);
h.settle(SETTLE);
}
/// Versions panel: resting on a version previews it in the loupe without changing the photo;
/// a click restores it.
#[test]
fn versions_preview_on_hover_and_restore_on_click() {
let mut h = demo([1300.0, 900.0]);
let t = Duration::from_secs(10);
let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t);
h.request("ui.set", json!({"view": "detail", "right": "versions"}), t);
exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0}}));
exec(&mut h, "version.create", json!({"name": "Bright"}));
exec(&mut h, "develop.set", json!({"values": {"light.exposure": -1.0}}));
h.settle(SETTLE);
let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure;
assert_eq!(h.request("ui.hoverWidget", json!({"id": "version:0"}), t)["ok"], true);
h.step();
assert_eq!(h.app.hover_preview.as_ref().map(|p| p.settings.light.exposure), Some(1.0));
assert_eq!(exposure(&h), -1.0, "hovering changes nothing");
assert_eq!(h.request("ui.clickWidget", json!({"id": "version:0"}), t)["ok"], true);
h.settle(Duration::from_secs(5));
assert_eq!(exposure(&h), 1.0);
}
/// ↑ / ↓ over a slider nudge it (one undo step each); ⇧Z picks and moves to the next photo.
#[test]
fn slider_nudge_and_pick_advance() {
let mut h = demo([1300.0, 900.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "edit"}), t);
h.settle(SETTLE);
let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure;
assert_eq!(h.request("ui.hoverWidget", json!({"id": "slider:light.exposure"}), t)["ok"], true);
h.request("ui.key", json!({"key": "Up"}), t);
h.request("ui.key", json!({"key": "Up", "shift": true}), t);
assert!((exposure(&h) - 0.30).abs() < 1e-9, "{}", exposure(&h));
h.request("ui.key", json!({"key": "Down"}), t);
assert!((exposure(&h) - 0.25).abs() < 1e-9, "{}", exposure(&h));
let first = h.app.session.active().unwrap();
h.request("ui.key", json!({"key": "Z", "shift": true}), t);
assert_eq!(h.app.session.catalog.photo(first).unwrap().flag, lightcraft_catalog::Flag::Pick);
assert_ne!(h.app.session.active(), Some(first), "advanced");
}
/// Return commits a tool panel back to Edit; elsewhere it does nothing.
#[test]
fn return_commits_the_crop_tool() {
let mut h = demo([1200.0, 800.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail"}), t);
h.request("engine.execute", json!({"command": "panel.crop"}), t);
assert_eq!(h.app.ui.right, crate::state::RightPanel::Crop);
h.request("ui.key", json!({"key": "Enter"}), t);
assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit);
h.request("ui.key", json!({"key": "Enter"}), t);
assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit, "no-op outside tools");
}
/// ⌘Q (File → Quit LightCraft) closes the window.
#[test]
fn cmd_q_quits() {
let mut h = demo([900.0, 600.0]);
assert!(!h.quit_requested());
h.request("ui.key", json!({"key": "Q", "cmd": true}), Duration::from_secs(10));
h.step();
h.step();
assert!(h.quit_requested());
}
/// File → Import from Folder… opens the import review for a folder (searched recursively).
#[test]
fn add_folder_opens_the_import_review() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-addfolder-{}", std::process::id()));
let sub = dir.join("day 2");
std::fs::create_dir_all(&sub).unwrap();
let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
let png = lightcraft_engine::export::encode_image(
&img,
&lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() },
)
.unwrap();
std::fs::write(dir.join("a.png"), &png).unwrap();
std::fs::write(sub.join("b.png"), &png).unwrap();
let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review: {:?}", h.app.ui.dialog) };
assert_eq!(opts.candidates.len(), 2, "both files, the subfolder's too");
assert!(!opts.copy, "a folder is added in place by default");
// the review names its source; scanning doesn't save a Local location
assert_eq!(opts.sources, vec![dir.to_string_lossy().to_string()]);
let name = dir.file_name().unwrap().to_string_lossy().to_string();
assert_eq!(crate::import::source_summary(&opts.sources), format!("Folder “{name}” (and its subfolders)"));
let r = h.request("ui.widgets", json!({}), t);
assert!(r.to_string().contains("label:importSource"), "source shown");
assert!(h.app.ui.local_roots.is_empty(), "no Local shortcut saved");
// a camera / card folder: copied into the library by default
h.app.ui.dialog = None;
let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
assert!(opts.copy);
let _ = std::fs::remove_dir_all(&dir);
}
/// The folder scan runs in the background: the request returns at once and the review opens
/// when the scan finishes (a folder on a network share must not freeze the window).
/// Clicking the folder being read again keeps the running read (and its progress).
#[test]
fn browsing_the_same_folder_again_does_not_restart() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-browse-again-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() };
std::fs::write(dir.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap();
let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let started = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (g, n) = (gate.clone(), started.clone());
h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| {
n.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
while !g.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(5));
}
Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() })
}));
let browse =
|h: &mut Headless| h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t);
assert_eq!(browse(&mut h)["result"]["scanning"], true);
std::thread::sleep(Duration::from_millis(200));
assert_eq!(browse(&mut h)["result"]["scanning"], true);
std::thread::sleep(Duration::from_millis(200));
assert_eq!(started.load(std::sync::atomic::Ordering::Relaxed), 1, "the second click must not start another read");
gate.store(true, std::sync::atomic::Ordering::Relaxed);
h.settle(SETTLE);
assert_eq!(h.app.session.visible_cloned().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn add_folder_scans_in_the_background() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-scanbg-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
let png = lightcraft_engine::export::encode_image(
&img,
&lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() },
)
.unwrap();
std::fs::write(dir.join("a.png"), &png).unwrap();
let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
assert!(h.app.scan.is_some() || h.app.ui.dialog.is_some());
h.settle(SETTLE);
assert!(h.app.scan.is_none());
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
let _ = std::fs::remove_dir_all(&dir);
}
/// Cancel closes the scan at once and opens no review; Add from Device while a scan runs is
/// refused and leaves the running scan's options alone.
#[test]
fn folder_scan_cancel_and_busy() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-scancancel-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
let png = lightcraft_engine::export::encode_image(
&img,
&lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() },
)
.unwrap();
std::fs::write(dir.join("a.png"), &png).unwrap();
// a scan that can't finish until the test lets it: the probe waits on a flag
let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let g = gate.clone();
h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| {
while !g.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(5));
}
Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() })
}));
let r = h.request("engine.execute", json!({"command": "file.addFolder", "params": {"path": dir.to_string_lossy()}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": dir.to_string_lossy()}}), t);
assert_ne!(r["ok"], true, "a second scan is refused: {r}");
assert!(!h.app.scan.as_ref().unwrap().copy, "the running scan keeps its options");
let r = h.request("ui.inspect", json!({}), t);
assert!(r["result"]["scan"].is_object(), "{r}");
let r = h.request("ui.clickWidget", json!({"id": "button:scanCancel"}), t);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.scan.is_none(), "Cancel closes the scan at once");
gate.store(true, std::sync::atomic::Ordering::Relaxed);
h.settle(SETTLE);
assert!(h.app.ui.dialog.is_none(), "a cancelled scan opens no review");
let _ = std::fs::remove_dir_all(&dir);
}
/// ⇧⌘V opens Paste Selected Settings (prefilled with the copied groups); unchecking a group
/// leaves it alone. ⌘F puts typing into the search field.
#[test]
fn paste_selected_settings_and_find() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t);
exec(&mut h, "library.select", json!({"ids": [ids[0]]}));
exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0, "color.vibrance": 30}}));
exec(&mut h, "develop.copy", json!({"groups": ["light", "color"]}));
exec(&mut h, "library.select", json!({"ids": [ids[1]]}));
h.request("ui.key", json!({"key": "V", "cmd": true, "shift": true}), t);
h.settle(SETTLE);
let Some(crate::state::Dialog::PasteSettings { groups }) = &h.app.ui.dialog else { panic!("dialog not open: {:?}", h.app.ui.dialog) };
assert_eq!(groups, &["light", "color"]);
let r = h.request("ui.clickWidget", json!({"id": "pasteGroup:color"}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(h.request("ui.dialog.confirm", json!({}), t)["ok"], true);
let d = h.app.session.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap();
assert_eq!((d.light.exposure, d.color.vibrance), (1.0, 0.0));
// ⌘F, then typing filters the grid by text
h.request("ui.set", json!({"view": "grid"}), t);
h.request("ui.key", json!({"key": "F", "cmd": true}), t);
h.settle(SETTLE);
h.request("ui.text", json!({"text": "zz-no-such-photo"}), t);
h.settle(SETTLE);
assert_eq!(h.app.ui.search, "zz-no-such-photo");
assert!(h.app.session.visible_cloned().is_empty());
}
/// Info panel → Edit Capture Time…: a time-zone shift moves the selected photos.
#[test]
fn capture_time_dialog_shifts_time_zone() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let id = h.app.session.visible_cloned()[0];
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t);
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap();
let r = h.request("ui.clickWidget", json!({"id": "icon:editCaptureTime"}), t);
assert_eq!(r["ok"], true, "{r}");
h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t);
if let Some(crate::state::Dialog::CaptureTime { zone, .. }) = &mut h.app.ui.dialog {
*zone = -3.0;
} else {
panic!("dialog not open: {:?}", h.app.ui.dialog);
}
h.settle(SETTLE);
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
let after = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap();
let secs = lightcraft_catalog::dates::iso_seconds;
assert_eq!(secs(&after).unwrap() - secs(&before).unwrap(), -3 * 3600);
h.settle(SETTLE);
}
/// Colour labels: Info-panel swatches set/clear the label, the names dialog renames them and
/// the Photo menu shows the names.
#[test]
fn label_swatches_and_names() {
let mut h = demo([1200.0, 900.0]);
let t = Duration::from_secs(10);
let id = h.app.session.visible_cloned()[0];
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t);
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
let r = h.request("ui.clickWidget", json!({"id": "label:green"}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.catalog.photo(id).unwrap().label, Some(lightcraft_catalog::ColorLabel::Green));
h.request("engine.execute", json!({"command": "dialog.labelNames"}), t);
if let Some(crate::state::Dialog::LabelNames { names, .. }) = &mut h.app.ui.dialog {
names[2] = "Approved".into();
} else {
panic!("no dialog");
}
h.settle(SETTLE);
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
let tree = h.request("ui.menu.tree", json!({}), t).to_string();
assert!(tree.contains("Approved (Green)"), "menu shows the name");
h.request("ui.clickWidget", json!({"id": "label:green"}), t);
assert_eq!(h.app.session.catalog.photo(id).unwrap().label, None, "clicking the current label clears it");
h.settle(SETTLE);
}
/// File → Import Photos… opens the import review: candidates with thumbnails, the duplicate is
/// unchecked; unchecking a cell and confirming imports the rest in batches, into a new album
/// with keywords, as one undo step.
#[test]
fn import_review_dialog() {
let dir = std::env::temp_dir().join(format!("lc-ui-import-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..5u8 {
let (w, h) = (40usize, 30usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|k| [(k % w * 6) as u8, i * 40, (k / w * 8) as u8, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(dir.join(format!("img{i}.png")), png).unwrap();
}
std::fs::copy(dir.join("img0.png"), dir.join("img0-copy.png")).unwrap();
let services = crate::Services { png: None, ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
let t = Duration::from_secs(10);
let n0 = h.app.session.catalog.len();
let undo0 = h.app.session.undo.len();
let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
h.settle(SETTLE);
h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
h.step_until(SETTLE, |h| h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5);
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
assert_eq!(opts.candidates.len(), 6);
assert_eq!(opts.candidates.iter().filter(|c| c.duplicate.is_some()).count(), 1);
assert!(h.app.renderer.textures.keys().filter(|s| matches!(s, crate::render::Slot::Import(_))).count() >= 5, "thumbnails");
// uncheck the first photo; name a new album and keywords
let r = h.request("ui.clickWidget", json!({"id": "import:0"}), t);
assert_eq!(r["ok"], true, "{r}");
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
assert_eq!(opts.selected_paths().len(), 4, "duplicate and unchecked photo left out");
opts.new_album = "Card 1".into();
opts.keywords = "cardone, 2026".into();
} else {
panic!("no import dialog");
}
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(h.app.import.is_none(), "finished");
assert_eq!(h.app.session.catalog.len(), n0 + 4);
let album = h.app.session.catalog.albums().find(|a| a.name == "Card 1").expect("album").id;
assert_eq!(h.app.session.catalog.album_count(album), 4);
assert!(h.app.session.catalog.photos().filter(|p| p.meta.keywords.contains(&"cardone".to_string())).count() == 4);
assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step");
h.request("engine.execute", json!({"command": "edit.undo"}), t);
assert_eq!(h.app.session.catalog.len(), n0);
h.settle(SETTLE);
let _ = std::fs::remove_dir_all(&dir);
}
/// The import review offers what to do with files that are in Recently Deleted (issue #298):
/// they are unchecked until Restore or Import as new is chosen; then confirming restores the
/// photo (with its edits) or imports the file afresh.
#[test]
fn import_review_offers_recently_deleted_files() {
for (choice, tag) in [("restore", "r"), ("fresh", "f")] {
let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..2u8 {
let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data };
let png =
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(dir.join(format!("img{i}.png")), png).unwrap();
}
let services = crate::Services { png: None, ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
let t = Duration::from_secs(10);
let r =
h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t);
let id = r["result"]["imported"][0].as_u64().unwrap();
h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t);
h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t);
h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t);
h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") };
assert_eq!(opts.selected_paths().len(), 1, "only the new file: the trashed one waits for a choice");
opts.set_on_deleted(choice);
assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked once a choice is made");
// a per-cell choice survives switching between the two options, and "Leave them" unchecks
let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).unwrap();
opts.checked[trashed] = false;
opts.set_on_deleted(if choice == "restore" { "fresh" } else { "restore" });
assert_eq!(opts.selected_paths().len(), 1, "still unchecked");
opts.set_on_deleted("");
assert_eq!(opts.selected_paths().len(), 1);
opts.set_on_deleted(choice);
assert_eq!(opts.selected_paths().len(), 2);
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
// frames until the import has finished, so its (timed) toast is read before it expires
let t0 = std::time::Instant::now();
while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) {
h.step();
}
assert!(h.app.import.is_none(), "finished");
let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default();
let photos = &h.app.session.catalog;
assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2, "{choice}");
let old = photos.photo(lightcraft_catalog::PhotoId(id));
if choice == "restore" {
assert!(toast.contains("1 restored"), "{toast}");
assert!(old.is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits");
assert_eq!(photos.len(), 2);
} else {
assert!(old.is_none(), "the trashed record is gone");
assert_eq!(photos.len(), 2);
}
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Adding a file again that is in Recently Deleted shows it there (side panel opened, photo
/// selected) instead of only saying "duplicate skipped"; its menus offer Restore, and once
/// restored a re-add selects it in All Photos.
#[test]
fn readding_a_deleted_photo_shows_it_in_recently_deleted() {
let dir = std::env::temp_dir().join(format!("lc-ui-readd-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let img = lightcraft_raster::Rgba8 { width: 16, height: 12, data: vec![[200, 120, 40, 255]; 16 * 12] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
let file = dir.join("flower.png");
std::fs::write(&file, png).unwrap();
let paths = vec![file.to_string_lossy().to_string()];
let services = crate::Services { png: None, ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
let t = Duration::from_secs(10);
// frames until the import has finished, so its (timed) toast is read before it expires
let finish_import = |h: &mut Headless| {
let t0 = std::time::Instant::now();
while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) {
h.step();
}
assert!(h.app.import.is_none(), "import finished");
};
crate::import::start_paths(&mut h.app, paths.clone()).unwrap();
h.settle(SETTLE);
let id = h.app.session.selection.active.expect("imported photo selected");
h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t);
assert!(h.app.session.catalog.photo(id).unwrap().deleted);
let photo_items = |app: &LightcraftApp| -> Vec<String> {
let bar = crate::menubar::menu_bar(app);
let items = &bar.iter().find(|(title, _)| title == "Photo").expect("Photo menu").1;
items.iter().filter_map(|n| if let crate::menubar::MenuNode::Item { id, .. } = n { Some(id.clone()) } else { None }).collect()
};
assert!(!photo_items(&h.app).contains(&"photo.restore".to_string()), "Restore only for deleted photos");
h.app.ui.left_panel = false;
crate::import::start_paths(&mut h.app, paths.clone()).unwrap();
finish_import(&mut h);
assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted);
assert_eq!(h.app.session.selection.ids, vec![id]);
assert!(h.app.ui.left_panel, "the side panel listing Recently Deleted is opened");
let toast = h.app.ui.toast.clone().expect("toast").0;
assert!(toast.contains("Recently Deleted") && toast.contains("Restore"), "{toast}");
let items = photo_items(&h.app);
assert!(items.contains(&"photo.restore".to_string()) && items.contains(&"photo.deletePermanently".to_string()), "{items:?}");
assert!(!items.contains(&"photo.delete".to_string()), "{items:?}");
// a right-click on its filmstrip thumbnail opens the photo menu
h.request("ui.set", json!({"view": "detail"}), t);
h.settle(SETTLE);
let cell = h.app.widgets.iter().find(|(w, _)| *w == format!("film:{}", id.0)).map(|(_, r)| *r).expect("filmstrip cell");
assert!(!egui::Popup::is_any_open(&h.view.ctx));
let r = h.request("ui.click", json!({"x": cell.center().x, "y": cell.center().y, "button": "right"}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(egui::Popup::is_any_open(&h.view.ctx), "filmstrip context menu");
// Empty Recently Deleted is offered while the trash is the source
assert!(photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string()));
let r = h.request("ui.menu.invoke", json!({"id": "photo.restore"}), t);
assert_eq!(r["ok"], true, "{r}");
assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "restored");
crate::import::start_paths(&mut h.app, paths).unwrap();
finish_import(&mut h);
assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All);
assert_eq!(h.app.session.selection.ids, vec![id]);
let toast = h.app.ui.toast.clone().expect("toast").0;
assert!(toast.contains("All Photos"), "{toast}");
assert!(!photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string()), "Empty is for the trash view only");
// deleted permanently, the file imports afresh as a new photo
h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t);
h.request("engine.execute", json!({"command": "photo.deletePermanently", "params": {"ids": [id.0]}}), t);
assert!(h.app.session.catalog.photo(id).is_none());
crate::import::start_paths(&mut h.app, vec![file.to_string_lossy().to_string()]).unwrap();
h.settle(SETTLE);
let fresh = h.app.session.selection.active.expect("re-imported photo selected");
assert_ne!(fresh, id);
assert!(!h.app.session.catalog.photo(fresh).unwrap().deleted);
let _ = std::fs::remove_dir_all(&dir);
}
/// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across
/// the import's batches.
#[test]
fn import_copy_renames_into_destination() {
let base = std::env::temp_dir().join(format!("lc-ui-import-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
for i in 0..10u8 {
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 20, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap();
}
let dest_s = dest.to_string_lossy().to_string();
let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
let t = Duration::from_secs(10);
let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
h.settle(SETTLE);
h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
assert_eq!(opts.candidates.len(), 10);
for id in ["button:importCopy", "button:importDest"] {
let r = h.request("ui.clickWidget", json!({"id": id}), t);
assert_eq!(r["ok"], true, "{id}: {r}");
}
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
assert!(opts.copy);
assert_eq!(opts.destination, dest.to_string_lossy(), "Choose… sets the folder");
opts.organize = "flat".into();
opts.rename = "Trip-{seq:2}".into();
} else {
panic!("no import dialog");
}
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(h.app.import.is_none(), "finished");
let mut names: Vec<String> = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
names.sort();
let want: Vec<String> = (1..=10).map(|i| format!("Trip-{i:02}.png")).collect();
assert_eq!(names, want, "numbered across batches of {}", crate::import::BATCH);
let _ = std::fs::remove_dir_all(&base);
}
/// Import review, Copy, Folders → Custom template…: the default `{date:%Y}/{date:%Y%m%d}`
/// files copies under `2026/20260114/`, the example line shows the full destination, and a
/// template that would climb out of the destination is refused.
#[test]
fn import_copy_into_a_custom_folder_template() {
let base = std::env::temp_dir().join(format!("lc-ui-import-tpl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("IMG_01.png"), png).unwrap();
let dest_s = dest.to_string_lossy().to_string();
let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() };
let mut session = lightcraft_engine::Session::with_demo().with_fs();
// undated files are filed by the import time
session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let mut app = LightcraftApp::new(session, services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 1000.0], 1.0);
let t = Duration::from_secs(10);
let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
h.settle(SETTLE);
for id in ["button:importCopy", "button:importDest"] {
let r = h.request("ui.clickWidget", json!({"id": id}), t);
assert_eq!(r["ok"], true, "{id}: {r}");
}
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
opts.organize = "custom".into();
opts.folder_template = "../{date:%Y}".into();
}
h.step();
// refused: the dialog stays open, nothing is imported
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_ne!(r["ok"], true, "{r}");
assert!(h.app.import.is_none());
let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("dialog closed") };
opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into();
let opts = opts.clone();
let sep = std::path::MAIN_SEPARATOR;
let example = crate::import::example_destination(&h.app, &opts).expect("example");
assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}IMG_01.png", dest.to_string_lossy()));
h.step();
let r = h.request("ui.widgets", json!({}), t);
assert!(r.to_string().contains("label:importExample"), "example shown");
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(dest.join("2026").join("20260114").join("IMG_01.png").is_file());
let _ = std::fs::remove_dir_all(&base);
}
/// Import review, Move: an explicit choice with its explanation, the confirm button says
/// Move; the files land in the folder template, renamed, and leave the card.
#[test]
fn import_move_into_a_folder_template() {
let base = std::env::temp_dir().join(format!("lc-ui-import-move-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("Photos"));
std::fs::create_dir_all(&src).unwrap();
for i in 0..2u8 {
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40 + i, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join(format!("IMG_0{i}.png")), png).unwrap();
}
let dest_s = dest.to_string_lossy().to_string();
let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() };
let mut session = lightcraft_engine::Session::with_demo().with_fs();
session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let mut app = LightcraftApp::new(session, services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 1000.0], 1.0);
let t = Duration::from_secs(10);
let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
h.settle(SETTLE);
for id in ["button:importMove", "button:importDest"] {
let r = h.request("ui.clickWidget", json!({"id": id}), t);
assert_eq!(r["ok"], true, "{id}: {r}");
}
let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import dialog") };
assert!(opts.copy && opts.move_files, "Move chosen");
opts.organize = "custom".into();
opts.folder_template = crate::import::DEFAULT_FOLDER_TEMPLATE.into();
opts.rename = "{date:%Y%m%d}_{seq:3}".into();
let opts = opts.clone();
let sep = std::path::MAIN_SEPARATOR;
let example = crate::import::example_destination(&h.app, &opts).expect("example");
assert_eq!(example, format!("{}{sep}2026{sep}20260114{sep}20260114_001.png", dest.to_string_lossy()));
h.step();
let r = h.request("ui.widgets", json!({}), t);
assert!(r.to_string().contains("label:importModeHelp"), "the Move explanation is shown");
assert!(!r.to_string().contains("check:importDng"), "no Copy as DNG when moving");
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(h.app.import.is_none(), "finished");
let day = dest.join("2026").join("20260114");
assert!(day.join("20260114_001.png").is_file() && day.join("20260114_002.png").is_file());
assert_eq!(std::fs::read_dir(&src).unwrap().count(), 0, "moved off the card");
let _ = std::fs::remove_dir_all(&base);
}
/// Import review, Copy: Tags beside the Rename field lists the template tags; clicking one
/// inserts it at the text cursor (not appended, not replacing the template).
#[test]
fn import_rename_tags_insert_at_the_cursor() {
let dir = std::env::temp_dir().join(format!("lc-ui-import-tags-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(dir.join("IMG_0007.png"), png).unwrap();
let lib = dir.join("lib");
let mut session = lightcraft_engine::Session::with_demo().with_fs();
session.open_library(&lib, false).unwrap();
let mut app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() });
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 1000.0], 1.0);
let t = Duration::from_secs(10);
let r =
h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.join("IMG_0007.png").to_string_lossy()]}}), t);
assert_eq!(r["result"]["scanning"], true, "{r}");
h.settle(SETTLE);
let r = h.request("ui.clickWidget", json!({"id": "button:importCopy"}), t);
assert_eq!(r["ok"], true, "{r}");
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
opts.rename = "Trip-_x".into();
}
// the cursor sits after "Trip-"
let id = egui::Id::new("import-rename");
let mut st = egui::text_edit::TextEditState::default();
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(5))));
st.store(&h.view.ctx, id);
let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTags"}), t);
assert_eq!(r["ok"], true, "{r}");
let seq3 = lightcraft_engine::rename::TOKENS.iter().position(|x| x.tag == "{seq:3}").unwrap();
let r = h.request("ui.clickWidget", json!({"id": format!("button:importRenameTag-{seq3}")}), t);
assert_eq!(r["ok"], true, "{r}");
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") };
assert_eq!(opts.rename, "Trip-{seq:3}_x", "inserted at the cursor");
// a second tag goes after the first (the cursor moved past it)
let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTag-0"}), t);
assert_eq!(r["ok"], true, "{r}");
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") };
assert_eq!(opts.rename, "Trip-{seq:3}{name}_x");
let _ = std::fs::remove_dir_all(&dir);
}
/// Help ▸ About: the About, Contributors and Models tabs switch and paint (credits are
/// compiled in).
#[test]
fn about_dialog_tabs_show_the_credits() {
// an empty library: the dialog needs no photos, and no decodes compete with other tests
let services = crate::Services { png: None, ..Default::default() };
let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::new(), services), [1300.0, 820.0], 1.0);
let t = Duration::from_secs(10);
let r = h.request("ui.menu.invoke", json!({"id": "app.about"}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About));
// a new window sizes itself on its first frame: let it settle before clicking its tabs
h.step();
h.step();
for (i, (tab, _)) in crate::panels::dialogs::ABOUT_TABS.iter().enumerate().rev() {
let r = h.request("ui.clickWidget", json!({"id": format!("button:aboutTab-{tab}")}), t);
assert_eq!(r["ok"], true, "{tab}: {r}");
h.step();
let shown = h.view.ctx.data_mut(|d| d.get_temp::<u8>(egui::Id::new("about_tab")));
assert_eq!(shown.map(usize::from), Some(i), "{tab}");
}
h.request("ui.clickWidget", json!({"id": "button:aboutTab-contributors"}), t);
let img = h.snapshot(SETTLE);
assert_eq!(img.size, [1300, 820]);
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About), "switching tabs keeps the dialog open");
}
/// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through
/// the engine; the delete confirmation guards ⌫.
#[test]
fn settings_dialog_by_keyboard_and_clicks() {
let mut h = demo([1300.0, 820.0]);
let t = Duration::from_secs(10);
let r = h.request("ui.key", json!({"key": ",", "cmd": true}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: "general".into() }));
for tab in ["import", "performance", "interface", "general"] {
let r = h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t);
assert_eq!(r["ok"], true, "{tab}: {r}");
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() }));
}
// General: confirm before delete, startup view
h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t);
assert!(h.app.ui.settings.confirm_delete);
h.request("ui.clickWidget", json!({"id": "button:settingsStartup-2"}), t);
assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail);
// Interface: filmstrip names off, grid badges always
h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t);
h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t);
assert!(!h.app.ui.settings.film_names);
h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t);
assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always);
// Performance: the thumbnail cache size goes to the library preferences
h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t);
h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t);
assert_eq!(h.app.session.cache_mb, 512);
h.request("ui.clickWidget", json!({"id": "button:settingsPreview-2"}), t);
assert_eq!(h.app.ui.settings.preview_edge, 3840);
h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t);
assert_eq!(h.app.ui.settings.memory_mb, 1024);
h.step();
assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget");
h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t);
h.step();
assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic");
// Import: per-camera defaults
h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t);
h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t);
assert!(h.app.session.import_defaults.per_camera);
let img = h.snapshot(SETTLE);
assert_eq!(img.size, [1300, 820]);
// Escape closes; ⌫ now asks first
h.request("ui.key", json!({"key": "escape"}), t);
assert_eq!(h.app.ui.dialog, None);
let active = h.app.session.active().unwrap();
h.request("ui.key", json!({"key": "delete"}), t);
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ConfirmDelete { count: 1 }));
assert!(!h.app.session.catalog.photo(active).unwrap().deleted);
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.session.catalog.photo(active).unwrap().deleted);
// app settings survive a save/load round trip of the UI state (ui.json)
let saved = serde_json::to_string(&h.app.ui).unwrap();
let back = serde_json::from_str::<crate::UiState>(&saved).unwrap().sanitized();
assert_eq!(back.settings, h.app.ui.settings);
assert_eq!(back.view, crate::state::ViewMode::Detail, "startup view applied on load");
h.settle(SETTLE);
}
/// F: full-screen preview (arrows step, I cycles the info overlay, Esc exits); ⌘I cycles the
/// overlay in Detail; ⇧⌘F asks the host for window full screen.
#[test]
fn full_screen_preview_and_info_overlay_by_keyboard() {
use crate::state::InfoOverlay;
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0]]}}), t);
h.request("ui.key", json!({"key": "f"}), t);
assert!(h.app.ui.fullscreen);
h.request("ui.key", json!({"key": "right"}), t);
assert_eq!(h.app.session.active().map(|p| p.0), Some(vis[1]));
let right = h.app.ui.right;
h.request("ui.key", json!({"key": "i"}), t);
assert_eq!(h.app.ui.info_overlay, InfoOverlay::Basic);
assert_eq!(h.app.ui.right, right, "I doesn't open the Info panel in full screen");
let img = h.snapshot(SETTLE);
// no chrome: the frame's corners are black
for (x, y) in [(2usize, 2usize), (997, 2), (2, 697), (997, 697)] {
let c = img.pixels[y * 1000 + x];
assert!(c.r() < 8 && c.g() < 8 && c.b() < 8, "({x},{y}) = {c:?}");
}
assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay"));
h.request("ui.key", json!({"key": "escape"}), t);
assert!(!h.app.ui.fullscreen);
assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid, "Esc leaves full screen only, back to where it was entered");
h.request("ui.set", json!({"view": "detail"}), t);
h.request("ui.key", json!({"key": "i", "cmd": true}), t);
assert_eq!(h.app.ui.info_overlay, InfoOverlay::Exposure);
h.request("ui.key", json!({"key": "i", "cmd": true}), t);
assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off);
h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t);
assert!(h.app.window_is_fullscreen);
h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t);
assert!(!h.app.window_is_fullscreen);
h.settle(SETTLE);
}
/// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the
/// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit.
#[test]
fn click_zoom_ratio_animates() {
use crate::render::Slot;
use crate::state::Zoom;
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
h.settle(SETTLE);
assert_eq!(h.app.ui.click_zoom, 100, "1:1 by default");
let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 3}}), t);
assert_eq!(r["result"]["ratio"], 3, "{r}");
let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 5}}), t);
assert_ne!(r["ok"], true, "{r}");
let fit = h.app.image_rect.unwrap();
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
assert!(h.app.ui.zoom_anim, "animation started");
// every animation frame asks for the same (final-size) render
let mut keys = std::collections::HashSet::new();
for _ in 0..40 {
h.step();
keys.extend(h.app.renderer.wanted(Slot::Main));
if !h.app.ui.zoom_anim {
break;
}
}
assert_eq!(keys.len(), 1, "one render for the whole animation: {keys:?}");
h.settle(SETTLE);
assert!(!h.app.ui.zoom_anim, "animation finished");
assert!(h.app.image_rect.unwrap().width() > fit.width());
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
assert_eq!(h.app.ui.zoom, Zoom::Fit);
// Z zooms to the same ratio as a click
h.request("ui.key", json!({"key": "z"}), t);
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
h.request("ui.key", json!({"key": "z"}), t);
assert_eq!(h.app.ui.zoom, Zoom::Fit);
h.settle(SETTLE);
}
/// The Navigator appears when zoomed in; clicking it pans to that point.
#[test]
fn navigator_pans_the_zoomed_loupe() {
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
h.settle(SETTLE);
assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "hidden at Fit");
h.request("engine.execute", json!({"command": "view.zoom100"}), t);
h.settle(SETTLE);
assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "navigator shown when zoomed");
h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t);
let (u, v) = h.app.ui.pan;
assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan);
assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100), "the click didn't reach the loupe (which would zoom out)");
h.settle(SETTLE);
let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap();
let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7);
h.request("ui.dragWidget", json!({"id": "canvas:navigator", "fx": 0.5, "fy": 0.5, "toX": to.x, "toY": to.y}), t);
let (u, v) = h.app.ui.pan;
assert!((u - 0.8).abs() < 0.03 && (v - 0.7).abs() < 0.03, "drag pan {:?}", h.app.ui.pan);
h.request("engine.execute", json!({"command": "view.navigator"}), t);
h.settle(SETTLE);
assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off");
}
#[test]
fn screenshot_request_is_answered_without_a_window() {
let mut h = demo([800.0, 500.0]);
let r = h.request("ui.screenshot", json!({}), SETTLE);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(r["result"]["width"], 800);
assert_eq!(r["result"]["height"], 500);
let r = h.request("ui.resize", json!({"width": 640, "height": 400}), Duration::from_secs(5));
assert_eq!(r["ok"], true);
let r = h.request("ui.screenshot", json!({"headless": true}), SETTLE);
assert_eq!(r["result"]["width"], 640, "{r}");
}
}