Files
DarkRoom/ui/dr-ui/src/library/spans.rs
T
dtourolle 9b580c3720 Satisfy rustfmt and clippy on the album and folder picker changes
rustfmt over the files the albums work touched, and the album merge's
incoming row as a named struct rather than an eight-field tuple, which
clippy's type_complexity refused.
2026-09-26 14:13:54 -04:00

1049 lines
40 KiB
Rust

//! 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<Scope>,
filter: &RatingFilter,
) -> Result<usize, dr_catalog::CatalogError> {
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<Vec<dr_types::ImageId>, 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::<Result<Vec<_>, _>>()?;
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<dr_types::ImageId> {
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<Scope>,
) -> (String, Vec<rusqlite::types::Value>) {
// 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<Scope>,
) -> Result<(String, Vec<rusqlite::types::Value>), 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::<Vec<_>>()
.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<Scope>,
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<i64>>(0)?, r.get::<_, Option<i64>>(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<Scope>,
filter: &RatingFilter,
from: i64,
to: i64,
bins: u32,
) -> Result<Vec<dr_catalog::TimeBucket>, 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::<Result<Vec<_>, _>>()?;
// 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<dr_catalog::TimeBucket> = (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<usize, dr_catalog::CatalogError> {
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<usize, dr_catalog::CatalogError> {
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<dr_types::ImageId> {
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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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<String> = 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::<u32>(),
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::<u32>(), 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::<u32>(),
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::<u32>(),
0,
"nothing here is rated"
);
}
}