use serde_json::{Value, json}; use crate::{Session, command_specs}; fn demo() -> Session { Session::with_demo() } fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings { (*s.develop_of(s.active().unwrap()).unwrap()).clone() } #[test] fn changing_one_wb_control_resolves_as_shot_without_stale_tint() { use lightcraft_catalog::{Photo, PhotoId, Source}; use lightcraft_develop::{DevelopSettings, WbMode}; for format in ["ARW", "NEF", "NRW", "RAF"] { let mut s = demo(); let id = PhotoId(100); let name = format!("synthetic.{format}"); let mut p = Photo::new(id, Source::File { path: name.clone() }, &name, format, 16, 16, ""); // A catalog made by the old generic-matrix Kelvin inference. p.as_shot_wb = Some((6829.0, -127.0)); p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0)); s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap(); s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap(); let d = active_dev(&s); assert_eq!(d.wb.mode, WbMode::Custom); assert_eq!(d.wb.temp, 8000.0); assert_eq!(d.wb.tint, 0.0, "{format}: untouched tint comes from the current as-shot reference"); s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap(); s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap(); assert_eq!(active_dev(&s).wb.temp, 6500.0, "{format}"); s.execute("develop.reset", &json!({})).unwrap(); assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0), "{format}"); } } #[test] fn demo_library_loads() { let mut s = demo(); assert_eq!(s.visible().len(), 24); assert!(s.active().is_some()); let st = s.execute("catalog.stats", &json!({})).unwrap(); assert_eq!(st["photos"], 24); let albums = s.execute("albums.list", &json!({})).unwrap(); assert!(albums.as_array().unwrap().iter().any(|a| a["folder"] == true)); } #[test] fn rating_flag_undo_redo() { let mut s = demo(); let id = s.active().unwrap(); s.execute("photo.rate", &json!({"rating": 5})).unwrap(); s.execute("photo.flag", &json!({"flag": "reject"})).unwrap(); assert_eq!(s.catalog.photo(id).unwrap().rating, 5); s.execute("edit.undo", &json!({})).unwrap(); assert_ne!(s.catalog.photo(id).unwrap().flag, lightcraft_catalog::Flag::Reject); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.catalog.photo(id).unwrap().flag, lightcraft_catalog::Flag::Reject); assert!(s.execute("photo.rate", &json!({"rating": 7})).is_err()); } #[test] fn develop_set_and_interaction_coalesces() { let mut s = demo(); s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); assert_eq!(active_dev(&s).light.exposure, 0.5); let undo_before = s.undo.len(); s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap(); for v in [0.6, 0.8, 1.2] { s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap(); } s.execute("develop.endInteraction", &json!({})).unwrap(); assert_eq!(s.undo.len(), undo_before + 1, "a drag is one undo step"); assert_eq!(active_dev(&s).light.exposure, 1.2); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(active_dev(&s).light.exposure, 0.5); // unknown control and bad values are rejected assert!(s.execute("develop.set", &json!({"control": "nope", "value": 1})).is_err()); assert!(s.execute("develop.set", &json!({"values": {"light.contrast": "x"}})).is_err()); // clamped s.execute("develop.set", &json!({"values": {"light.contrast": 500}})).unwrap(); assert_eq!(active_dev(&s).light.contrast, 100.0); } #[test] fn copy_paste_sync_presets() { let mut s = demo(); let vis = s.visible_cloned(); s.execute("develop.set", &json!({"values": {"effects.clarity": 30, "light.shadows": 20}})).unwrap(); s.execute("develop.copy", &json!({})).unwrap(); s.execute("library.select", &json!({"ids": [vis[1].0, vis[2].0]})).unwrap(); s.execute("develop.paste", &json!({})).unwrap(); assert_eq!(s.develop_of(vis[2]).unwrap().effects.clarity, 30.0); s.execute("preset.apply", &json!({"id": "lc.bw-high-contrast", "amount": 100})).unwrap(); assert_eq!(s.develop_of(vis[1]).unwrap().treatment, lightcraft_develop::Treatment::Bw); let r = s.execute("preset.create", &json!({"name": "Mine", "groups": ["light", "effects"]})).unwrap(); assert!(r["id"].as_str().unwrap().starts_with("user.")); assert!(s.execute("preset.delete", &json!({"id": "lc.moody"})).is_err()); } #[test] fn albums_crud() { let mut s = demo(); let f = s.execute("album.create", &json!({"name": "Trips", "folder": true})).unwrap()["id"].as_u64().unwrap(); let a = s.execute("album.create", &json!({"name": "Best", "parent": f, "addSelected": true})).unwrap()["id"].as_u64().unwrap(); s.execute("library.source", &json!({"kind": "album", "id": a})).unwrap(); assert_eq!(s.visible().len(), 1); s.execute("album.rename", &json!({"id": a, "name": "Best of"})).unwrap(); s.execute("library.source", &json!({"kind": "all"})).unwrap(); s.execute("library.selectAll", &json!({})).unwrap(); s.execute("album.addPhotos", &json!({"id": a})).unwrap(); assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().photos.len(), 24); assert!(s.execute("album.delete", &json!({"id": f})).is_err(), "non-empty folder"); s.execute("album.delete", &json!({"id": a})).unwrap(); s.execute("album.delete", &json!({"id": f})).unwrap(); } #[test] fn filters_and_delete_restore() { let mut s = demo(); s.execute("library.filter", &json!({"rating": 4})).unwrap(); let n = s.visible().len(); assert!(n > 0 && n < 24); s.execute("library.clearFilter", &json!({})).unwrap(); s.execute("photo.delete", &json!({})).unwrap(); assert_eq!(s.visible().len(), 23); s.execute("library.source", &json!({"kind": "recentlyDeleted"})).unwrap(); assert_eq!(s.visible().len(), 1); s.execute("photo.restore", &json!({})).unwrap(); s.execute("library.source", &json!({"kind": "all"})).unwrap(); assert_eq!(s.visible().len(), 24); } #[test] fn masks_crop_and_render() { let mut s = demo(); s.execute("mask.add", &json!({"kind": "radial", "center": [0.5, 0.5]})).unwrap(); s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap(); s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]], "size": 0.05})).unwrap(); let d = active_dev(&s); assert_eq!(d.masks.len(), 1); assert_eq!(d.masks[0].components.len(), 2); s.execute("crop.aspect", &json!({"aspect": "1x1"})).unwrap(); s.execute("crop.straighten", &json!({"angle": 5.0})).unwrap(); let id = s.active().unwrap(); let r = s.render_now(id, 200, 200).unwrap(); assert_eq!((r.image.width, r.image.height), (200, 200)); s.execute("develop.auto", &json!({})).unwrap(); s.execute("develop.wb", &json!({"mode": "auto"})).unwrap(); s.execute("develop.wbPick", &json!({"x": 0.5, "y": 0.5})).unwrap(); } #[test] fn versions_history_and_reset() { let mut s = demo(); s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap(); s.execute("version.create", &json!({"name": "bright"})).unwrap(); s.execute("develop.reset", &json!({})).unwrap(); assert!(active_dev(&s).is_unedited()); s.execute("version.restore", &json!({"index": 0})).unwrap(); assert_eq!(active_dev(&s).light.exposure, 1.0); let h = s.execute("history.list", &json!({})).unwrap(); assert!(h["history"].as_array().unwrap().len() >= 3); } #[test] fn op_log_replay_reproduces_catalog() { let mut s = demo(); let snap = s.catalog.to_snapshot(); let _ = s.drain_log(); s.execute("photo.rate", &json!({"rating": 2})).unwrap(); s.execute("develop.set", &json!({"control": "effects.dehaze", "value": 40})).unwrap(); s.execute("album.create", &json!({"name": "X"})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect(); let mut c = lightcraft_catalog::Catalog::from_snapshot(&snap).unwrap(); c.replay(&log).unwrap(); assert_eq!(c.to_snapshot(), s.catalog.to_snapshot()); } /// Every command runs (or fails cleanly with an error) with empty params and has metadata. #[test] fn command_sweep() { let mut s = demo(); let mut ids = std::collections::HashSet::new(); for c in command_specs() { assert!(ids.insert(c.id), "duplicate {}", c.id); assert!(!c.label.is_empty() && !c.params.is_empty(), "{}", c.id); let _ = s.execute(c.id, &Value::Null); } assert!(s.commands().len() >= 70, "{}", s.commands().len()); } #[test] fn upright_and_guides_commands() { let mut s = demo(); let r = s.execute("geometry.upright", &json!({"mode": "level"})).unwrap(); assert_eq!(r["mode"], "level"); let d = active_dev(&s); assert_eq!(d.geometry.upright, lightcraft_develop::Upright::Level); assert!(d.geometry.upright_transform.is_some(), "analysis result is stored"); s.execute("geometry.upright", &json!({"mode": "off"})).unwrap(); assert!(active_dev(&s).geometry.upright_transform.is_none()); for i in 0..5 { let x = 0.1 + 0.15 * i as f64; s.execute("geometry.guides", &json!({"guides": [[x, 0.2, x + 0.02, 0.8]], "add": true})).unwrap(); } let d = active_dev(&s); assert_eq!(d.geometry.upright, lightcraft_develop::Upright::Guided); assert_eq!(d.geometry.guides.len(), 4, "at most four guides"); assert!((d.geometry.guides[0].0.x - 0.25).abs() < 1e-9, "oldest dropped"); let id = s.active().unwrap(); assert!(s.render_now(id, 160, 160).is_ok()); assert!(s.execute("geometry.upright", &json!({"mode": "sideways"})).is_err()); } #[test] fn memory_report_counts_decoded_sources_and_renders() { let mut s = demo(); let r = s.execute("library.memory", &json!({})).unwrap(); assert_eq!(r["previewSources"]["count"], 0); assert!(r["gpu"]["allocated"].is_u64() && r.get("engineBytes").is_some()); let id = s.active().unwrap(); s.render_now(id, 800, 600).unwrap(); let m = s.memory_report(); assert_eq!(m.preview_sources.count, 1); // a 2560 px linear float source: 12 bytes per pixel assert!(m.preview_sources.bytes >= 12 * 2560 * 1000, "{:?}", m.preview_sources); assert_eq!(m.engine_bytes, m.thumb_sources.bytes + m.preview_sources.bytes + m.full_source.bytes + m.rendered.bytes); let r = s.thumb_job(id, 200).unwrap().run(); s.accept(&r); let m = s.memory_report(); assert_eq!(m.thumb_sources.count, 1); assert!(m.rendered.count >= 1, "the thumbnail render is cached"); } #[test] fn paste_selected_settings_pastes_only_chosen_copied_groups() { let mut s = demo(); let ids: Vec<_> = s.visible().iter().copied().take(2).collect(); s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap(); s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30, "detail.sharpenAmount": 90}})).unwrap(); s.execute("develop.copy", &json!({"groups": ["light", "color"]})).unwrap(); // `detail` was not copied: asking for it pastes nothing for it s.execute("develop.paste", &json!({"ids": [ids[1].0], "groups": ["light", "detail"]})).unwrap(); let d = s.develop_of(ids[1]).unwrap(); let base = lightcraft_develop::DevelopSettings::default(); assert_eq!(d.light.exposure, 1.0); assert_eq!(d.color.vibrance, base.color.vibrance); assert_ne!(d.detail.sharpen_amount, 90.0); // without groups: everything copied s.execute("develop.paste", &json!({"ids": [ids[1].0]})).unwrap(); assert_eq!(s.develop_of(ids[1]).unwrap().color.vibrance, 30.0); } #[test] fn masks_reorder_and_duplicate_and_invert() { let mut s = demo(); for kind in ["linear", "radial", "brush"] { s.execute("mask.add", &json!({"kind": kind})).unwrap(); } let ids = |s: &Session| active_dev(s).masks.iter().map(|m| m.id).collect::>(); let [a, b, c] = ids(&s)[..] else { panic!("{:?}", ids(&s)) }; s.execute("mask.move", &json!({"id": c, "to": 0})).unwrap(); assert_eq!(ids(&s), [c, a, b]); s.execute("mask.move", &json!({"id": c, "delta": 1})).unwrap(); assert_eq!(ids(&s), [a, c, b]); s.execute("mask.move", &json!({"id": a, "delta": -5})).unwrap(); assert_eq!(ids(&s), [a, c, b], "clamped at the top"); assert!(s.execute("mask.move", &json!({"id": a})).is_err()); // one undo step per move s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(ids(&s), [c, a, b]); // duplicate and invert: right after the original, inverted, selected let r = s.execute("mask.duplicate", &json!({"id": a, "invert": true})).unwrap(); let copy = r["activeMask"].as_u64().unwrap() as u32; let d = active_dev(&s); let pos = d.masks.iter().position(|m| m.id == copy).unwrap(); assert_eq!(d.masks[pos - 1].id, a); assert!(d.masks[pos].invert && !d.masks[pos - 1].invert); assert_eq!(d.masks[pos].components, d.masks[pos - 1].components); assert_eq!(s.active_mask, Some(copy)); } #[test] fn before_is_the_import_state_and_can_be_set_copied_and_swapped() { let mut s = demo(); let id = s.active().unwrap(); let import = s.catalog.photo(id).unwrap().import_defaults(); s.execute("develop.set", &json!({"values": {"light.exposure": 1.5}})).unwrap(); s.execute("crop.set", &json!({"rect": [0.1, 0.1, 0.9, 0.9]})).unwrap(); let before = |s: &Session| s.before_settings(s.catalog.photo(s.active().unwrap()).unwrap()); // default: the import state, with the current crop so both sides line up let b = before(&s); assert_eq!(b.light.exposure, import.light.exposure); assert_eq!(b.crop, active_dev(&s).crop); // from the current settings, then a history step s.execute("beforeAfter.copyAfterToBefore", &json!({})).unwrap(); assert_eq!(before(&s).light.exposure, 1.5); s.execute("develop.set", &json!({"values": {"light.exposure": -1.0}})).unwrap(); let steps = s.catalog.photo(id).unwrap().history.len(); let r = s.execute("beforeAfter.setBefore", &json!({"source": "history", "index": steps - 1})).unwrap(); assert_eq!(r["before"], "custom"); assert_eq!(before(&s).light.exposure, -1.0); assert!(s.execute("beforeAfter.setBefore", &json!({"source": "history", "index": 999})).is_err()); // swap: the photo gets the before settings, the before side the old current ones s.execute("beforeAfter.setBefore", &json!({"source": "import"})).unwrap(); s.execute("beforeAfter.swap", &json!({})).unwrap(); assert_eq!(active_dev(&s).light.exposure, import.light.exposure); assert_eq!(before(&s).light.exposure, -1.0); // copy before → after is one undoable edit s.execute("beforeAfter.copyBeforeToAfter", &json!({})).unwrap(); assert_eq!(active_dev(&s).light.exposure, -1.0); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(active_dev(&s).light.exposure, import.light.exposure); // the before render follows (the render key changes with the before settings) let k1 = s.render_job(id, 64, 64, true, true).unwrap().key; s.execute("beforeAfter.resetBefore", &json!({})).unwrap(); let k2 = s.render_job(id, 64, 64, true, true).unwrap().key; assert_ne!(k1, k2); } #[test] fn presets_versions_history_and_select_by() { let mut s = demo(); s.execute("develop.set", &json!({"values": {"light.exposure": 0.5}})).unwrap(); let pid = s.execute("preset.create", &json!({"name": "Warm", "groups": ["light"]})).unwrap()["id"].as_str().unwrap().to_string(); let preset = |s: &Session| s.presets.iter().find(|p| p.id == pid).cloned().unwrap(); s.execute("preset.rename", &json!({"id": pid, "name": "Warmer"})).unwrap(); s.execute("preset.move", &json!({"id": pid, "group": "Mine"})).unwrap(); assert_eq!((preset(&s).name.as_str(), preset(&s).group.as_str()), ("Warmer", "Mine")); // update keeps the preset's groups and takes the current values s.execute("develop.set", &json!({"values": {"light.exposure": 1.25, "color.vibrance": 40}})).unwrap(); s.execute("preset.update", &json!({"id": pid})).unwrap(); assert_eq!(preset(&s).settings["light"]["exposure"], 1.25); assert!(preset(&s).settings.get("color").is_none(), "only the groups it already had"); let builtin = s.presets.iter().find(|p| p.builtin).unwrap().id.clone(); assert!(s.execute("preset.rename", &json!({"id": builtin, "name": "x"})).is_err()); // versions s.execute("version.create", &json!({"name": "A"})).unwrap(); s.execute("version.rename", &json!({"index": 0, "name": "First"})).unwrap(); s.execute("develop.set", &json!({"values": {"light.exposure": -2.0}})).unwrap(); s.execute("version.update", &json!({"index": 0})).unwrap(); let v = s.catalog.photo(s.active().unwrap()).unwrap().versions[0].clone(); assert_eq!((v.name.as_str(), v.settings.light.exposure), ("First", -2.0)); // history s.execute("history.clear", &json!({})).unwrap(); let h = &s.catalog.photo(s.active().unwrap()).unwrap().history; assert_eq!(h.len(), 1); assert_eq!(h[0].settings.light.exposure, -2.0); s.execute("edit.undo", &json!({})).unwrap(); assert!(s.catalog.photo(s.active().unwrap()).unwrap().history.len() > 1, "undoable"); // select by: the demo has rated and picked photos let vis = s.visible_cloned(); let picks = vis.iter().filter(|id| s.catalog.photo(**id).unwrap().flag == lightcraft_catalog::Flag::Pick).count(); let r = s.execute("library.selectBy", &json!({"flag": "pick"})).unwrap(); assert_eq!(r["selected"].as_u64(), Some(picks as u64)); assert!(picks > 0); let four = vis.iter().filter(|id| s.catalog.photo(**id).unwrap().rating >= 4).count(); assert_eq!(s.execute("library.selectBy", &json!({"rating": 4})).unwrap()["selected"].as_u64(), Some(four as u64)); let r = s.execute("library.selectBy", &json!({"rating": 0, "ratingOp": "eq", "add": true})).unwrap(); assert!(r["selected"].as_u64().unwrap() >= four as u64); assert!(s.execute("library.selectBy", &json!({})).is_err()); assert!(s.execute("library.selectBy", &json!({"label": "mauve"})).is_err()); } #[test] fn crop_aspect_lock_current_and_toggle() { let mut s = demo(); s.execute("crop.set", &json!({"rect": [0.1, 0.2, 0.7, 0.6]})).unwrap(); let rect = active_dev(&s).crop.geometry.rect; s.execute("crop.aspect", &json!({"aspect": "toggle"})).unwrap(); let d = active_dev(&s); assert_eq!(d.crop.geometry.rect, rect, "locking keeps the rectangle"); let (aw, ah) = d.crop.aspect.expect("locked"); let id = s.active().unwrap(); let p = s.catalog.photo(id).unwrap(); let (w, h) = if d.orientation.swaps_axes() { (p.height as f64, p.width as f64) } else { (p.width as f64, p.height as f64) }; let want = (rect.x1 - rect.x0) * w / ((rect.y1 - rect.y0) * h); assert!((aw as f64 / ah as f64 - want).abs() < 0.01, "{aw}:{ah} vs {want}"); s.execute("crop.aspect", &json!({"aspect": "toggle"})).unwrap(); assert!(active_dev(&s).crop.aspect.is_none(), "toggle unlocks"); } // Feature: dragging crop handles (issue 295) #[test] fn crop_drag_stops_at_the_image_edge_and_keeps_the_anchor() { let mut s = demo(); s.execute("crop.set", &json!({"rect": [0.2, 0.2, 0.8, 0.8]})).unwrap(); s.execute("crop.drag", &json!({"handle": "bottomRight", "from": [0.8, 0.8], "to": [1.4, 0.7]})).unwrap(); let r = active_dev(&s).crop.geometry.rect; assert!((r.x0 - 0.2).abs() < 1e-6 && (r.y0 - 0.2).abs() < 1e-6, "anchor moved: {r:?}"); assert!((r.x1 - 1.0).abs() < 1e-6 && (r.y1 - 0.7).abs() < 1e-6, "{r:?}"); } #[test] fn crop_drag_keeps_a_locked_ratio_on_every_handle() { let mut s = demo(); s.execute("crop.aspect", &json!({"aspect": "16x9"})).unwrap(); let d = active_dev(&s); assert!(d.crop.aspect.is_some()); let want = d.crop.geometry.rect.aspect(); let r = d.crop.geometry.rect; for (handle, to) in [("left", [r.x0 + 0.1, 0.5]), ("top", [0.5, r.y0 + 0.05]), ("bottomRight", [1.3, 1.3]), ("right", [0.9, 0.5])] { s.execute("crop.drag", &json!({"handle": handle, "from": [0.5, 0.5], "to": to, "start": [r.x0, r.y0, r.x1, r.y1]})).unwrap(); let g = active_dev(&s).crop.geometry.rect; assert!((g.aspect() - want).abs() < 1e-6, "{handle}: {} vs {want}", g.aspect()); } } #[test] fn crop_drag_rejects_unknown_handles_and_bad_points() { let mut s = demo(); assert!(s.execute("crop.drag", &json!({"handle": "sideways", "from": [0.5, 0.5], "to": [0.6, 0.6]})).is_err()); assert!(s.execute("crop.drag", &json!({"handle": "move", "from": [0.5], "to": [0.6, 0.6]})).is_err()); assert!(s.execute("crop.drag", &json!({"handle": "move", "to": [0.6, 0.6]})).is_err()); } #[test] fn crop_aspect_rejects_degenerate_custom_ratios() { let mut s = demo(); let before = active_dev(&s).crop; for bad in [json!([0.004, 3]), json!([3, 0]), json!([-1, 2]), json!([1000, 0.01]), json!(["a", 2]), json!([1, 2, 3])] { assert!(s.execute("crop.aspect", &json!({"aspect": bad.clone()})).is_err(), "{bad}"); } assert!(s.execute("crop.aspect", &json!({"aspect": "0x5"})).is_err()); let after = active_dev(&s).crop; assert_eq!(before.geometry, after.geometry, "a rejected ratio leaves the crop alone"); s.execute("crop.aspect", &json!({"aspect": [2.5, 1]})).unwrap(); assert!(!active_dev(&s).crop.geometry.rect.is_empty()); } #[test] fn crop_drag_rejects_non_numeric_points() { let mut s = demo(); assert!(s.execute("crop.drag", &json!({"handle": "move", "from": ["a", 0.5, 0.5], "to": [0.6, 0.6]})).is_err()); assert!(s.execute("crop.drag", &json!({"handle": "move", "from": [0.5, 0.5], "to": [0.6, 0.6], "start": [0.1, "x", 0.5, 0.5]})).is_err()); } #[test] fn paste_from_previous_and_copy_paste_metadata() { let mut s = demo(); let ids: Vec<_> = s.visible().iter().copied().take(3).collect(); s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap(); assert!(s.execute("develop.pastePrevious", &json!({})).is_err(), "nothing before"); s.execute("develop.set", &json!({"values": {"light.exposure": 0.8}})).unwrap(); s.execute("photo.setMeta", &json!({"title": "Dawn", "keywords": ["sky", "red"], "creator": "Me"})).unwrap(); s.execute("photo.copyMetadata", &json!({})).unwrap(); // move on: the first photo becomes "previous" s.execute("library.select", &json!({"ids": [ids[1].0]})).unwrap(); assert_eq!(s.previous_active, Some(ids[0])); let r = s.execute("develop.pastePrevious", &json!({})).unwrap(); assert_eq!(r["from"].as_u64(), Some(ids[0].0)); assert_eq!(active_dev(&s).light.exposure, 0.8); // metadata: only the chosen fields s.execute("photo.pasteMetadata", &json!({"fields": ["title", "keywords"]})).unwrap(); let m = &s.catalog.photo(ids[1]).unwrap().meta; assert_eq!((m.title.as_str(), m.keywords.clone()), ("Dawn", vec!["sky".to_string(), "red".to_string()])); assert_ne!(m.creator, "Me"); s.execute("photo.pasteMetadata", &json!({"ids": [ids[2].0]})).unwrap(); assert_eq!(s.catalog.photo(ids[2]).unwrap().meta.creator, "Me"); } #[test] fn stacks_move_and_split() { let mut s = demo(); let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); s.execute("library.select", &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "active": ids[0].0})).unwrap(); s.execute("stack.group", &json!({"collapsed": false})).unwrap(); let order = |s: &Session| s.catalog.stack_of(ids[0]).or(s.catalog.stack_of(ids[4])).map(|st| st.photos.clone()).unwrap_or_default(); let before = order(&s); assert_eq!(before.len(), 5); s.execute("stack.moveUp", &json!({"id": before[2].0})).unwrap(); assert_eq!(order(&s)[1], before[2]); s.execute("stack.moveDown", &json!({"id": before[2].0})).unwrap(); assert_eq!(order(&s), before); // split at the 4th photo: 3 stay, 2 form a new stack s.execute("stack.split", &json!({"id": before[3].0})).unwrap(); assert_eq!(s.catalog.stack_of(before[0]).unwrap().photos, before[..3].to_vec()); assert_eq!(s.catalog.stack_of(before[3]).unwrap().photos, before[3..].to_vec()); // splitting at the last photo of a 2-stack leaves both unstacked s.execute("stack.split", &json!({"id": before[4].0})).unwrap(); assert!(s.catalog.stack_of(before[3]).is_none() && s.catalog.stack_of(before[4]).is_none()); assert!(s.execute("stack.split", &json!({"id": before[0].0})).is_err(), "the top can't split"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.stack_of(before[3]).unwrap().photos.len(), 2); } #[test] fn filter_presets_save_and_apply() { let mut s = demo(); assert!(s.execute("filter.savePreset", &json!({"name": "Empty"})).is_err(), "nothing to save"); s.execute("library.filter", &json!({"rating": 4})).unwrap(); let four = s.visible_cloned().len(); s.execute("filter.savePreset", &json!({"name": "Best"})).unwrap(); s.execute("library.clearFilter", &json!({})).unwrap(); assert!(s.visible_cloned().len() > four); let r = s.execute("filter.applyPreset", &json!({"name": "best"})).unwrap(); assert_eq!(r["photos"].as_u64(), Some(four as u64)); s.execute("filter.deletePreset", &json!({"name": "Best"})).unwrap(); assert!(s.filter_presets.is_empty()); } #[test] fn auto_bw_mix_separates_colours() { let mut s = demo(); s.execute("develop.autoBwMix", &json!({})).unwrap(); let d = active_dev(&s); assert_eq!(d.treatment, lightcraft_develop::Treatment::Bw); let m = d.bw_mix.bands(); assert!(m.iter().any(|v| *v != 0.0), "some band moved: {m:?}"); assert!(m.iter().all(|v| v.abs() <= 60.0)); s.execute("edit.undo", &json!({})).unwrap(); assert_ne!(active_dev(&s).treatment, lightcraft_develop::Treatment::Bw, "one undo step"); } #[test] fn auto_sync_carries_only_the_changed_settings() { use lightcraft_catalog::PhotoId; let mut s = demo(); let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); // the second photo has its own contrast, which must survive s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap(); s.execute("develop.set", &json!({"control": "light.contrast", "value": 25})).unwrap(); s.execute("library.select", &json!({"ids": ids})).unwrap(); s.selection.active = Some(PhotoId(ids[0])); assert_eq!(s.execute("develop.autoSync", &json!({})).unwrap(), json!({"on": true})); s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.7})).unwrap(); for id in &ids { assert_eq!(s.develop_of(PhotoId(*id)).unwrap().light.exposure, 0.7, "photo {id}"); } assert_eq!(s.develop_of(PhotoId(ids[1])).unwrap().light.contrast, 25.0, "untouched settings stay"); // a slider drag syncs once, on release let shadows: Vec = ids.iter().map(|id| s.develop_of(PhotoId(*id)).unwrap().light.shadows).collect(); s.begin_interaction("Shadows").unwrap(); let mut d = (*s.develop_of(PhotoId(ids[0])).unwrap()).clone(); d.light.shadows = 40.0; s.set_develop(PhotoId(ids[0]), d, "Shadows").unwrap(); assert_eq!(s.develop_of(PhotoId(ids[2])).unwrap().light.shadows, shadows[2], "not while dragging"); s.end_interaction().unwrap(); assert_eq!(s.develop_of(PhotoId(ids[2])).unwrap().light.shadows, 40.0); // one undo step reverts every photo s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(ids.iter().map(|id| s.develop_of(PhotoId(*id)).unwrap().light.shadows).collect::>(), shadows); // spot removal belongs to one photo let mut d = (*s.develop_of(PhotoId(ids[0])).unwrap()).clone(); d.spots.push(lightcraft_develop::Spot::default()); s.set_develop(PhotoId(ids[0]), d, "Remove").unwrap(); assert!(s.develop_of(PhotoId(ids[1])).unwrap().spots.is_empty()); // off: only the active photo s.execute("develop.autoSync", &json!({"on": false})).unwrap(); s.execute("develop.set", &json!({"control": "light.exposure", "value": -1})).unwrap(); assert_eq!(s.develop_of(PhotoId(ids[1])).unwrap().light.exposure, 0.7); } #[test] fn json_delta_keeps_only_changes() { let a = json!({"light": {"exposure": 0, "contrast": 5}, "masks": [1], "x": 1}); let b = json!({"light": {"exposure": 1, "contrast": 5}, "masks": [1, 2], "x": 1}); assert_eq!(crate::json_delta(&a, &b), Some(json!({"light": {"exposure": 1}, "masks": [1, 2]}))); assert_eq!(crate::json_delta(&a, &a), None); } #[test] fn build_previews_fills_the_cache() { use lightcraft_catalog::PhotoId; let mut s = demo(); let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); let cached = |s: &mut Session, id: u64| { let t = s.thumb_job(PhotoId(id), 512).unwrap(); let (c, k) = t.cache.clone().unwrap(); let q = s.quick_view_job(PhotoId(id), 1024, true).unwrap(); let (vc, vk) = q.cached[0].clone(); (c.get(k).is_some(), vc.get(vk).is_some()) }; assert_eq!(cached(&mut s, ids[0]), (false, false)); let r = s.execute("library.buildPreviews", &json!({"ids": [ids[0], ids[1]], "size": "standard", "edge": 512, "wait": true})).unwrap(); assert_eq!((r["total"].as_u64(), r["done"].as_u64(), r["failed"].as_u64(), r["running"].as_bool()), (Some(2), Some(2), Some(0), Some(false))); // view previews are written to the cache in the background let t0 = std::time::Instant::now(); while cached(&mut s, ids[1]) != (true, true) && t0.elapsed().as_secs() < 20 { std::thread::sleep(std::time::Duration::from_millis(20)); } assert_eq!(cached(&mut s, ids[0]), (true, true), "thumbnail and loupe view are ready"); assert_eq!(cached(&mut s, ids[2]), (false, false), "only the photos asked for"); // background build: returns at once, progress by command let r = s.execute("library.buildPreviews", &json!({"ids": [ids[2]], "edge": 512})).unwrap(); assert_eq!(r["total"], 1); let t0 = std::time::Instant::now(); loop { let p = s.execute("library.previewProgress", &json!({})).unwrap(); if p["running"] == false { assert_eq!(p["done"], 1); break; } assert!(t0.elapsed().as_secs() < 60, "build finishes"); std::thread::sleep(std::time::Duration::from_millis(20)); } assert!(s.execute("library.buildPreviews", &json!({"size": "huge"})).is_err()); } /// Colour range: a click samples the colour under it, so the mask selects that colour (white /// in the mask view) and not a different one; ⇧ adds samples, up to five. #[test] fn color_range_sampling_selects_the_clicked_colour() { use lightcraft_pipeline::{MaskView, Overlay}; let mut s = demo(); // a flower: magenta petals at the centre, green around them let id = s.catalog.photos().find(|p| p.meta.keywords.iter().any(|k| k == "flower")).unwrap().id; s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); s.execute("mask.add", &json!({"kind": "colorRange"})).unwrap(); let mask = |s: &mut Session| { let m = s.develop_of(id).unwrap().masks[0].clone(); let job = s.render_job(id, 200, 200, false, true).unwrap().with_overlay(Overlay::Mask { id: m.id as u16, view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100, }); job.run().rendered.unwrap().image }; let at = |img: &lightcraft_raster::Rgba8, x: f64, y: f64| img.data[(y * img.height as f64) as usize * img.width + (x * img.width as f64) as usize][0]; let r = s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.5})).unwrap(); assert_eq!(r["samples"].as_array().unwrap().len(), 1); let img = mask(&mut s); let (centre, corner) = (at(&img, 0.5, 0.5), at(&img, 0.03, 0.03)); assert!(centre > 200 && corner < centre / 2, "the sampled colour is selected: centre {centre}, corner {corner}"); // ⇧-click adds, at most five for _ in 0..6 { s.execute("mask.sampleColor", &json!({"x": 0.03, "y": 0.03, "add": true})).unwrap(); } let n = s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.52, "add": true})).unwrap()["samples"].as_array().unwrap().len(); assert_eq!(n, 5); assert!(at(&mask(&mut s), 0.03, 0.03) > 200, "the added colour is selected too"); // a plain click starts over assert_eq!(s.execute("mask.sampleColor", &json!({"x": 0.5, "y": 0.5})).unwrap()["samples"].as_array().unwrap().len(), 1); assert!(s.execute("mask.sampleColor", &json!({"x": 1.5, "y": 0.5})).is_err()); } #[test] fn leaving_an_edited_photo_keeps_an_auto_version() { use lightcraft_catalog::PhotoId; let mut s = demo(); let ids: Vec = s.catalog.photos().take(2).map(|p| p.id.0).collect(); s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap(); let before = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.len(); s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap(); let undo = s.undo.len(); s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap(); let v = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.clone(); assert_eq!(v.len(), before + 1); assert!(v.last().unwrap().auto && v.last().unwrap().settings.light.exposure == 0.4); assert_eq!(s.undo.len(), undo, "not an undo step"); // back and away again without changes: nothing new s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap(); s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap(); assert_eq!(s.catalog.photo(PhotoId(ids[0])).unwrap().versions.len(), before + 1); // at most AUTO_VERSIONS for i in 0..(crate::AUTO_VERSIONS + 5) { s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap(); s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.01 * i as f64 + 0.5})).unwrap(); s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap(); } let autos = s.catalog.photo(PhotoId(ids[0])).unwrap().versions.iter().filter(|v| v.auto).count(); assert_eq!(autos, crate::AUTO_VERSIONS); } #[test] fn match_total_exposures() { use lightcraft_catalog::PhotoId; let mut s = demo(); let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); // reference 1/100 f/4 ISO 100, slider +0.5; other 1/50 f/4 ISO 100 (one stop more light) for (i, (sh, ap, iso)) in [("1/100", 4.0f32, 100u32), ("1/50", 4.0, 100), ("", 4.0, 100)].iter().enumerate() { let mut m = s.catalog.photo(PhotoId(ids[i])).unwrap().meta.clone(); m.shutter = sh.to_string(); m.aperture = Some(*ap); m.iso = Some(*iso); s.commit("t", lightcraft_catalog::Op::SetMeta { id: PhotoId(ids[i]), meta: Box::new(m) }).unwrap(); } s.execute("library.select", &json!({"ids": ids})).unwrap(); s.selection.active = Some(PhotoId(ids[0])); s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5, "ids": [ids[0]]})).unwrap(); let r = s.execute("develop.matchExposure", &json!({})).unwrap(); assert_eq!((r["changed"].as_u64(), r["skipped"].clone()), (Some(1), json!([ids[2]]))); let e = s.develop_of(PhotoId(ids[1])).unwrap().light.exposure; assert!((e - (-0.5)).abs() < 1e-9, "one stop more light → one stop less exposure: {e}"); } #[test] fn find_similar_filters_to_look_alikes() { let mut s = demo(); let id = s.catalog.photos().next().unwrap().id; let r = s.execute("library.findSimilar", &json!({"id": id.0, "similarity": 0.5})).unwrap(); let hits: Vec = r["photos"].as_array().unwrap().iter().map(|h| h["id"].as_u64().unwrap()).collect(); assert_eq!(hits[0], id.0, "the photo itself is the closest"); assert!(hits.len() < s.catalog.len(), "not everything looks alike"); let vis = s.visible_cloned(); assert!(!vis.is_empty() && vis.iter().all(|v| hits.contains(&v.0)), "the view shows only them"); s.execute("library.clearFilter", &json!({})).unwrap(); assert!(s.visible_cloned().len() > vis.len()); } #[test] fn quick_develop_adds_to_each_photo() { use lightcraft_catalog::PhotoId; let mut s = demo(); let ids: Vec = s.catalog.photos().take(2).map(|p| p.id.0).collect(); s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5, "ids": [ids[0]]})).unwrap(); s.execute("library.select", &json!({"ids": ids})).unwrap(); let before: Vec = ids.iter().map(|i| s.develop_of(PhotoId(*i)).unwrap().light.exposure).collect(); s.execute("develop.quickAdjust", &json!({"control": "light.exposure", "delta": 1.0 / 3.0})).unwrap(); for (i, id) in ids.iter().enumerate() { let e = s.develop_of(PhotoId(*id)).unwrap().light.exposure; assert!((e - before[i] - 1.0 / 3.0).abs() < 0.02, "{e} vs {}", before[i]); } s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.develop_of(PhotoId(ids[0])).unwrap().light.exposure, before[0], "one undo step"); } #[test] fn mask_components_invert_duplicate_rename_change_mode_and_delete() { use lightcraft_develop::MaskOp; let mut s = demo(); s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); s.execute("mask.addComponent", &json!({"op": "subtract", "kind": "linear"})).unwrap(); let mask = |s: &Session| active_dev(s).masks[0].clone(); let id = mask(&s).id; s.execute("mask.component", &json!({"component": 1, "action": "invert"})).unwrap(); assert!(mask(&s).components[1].invert); s.execute("mask.component", &json!({"component": 1, "action": "op", "op": "intersect"})).unwrap(); assert_eq!(mask(&s).components[1].op, MaskOp::Intersect); s.execute("mask.component", &json!({"component": 0, "action": "rename", "name": " Face "})).unwrap(); assert_eq!(mask(&s).components[0].name.as_deref(), Some("Face")); s.execute("mask.component", &json!({"component": 0, "action": "duplicate"})).unwrap(); assert_eq!(mask(&s).components.len(), 3); assert_eq!(mask(&s).components[1].name.as_deref(), Some("Face")); // a cleared name falls back to the kind; one undo step each s.execute("mask.component", &json!({"component": 1, "action": "rename", "name": ""})).unwrap(); assert_eq!(mask(&s).components[1].name, None); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(mask(&s).components[1].name.as_deref(), Some("Face")); assert!(s.execute("mask.component", &json!({"component": 9, "action": "invert"})).is_err()); assert!(s.execute("mask.component", &json!({"component": 0, "action": "explode"})).is_err()); // deleting the last component deletes the mask for _ in 0..3 { s.execute("mask.component", &json!({"id": id, "component": 0, "action": "delete"})).unwrap(); } assert!(active_dev(&s).masks.is_empty()); assert_eq!(s.active_mask, None); } #[test] fn curve_reset_by_channel_and_whole() { let mut s = demo(); let s_curve = [[0.0, 0.0], [0.25, 0.15], [0.75, 0.85], [1.0, 1.0]]; for ch in ["master", "red", "green", "blue"] { s.execute("develop.curve", &json!({"channel": ch, "points": s_curve})).unwrap(); } s.execute("develop.set", &json!({"control": "curve.shadows", "value": 30})).unwrap(); s.execute("develop.set", &json!({"control": "curve.refineSaturation", "value": 50})).unwrap(); // one channel s.execute("curve.reset", &json!({"channel": "red"})).unwrap(); let c = active_dev(&s).curve; assert!(c.red.is_empty() && c.green.len() == 4 && c.master.len() == 4, "only red resets"); assert_eq!(c.shadows, 30.0); // parametric only s.execute("curve.reset", &json!({"channel": "parametric"})).unwrap(); let c = active_dev(&s).curve; assert_eq!(c.shadows, 0.0); assert_eq!(c.master.len(), 4, "point curves stay"); // all point curves, then undo restores them in one step s.execute("curve.reset", &json!({"channel": "point"})).unwrap(); let c = active_dev(&s).curve; assert!(c.master.is_empty() && c.green.is_empty() && c.blue.is_empty()); assert_eq!(c.refine_saturation, 50.0); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(active_dev(&s).curve.master.len(), 4); // everything (the default) s.execute("curve.reset", &json!({})).unwrap(); assert_eq!(active_dev(&s).curve, lightcraft_develop::ToneCurve::default()); assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err()); } #[test] fn ai_masks_without_the_model_explain_themselves() { let mut s = demo(); let id = s.active().unwrap(); // an Object mask that could never be computed is refused, not left empty let e = s.execute("mask.add", &json!({"kind": "object"})).unwrap_err().to_string(); assert!(e.contains("SAM 3") || e.contains("not available"), "{e}"); assert!(!e.contains("invalid parameters"), "shown as is: {e}"); assert!(s.develop_of(id).unwrap().masks.is_empty()); // an empty Object component (as an older document may hold) still takes clicks let shape = lightcraft_develop::MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 }; let mut d = (*s.develop_of(id).unwrap()).clone(); d.masks.push(lightcraft_develop::Mask { id: 1, components: vec![lightcraft_develop::MaskComponent { name: None, op: lightcraft_develop::MaskOp::Add, invert: false, shape }], ..Default::default() }); s.set_develop(id, d, "test").unwrap(); s.active_mask = Some(1); // clicks and descriptions need the model: an error that says what's missing, nothing changed let e = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap_err().to_string(); assert!(e.contains("SAM 3") || e.contains("not available"), "{e}"); let e = s.execute("mask.add", &json!({"kind": "prompt", "text": "sky"})).unwrap_err().to_string(); assert!(e.contains("SAM 3") || e.contains("not available"), "{e}"); let empty = std::env::temp_dir().join(format!("lc-no-sam3-{}", std::process::id())); s.segmenter.dir = Some(empty.clone()); let e = s.execute("mask.objectPoint", &json!({"x": 0.5, "y": 0.5})).unwrap_err().to_string(); assert!(e.contains(&empty.display().to_string()) || e.contains("not available"), "names the folder: {e}"); assert_eq!(s.develop_of(id).unwrap().masks.len(), 1); // bad input is an error, not a crash assert!(s.execute("mask.objectPoint", &json!({"x": 1.5, "y": 0.5})).is_err()); assert!(s.execute("mask.add", &json!({"kind": "prompt", "text": " "})).is_err()); } /// A People card's close-up is a square (in pixels) inside the photo, around the face, whatever the /// face rectangle says: corners, oversized and degenerate rectangles never leave the frame or panic. #[test] fn face_job_crops_a_square_inside_the_photo() { use lightcraft_geom::Rect; let mut s = demo(); let id = s.active().unwrap(); let (w, h) = { let p = s.catalog.photo(id).unwrap(); (f64::from(p.width), f64::from(p.height)) }; let faces = [ Rect { x0: 0.40, y0: 0.30, x1: 0.50, y1: 0.45 }, Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.05 }, Rect { x0: 0.95, y0: 0.95, x1: 1.0, y1: 1.0 }, Rect { x0: -5.0, y0: -5.0, x1: 5.0, y1: 5.0 }, Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 }, Rect { x0: f64::NAN, y0: 0.5, x1: 0.6, y1: f64::INFINITY }, ]; for face in faces { let job = s.face_job(id, face, 256).expect("a job for a photo in the library"); let r = job.settings.crop.geometry.rect; assert!((0.0..=1.0).contains(&r.x0) && (0.0..=1.0).contains(&r.y0) && r.x1 <= 1.0 + 1e-9 && r.y1 <= 1.0 + 1e-9, "{face:?} → {r:?}"); assert!(r.x1 > r.x0 && r.y1 > r.y0, "{face:?} → {r:?}"); let (pw, ph) = ((r.x1 - r.x0) * w, (r.y1 - r.y0) * h); assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in pixels: {face:?} → {pw} × {ph}"); assert_eq!(job.settings.crop.geometry.angle, 0.0); } // a face around (0.45, 0.375) is centred in its crop (room to spare on every side) let r = s.face_job(id, faces[0], 256).unwrap().settings.crop.geometry.rect; assert!((((r.x0 + r.x1) / 2.0) - 0.45).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.375).abs() < 1e-9, "{r:?}"); assert!(s.face_job(lightcraft_catalog::PhotoId(u64::MAX), faces[0], 256).is_none()); } /// Regions are on the upright photo; after Rotate Right the close-up still frames the face: the box /// is carried into the rotated frame the crop lives in, and the crop stays square in rotated pixels. #[test] fn face_job_follows_rotate_right() { use lightcraft_geom::Rect; let mut s = demo(); let id = s.active().unwrap(); let (w, h) = { let p = s.catalog.photo(id).unwrap(); (f64::from(p.width), f64::from(p.height)) }; // a small face left of centre on the upright photo let face = Rect { x0: 0.30, y0: 0.45, x1: 0.35, y1: 0.50 }; s.execute("photo.rotateRight", &json!({})).unwrap(); let r = s.face_job(id, face, 256).unwrap().settings.crop.geometry.rect; // a quarter turn clockwise puts the left of the photo at the top: centre (0.325, 0.475) → (0.525, 0.325) assert!((((r.x0 + r.x1) / 2.0) - 0.525).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.325).abs() < 1e-9, "{r:?}"); // the rotated photo is h × w pixels let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w); assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}"); } /// Selecting an id that is not in the library is an error, and the selection stays as it was /// (#182: `select_photos {"ids": [9999]}` answered `selected: 1` and the session lost its photo). #[test] fn selecting_an_unknown_photo_is_an_error_and_keeps_the_selection() { let mut s = demo(); let vis = s.visible_cloned(); s.execute("library.select", &json!({"ids": [vis[0].0]})).unwrap(); let before = s.selection.clone(); for p in [ json!({"ids": [9999]}), json!({"ids": [9999], "active": 9999}), json!({"ids": [vis[1].0], "active": 9999}), json!({"ids": [vis[1].0, 9999]}), json!({"ids": [9999], "mode": "add"}), json!({"ids": [9999], "mode": "toggle"}), json!({"ids": [9999], "mode": "range"}), ] { let e = s.execute("library.select", &p).unwrap_err().to_string(); assert!(e.contains("no such photo 9999"), "{p}: {e}"); assert_eq!(s.selection, before, "{p}"); } assert_eq!(s.active(), Some(vis[0])); assert!(s.execute("develop.get", &json!({})).is_ok()); // queries about an explicit id check it too for c in ["develop.get", "photo.inspect", "photo.allMetadata", "history.list"] { let e = s.execute(c, &json!({"id": 9999})).unwrap_err().to_string(); assert!(e.contains("no such photo 9999"), "{c}: {e}"); assert!(s.execute(c, &json!({"id": vis[1].0})).is_ok(), "{c}"); } // an empty selection is still allowed assert_eq!(s.execute("library.select", &json!({"ids": []})).unwrap()["selected"], 0); } /// A preset saved by another version may hold keys this one doesn't know: exporting with it keeps /// working (the stale keys are dropped), while a typo the caller makes is still refused (#181). #[test] fn saved_presets_with_unknown_keys_still_export() { use crate::export::{ExportOptions, ExportPreset}; let mut s = demo(); s.export_presets.push(ExportPreset { name: "Old".into(), params: json!({"format": "png", "retiredOption": 3, "watermark": {"text": "©", "retiredKey": true}}), }); let p = s.export_params(&json!({"preset": "old", "quality": 80})).unwrap(); let o = ExportOptions::from_params(&p).unwrap(); assert_eq!(o.format, crate::export::ExportFormat::Png); assert_eq!(o.watermark.map(|w| w.text), Some("©".to_string())); assert!(ExportOptions::from_params(&s.export_params(&json!({"preset": "old", "qualty": 80})).unwrap()).is_err()); } /// Undo and redo bring the photo they change on screen (issue #293): after editing one photo and /// moving on, Undo makes it the active photo again; a step that changes several photos leaves the /// selection alone. #[test] fn undo_and_redo_show_the_photo_they_change() { let mut s = demo(); let vis = s.visible_cloned(); let (a, b) = (vis[0], vis[1]); s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap(); s.execute("library.select", &json!({"ids": [b.0]})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.active(), Some(a), "undo shows the photo it changed"); assert_eq!(s.selection.ids, vec![a]); s.execute("library.select", &json!({"ids": [b.0]})).unwrap(); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.active(), Some(a), "so does redo"); // rating both selected photos is one step over two photos: undoing it leaves the selection s.execute("library.select", &json!({"ids": [a.0, b.0], "active": b.0})).unwrap(); s.execute("photo.rate", &json!({"rating": 3})).unwrap(); s.execute("library.select", &json!({"ids": [a.0, b.0], "active": a.0})).unwrap(); let before = s.selection.clone(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.selection, before, "a step over several photos keeps the selection"); }