library.rs was 7,729 lines wiring together everything "open a remote library" touches: scanning, pulling other devices' judgements out of sidecars found along the way, writing local edits back out to the sidecar outbox, pushing/reloading XMP by hand, fetching and prefetching thumbnails and originals, generating thumbnails locally, the metadata and thumbnail background sweeps, on-disk paths for the catalog and model files, and reading the grid's cells, spans and rating filter. Same motivation as the develop.rs split (docs/dev/code-health.md CH-1): a pure, no-behaviour-change move into one file per area, each under about 1,500 lines. Tracing actual call sites rather than trusting the file's physical layout mattered here: `persist`, `load_folder_etags`, `pull_sidecars`, `load_sidecar_etags`, `record_sidecar_read` and `apply_judgement` sit textually beside the XMP push/reload functions but are called only from `run_scan` (pulling a device's own past judgements out of the sidecars a scan just walked), so they went to scan.rs and not xmp.rs. `cells` came out at over 1,800 lines once its tests moved with it and split further into cells.rs (windowed reads, trash, ordinals) and spans.rs (collection scope, manual reordering, the capture-time histogram) -- ten submodules rather than the nine first planned. Previously-private items reached from a sibling module became `pub(super)`, narrower than the whole-crate reachability one file gave them. Tests moved with the code they test; the two test fixtures used across more than one file (`scanned`, and develop.rs's `session_with_a_left_half_subject` in the matching commit) joined the shared `test_support` module alongside the existing `entry`/ `with_images`/`image_ids` helpers. `mod.rs` re-exports every module's public items under `library::`, including the `pub(crate)` `test_support` module `repairs.rs` reads its fixtures from, so no file outside `library` needed a change. The previous commit split develop.rs the same way; taken alone it left dr-ui without library.rs, so that intermediate commit does not build on its own. This one restores it.
918 lines
36 KiB
Rust
918 lines
36 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::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<dr_types::CollectionId>,
|
|
filter: &RatingFilter,
|
|
) -> Result<usize, dr_catalog::CatalogError> {
|
|
let Some(scope) = scope else {
|
|
return total_images_filtered(catalog, filter);
|
|
};
|
|
|
|
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
|
|
let placeholders = std::iter::repeat_n("?", ids.len())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
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 `collection_members` 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}
|
|
AND i.id IN (SELECT image_id FROM collection_members
|
|
WHERE collection_id IN ({placeholders}))"
|
|
);
|
|
let params: Vec<rusqlite::types::Value> = ids
|
|
.iter()
|
|
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
|
.collect();
|
|
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<dr_types::CollectionId>,
|
|
) -> (String, Vec<rusqlite::types::Value>) {
|
|
let Some(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` and its descendants, with the bound
|
|
/// parameters to go with it.
|
|
///
|
|
/// Shared by the span and the histogram so the two cannot drift: an axis drawn
|
|
/// over one set of images and bars counted over another puts the bars in the
|
|
/// wrong place.
|
|
pub(super) fn scope_clause(
|
|
catalog: &Catalog,
|
|
scope: Option<dr_types::CollectionId>,
|
|
) -> Result<(String, Vec<rusqlite::types::Value>), dr_catalog::CatalogError> {
|
|
let Some(scope) = scope else {
|
|
return Ok((String::new(), Vec::new()));
|
|
};
|
|
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
|
|
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(),
|
|
))
|
|
}
|
|
|
|
/// 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<dr_types::CollectionId>,
|
|
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<dr_types::CollectionId>,
|
|
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();
|
|
let folded = uncollapsed("i");
|
|
let n: i64 = catalog.connection().query_row(
|
|
&format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}{folded}"),
|
|
[],
|
|
|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.
|
|
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 EXISTS (SELECT 1 FROM image_cache ic
|
|
WHERE ic.image_id = i.id AND 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 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(c), &RatingFilter::default(), 0, 120).unwrap();
|
|
assert_eq!(cells.len(), 3);
|
|
assert_eq!(
|
|
total_images_scoped(&catalog, Some(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(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(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(trips), &RatingFilter::default(), 0, 120).unwrap();
|
|
assert_eq!(cells.len(), 3, "images 0..3, each once");
|
|
assert_eq!(
|
|
total_images_scoped(&catalog, Some(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(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(c), &RatingFilter::default(), 0, 120)
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
assert_eq!(
|
|
total_images_scoped(&catalog, Some(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(¤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(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(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(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(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"
|
|
);
|
|
}
|
|
}
|