mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 22:17:53 -06:00
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>
201 lines
9.4 KiB
Rust
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");
|
|
}
|