//! Scoping the grid to a collection, spanning and reordering a manual //! collection's membership, and the capture-time histogram. use dr_catalog::Catalog; use super::Scope; use super::cells::uncollapsed; #[cfg(test)] use super::cells::{read_cells_all, read_cells_scoped, read_ids_span}; use super::filters::{RatingFilter, GRID_ORDER, VISIBLE}; /// Total images in the catalog, or in one collection and its descendants. /// /// Counts exactly what [`read_cells_scoped`] would list, filter included. The /// two must agree: the header says "412 images" and the grid's scrollbar is /// sized from the same number, so a count that ignored the filter would leave /// the user scrolling through empty rows. pub fn total_images_scoped( catalog: &Catalog, scope: Option, filter: &RatingFilter, ) -> Result { if scope.is_none() { return total_images_filtered(catalog, filter); } let (clause, params) = scope_clause(catalog, scope)?; let rated = filter.sql(); // DISTINCT: an image in both a parent and a child is one photograph, and a // count that disagrees with the number of cells drawn is worse than either // number alone. // // Counted through `images` rather than over the membership table alone, so // `VISIBLE` applies — a trashed photograph is still a member row, and // counting it made the header claim images the grid would not draw. let folded = uncollapsed("i"); let sql = format!( "SELECT count(DISTINCT i.id) FROM images i WHERE {VISIBLE}{rated}{folded}{clause}" ); let n: i64 = catalog .connection() .query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| { r.get(0) })?; Ok(n as usize) } /// TRACES: FR-CAT-7 /// Every member of `scope`, in the order its positions put them. /// /// The *whole* membership, not the window and not the filtered view. A reorder /// rewrites positions, and [`dr_catalog::collections::set_order`] only touches /// the rows it is given — so writing back a filtered subset would leave the /// images the filter is hiding at their old positions, interleaved with the new /// ones arbitrarily. The user reorders what they can see; the rows they cannot /// keep their place relative to it. pub fn read_member_order( catalog: &Catalog, scope: dr_types::CollectionId, ) -> Result, dr_catalog::CatalogError> { let mut stmt = catalog.connection().prepare( "SELECT image_id FROM collection_members WHERE collection_id = ?1 ORDER BY position ASC, image_id ASC", )?; let ids = stmt .query_map([scope.0 as i64], |r| { Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64)) })? .collect::, _>>()?; Ok(ids) } /// TRACES: FR-CAT-7 /// `current` with `moving` lifted out and set down beside `target`. /// /// `target` names a *photograph*, not an index, and that is the point: the grid /// may be filtered, so the cell the user dropped on sits at one position in /// what they can see and another in the membership being rewritten. An id /// survives both. `after` puts the run on the far side of it, which is the only /// way to name the last place in a collection — there is no cell beyond the /// last one to drop in front of. /// /// The run keeps the order `current` has it in rather than the order the /// selection was built in: the user is looking at the grid, and a selection /// gathered by tapping the last frame first should not reverse itself on being /// moved. /// /// A `target` that is itself being moved leaves the run at the end. There is no /// gap between a run and itself to land in, so the caller refuses that drop /// before it gets here; this is what the function does rather than panicking if /// one ever arrives. /// /// Pure, so the awkward half of a drag can be tested without a window. pub fn reordered( current: &[dr_types::ImageId], moving: &[dr_types::ImageId], target: dr_types::ImageId, after: bool, ) -> Vec { let lifting: std::collections::BTreeSet<_> = moving.iter().copied().collect(); let rest: Vec<_> = current .iter() .copied() .filter(|id| !lifting.contains(id)) .collect(); let run: Vec<_> = current .iter() .copied() .filter(|id| lifting.contains(id)) .collect(); // Resolved against `rest`, not against `current`: the run has already been // lifted, so an index into the original list would be off by however many // of it sat ahead of the target. let at = match rest.iter().position(|id| *id == target) { Some(at) if after => at + 1, Some(at) => at, None => rest.len(), }; let mut out = Vec::with_capacity(current.len()); out.extend_from_slice(&rest[..at]); out.extend(run); out.extend_from_slice(&rest[at..]); out } /// TRACES: FR-CAT-7 /// The ORDER BY the grid reads `scope` with, and the parameters it binds. /// /// Manual position where the grid is scoped to a single manual collection with /// no children; [`GRID_ORDER`] — capture time, then filename — everywhere else. /// /// **Why the narrowing.** `position` is a column of `collection_members`, so it /// only exists relative to one collection. A collection *set* shows its /// descendants' images too, and two children's positions are unrelated integers /// that would interleave arbitrarily; a smart collection has no member rows to /// carry a position at all. Outside those cases there is no manual order to /// read, and falling back is the only honest answer. /// /// **Why every reader must agree.** An ordinal only names a photograph relative /// to an ordering. The window read and the span read are two halves of one /// grid: a shift-click resolved through a different ORDER BY than the cells /// were drawn with selects a different run than the one on screen, and the user /// finds out when the export runs. That is the same invariant /// [`read_ids_span`] already states about `GRID_ORDER`, widened to cover the /// case where the ordering depends on the scope. /// /// A correlated subquery rather than a join, so the FROM and WHERE the two /// readers already share are untouched: position is looked up per row through /// `collection_members`' primary key, which is `(collection_id, image_id)`. pub(super) fn grid_order_for( catalog: &Catalog, scope: Option, ) -> (String, Vec) { // An album has no order of its own to keep: it is a record of what was // exported, and capture time is how its photographs read best. let Some(Scope::Collection(id)) = scope else { return (GRID_ORDER.to_string(), Vec::new()); }; // A set orders by capture time. `descendants` includes the collection // itself, so one entry means it has no children. let alone = dr_catalog::collections::descendants(catalog.connection(), id) .map(|d| d.len() == 1) .unwrap_or(false); let manual = matches!( dr_catalog::collections::kind(catalog.connection(), id), Ok(Some(dr_catalog::collections::CollectionKind::Manual)) ); if !alone || !manual { return (GRID_ORDER.to_string(), Vec::new()); } // `i.id` breaks the tie. Positions are dense after a `set_order`, but a // collection that has never been reordered by hand has whatever // `add_images` assigned, and two rows can share a position if a merge from // another device brought one in — an ordering that is not total is an // ordering the window read and the span read can disagree about. ( "ORDER BY (SELECT cm.position FROM collection_members cm WHERE cm.collection_id = ? AND cm.image_id = i.id) ASC, i.id ASC" .to_string(), vec![rusqlite::types::Value::Integer(id.0 as i64)], ) } /// The SQL restricting a query to `scope`, with the bound parameters to go /// with it: a collection and its descendants, or the photographs an album's /// files were exported from. /// /// Every scoped reader goes through this, so the count, the cells, the span /// and the histogram cannot drift: an axis drawn over one set of images and /// bars counted over another puts the bars in the wrong place, and a header /// count that disagrees with the cells drawn leaves empty rows to scroll. pub(super) fn scope_clause( catalog: &Catalog, scope: Option, ) -> Result<(String, Vec), dr_catalog::CatalogError> { match scope { None => Ok((String::new(), Vec::new())), Some(Scope::Collection(scope)) => { let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?; // Placeholders are generated from the *count* of ids, never from // user text. let placeholders = std::iter::repeat_n("?", ids.len()) .collect::>() .join(","); Ok(( format!( " AND i.id IN (SELECT image_id FROM collection_members WHERE collection_id IN ({placeholders}))" ), ids.iter() .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) .collect(), )) } Some(Scope::Album(album)) => { // The table exists only once an album has; an album scope with no // table behind it has nothing in it, and the query must still run. dr_catalog::albums::ensure_tables(catalog.connection())?; Ok(( " AND i.id IN (SELECT image_id FROM album_exports WHERE album_id = ?)".to_string(), vec![rusqlite::types::Value::Integer(album.0 as i64)], )) } } } /// Earliest and latest capture time within `scope`, honouring the filter. /// /// The timeline's extent. Taken over the same images the histogram counts, so /// opening a collection shows that collection's years rather than the whole /// library's — the axis was previously spanning everything, which left a /// collection's bars crushed into a sliver of it. pub fn span_scoped( catalog: &Catalog, scope: Option, filter: &RatingFilter, ) -> Option<(i64, i64)> { let (clause, params) = scope_clause(catalog, scope).ok()?; // Full extent, not the chosen range — see `without_date_range`. let rated = filter.without_date_range().sql(); let sql = format!( "SELECT min(i.captured_at), max(i.captured_at) FROM images i WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause}" ); catalog .connection() .query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| { Ok((r.get::<_, Option>(0)?, r.get::<_, Option>(1)?)) }) .ok() .and_then(|(lo, hi)| Some((lo?, hi?))) } /// TRACES: FR-CAT-6 /// The capture-time histogram as a fixed number of equal bins across /// `from..=to`, empty ones included. /// /// # Why not calendar buckets /// /// [`timeline_scoped`] groups by year, month, day or hour, which has two /// consequences the axis cannot live with. /// /// It emits **only the buckets that hold photographs**, and the widget gives /// every bar an equal slot — so a library with a gap in it drew a February /// that was six months wide. The position marker, the range band and a click /// are all linear in time, so on a sparse library they pointed at bars that /// were somewhere else. Equal bins including the empty ones make a bar's /// position on the track and the date under it the same quantity. /// /// And the **count is free to jump by a factor of twelve** between one unit /// and the next, so each zoom step halved the number of bars until a /// threshold was crossed: zooming in made the picture coarser, twice out of /// every three steps. A fixed count re-bins on every zoom instead, which is /// what makes each step show finer structure rather than the same structure /// drawn wider. /// /// The date range is lifted from the filter, like the bars' other terms are /// not: this histogram is *how a range is chosen*, and drawing through the /// range would empty every bin outside it and leave nothing to widen into. /// /// `bins` is clamped to at least one — a zero would be a division by zero in /// SQL, and the caller's number comes from a hand-editable settings file. pub fn timeline_uniform( catalog: &Catalog, scope: Option, filter: &RatingFilter, from: i64, to: i64, bins: u32, ) -> Result, dr_catalog::CatalogError> { let bins = bins.max(1) as i64; // At least one second, or every photograph lands in bin zero. let span = (to - from).max(1); let (clause, params) = scope_clause(catalog, scope)?; let rated = filter.without_date_range().sql(); // The bin index is arithmetic on the stored UTC instant, not `strftime` on // a local one. A bin is not a calendar unit — it has no local midnight to // respect — and the axis it is drawn on is labelled from the same UTC // instants, so bucketing the two differently is the one way they could // disagree about which bar a photograph belongs to. // // `min` caps the last edge: an image captured at exactly `to` divides to // `bins`, which would be a bin past the end of the axis. // // Integers this code owns, formatted straight in — the same rule the // rating terms follow. They cannot be bound parameters here without // ordering them against the scope clause's, which appears later in the // text but is bound first. let sql = format!( "SELECT min({bins} - 1, (i.captured_at - {from}) * {bins} / {span}) AS b, count(*) AS n FROM images i WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause} AND i.captured_at >= {from} AND i.captured_at <= {to} GROUP BY b ORDER BY b ASC" ); let conn = catalog.connection(); let mut stmt = conn.prepare(&sql)?; let counted = stmt .query_map(rusqlite::params_from_iter(params.iter()), |r| { Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)? as u32)) })? .collect::, _>>()?; // Every bin, in order, whether or not the query returned one for it. The // start is the bin's own left edge rather than the earliest photograph in // it: an empty bin has no photograph to take one from, and a bar drawn at // its contents' position rather than its bin's would put the axis back // where the calendar buckets left it. let mut bars: Vec = (0..bins) .map(|i| dr_catalog::TimeBucket { start: from + (i * span) / bins, count: 0, }) .collect(); for (i, n) in counted { if let Some(bar) = bars.get_mut(i.clamp(0, bins - 1) as usize) { bar.count = n; } } Ok(bars) } /// Total images in the catalog, honouring the rating filter. pub(super) fn total_images_filtered( catalog: &Catalog, filter: &RatingFilter, ) -> Result { let rated = filter.sql(); // Everything the filter keeps, less the frames a collapsed burst stands // in for -- rather than `uncollapsed` asked of every image. Paid on // every scroll reload: the predicate probed `burst_members` and // `burst_expanded` once per visible image, 19,000 times on the reference // library, to hide none. The subtraction walks only the burst members. let hidden = dr_catalog::bursts::collapsed_away_frames("i"); let n: i64 = catalog.connection().query_row( &format!( "SELECT (SELECT count(*) FROM images i WHERE {VISIBLE}{rated}) - (SELECT count(*) FROM {hidden} AND {VISIBLE}{rated})" ), [], |r| r.get(0), )?; Ok(n as usize) } /// TRACES: FR-CAT-9 /// How many visible images have their original stored on this device. /// /// Whole-library, like the star counts beside it: the chip says what narrowing /// to it would show, so counting only the current window would make it /// describe the view it exists to change. /// /// Driven from `image_cache`, which holds a row only for what has been /// fetched: an `IN` list the planner walks, probing `images` by id for each. /// Spelled as a correlated `EXISTS` it walked every visible image and /// probed the cache for each -- 19,000 probes to find 254 originals on the /// reference library, 2 ms paid on every star keystroke beside the rating /// counts. `image_id` is the cache's key, so the list holds each image once /// and the count is the one the `EXISTS` gave. pub fn local_original_count(catalog: &Catalog) -> Result { let n: i64 = catalog.connection().query_row( &format!( "SELECT count(*) FROM images i WHERE {VISIBLE} AND i.id IN (SELECT ic.image_id FROM image_cache ic WHERE ic.tier_actual >= {})", dr_types::Tier::Original.stored() ), [], |r| r.get(0), )?; Ok(n as usize) } #[cfg(test)] mod tests { use super::*; use crate::library::test_support::*; #[test] fn an_album_scopes_the_grid_to_what_was_exported_into_it() { use dr_catalog::albums::{self, Place}; let catalog = with_images(10); let ids = image_ids(&catalog); let album = albums::create(catalog.connection(), "Web", &Place::Local("/out".into())) .unwrap(); // Two files from one photograph, one from another: two cells. albums::record_exports( catalog.connection(), album, &[ (ids[1], "a.jpg".into()), (ids[1], "a-crop.jpg".into()), (ids[7], "b.jpg".into()), ], ) .unwrap(); let scope = Some(Scope::Album(album)); let cells = read_cells_scoped(&catalog, scope, &RatingFilter::default(), 0, 120).unwrap(); assert_eq!(cells.len(), 2); assert_eq!( total_images_scoped(&catalog, scope, &RatingFilter::default()).unwrap(), 2 ); assert_eq!( read_ids_span(&catalog, scope, &RatingFilter::default(), false, 0, 1) .unwrap() .len(), 2, "a shift-click over the album selects what the grid drew" ); } #[test] fn a_scoped_grid_shows_only_that_collections_images() { use dr_catalog::collections::{self as coll, CollectionKind}; let catalog = with_images(10); let ids = image_ids(&catalog); let c = coll::create( catalog.connection(), "Selects", None, CollectionKind::Manual, ) .unwrap(); coll::add_images(catalog.connection(), c, &ids[2..5]).unwrap(); let cells = read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 0, 120).unwrap(); assert_eq!(cells.len(), 3); assert_eq!( total_images_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default()).unwrap(), 3 ); // Unscoped is still the whole library. assert_eq!( total_images_scoped(&catalog, None, &RatingFilter::default()).unwrap(), 10 ); } #[test] fn a_collection_set_shows_its_childrens_images() { // A parent whose children hold everything must not read as empty — // that is what makes nesting look broken. use dr_catalog::collections::{self as coll, CollectionKind}; let catalog = with_images(10); let ids = image_ids(&catalog); let trips = coll::create(catalog.connection(), "Trips", None, CollectionKind::Manual).unwrap(); let iceland = coll::create( catalog.connection(), "Iceland", Some(trips), CollectionKind::Manual, ) .unwrap(); coll::add_images(catalog.connection(), iceland, &ids[0..4]).unwrap(); // The parent itself has no direct members at all. let cells = read_cells_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default(), 0, 120).unwrap(); assert_eq!(cells.len(), 4, "the set shows what its children hold"); assert_eq!( total_images_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default()).unwrap(), 4 ); } #[test] fn an_image_in_both_a_parent_and_a_child_is_shown_once() { // The count and the number of cells drawn must agree, or neither is // believable. use dr_catalog::collections::{self as coll, CollectionKind}; let catalog = with_images(10); let ids = image_ids(&catalog); let trips = coll::create(catalog.connection(), "Trips", None, CollectionKind::Manual).unwrap(); let iceland = coll::create( catalog.connection(), "Iceland", Some(trips), CollectionKind::Manual, ) .unwrap(); coll::add_images(catalog.connection(), trips, &ids[0..2]).unwrap(); coll::add_images(catalog.connection(), iceland, &ids[0..3]).unwrap(); let cells = read_cells_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default(), 0, 120).unwrap(); assert_eq!(cells.len(), 3, "images 0..3, each once"); assert_eq!( total_images_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default()).unwrap(), 3 ); } #[test] fn a_scoped_window_still_pages() { // FR-CAT-4 applies inside a collection too: a 5,000-image collection // must not become 5,000 rows. use dr_catalog::collections::{self as coll, CollectionKind}; let catalog = with_images(30); let ids = image_ids(&catalog); let c = coll::create(catalog.connection(), "Big", None, CollectionKind::Manual).unwrap(); coll::add_images(catalog.connection(), c, &ids).unwrap(); let page = read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 10, 5).unwrap(); assert_eq!(page.len(), 5); assert_eq!(page[0].name, "img010.CR2"); } #[test] fn an_empty_collection_reads_as_empty_rather_than_as_the_whole_library() { // The failure that would make scoping useless: an empty IN-list // matching everything. use dr_catalog::collections::{self as coll, CollectionKind}; let catalog = with_images(10); let c = coll::create(catalog.connection(), "Empty", None, CollectionKind::Manual).unwrap(); assert!( read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 0, 120) .unwrap() .is_empty() ); assert_eq!( total_images_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default()).unwrap(), 0 ); } // --- manual order within a collection (FR-CAT-7) ------------------------ fn ids(n: &[u64]) -> Vec { n.iter().copied().map(dr_types::ImageId).collect() } #[test] fn a_run_moved_forward_lands_before_the_photograph_it_was_dropped_on() { let current = ids(&[1, 2, 3, 4, 5]); assert_eq!( reordered(¤t, &ids(&[4]), dr_types::ImageId(2), false), ids(&[1, 4, 2, 3, 5]) ); } #[test] fn a_run_moved_backward_lands_before_it_too() { // The direction of travel must not change what "before this one" means, // or the same drop would land in two different places depending on // where the photograph came from. let current = ids(&[1, 2, 3, 4, 5]); assert_eq!( reordered(¤t, &ids(&[2]), dr_types::ImageId(5), false), ids(&[1, 3, 4, 2, 5]) ); } #[test] fn the_trailing_half_of_the_last_cell_is_how_the_end_is_reached() { // There is no cell beyond the last one to drop in front of, so without // `after` the final position is unreachable — which is exactly the // place a "put this at the end" drag is aiming for. let current = ids(&[1, 2, 3]); assert_eq!( reordered(¤t, &ids(&[1]), dr_types::ImageId(3), true), ids(&[2, 3, 1]) ); } #[test] fn a_moved_run_keeps_the_order_the_grid_shows_it_in() { // Not the order the selection was built in. A user who tapped the last // frame first has said nothing about how the run should be arranged — // only about where it should go. let current = ids(&[1, 2, 3, 4, 5]); assert_eq!( reordered(¤t, &ids(&[5, 1]), dr_types::ImageId(3), false), ids(&[2, 1, 5, 3, 4]) ); } #[test] fn a_run_dropped_on_one_of_its_own_members_stays_together() { // The caller refuses this drop, so it is only reachable if that guard // is ever lost. It must not lose photographs when it is. let current = ids(&[1, 2, 3, 4]); let moved = reordered(¤t, &ids(&[2, 3]), dr_types::ImageId(3), false); assert_eq!(moved.len(), current.len(), "nothing was dropped"); let mut sorted = moved.clone(); sorted.sort(); assert_eq!(sorted, ids(&[1, 2, 3, 4]), "and nothing was invented"); } #[test] fn a_reorder_never_loses_or_duplicates_a_member() { // The property that matters most: this writes the whole membership // back, so a run that dropped one image would delete it from the // collection. let current = ids(&[1, 2, 3, 4, 5, 6]); for target in [1u64, 2, 3, 4, 5, 6] { for after in [false, true] { let moved = reordered(¤t, &ids(&[2, 5]), dr_types::ImageId(target), after); let mut sorted = moved.clone(); sorted.sort(); assert_eq!( sorted, ids(&[1, 2, 3, 4, 5, 6]), "target {target}, after {after}" ); } } } /// The scoped grid and the range a shift-click resolves are two halves of /// one ordering. This is the assertion that keeps them one: an ordinal read /// through a different ORDER BY names a different photograph, and the user /// finds out when the export runs. #[test] fn a_manual_collection_is_read_and_spanned_in_the_order_it_was_given() { let catalog = with_images(5); let all = image_ids(&catalog); let id = dr_catalog::collections::create( catalog.connection(), "Trip", None, dr_catalog::collections::CollectionKind::Manual, ) .unwrap(); dr_catalog::collections::add_images(catalog.connection(), id, &all).unwrap(); // Reversed, so position and capture time disagree about everything. let wanted: Vec<_> = all.iter().rev().copied().collect(); dr_catalog::collections::set_order(catalog.connection(), id, &wanted).unwrap(); let cells = read_cells_scoped(&catalog, Some(Scope::Collection(id)), &RatingFilter::default(), 0, 50).unwrap(); let drawn: Vec<_> = cells .iter() .map(|c| dr_types::ImageId(c.image_id as u64)) .collect(); assert_eq!(drawn, wanted, "the grid draws the order that was written"); let spanned = read_ids_span(&catalog, Some(Scope::Collection(id)), &RatingFilter::default(), false, 0, 4).unwrap(); assert_eq!(spanned, wanted, "and a range resolves through the same one"); assert_eq!( read_member_order(&catalog, id).unwrap(), wanted, "and so does the membership a reorder rewrites" ); } #[test] fn a_collection_with_children_falls_back_to_capture_time() { // A set draws its descendants' images too, and two children's positions // are unrelated integers. Ordering by them interleaves the two // arbitrarily, which is worse than an order that at least means // something. let catalog = with_images(4); let all = image_ids(&catalog); let parent = dr_catalog::collections::create( catalog.connection(), "Iceland", None, dr_catalog::collections::CollectionKind::Manual, ) .unwrap(); dr_catalog::collections::create( catalog.connection(), "Day one", Some(parent), dr_catalog::collections::CollectionKind::Manual, ) .unwrap(); dr_catalog::collections::add_images(catalog.connection(), parent, &all).unwrap(); let reversed: Vec<_> = all.iter().rev().copied().collect(); dr_catalog::collections::set_order(catalog.connection(), parent, &reversed).unwrap(); let cells = read_cells_scoped(&catalog, Some(Scope::Collection(parent)), &RatingFilter::default(), 0, 50).unwrap(); let drawn: Vec<_> = cells .iter() .map(|c| dr_types::ImageId(c.image_id as u64)) .collect(); assert_eq!(drawn, all, "capture time, not the positions that were set"); } #[test] fn a_smart_collection_has_no_manual_order_to_read() { // No member rows at all, so `position` is not a column any of its // images have. Falling back is the only thing there is to do. let catalog = with_images(3); let id = dr_catalog::collections::create( catalog.connection(), "Picks", None, dr_catalog::collections::CollectionKind::Smart, ) .unwrap(); let (order, params) = grid_order_for(&catalog, Some(Scope::Collection(id))); assert_eq!(order, GRID_ORDER); assert!(params.is_empty()); } #[test] fn a_date_range_narrows_the_grid_and_the_count_together() { // The whole reason the range lives on `RatingFilter`: every query path // threads that one struct, so the header cannot claim a total the grid // does not draw. let catalog = scanned(3); let conn = catalog.connection(); for (n, at) in [(1, 1_000), (2, 5_000), (3, 9_000)] { conn.execute( "UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1", rusqlite::params![format!("%IMG_000{n}%"), at], ) .unwrap(); } let ranged = RatingFilter { captured_from: Some(4_000), captured_to: Some(6_000), ..Default::default() }; assert_eq!(read_cells_all(&catalog, &ranged, 0, 50).unwrap().len(), 1); assert_eq!(total_images_filtered(&catalog, &ranged).unwrap(), 1); } #[test] fn an_undated_image_is_not_shown_inside_a_date_range() { // It cannot be in or out of a span. Drawing it anyway makes a range the // user just chose look as though it had not applied. let catalog = scanned(2); catalog .connection() .execute("UPDATE images SET captured_at = NULL", []) .unwrap(); let ranged = RatingFilter { captured_from: Some(0), captured_to: Some(i64::MAX), ..Default::default() }; assert!(read_cells_all(&catalog, &ranged, 0, 50).unwrap().is_empty()); } #[test] fn a_span_reads_the_whole_run_whether_or_not_it_is_loaded() { // The shift-click this exists for. The grid holds a window of five and // the user names a run of twelve, so seven of them have no cell and no // id anywhere in the UI — but they are still what was asked for, and // the catalog is what knows them. let catalog = scanned(12); let filter = RatingFilter::default(); let loaded = read_cells_all(&catalog, &filter, 0, 5).unwrap(); assert_eq!(loaded.len(), 5, "the window is smaller than the run"); let whole: Vec<_> = read_cells_all(&catalog, &filter, 0, 50) .unwrap() .iter() .map(|c| dr_types::ImageId(c.image_id as u64)) .collect(); let span = read_ids_span(&catalog, None, &filter, false, 0, 11).unwrap(); assert_eq!(span.len(), 12); assert_eq!(span, whole, "the run is the grid's own list, in its order"); } #[test] fn a_span_starts_and_ends_where_it_was_asked_to() { // Ordinals index the grid's list, so a run has to be exactly the slice // of it the two ends name — one off at either end selects a // photograph the user did not point at. let catalog = scanned(12); let filter = RatingFilter::default(); let whole: Vec<_> = read_cells_all(&catalog, &filter, 0, 50) .unwrap() .iter() .map(|c| dr_types::ImageId(c.image_id as u64)) .collect(); let span = read_ids_span(&catalog, None, &filter, false, 4, 6).unwrap(); assert_eq!(span, whole[4..=6], "ordinals 4..=6, inclusive at both ends"); } #[test] fn a_span_is_ordered_by_capture_time_rather_than_by_name() { // A card written by two cameras interleaves names that have nothing to // do with each other. What a photographer means by "everything between // these two" is a stretch of an afternoon, so the run has to be taken // through capture time — the ordering the grid draws them in. let catalog = scanned(3); let conn = catalog.connection(); for (n, at) in [(1, 9_000), (2, 5_000), (3, 1_000)] { conn.execute( "UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1", rusqlite::params![format!("%IMG_000{n}%"), at], ) .unwrap(); } let span = read_ids_span(&catalog, None, &RatingFilter::default(), false, 0, 2).unwrap(); let names: Vec = span .iter() .map(|id| { conn.query_row( "SELECT source_ref FROM images WHERE id = ?1", [id.0 as i64], |r| r.get::<_, String>(0), ) .unwrap() }) .collect(); assert!( names[0].ends_with("IMG_0003.CR2") && names[1].ends_with("IMG_0002.CR2") && names[2].ends_with("IMG_0001.CR2"), "earliest first, which here is the reverse of the file names: {names:?}" ); } #[test] fn the_histogram_ignores_the_range_it_is_used_to_choose() { // Drawing the axis through the chosen range would collapse it onto the // selection, leaving nowhere to widen back out from. let catalog = dated_three(); let ranged = RatingFilter { captured_from: Some(4_000), captured_to: Some(6_000), ..Default::default() }; assert_eq!( span_scoped(&catalog, None, &ranged), Some((1_000, 9_000)), "the axis must keep describing the whole extent" ); } /// Three photographs at 1000, 5000 and 9000 seconds. fn dated_three() -> Catalog { let catalog = scanned(3); let conn = catalog.connection(); for (n, at) in [(1, 1_000), (2, 5_000), (3, 9_000)] { conn.execute( "UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1", rusqlite::params![format!("%IMG_000{n}%"), at], ) .unwrap(); } catalog } #[test] fn the_histogram_has_the_number_of_bins_it_was_asked_for() { // Fixed, whatever the span holds. The axis draws one bar per bin and // positions it by index, so a query that returned only the occupied // ones would put the bars at the wrong dates. let catalog = dated_three(); let filter = RatingFilter::default(); for bins in [1_u32, 8, 32, 64] { let bars = timeline_uniform(&catalog, None, &filter, 1_000, 9_000, bins).unwrap(); assert_eq!(bars.len() as u32, bins); assert_eq!( bars.iter().map(|b| b.count).sum::(), 3, "every photograph is counted exactly once" ); } } #[test] fn a_bin_starts_where_the_axis_says_it_does() { // The bar's start is its bin's left edge, not the earliest photograph // in it. It is what the position marker and the range band are drawn // against, and an empty bin has no photograph to borrow a date from. let catalog = dated_three(); let bars = timeline_uniform(&catalog, None, &RatingFilter::default(), 0, 8_000, 8).unwrap(); for (i, bar) in bars.iter().enumerate() { assert_eq!(bar.start, i as i64 * 1_000); } // 1000 and 5000 land in their own bins, 9000 is past the end. assert_eq!(bars[1].count, 1); assert_eq!(bars[5].count, 1); assert_eq!(bars.iter().map(|b| b.count).sum::(), 2); } #[test] fn the_last_bin_holds_a_photograph_taken_at_the_very_end() { // The division puts an image captured at exactly `to` one bin past the // axis. Uncapped it would be dropped from the histogram — and it is // precisely the image that defines the extent, so it would go missing // on every unzoomed library. let catalog = dated_three(); let bars = timeline_uniform(&catalog, None, &RatingFilter::default(), 1_000, 9_000, 4).unwrap(); assert_eq!(bars.len(), 4); assert_eq!(bars[3].count, 1, "the image at 9000 is in the last bin"); assert_eq!(bars[0].count, 1); } #[test] fn the_bins_ignore_the_range_they_are_used_to_choose() { // Same rule as the extent: the bars outside the band are what the // range is widened back into, so counting through the range would // leave every one of them empty. let catalog = dated_three(); let ranged = RatingFilter { captured_from: Some(4_000), captured_to: Some(6_000), ..Default::default() }; let bars = timeline_uniform(&catalog, None, &ranged, 1_000, 9_000, 4).unwrap(); assert_eq!( bars.iter().map(|b| b.count).sum::(), 3, "all three, not just the one inside the range" ); } #[test] fn the_bins_still_honour_every_other_filter() { // A histogram of the five-star frames is a fair question, and the bars // have to agree with the grid beneath them. let catalog = dated_three(); let strict = RatingFilter { min_rating: 4, ..Default::default() }; let bars = timeline_uniform(&catalog, None, &strict, 1_000, 9_000, 4).unwrap(); assert_eq!(bars.len(), 4, "the axis keeps its shape"); assert_eq!( bars.iter().map(|b| b.count).sum::(), 0, "nothing here is rated" ); } }