//! Headless tests of Compare with the filmstrip. use std::time::Duration; use serde_json::json; use crate::headless::Headless; use crate::state::Zoom; use crate::{LightcraftApp, Services}; const T: Duration = Duration::from_secs(20); const SETTLE: Duration = Duration::from_secs(120); fn demo() -> Headless { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); Headless::new(app, [1400.0, 900.0], 1.0) } /// In Compare, the photo the Edit panel and the rating keys act on is always one of the two on /// screen: a filmstrip click must not leave the pair showing while a third photo becomes active. #[test] fn filmstrip_click_in_compare_keeps_the_active_photo_on_screen() { let mut h = demo(); let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).map(|p| p.rating); for zoom in [Zoom::Fit, Zoom::Percent(100.0)] { h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T); let r = h.request("engine.execute", json!({"command": "view.compare"}), T); assert_eq!(r["ok"], true, "{r}"); h.app.ui.zoom = zoom; h.settle(SETTLE); let target = vis[4]; let before = rating(&h, target); let r = h.request("ui.clickWidget", json!({"id": format!("film:{target}")}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); let (sel, cand) = h.app.ui.compare.expect("still comparing"); let active = h.app.session.selection.active.map(|p| p.0).expect("an active photo"); // a rating key must not change a photo that isn't shown h.request("ui.key", json!({"key": "3"}), T); if target != sel && target != cand { assert_eq!(rating(&h, target), before, "zoom {zoom:?}: the hidden photo {target} was rated"); } assert!(active == sel || active == cand, "zoom {zoom:?}: active photo {active} is not on screen (compare shows {sel} | {cand})"); } } /// A filmstrip click in Compare puts the photo into the active side: the candidate after /// entering Compare, the select after clicking the select's pane; a photo already on the other /// side swaps the two. `compare.set` does the same for agents, with an explicit `side`. #[test] fn filmstrip_click_replaces_the_active_side_in_compare() { let mut h = demo(); let vis: Vec = 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]], "active": vis[0]}}), T); let r = h.request("engine.execute", json!({"command": "view.compare"}), T); let (sel, cand) = (r["result"]["select"].as_u64().unwrap(), r["result"]["candidate"].as_u64().unwrap()); h.settle(SETTLE); let click_film = |h: &mut Headless, id: u64| { let r = h.request("ui.clickWidget", json!({"id": format!("film:{id}")}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); }; let active = |h: &Headless| h.app.session.selection.active.map(|p| p.0); let others: Vec = vis.iter().copied().filter(|v| *v != sel && *v != cand).take(3).collect(); // the candidate is active after entering Compare: it is replaced click_film(&mut h, others[0]); assert_eq!(h.app.ui.compare, Some((sel, others[0]))); assert_eq!(active(&h), Some(others[0])); // the select's photo, clicked in the filmstrip: the two swap, it stays active click_film(&mut h, sel); assert_eq!(h.app.ui.compare, Some((others[0], sel))); assert_eq!(active(&h), Some(sel)); // a side chosen explicitly let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": others[1], "side": "select"}}), T); assert_eq!(r["ok"], true, "{r}"); assert_eq!(h.app.ui.compare, Some((others[1], sel))); assert_eq!(active(&h), Some(others[1])); // the select is active now: the next filmstrip click replaces it click_film(&mut h, others[2]); assert_eq!(h.app.ui.compare, Some((others[2], sel))); let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": others[0], "side": "left"}}), T); assert_eq!(r["ok"], false, "unknown side is an error: {r}"); } /// A ⌘/Ctrl-click or Shift-click on the filmstrip in Compare changes the selection as it does in /// the grid (#187): the photo is toggled, or the range from the active photo is added. The two /// photos shown stay selected and the active one stays active, so a rating key still acts on a /// photo that is on screen (#744). #[test] fn modified_filmstrip_clicks_in_compare_change_the_selection_not_the_pair() { let mut h = demo(); let vis: Vec = 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]], "active": vis[0]}}), T); let r = h.request("engine.execute", json!({"command": "view.compare"}), T); let (sel, cand) = (r["result"]["select"].as_u64().unwrap(), r["result"]["candidate"].as_u64().unwrap()); h.settle(SETTLE); let click_film = |h: &mut Headless, id: u64, cmd: bool, shift: bool| { let r = h.request("ui.clickWidget", json!({"id": format!("film:{id}"), "cmd": cmd, "shift": shift}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); }; let selected = |h: &Headless| -> Vec { let s = &h.app.session.selection; vis.iter().copied().filter(|v| s.contains(lightcraft_catalog::PhotoId(*v))).collect() }; let active = |h: &Headless| h.app.session.selection.active.map(|p| p.0); let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).map(|p| p.rating); let others: Vec = vis.iter().copied().filter(|v| *v != sel && *v != cand).collect(); assert!(others.len() >= 4, "the demo library has {} photos", vis.len()); let before = active(&h); // ⌘-click: a third photo joins the selection; the pair and the active photo stay click_film(&mut h, others[0], true, false); assert_eq!(selected(&h), { let mut v = vec![sel, cand, others[0]]; v.sort_by_key(|id| vis.iter().position(|v| v == id)); v }); assert_eq!(h.app.ui.compare, Some((sel, cand)), "the pair is untouched"); assert_eq!(active(&h), before, "the active photo is untouched"); // ⌘-click again: it leaves click_film(&mut h, others[0], true, false); assert_eq!(selected(&h), { let mut v = vec![sel, cand]; v.sort_by_key(|id| vis.iter().position(|v| v == id)); v }); assert_eq!(h.app.ui.compare, Some((sel, cand))); assert_eq!(active(&h), before); // Shift-click: everything from the active photo to the clicked one is selected let far = others[3]; click_film(&mut h, far, false, true); let (a, b) = (vis.iter().position(|v| Some(*v) == before).unwrap(), vis.iter().position(|v| *v == far).unwrap()); let now = selected(&h); for id in &vis[a.min(b)..=a.max(b)] { assert!(now.contains(id), "{id} is in the range {before:?}..{far}: {now:?}"); } assert!(now.contains(&sel) && now.contains(&cand), "the pair stays selected: {now:?}"); assert_eq!(h.app.ui.compare, Some((sel, cand))); assert_eq!(active(&h), before, "Shift-click does not move the active photo in Compare"); // ⌘-click on a photo that is on screen: it cannot leave the selection while it is compared click_film(&mut h, sel, true, false); assert!(selected(&h).contains(&sel), "the select stays selected"); assert_eq!(h.app.ui.compare, Some((sel, cand))); assert_eq!(active(&h), before); // ⌘-click on the active photo: `toggle` would hand "active" to another photo; it stays put click_film(&mut h, before.unwrap(), true, false); assert!(selected(&h).contains(&before.unwrap()), "the active photo stays selected"); assert_eq!(h.app.ui.compare, Some((sel, cand))); assert_eq!(active(&h), before); // a rating key acts on the active photo, which is on screen; a merely selected photo is left alone let was = rating(&h, far); h.request("ui.key", json!({"key": "3"}), T); h.settle(SETTLE); assert_eq!(rating(&h, before.unwrap()), Some(3)); assert_eq!(rating(&h, far), was, "the hidden photo was rated"); // a plain click still replaces the active side click_film(&mut h, others[1], false, false); assert_ne!(h.app.ui.compare, Some((sel, cand))); assert!(h.app.ui.compare.is_some_and(|(s, c)| s == others[1] || c == others[1])); } /// Compare is where sharpness is judged: while a pane's render of an edit is on its way, that pane /// says "Rendering…" once it has lasted a moment, and stops when the render lands. #[test] fn a_compare_pane_says_rendering_while_its_edit_renders() { use std::{cell::Cell, rc::Rc}; let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w.starts_with("compare:rendering:")); let mut h = demo(); let vis: Vec = 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]], "active": vis[0]}}), T); let r = h.request("engine.execute", json!({"command": "view.compare"}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); assert!(!pill(&h), "finished renders"); let open = Rc::new(Cell::new(false)); h.app.renderer.set_offload(Box::new(crate::headless::RenderGate::new(open.clone()))); h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); for _ in 0..12 { h.step(); } assert!(!pill(&h), "not at once"); for _ in 0..30 { h.step(); } assert!(pill(&h), "the edited pane's render is on its way"); open.set(true); assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); } /// A pane whose render fails behind its thumbnail says so, as the loupe does, instead of showing /// the thumbnail as if it were the photo. #[test] fn a_compare_pane_says_when_its_render_failed() { use crate::render::Slot; use std::{cell::Cell, rc::Rc}; let failed = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w.starts_with("compare:renderFailed:")); let mut h = demo(); let vis: Vec = 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]], "active": vis[0]}}), T); h.request("engine.execute", json!({"command": "view.compare"}), T); h.settle(SETTLE); assert!(!failed(&h)); let open = Rc::new(Cell::new(true)); h.app.renderer.set_offload(Box::new(crate::headless::RenderGate::failing(open, |s| matches!(s, Slot::Compare(_))))); // another photo in the second pane: its render fails, its thumbnail stays up let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": vis[2], "side": "candidate"}}), T); assert_eq!(r["ok"], true, "{r}"); assert!(h.step_until(SETTLE, failed), "the failure is said"); assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("compare:rendering:"))); }