Files
lightcraft/crates/engine/src/tests_hold_view.rs
T
Nathaniel LedfordandClaude Opus 5.5 5f1037a192 PERF-EDIT: an edit is followed from what it touched, not worked out from every photo
Everything derived from the catalog was keyed on its revision, which every
op raises. So each edit (a keyword, a rating, every tick of a slider) had
the grid's list queried and sorted again from every photo, the source
counted by a second full query, and the side panels tally the whole library
again. In a library of 67,000 photos the frame after one keyword took about
156 ms with both side panels open (an idle frame: 2 ms).

The catalog now remembers which photos its latest ops touched and what of
them they changed (`Catalog::touched_since`, `aspects_since`, `Aspects`). It
answers "not known" for an op that changes more than photos of its own (an
album, a stack, the keyword list), for changes too long ago, and for a
revision it did not make itself (a library just loaded).

- The view (`Session::visible`): when the same view is asked again after a
  small edit, the photos the edit touched are taken out of the list it had,
  asked about again, and put back at their place in the order
  (`Catalog::follow`). 37 ms -> 0.6 ms. Worked out whole as before: more
  than 256 photos touched, a change that is not photos' own, an album in its
  own order, Recently Added, Missing Photos, and a view that asks about "the
  last N days" (it changes with the clock too).
- The source's count: taken from the list when no filter is set, else
  counted without sorting (`Catalog::count`). 37 ms -> 0.
- The side panels (`Caches`): the folder tree, keyword tree, date groups,
  people's names and library counts are kept through an edit that changed
  nothing they read. A rating or a slider changes none; a keyword leaves the
  folders, dates and counts.

The frame after a keyword: about 156 ms -> 54 ms with both side panels
open, 71 ms -> 2 ms with the grid alone (`tests_view_cost.rs`, a synthetic
library, run with --ignored --nocapture).

Still paid on each edit: the count beside every album and smart album in the
left panel (about 25 ms for a smart album there), and the filter bar's lists.

Tests: 1,440 random edits of every photo-local kind across sources, sorts,
filters and the hold, each compared with the list worked out whole; 600
random edits comparing every kept tally with a fresh one; the clock; the
record itself, with its failure paths. `ui.inspect` -> `perf` reports
`viewFollowed` and `viewWorkedOut`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:46:30 -04:00

201 lines
9.4 KiB
Rust

