mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 19:17:02 -06:00
docs/parity-checklist.md: header and revision rows from both sides; LRC-LIB-LIBFOLDERS row takes both wording changes
410 lines
19 KiB
Rust
410 lines
19 KiB
Rust
//! Synchronize Folder in the app (see `lightcraft_engine::sync`).
|
|
//!
|
|
//! Scenarios, in the words of someone whose folder changed outside LightCraft:
|
|
//!
|
|
//! * Right-click a folder in the sidebar's Folders section ▸ Synchronize Folder…: a dialog opens
|
|
//! at once and scans the folder without holding up the app, then says how many photos are new,
|
|
//! missing, or have metadata updates.
|
|
//! * Right-click a folder in the sidebar's Local section: Synchronize Folder… is there when the
|
|
//! folder holds library photos (or has a folder with some inside), and not when it holds none.
|
|
//! * Synchronize with the defaults imports the new photos and leaves missing ones alone.
|
|
//! * Ticking "Remove missing photos" also moves those to Recently Deleted.
|
|
//! * Cancel leaves the library as it was.
|
|
//! * A folder that has nothing to bring in only says so: the dialog has one Close and no
|
|
//! choices, and closing it starts nothing.
|
|
//! * Synchronize hands the work to the background at once: the dialog closes, the app keeps
|
|
//! answering, and the photos arrive over the next frames under a row in the activity stack, as one undo
|
|
//! step.
|
|
//! * While the folder is listed (minutes, on a network share), the scan's row says how many
|
|
//! folders and files it has listed, with a bar that moves on its own, not "0 of 64085"; then it
|
|
//! counts the files and photos checked.
|
|
|
|
use std::time::Duration;
|
|
|
|
use serde_json::json;
|
|
|
|
use crate::headless::Headless;
|
|
use crate::state::Dialog;
|
|
use crate::{LightcraftApp, Services};
|
|
|
|
const T: Duration = Duration::from_secs(20);
|
|
const SETTLE: Duration = Duration::from_secs(120);
|
|
|
|
/// A scratch folder that goes away with the test, however it ends.
|
|
struct Scratch(std::path::PathBuf);
|
|
|
|
impl Scratch {
|
|
fn new(tag: &str) -> Scratch {
|
|
let dir = std::env::temp_dir().join(format!("lc-ui-sync-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
Scratch(dir)
|
|
}
|
|
/// The path as the folder tree writes it (forward slashes; widget ids carry it).
|
|
fn path(&self, rel: &str) -> String {
|
|
self.0.join(rel).to_string_lossy().replace('\\', "/")
|
|
}
|
|
}
|
|
|
|
impl Drop for Scratch {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn write_png(path: &str, seed: u8) {
|
|
let (w, h) = (24usize, 16usize);
|
|
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) as u8, seed, 255]).collect();
|
|
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
|
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
|
std::fs::create_dir_all(std::path::Path::new(path).parent().unwrap()).unwrap();
|
|
std::fs::write(path, bytes).unwrap();
|
|
}
|
|
|
|
/// The app with `trip/a.png` and `trip/b.png` imported, then `c.png` added and `b.png` deleted
|
|
/// on disk behind its back.
|
|
fn changed_folder(dir: &Scratch) -> Headless {
|
|
imported_folder(dir, || {
|
|
write_png(&dir.path("trip/c.png"), 3);
|
|
std::fs::remove_file(dir.path("trip/b.png")).unwrap();
|
|
})
|
|
}
|
|
|
|
/// The app with `trip/a.png` and `trip/b.png` imported, and `then` done on disk after.
|
|
fn imported_folder(dir: &Scratch, then: impl FnOnce()) -> Headless {
|
|
write_png(&dir.path("trip/a.png"), 1);
|
|
write_png(&dir.path("trip/b.png"), 2);
|
|
let mut session = lightcraft_engine::Session::new().with_fs();
|
|
session.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap();
|
|
then();
|
|
let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
|
|
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
|
|
let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
h.settle(SETTLE);
|
|
h
|
|
}
|
|
|
|
fn in_library(h: &Headless) -> Vec<String> {
|
|
let mut v: Vec<String> = h.app.session.catalog.photos().filter(|p| p.in_library()).map(|p| p.file_name.clone()).collect();
|
|
v.sort();
|
|
v
|
|
}
|
|
|
|
/// Open the dialog from the folder's menu and wait for the scan.
|
|
fn open_and_scan(h: &mut Headless, folder: &str) {
|
|
// the rows above it hold one folder each and open by themselves
|
|
let row = format!("source:libfolder:{folder}");
|
|
assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "no row {row}");
|
|
let rect = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap_or_else(|| panic!("no row {row}"));
|
|
let c = rect.center();
|
|
let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
h.step();
|
|
h.step();
|
|
let r = h.request("ui.clickWidget", json!({"id": "folderSynchronize"}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
h.step();
|
|
assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog opens at once");
|
|
let scanned = h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. })));
|
|
assert!(scanned, "the scan finished: {:?}", h.app.ui.dialog);
|
|
}
|
|
|
|
/// Synchronize: the dialog closes and the work goes to the background at once (nothing has
|
|
/// changed yet when the confirm returns); then wait for it.
|
|
fn synchronize(h: &mut Headless) {
|
|
let before = in_library(h);
|
|
// the dialog's confirm itself, between frames: it only hands the work over
|
|
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
|
|
let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg);
|
|
assert!(r.is_ok(), "{r:?}");
|
|
assert!(h.app.sync_run.is_some(), "the work runs in the background");
|
|
assert_eq!(in_library(h), before, "nothing was done on the UI thread");
|
|
assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "the work finishes");
|
|
}
|
|
|
|
/// Tick "Remove missing photos" and wait until the click has landed. A click is queued input:
|
|
/// on a loaded machine the dialog can still be settling into place when it arrives, so it is
|
|
/// clicked again (at the box's current place) until it lands.
|
|
fn tick_remove_missing(h: &mut Headless) {
|
|
let ticked = |h: &Headless| matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { remove_missing: true, .. }));
|
|
for _ in 0..5 {
|
|
h.step();
|
|
let r = h.request("ui.clickWidget", json!({"id": "syncRemoveMissing"}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
if h.step_until(Duration::from_secs(3), ticked) {
|
|
return;
|
|
}
|
|
}
|
|
panic!("the box never ticked: {:?}", h.app.ui.dialog);
|
|
}
|
|
|
|
fn counts(h: &Headless) -> crate::state::SyncCounts {
|
|
match &h.app.ui.dialog {
|
|
Some(Dialog::SynchronizeFolder { counts: Some(c), .. }) => c.clone(),
|
|
d => panic!("no scanned dialog: {d:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_dialog_says_what_changed_in_the_folder() {
|
|
let dir = Scratch::new("counts");
|
|
let mut h = changed_folder(&dir);
|
|
open_and_scan(&mut h, &dir.path("trip"));
|
|
let c = counts(&h);
|
|
assert_eq!((c.new, c.missing, c.metadata), (1, 1, 0), "{c:?}");
|
|
assert_eq!(in_library(&h), vec!["a.png", "b.png"], "scanning changed nothing");
|
|
}
|
|
|
|
#[test]
|
|
fn synchronizing_with_the_defaults_imports_the_new_photos_only() {
|
|
let dir = Scratch::new("defaults");
|
|
let mut h = changed_folder(&dir);
|
|
open_and_scan(&mut h, &dir.path("trip"));
|
|
synchronize(&mut h);
|
|
assert_eq!(in_library(&h), vec!["a.png", "b.png", "c.png"], "c came in; the missing b stays");
|
|
}
|
|
|
|
#[test]
|
|
fn ticking_remove_missing_also_removes_the_missing_photos() {
|
|
let dir = Scratch::new("remove");
|
|
let mut h = changed_folder(&dir);
|
|
open_and_scan(&mut h, &dir.path("trip"));
|
|
tick_remove_missing(&mut h);
|
|
synchronize(&mut h);
|
|
assert_eq!(in_library(&h), vec!["a.png", "c.png"], "b went to Recently Deleted");
|
|
let r = h.request("engine.execute", json!({"command": "edit.undo", "params": {}}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
assert_eq!(in_library(&h), vec!["a.png", "b.png"], "one undo step");
|
|
}
|
|
|
|
#[test]
|
|
fn cancel_leaves_the_library_as_it_was() {
|
|
let dir = Scratch::new("cancel");
|
|
let mut h = changed_folder(&dir);
|
|
open_and_scan(&mut h, &dir.path("trip"));
|
|
h.request("ui.key", json!({"key": "escape"}), T);
|
|
h.step();
|
|
h.step();
|
|
assert!(h.app.ui.dialog.is_none());
|
|
assert_eq!(in_library(&h), vec!["a.png", "b.png"]);
|
|
assert!(h.app.session.folder_changes.is_none(), "the scan is let go");
|
|
}
|
|
|
|
fn shown(h: &Headless, widget: &str) -> bool {
|
|
h.app.widgets.iter().any(|(w, _)| w == widget)
|
|
}
|
|
|
|
#[test]
|
|
fn a_folder_with_nothing_to_bring_in_is_only_reported() {
|
|
let dir = Scratch::new("uptodate");
|
|
let mut h = imported_folder(&dir, || {});
|
|
open_and_scan(&mut h, &dir.path("trip"));
|
|
h.step();
|
|
assert!(shown(&h, "button:dialogOk"), "one button, to close it");
|
|
for gone in ["button:dialogCancel", "syncImportNew", "syncRelinkMoved", "syncRemoveMissing", "syncReadMetadata"] {
|
|
assert!(!shown(&h, gone), "nothing to choose: {gone}");
|
|
}
|
|
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
assert!(h.step_until(T, |h| h.app.ui.dialog.is_none()), "the button closes it");
|
|
assert!(h.app.sync_run.is_none() && h.app.session.activity.list().is_empty(), "and starts nothing");
|
|
assert!(h.app.session.folder_changes.is_none(), "the scan is let go");
|
|
assert_eq!(in_library(&h), vec!["a.png", "b.png"]);
|
|
}
|
|
|
|
#[test]
|
|
fn confirming_a_folder_with_nothing_to_bring_in_starts_nothing() {
|
|
let dir = Scratch::new("uptodate-agent");
|
|
let mut h = imported_folder(&dir, || {});
|
|
open_and_scan(&mut h, &dir.path("trip"));
|
|
let r = h.request("ui.dialog.confirm", json!({}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
assert_eq!(r["result"]["upToDate"], true, "{r}");
|
|
assert!(h.app.ui.dialog.is_none());
|
|
assert!(h.app.sync_run.is_none() && h.app.session.activity.list().is_empty(), "no job that does nothing");
|
|
h.step();
|
|
assert!(h.app.session.folder_changes.is_none(), "the scan is let go");
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_scan_is_kept_between_frames() {
|
|
let dir = Scratch::new("agent");
|
|
let mut h = changed_folder(&dir);
|
|
let r = h.request("engine.execute", json!({"command": "folder.scanChanges", "params": {"path": dir.path("trip")}}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
for _ in 0..5 {
|
|
h.step();
|
|
}
|
|
assert!(h.app.session.folder_changes.is_some(), "the app doesn't drop a scan it didn't make");
|
|
}
|
|
|
|
#[test]
|
|
fn a_scan_gone_stale_in_the_dialog_is_made_again_in_the_background() {
|
|
let dir = Scratch::new("restale");
|
|
let mut h = changed_folder(&dir);
|
|
open_and_scan(&mut h, &dir.path("trip"));
|
|
tick_remove_missing(&mut h);
|
|
// a photo of the folder changes while the dialog is open
|
|
let a = h.app.session.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
|
let r = h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [a.0], "rating": 3}}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
let r = h.request("ui.dialog.confirm", json!({}), T);
|
|
assert_eq!(r["ok"], false, "not done on a stale scan: {r}");
|
|
assert_eq!(in_library(&h), vec!["a.png", "b.png"]);
|
|
assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog stays open");
|
|
let rescanned = h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. })));
|
|
assert!(rescanned, "and scans again");
|
|
assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { remove_missing: true, .. })), "keeping what was ticked");
|
|
synchronize(&mut h);
|
|
assert_eq!(in_library(&h), vec!["a.png", "c.png"], "c came in, the missing b went");
|
|
}
|
|
|
|
#[test]
|
|
fn a_stopped_synchronize_says_it_was_stopped_and_stays_at_most_one_step() {
|
|
let dir = Scratch::new("stop");
|
|
let mut h = changed_folder(&dir);
|
|
open_and_scan(&mut h, &dir.path("trip"));
|
|
let steps = h.app.session.undo.len();
|
|
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
|
|
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok());
|
|
// Cancel (what the row's ✕ does), before any frame committed anything
|
|
h.app.sync_run.as_mut().expect("running").cancel();
|
|
assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends");
|
|
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
|
assert!(toast.contains("Stopped"), "never 'up to date': {toast:?}");
|
|
assert!(h.app.session.undo.len() <= steps + 1, "what was done by then is at most one step");
|
|
}
|
|
|
|
#[test]
|
|
fn a_synchronize_and_an_import_never_run_at_once() {
|
|
let dir = Scratch::new("exclusive");
|
|
let mut h = changed_folder(&dir);
|
|
open_and_scan(&mut h, &dir.path("trip"));
|
|
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
|
|
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok());
|
|
// dropping the same folder on the window while the run works would add its files twice
|
|
let r = crate::import::start_paths(&mut h.app, vec![dir.path("trip")]);
|
|
assert!(r.is_err(), "no import while synchronizing: {r:?}");
|
|
assert!(h.step_until(T, |h| h.app.sync_run.is_none()));
|
|
// and the other way round: an import is running (no frame has run since it started)
|
|
let r = crate::import::start_paths(&mut h.app, vec![dir.path("trip/a.png")]);
|
|
assert!(r.is_ok(), "{r:?}");
|
|
assert!(h.app.import.is_some());
|
|
let d = Dialog::SynchronizeFolder {
|
|
path: dir.path("trip"),
|
|
name: "trip".into(),
|
|
disk: false,
|
|
counts: Some(crate::state::SyncCounts { new: 1, ..Default::default() }),
|
|
import_new: true,
|
|
relink_moved: true,
|
|
remove_missing: false,
|
|
read_metadata: false,
|
|
};
|
|
let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &d);
|
|
assert!(r.is_err() && h.app.sync_run.is_none(), "no synchronize while importing: {r:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_synchronize_shows_a_row_and_its_cross_stops_it() {
|
|
let dir = Scratch::new("row");
|
|
let mut h = changed_folder(&dir);
|
|
open_and_scan(&mut h, &dir.path("trip"));
|
|
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
|
|
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok());
|
|
let rows = h.app.session.activity.list();
|
|
assert_eq!(rows.len(), 1, "{rows:?}");
|
|
assert_eq!((rows[0].kind, rows[0].label.as_str()), ("sync", "Synchronizing folder"));
|
|
assert!(rows[0].detail.ends_with("trip"), "the folder as the dialog named it: {:?}", rows[0].detail);
|
|
assert!(rows[0].cancellable);
|
|
// the row's ✕ (as `activity.cancel`): what was done stays, and the end says it was stopped
|
|
h.app.session.activity.cancel(rows[0].id).unwrap();
|
|
assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends");
|
|
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
|
assert!(toast.contains("Stopped"), "{toast:?}");
|
|
assert!(h.app.session.activity.list().is_empty(), "the row goes with the run");
|
|
}
|
|
|
|
#[test]
|
|
fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() {
|
|
let dir = Scratch::new("scanrow");
|
|
let mut h = changed_folder(&dir);
|
|
// a scan that can't finish until the test lets it: reading the new file 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() })
|
|
}));
|
|
crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap();
|
|
let rows = h.app.session.activity.list();
|
|
assert_eq!(rows.len(), 1, "{rows:?}");
|
|
assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("sync", "Looking for changes", "trip"));
|
|
assert!(rows[0].cancellable);
|
|
h.app.session.activity.cancel(rows[0].id).unwrap();
|
|
h.step();
|
|
assert!(h.app.sync.is_none(), "✕ stops the scan");
|
|
assert!(h.app.ui.dialog.is_none(), "and closes its dialog: there is nothing to show");
|
|
gate.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
assert!(h.app.session.activity.list().is_empty());
|
|
h.settle(SETTLE);
|
|
assert!(h.app.ui.dialog.is_none(), "a stopped scan opens nothing later");
|
|
}
|
|
|
|
/// Right-click the Local row of `folder`; whether Synchronize Folder… is in its menu.
|
|
fn local_menu_offers_synchronize(h: &mut Headless, folder: &str) -> bool {
|
|
let row = format!("source:local:{folder}");
|
|
assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "no row {row}");
|
|
let rect = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap();
|
|
let c = rect.center();
|
|
let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
h.step();
|
|
h.step();
|
|
h.app.widgets.iter().any(|(w, _)| *w == "folderSynchronize")
|
|
}
|
|
|
|
#[test]
|
|
fn a_local_folder_with_library_photos_offers_synchronize() {
|
|
let dir = Scratch::new("local-menu");
|
|
let mut h = changed_folder(&dir);
|
|
std::fs::create_dir_all(dir.path("empty")).unwrap();
|
|
// Local writes paths the way the OS does
|
|
let native = |f: &str| dir.0.join(f).to_string_lossy().to_string();
|
|
for f in ["trip", "empty"] {
|
|
let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": native(f)}}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
}
|
|
h.step();
|
|
assert!(!local_menu_offers_synchronize(&mut h, &native("empty")), "no library photos in it: nothing to synchronize");
|
|
// close that menu, then open the other folder's
|
|
let r = h.request("ui.key", json!({"key": "Escape"}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
h.step();
|
|
h.step();
|
|
assert!(local_menu_offers_synchronize(&mut h, &native("trip")), "the folder holds library photos");
|
|
let r = h.request("ui.clickWidget", json!({"id": "folderSynchronize"}), T);
|
|
assert_eq!(r["ok"], true, "{r}");
|
|
h.step();
|
|
assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog opens at once");
|
|
assert!(h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. }))));
|
|
}
|
|
|
|
#[test]
|
|
fn while_the_folder_is_listed_its_row_says_what_was_listed_and_its_bar_moves_on_its_own() {
|
|
use std::sync::atomic::Ordering::Relaxed;
|
|
let p = lightcraft_engine::sync::SyncProgress::default();
|
|
p.to_check.store(64085, Relaxed);
|
|
p.folders_listed.store(312, Relaxed);
|
|
p.files_listed.store(18402, Relaxed);
|
|
let (done, total, detail) = crate::sync::scan_row(&p, "photos/Various");
|
|
assert_eq!((done, total), (0, 0), "a bar that moves on its own, not 0 of 64085");
|
|
assert_eq!(detail, "Listing folders — folders: 312, files: 18402 · photos/Various");
|
|
// the listing is over: the files and photos checked, of how many
|
|
p.listed.store(true, Relaxed);
|
|
p.checked.store(10, Relaxed);
|
|
assert_eq!(crate::sync::scan_row(&p, "photos/Various"), (10, 64085, "photos/Various".to_string()));
|
|
}
|