//! A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop
//! matching a smart album's rules, Picks or the filter bar leaves it where it is, and the others where they are.
use lightcraft_catalog::PhotoId;
use serde_json::json;
use crate::{LibrarySource, Session};
/// The demo library with no photo keyworded, showing a smart album of the photos without keywords, at random.
fn unkeyworded() -> Session {
let mut s = Session::with_demo();
let all: Vec<u64> = s.visible_cloned().iter().map(|p| p.0).collect();
s.execute("photo.setMeta", &json!({"ids": all, "keywords": []})).unwrap();
let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "is", "value": 0}]}});
let album = s.execute("album.createSmart", &json!({"name": "Unkeyworded", "rules": rules})).unwrap()["id"].as_u64().unwrap();
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
s.execute("library.sort", &json!({"key": "random"})).unwrap();
s
}
fn hold(s: &mut Session) {
assert_eq!(s.execute("library.holdView", &json!({"on": true})).unwrap()["hold"], true);
}
fn keyword(s: &mut Session, id: PhotoId) {
s.execute("photo.setMeta", &json!({"ids": [id.0], "addKeywords": ["travel"]})).unwrap();
}
#[test]
fn a_keyword_given_in_a_held_view_leaves_the_photo_and_the_order_where_they_are() {
// Given a smart album of the photos without keywords, at random, held
let mut s = unkeyworded();
hold(&mut s);
let (before, seed) = (s.visible_cloned(), s.sort.seed);
assert!(before.len() > 10);
// When photos in it are given a keyword, one after the other
for at in [3, 7, 0] {
keyword(&mut s, before[at]);
// Then nothing moves: the same photos in the same places, under the same shuffle
assert_eq!(s.visible_cloned(), before, "after a keyword on the photo at {at}");
assert_eq!(s.sort.seed, seed);
}
// When the view is refreshed, Then they are gone and the others keep their order
s.execute("library.refresh", &json!({})).unwrap();
let after = s.visible_cloned();
let expected: Vec<PhotoId> = before.iter().copied().filter(|id| ![before[3], before[7], before[0]].contains(id)).collect();
assert_eq!(after, expected);
assert_eq!(s.sort.seed, seed, "a refresh is not a reshuffle");
}
#[test]
fn a_view_that_is_not_held_follows_every_edit_as_before() {
let mut s = unkeyworded();
assert!(!s.hold_view, "off unless asked for");
let before = s.visible_cloned();
keyword(&mut s, before[3]);
let after = s.visible_cloned();
assert!(!after.contains(&before[3]), "the photo leaves at once");
assert_eq!(after, before.iter().copied().filter(|id| *id != before[3]).collect::<Vec<_>>(), "the others keep their order");
}
#[test]
fn a_held_view_still_takes_in_what_starts_matching_and_lets_go_of_what_is_deleted() {
let mut s = unkeyworded();
let all = s.visible_cloned();
// one photo has a keyword before the view is held: it is not in it
keyword(&mut s, all[5]);
hold(&mut s);
let before = s.visible_cloned();
assert!(!before.contains(&all[5]));
// its keyword taken off again: it matches, and comes in at its place in the shuffle
s.execute("photo.setMeta", &json!({"ids": [all[5].0], "keywords": []})).unwrap();
assert_eq!(s.visible_cloned(), all, "back where the shuffle puts it");
// a photo that was kept after it stopped matching, then deleted, is not kept in the view
keyword(&mut s, all[2]);
assert!(s.visible_cloned().contains(&all[2]));
s.execute("library.select", &json!({"ids": [all[2].0]})).unwrap();
s.execute("photo.delete", &json!({})).unwrap();
assert!(!s.visible_cloned().contains(&all[2]), "Recently Deleted is somewhere else");
}
#[test]
fn another_source_filter_sort_or_shuffle_is_another_view() {
let mut s = unkeyworded();
hold(&mut s);
let before = s.visible_cloned();
// Reshuffle: a new order of what matches now
keyword(&mut s, before[1]);
assert!(s.visible_cloned().contains(&before[1]));
s.execute("library.shuffle", &json!({})).unwrap();
assert!(!s.visible_cloned().contains(&before[1]), "a reshuffle refreshes");
// another sort
let now = s.visible_cloned();
keyword(&mut s, now[1]);
s.execute("library.sort", &json!({"key": "fileName"})).unwrap();
assert!(!s.visible_cloned().contains(&now[1]), "another sort refreshes");
// the filter bar
let now = s.visible_cloned();
keyword(&mut s, now[1]);
s.execute("library.filter", &json!({"rating": 0, "text": "a"})).unwrap();
assert!(!s.visible_cloned().contains(&now[1]), "another filter refreshes");
s.execute("library.filter", &json!({"text": ""})).unwrap();
// away and back
let now = s.visible_cloned();
let source = s.source;
keyword(&mut s, now[1]);
s.execute("library.source", &json!({"kind": "all"})).unwrap();
let _ = s.visible_cloned();
s.source = source;
assert!(!s.visible_cloned().contains(&now[1]), "coming back to it refreshes");
}
#[test]
fn picks_and_the_filter_bar_hold_too_and_an_album_lets_go_of_what_is_taken_out_of_it() {
let mut s = Session::with_demo();
let all = s.visible_cloned();
// Picks
s.execute("library.select", &json!({"ids": [all[0].0, all[1].0, all[2].0]})).unwrap();
s.execute("photo.flag", &json!({"flag": "pick"})).unwrap();
s.execute("library.source", &json!({"kind": "picks"})).unwrap();
hold(&mut s);
let picks = s.visible_cloned();
assert!(picks.len() >= 3, "{picks:?}");
s.execute("library.select", &json!({"ids": [picks[1].0]})).unwrap();
s.execute("photo.flag", &json!({"flag": "none"})).unwrap();
assert_eq!(s.visible_cloned(), picks, "a pick unflagged stays until the view is refreshed");
s.execute("library.refresh", &json!({})).unwrap();
assert_eq!(s.visible_cloned(), picks.iter().copied().filter(|id| *id != picks[1]).collect::<Vec<_>>());
// the filter bar over an album: a rating under the filter's stays, a photo taken out of the album goes
let album = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap();
let ids: Vec<u64> = all.iter().take(5).map(|p| p.0).collect();
s.execute("library.select", &json!({"ids": ids})).unwrap();
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
s.execute("album.addPhotos", &json!({"id": album, "ids": ids})).unwrap();
s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap();
s.execute("library.filter", &json!({"rating": 3})).unwrap();
let shown = s.visible_cloned();
assert_eq!(shown.len(), 5);
s.execute("library.select", &json!({"ids": [shown[0].0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 1})).unwrap();
assert_eq!(s.visible_cloned().len(), 5, "rated under the filter: held");
s.execute("album.removePhotos", &json!({"id": album, "ids": [shown[0].0, shown[1].0]})).unwrap();
let left = s.visible_cloned();
assert!(!left.contains(&shown[0]) && !left.contains(&shown[1]), "out of the album is out of the view: {left:?}");
assert_eq!(left.len(), 3);
assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(album)));
}
#[test]
fn turning_the_hold_off_lets_go_at_once() {
let mut s = unkeyworded();
hold(&mut s);
let before = s.visible_cloned();
keyword(&mut s, before[4]);
assert!(s.visible_cloned().contains(&before[4]));
assert_eq!(s.execute("library.holdView", &json!({})).unwrap()["hold"], false, "toggles without `on`");
assert!(!s.visible_cloned().contains(&before[4]));
}
#[test]
fn changing_the_rules_of_the_smart_album_shown_is_another_view() {
let mut s = unkeyworded();
hold(&mut s);
let before = s.visible_cloned();
let LibrarySource::Album(album) = s.source else { panic!("a smart album is shown") };
// the album now asks for photos with a keyword: none has one, and none is kept from what it asked before
let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "gt", "value": 0}]}});
s.execute("album.setRules", &json!({"id": album.0, "rules": rules})).unwrap();
assert!(s.visible_cloned().is_empty(), "the old rules' photos are not held under the new ones");
// and undoing the change brings the first question back, with what matches it
s.undo_step().unwrap();
assert_eq!(s.visible_cloned(), before);
}
#[test]
fn a_source_with_no_filter_over_it_holds_nothing() {
// All Photos, an album or a folder with no filter: nothing an edit does can make a photo stop matching, so
// nothing is kept (and nothing is paid for the hold)
let mut s = Session::with_demo();
hold(&mut s);
let all = s.visible_cloned();
keyword(&mut s, all[0]);
assert_eq!(s.visible_cloned(), all);
// a photo deleted from All Photos leaves it, held or not
s.execute("library.select", &json!({"ids": [all[0].0]})).unwrap();
s.execute("photo.delete", &json!({})).unwrap();
assert!(!s.visible_cloned().contains(&all[0]));
}
#[test]
fn asking_for_the_state_it_has_changes_nothing_and_a_word_is_not_a_bool() {
let mut s = unkeyworded();
hold(&mut s);
let before = s.visible_cloned();
keyword(&mut s, before[2]);
hold(&mut s);
assert!(s.visible_cloned().contains(&before[2]), "on, asked for again: the held photos stay");
assert!(s.execute("library.holdView", &json!({"on": "false"})).is_err());
assert!(s.hold_view, "a refused call changes nothing");
}