Files
DarkRoom/ui/dr-ui/src/library/cells.rs
T
dtourolle 33779a70bd Answer a thumbnail miss with the other stored class before the network
Offline, a grid zoomed past 256px was blank wherever it had not been
zoomed over before. The store was asked only for the exact class the cell
wanted, and the sweep stores only the grid class, so every zoomed cell
missed and went to a server that was not there — with its 256px thumbnail
sitting in the store the whole time. Online it cost the same round trip,
just without the blank cell at the end of it.

The split now tries the other class on a miss. A smaller one stands in
and the fetch for the real class still goes out; a larger one answers
the request outright, since there is nothing a fetch would improve on.
`ThumbnailReady` carries the class its pixels are, and the drain records
that rather than the batch's class, so a stand-in is replaced on the next
reload instead of being counted as served. `already_served` counts a
held large thumbnail as serving the grid class too, so zooming back out
does not re-read the store for pixels already on screen.

The split moves into `split_by_store` so it can be tested without a
worker thread or a network.
2026-10-03 16:44:58 -04:00

1276 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Reading the grid's cells: the windowed read a scroll position asks
//! for, trash listing, and the ordinal a single photograph sits at.
use dr_catalog::Catalog;
use super::Scope;
#[cfg(test)]
use dr_sync::RemotePath;
#[cfg(test)]
use dr_thumbs::ThumbStore;
use super::filters::{RatingFilter, GRID_ORDER, TRASHED, TRASH_ORDER, VISIBLE, VISIBLE_UNALIASED};
#[cfg(test)]
use super::scan::persist;
#[cfg(test)]
use super::spans::total_images_filtered;
use super::spans::{grid_order_for, scope_clause};
#[cfg(test)]
use super::thumbnails_gen::ThumbnailReady;
/// TRACES: FR-CULL-5
/// The clause that hides the frames a collapsed burst is standing in for.
///
/// Subject to exactly the discipline [`VISIBLE`] is under, and for the same
/// reason: the header's count, the scrollbar's size, the run a shift-click
/// resolves and the ordinal a scrub lands on are four answers about one list.
/// A burst folded away in the cells but still counted in the total would leave
/// the grid ending in rows that draw nothing, with no clue why.
///
/// The predicate itself is `dr_catalog::bursts`'s, not this file's, so the
/// interface and the pass that writes the table cannot come to disagree about
/// what collapsed means.
///
/// A function rather than a constant because it has to name the image table,
/// and the grid aliases it as `i` where the timeline's queries do not. `image`
/// is a table name from this file and never anything a user supplied.
pub(super) fn uncollapsed(image: &str) -> String {
format!(" AND {}", dr_catalog::bursts::not_collapsed_away(image))
}
/// One grid cell's data, read from the catalog.
#[derive(Debug, Clone, PartialEq)]
pub struct LibraryCell {
pub image_id: i64,
pub name: String,
pub remote_path: String,
/// `oc:fileid`, the key the shared thumbnail store uses. `None` for an
/// image the scan found without a stable id.
pub file_id: Option<u64>,
/// File length, for bounds-checking a located preview range.
pub size: u64,
/// 0 = nothing, 1 = stat-only, 2 = full EXIF.
pub metadata_state: u8,
/// UTC seconds, once EXIF has been read.
pub captured_at: Option<i64>,
/// TRACES: FR-MRG-6
/// Width over height, where the catalog knows both: what decides whether
/// the cell spans columns. Read with the window, never per cell.
pub aspect: Option<f32>,
}
/// Read a window of cells out of the catalog.
///
/// Windowed rather than wholesale: a 17k-image library must not become 17k
/// rows in a Slint model (FR-CAT-4).
/// The unscoped form, kept as the name the tests and any future caller reach
/// for. The UI goes through [`read_cells_scoped`], because a collection may be
/// selected.
#[cfg(test)]
pub fn read_cells(
catalog: &Catalog,
offset: usize,
limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
read_cells_scoped(catalog, None, &RatingFilter::default(), offset, limit)
}
/// Read a window of cells, optionally narrowed to one collection.
///
/// A collection *set* shows its descendants' images too — a parent whose
/// children hold everything would otherwise read as empty, which makes nesting
/// look broken. The id list comes from
/// [`dr_catalog::collections::descendants`], which is depth-guarded.
///
/// Ordering comes from [`grid_order_for`]: manual position for a single manual
/// collection, capture time for a set — because position is only meaningful
/// inside one collection, and this query also serves sets, where two children's
/// positions are unrelated integers.
pub fn read_cells_scoped(
catalog: &Catalog,
scope: Option<Scope>,
filter: &RatingFilter,
offset: usize,
limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
if scope.is_none() {
return read_cells_all(catalog, filter, offset, limit);
}
let (clause, mut params) = scope_clause(catalog, scope)?;
let rated = filter.sql();
let folded = uncollapsed("i");
let (order, order_params) = grid_order_for(catalog, scope);
let sql = format!(
"SELECT {CELL_COLUMNS}
FROM images i
WHERE {VISIBLE}{rated}{folded}{clause}
{order}
LIMIT ? OFFSET ?"
);
// Bound in the order the `?`s appear: the scope's in the WHERE, then
// whatever the ORDER BY needs, then the window.
params.extend(order_params);
params.push(rusqlite::types::Value::Integer(limit as i64));
params.push(rusqlite::types::Value::Integer(offset as i64));
let mut rows = {
let mut stmt = catalog.connection().prepare(&sql)?;
let read = stmt
.query_map(rusqlite::params_from_iter(params.iter()), row_to_cell)?
.collect::<Result<Vec<_>, _>>()?;
read
};
attach_file_ids(catalog, &mut rows);
Ok(rows)
}
pub(super) fn read_cells_all(
catalog: &Catalog,
filter: &RatingFilter,
offset: usize,
limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
let rated = filter.sql();
let folded = uncollapsed("i");
let mut rows = {
let mut stmt = catalog.connection().prepare(&format!(
"SELECT {CELL_COLUMNS}
FROM images i
WHERE {VISIBLE}{rated}{folded}
{GRID_ORDER}
LIMIT ?1 OFFSET ?2"
))?;
let read = stmt
.query_map(rusqlite::params![limit as i64, offset as i64], row_to_cell)?
.collect::<Result<Vec<_>, _>>()?;
read
};
attach_file_ids(catalog, &mut rows);
Ok(rows)
}
/// TRACES: FR-CAT-15
/// Read a window of *trashed* cells, newest deletion first.
///
/// Ordered by when it was trashed rather than by capture time, which is what
/// every other view sorts by. The question in the trash is "what did I just
/// delete?", not "when was this taken" — a mistaken delete is corrected within
/// seconds, and burying it among photographs from the same afternoon would make
/// the one row the user is looking for the hardest one to find.
///
/// The rating filter is deliberately not applied. It narrows a *culling* pass,
/// and a trash that hid rows because of a filter set elsewhere would look like
/// it had lost them.
pub fn read_trashed_cells(
catalog: &Catalog,
offset: usize,
limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
let mut rows = {
let mut stmt = catalog.connection().prepare(&format!(
"SELECT {CELL_COLUMNS}
FROM images i
WHERE {TRASHED}
{TRASH_ORDER}
LIMIT ?1 OFFSET ?2"
))?;
let read = stmt
.query_map(rusqlite::params![limit as i64, offset as i64], row_to_cell)?
.collect::<Result<Vec<_>, _>>()?;
read
};
attach_file_ids(catalog, &mut rows);
Ok(rows)
}
/// TRACES: FR-CAT-15
/// How many images the trash view would list.
///
/// Counts exactly what [`read_trashed_cells`] lists — same predicate, no filter
/// — so the scrollbar and the header cannot disagree with the cells.
pub fn total_trashed(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogError> {
let n: i64 = catalog.connection().query_row(
&format!("SELECT count(*) FROM images i WHERE {TRASHED}"),
[],
|r| r.get(0),
)?;
Ok(n as usize)
}
/// TRACES: FR-CAT-5
/// The ids of grid rows `first..=last`, in the order the grid lists them.
///
/// What a shift-click actually means. The gesture names two *ordinals* and the
/// photographs between them are mostly not loaded — the grid is a window of a
/// hundred or so over a library of twenty thousand — so a range answered from
/// the window selected the handful on screen and silently dropped the rest.
/// The catalog knows the whole run, and with the ordering indexed it is one
/// seek rather than a scan.
///
/// Bounded by the same predicates, the same filter and the same [`GRID_ORDER`]
/// the window itself is read with. An ordinal only names a photograph relative
/// to an ordering, so a range taken through any other one is a range through a
/// different library.
///
/// `trash` picks the trash view's list, which is the other thing the grid can
/// be showing and is ordered by deletion time rather than capture time.
///
/// An empty result means the run is empty or the query failed; callers treat
/// the two the same, because both leave the selection where it was.
pub fn read_ids_span(
catalog: &Catalog,
scope: Option<Scope>,
filter: &RatingFilter,
trash: bool,
first: usize,
last: usize,
) -> Result<Vec<dr_types::ImageId>, dr_catalog::CatalogError> {
let Some(count) = (last + 1).checked_sub(first) else {
return Ok(Vec::new());
};
let (sql, mut params) = if trash {
(
format!("SELECT i.id FROM images i WHERE {TRASHED} {TRASH_ORDER} LIMIT ? OFFSET ?"),
Vec::new(),
)
} else {
let (clause, mut params) = scope_clause(catalog, scope)?;
let rated = filter.sql();
let folded = uncollapsed("i");
// The same ordering the cells were drawn with, from the same place.
// A range is a pair of ordinals, and an ordinal read through a
// different ORDER BY names a different photograph.
let (order, order_params) = grid_order_for(catalog, scope);
params.extend(order_params);
(
format!(
"SELECT i.id FROM images i
WHERE {VISIBLE}{rated}{folded}{clause}
{order}
LIMIT ? OFFSET ?"
),
params,
)
};
params.push(rusqlite::types::Value::Integer(count as i64));
params.push(rusqlite::types::Value::Integer(first as i64));
let mut stmt = catalog.connection().prepare(&sql)?;
let ids = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(ids)
}
/// TRACES: FR-UI-8
/// Where one photograph sits in the grid, by its remote path.
///
/// The inverse of [`read_ids_span`], and it exists for the same reason that one
/// does: an ordinal only names a photograph relative to an ordering, so it has
/// to be *computed* through the ordering the cells are drawn with rather than
/// guessed at. A restored place that landed on a row read through a different
/// `ORDER BY` would open the library at a photograph the user has never seen,
/// which looks exactly like the position having been forgotten.
///
/// # Why a window function rather than a count
///
/// The obvious implementation is "count the rows that sort before this one",
/// and it would need this file to spell the ordering out a second time — as an
/// inequality, with its own handling of the `captured_at IS NULL` term and its
/// own tie-break. [`grid_order_for`] already warns what a second spelling
/// costs, and a manually ordered collection makes it worse: that ordering is a
/// correlated subquery, and an inequality over it is not something anyone
/// should have to read.
///
/// `row_number() OVER ({order})` takes the ordering *verbatim* from the same
/// function the window read uses, so the two cannot disagree by construction.
/// It is a full pass over the scope rather than an index seek, which is the
/// cost of that guarantee — and it is paid once, at launch, against a query the
/// grid runs several times per screenful of scrolling.
///
/// `Ok(None)` means the path is not in this grid: deleted, trashed, filtered
/// out, or in a collection the place did not name. The caller falls back to
/// when the photograph was taken, which is what makes a place survive its
/// subject.
///
/// # Binding order
///
/// The window's parameters come first, unlike in [`read_ids_span`]. SQLite
/// binds anonymous `?` by their position **in the SQL text**, and here the
/// `OVER (...)` clause is in the select list — ahead of the `WHERE` the scope
/// narrows. Swapping the two silently looks up a collection by an image id.
pub fn ordinal_of_path(
catalog: &Catalog,
scope: Option<Scope>,
filter: &RatingFilter,
trash: bool,
path: &str,
) -> Result<Option<usize>, dr_catalog::CatalogError> {
let (inner, mut params) = if trash {
(
format!(
"SELECT i.source_ref AS sref,
row_number() OVER ({TRASH_ORDER}) - 1 AS ord
FROM images i
WHERE {TRASHED}"
),
Vec::new(),
)
} else {
let (clause, scope_params) = scope_clause(catalog, scope)?;
let rated = filter.sql();
let folded = uncollapsed("i");
let (order, mut params) = grid_order_for(catalog, scope);
// The window first, then the scope — see the note above.
params.extend(scope_params);
(
format!(
"SELECT i.source_ref AS sref,
row_number() OVER ({order}) - 1 AS ord
FROM images i
WHERE {VISIBLE}{rated}{folded}{clause}"
),
params,
)
};
params.push(rusqlite::types::Value::Text(path.to_string()));
let mut stmt = catalog
.connection()
.prepare(&format!("SELECT ord FROM ({inner}) WHERE sref = ?"))?;
let mut rows = stmt.query(rusqlite::params_from_iter(params.iter()))?;
match rows.next()? {
Some(r) => Ok(Some(r.get::<_, i64>(0)?.max(0) as usize)),
None => Ok(None),
}
}
/// TRACES: FR-MRG-6
/// Which rows of the catalog are wide enough to span columns, spelled as
/// [`crate::library_ui`]'s layout counts them: `w / h >= 1.9`, in integers
/// so the index below can be matched term for term. Change one, change the
/// other — `the_wide_predicate_is_the_layouts` holds them together.
pub(super) const WIDE: &str = "w * 10 >= h * 19";
/// Make sure the partial index of wide photographs exists.
///
/// Created on first use rather than by a migration: it holds only the
/// panoramas, it changes nothing an older build reads, and a schema bump
/// would make every older peer refuse this catalog's snapshot. Keyed on the
/// grid's ordering, so the planner can answer "is there any?" from it alone.
fn ensure_wide_index(catalog: &Catalog) -> Result<(), dr_catalog::CatalogError> {
catalog.connection().execute_batch(&format!(
"CREATE INDEX IF NOT EXISTS images_wide
ON images(captured_at, source_ref) WHERE {WIDE}"
))?;
Ok(())
}
/// TRACES: FR-MRG-6
/// The photographs in the grid's current list that are wide enough to span
/// columns: each one's ordinal and aspect, in order.
///
/// What the layout places them from. Answered from the small side first: a
/// library with no panorama in it — the reference library, and every one
/// until a merge writes a composite — is told so by one probe of the
/// partial index, and nothing else runs. Otherwise one pass over the list,
/// numbering it through the ordering the cells are read with, exactly as
/// [`ordinal_of_path`] does and for its reason; it runs when what the grid
/// lists changes, not per scroll.
pub fn wide_ordinals(
catalog: &Catalog,
scope: Option<Scope>,
filter: &RatingFilter,
trash: bool,
) -> Result<Vec<(usize, f32)>, dr_catalog::CatalogError> {
ensure_wide_index(catalog)?;
let any: bool = catalog.connection().query_row(
&format!("SELECT EXISTS(SELECT 1 FROM images WHERE {WIDE})"),
[],
|r| r.get(0),
)?;
if !any {
return Ok(Vec::new());
}
let (inner, params) = if trash {
(
format!(
"SELECT i.w AS w, i.h AS h,
row_number() OVER ({TRASH_ORDER}) - 1 AS ord
FROM images i
WHERE {TRASHED}"
),
Vec::new(),
)
} else {
let (clause, scope_params) = scope_clause(catalog, scope)?;
let rated = filter.sql();
let folded = uncollapsed("i");
let (order, mut params) = grid_order_for(catalog, scope);
// The window's parameters first — see `ordinal_of_path`.
params.extend(scope_params);
(
format!(
"SELECT i.w AS w, i.h AS h,
row_number() OVER ({order}) - 1 AS ord
FROM images i
WHERE {VISIBLE}{rated}{folded}{clause}"
),
params,
)
};
let mut stmt = catalog.connection().prepare(&format!(
"SELECT ord, w, h FROM ({inner}) WHERE {WIDE} ORDER BY ord"
))?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((
r.get::<_, i64>(0)?.max(0) as usize,
r.get::<_, i64>(1)? as f32 / r.get::<_, i64>(2)?.max(1) as f32,
))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// The columns every windowed read selects, in the order [`row_to_cell`] reads
/// them.
///
/// Named rather than repeated so the three readers cannot drift — and so that
/// the one column that is *not* here stays conspicuous. See
/// [`attach_file_ids`] for why the server's file id is fetched separately.
pub(super) const CELL_COLUMNS: &str =
"i.id, i.source_ref, i.file_size, i.metadata_state, i.captured_at, i.w, i.h";
/// Shared row mapping, so the scoped and unscoped queries cannot drift.
///
/// `file_id` is left empty here and filled by [`attach_file_ids`].
pub(super) fn row_to_cell(r: &rusqlite::Row) -> rusqlite::Result<LibraryCell> {
let path: String = r.get(1)?;
Ok(LibraryCell {
image_id: r.get(0)?,
name: path.rsplit(['/', ':']).next().unwrap_or(&path).to_string(),
remote_path: path,
file_id: None,
size: r.get::<_, Option<i64>>(2)?.unwrap_or(0) as u64,
metadata_state: r.get::<_, i64>(3)? as u8,
captured_at: r.get(4)?,
aspect: match (r.get::<_, Option<i64>>(5)?, r.get::<_, Option<i64>>(6)?) {
(Some(w), Some(h)) if w > 0 && h > 0 => Some(w as f32 / h as f32),
_ => None,
},
})
}
/// TRACES: NFR-P5
/// Fill in each cell's server file id, in one query for the whole window.
///
/// # Why this is not a `LEFT JOIN` any more
///
/// It was, and it was the single most expensive thing the grid did while a
/// finger was on it. A window is `ORDER BY ... LIMIT n OFFSET k`, and SQLite
/// answers a join like that by joining *first* and paging after — so reading
/// 280 cells at offset 20,000 meant an index seek into `remote` for all 24,000
/// rows, 23,720 of which were then discarded. Measured at 15.2 ms, inside the
/// scroll handler, against a 16.7 ms frame.
///
/// Paging over `images` alone is 0.36 ms with `images_grid_order` (schema V7),
/// and this fetches the ids for the 280 rows that survived. The same shape the
/// badge and rating reads already use: one query for the window, never one per
/// cell.
///
/// Silent on failure, and cells keep `file_id: None`: that is the same state a
/// photograph the scan has not reached the server for is in, and the callers
/// already treat it as "no cached thumbnail to key on" rather than an error.
pub(super) fn attach_file_ids(catalog: &Catalog, cells: &mut [LibraryCell]) {
if cells.is_empty() {
return;
}
let placeholders = std::iter::repeat_n("?", cells.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!("SELECT image_id, file_id FROM remote WHERE image_id IN ({placeholders})");
let params: Vec<rusqlite::types::Value> = cells
.iter()
.map(|c| rusqlite::types::Value::Integer(c.image_id))
.collect();
let mut stmt = match catalog.connection().prepare(&sql) {
Ok(s) => s,
Err(e) => {
log::debug!("reading file ids for the window: {e}");
return;
}
};
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, Option<i64>>(1)?))
});
let found: std::collections::HashMap<i64, Option<i64>> = match rows {
Ok(rows) => rows.flatten().collect(),
Err(e) => {
log::debug!("reading file ids for the window: {e}");
return;
}
};
for cell in cells {
cell.file_id = found
.get(&cell.image_id)
.copied()
.flatten()
.map(|v| v as u64);
}
}
/// Total images in the catalog, unfiltered.
///
/// What the scan reports and what the sidebar's "all images" row shows — the
/// size of the library itself, not of the current view.
pub fn total_images(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogError> {
let n: i64 = catalog.connection().query_row(
&format!("SELECT count(*) FROM images WHERE {VISIBLE_UNALIASED}"),
[],
|r| r.get(0),
)?;
Ok(n as usize)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::test_support::*;
use dr_sync::RemoteEntry;
#[test]
fn a_scrub_ordinal_matches_the_grid_position() {
// The scrub's count and the grid's window must use *identical*
// predicates and ordering, or the view lands somewhere else. Counting
// only dated images against a grid that also shows undated ones put a
// click near the end of the axis near the top of the library.
let catalog = Catalog::in_memory().unwrap();
let c = catalog.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
// A mix: dated, undated, and one shadowed by a RAW sibling.
for (id, name, captured, shadow) in [
(1i64, "a.CR2", Some(100i64), None),
(2, "b.CR2", Some(200), None),
(3, "b.JPG", Some(200), Some(2i64)),
(4, "c.CR2", Some(300), None),
(5, "d.CR2", None, None),
] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, shadowed_by, added_at)
VALUES (?1, 1, ?2, ?3, ?4, 0)",
rusqlite::params![id, name, captured, shadow],
)
.unwrap();
}
// The grid's own window, in its own order.
let cells = read_cells(&catalog, 0, 100).unwrap();
let names: Vec<&str> = cells.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
names,
vec!["a.CR2", "b.CR2", "c.CR2", "d.CR2"],
"shadowed hidden, undated last"
);
// Scrubbing to each image's instant must give its index in that list.
for (when, expected) in [(100i64, 0usize), (200, 1), (300, 2)] {
let ordinal: i64 = c
.query_row(
"SELECT count(*) FROM images
WHERE shadowed_by IS NULL
AND captured_at IS NOT NULL
AND captured_at < ?1",
[when],
|r| r.get(0),
)
.unwrap();
assert_eq!(
ordinal as usize, expected,
"scrubbing to {when} must land on grid row {expected}"
);
}
}
/// The fixture the ordinal tests share: a mix of dated, undated, shadowed
/// and trashed rows, which is what makes the grid's ordering non-obvious.
fn a_small_library() -> Catalog {
let catalog = Catalog::in_memory().unwrap();
let c = catalog.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
for (id, name, captured, shadow, trashed) in [
(1i64, "2019/a.CR2", Some(100i64), None, None),
(2, "2019/b.CR2", Some(200), None, None),
// Shadowed by its RAW sibling: never a row of the grid.
(3, "2019/b.JPG", Some(200), Some(2i64), None),
(4, "2020/c.CR2", Some(300), None, None),
// Undated sorts last, whatever its name.
(5, "2018/d.CR2", None, None, None),
// Trashed: out of the library, and the only row of the trash.
(6, "2020/e.CR2", Some(400), None, Some(9i64)),
] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, shadowed_by,
trashed_at, added_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, 0)",
rusqlite::params![id, name, captured, shadow, trashed],
)
.unwrap();
}
catalog
}
#[test]
fn every_cell_reports_the_ordinal_it_is_drawn_at() {
// TRACES: FR-UI-8
// The invariant the whole restore rests on, stated the strongest way
// there is: walk the window the grid actually draws and ask for each
// cell's ordinal by path. Anything less — a spot check, or a hand-built
// expectation — would pass while `ordinal_of_path` and `read_cells`
// disagreed about undated rows, shadowed rows or the tie-break, which
// is precisely where an ordering drifts.
let catalog = a_small_library();
let filter = RatingFilter::default();
let cells = read_cells(&catalog, 0, 100).unwrap();
assert_eq!(cells.len(), 4, "shadowed and trashed are not rows");
for (i, cell) in cells.iter().enumerate() {
assert_eq!(
ordinal_of_path(&catalog, None, &filter, false, &cell.remote_path).unwrap(),
Some(i),
"{} is drawn at row {i}",
cell.remote_path
);
}
}
#[test]
fn a_photograph_that_is_not_in_the_grid_reports_nothing() {
// Deleted, never scanned, or hidden behind its RAW. All three are the
// same answer, and the caller falls back to the capture time — which is
// only reachable if this says `None` rather than guessing.
let catalog = a_small_library();
let filter = RatingFilter::default();
assert_eq!(
ordinal_of_path(&catalog, None, &filter, false, "2019/gone.CR2").unwrap(),
None,
"never heard of it"
);
assert_eq!(
ordinal_of_path(&catalog, None, &filter, false, "2019/b.JPG").unwrap(),
None,
"shadowed by its RAW"
);
assert_eq!(
ordinal_of_path(&catalog, None, &filter, false, "2020/e.CR2").unwrap(),
None,
"trashed"
);
}
#[test]
fn the_trash_is_ordinalled_through_its_own_list() {
// The trash orders by deletion time and lists exactly what the library
// excludes, so an ordinal taken through the library's ordering would
// name a different photograph — or, here, nothing at all.
let catalog = a_small_library();
let filter = RatingFilter::default();
assert_eq!(
ordinal_of_path(&catalog, None, &filter, true, "2020/e.CR2").unwrap(),
Some(0)
);
assert_eq!(
ordinal_of_path(&catalog, None, &filter, true, "2019/a.CR2").unwrap(),
None,
"a live photograph is not in the trash"
);
}
#[test]
fn a_filter_moves_the_ordinal_with_the_grid() {
// TRACES: FR-UI-8
// A place carries the filter it was recorded under *and* the position,
// and the second is only meaningful under the first. Restoring them in
// the wrong order — position, then filter — would land on a row of a
// list that no longer exists, which is the bug this pairing exists to
// rule out.
let catalog = a_small_library();
let c = catalog.connection();
// Three stars on the third photograph only.
c.execute(
"INSERT INTO versions(id, image_id, uuid, name, is_default, rating)
VALUES (1, 4, 'u4', 'default', 1, 3)",
[],
)
.unwrap();
let unfiltered = RatingFilter::default();
assert_eq!(
ordinal_of_path(&catalog, None, &unfiltered, false, "2020/c.CR2").unwrap(),
Some(2)
);
let starred = RatingFilter {
min_rating: 3,
..RatingFilter::default()
};
assert_eq!(
ordinal_of_path(&catalog, None, &starred, false, "2020/c.CR2").unwrap(),
Some(0),
"the only survivor of the filter is the first row of it"
);
assert_eq!(
ordinal_of_path(&catalog, None, &starred, false, "2019/a.CR2").unwrap(),
None,
"filtered out, so it has no position in this grid"
);
}
#[test]
fn a_collection_is_ordinalled_through_its_own_scope() {
// The window's parameters are bound ahead of the scope's — see the note
// on `ordinal_of_path`. Getting that order wrong looks up a collection
// by an image id, which fails silently as "not in this grid".
let catalog = a_small_library();
let c = catalog.connection();
let coll =
dr_catalog::collections::create(c, "Trip", None, dr_catalog::CollectionKind::Manual)
.unwrap();
// The second and third photographs, out of order, so position matters.
dr_catalog::collections::add_images(c, coll, &[dr_types::ImageId(4), dr_types::ImageId(2)])
.unwrap();
let filter = RatingFilter::default();
let cells =
read_cells_scoped(&catalog, Some(Scope::Collection(coll)), &filter, 0, 100).unwrap();
assert_eq!(cells.len(), 2);
for (i, cell) in cells.iter().enumerate() {
assert_eq!(
ordinal_of_path(
&catalog,
Some(Scope::Collection(coll)),
&filter,
false,
&cell.remote_path
)
.unwrap(),
Some(i),
"{} is drawn at row {i} of the collection",
cell.remote_path
);
}
assert_eq!(
ordinal_of_path(
&catalog,
Some(Scope::Collection(coll)),
&filter,
false,
"2019/a.CR2"
)
.unwrap(),
None,
"not a member"
);
}
#[test]
fn cells_are_windowed_not_wholesale() {
let catalog = Catalog::in_memory().unwrap();
let images: Vec<RemoteEntry> = (0..50)
.map(|i| entry(&format!("PhotosRaw/img{i:03}.CR2"), i as u64, 1000))
.collect();
let result = dr_sync::ScanResult {
images,
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
let page = read_cells(&catalog, 10, 5).unwrap();
assert_eq!(page.len(), 5);
assert_eq!(page[0].name, "img010.CR2");
}
/// TRACES: NFR-P5
/// The grid's window read must be answered by walking `images_grid_order`,
/// never by sorting the library into a temp b-tree.
///
/// This asserts on the *query plan* rather than on a duration, because the
/// failure has no other symptom: a `GRID_ORDER` edited out of step with the
/// index in schema V7, or a column added back into the paging query that
/// drags `remote` in again, both still return the right cells. They just
/// return them after sorting 24,000 rows, inside the scroll handler — which
/// is the jitter this pair was introduced to remove, and it would come back
/// silently.
#[test]
fn the_window_read_walks_the_ordering_index() {
let catalog = with_images(20);
// Including the burst clause, because the grid includes it: a
// predicate that quietly cost the ordering index would put the sort
// back and this is the only place that would notice.
let folded = uncollapsed("i");
let plan: Vec<String> = catalog
.connection()
.prepare(&format!(
"EXPLAIN QUERY PLAN
SELECT {CELL_COLUMNS} FROM images i
WHERE {VISIBLE}{folded}
{GRID_ORDER}
LIMIT 10 OFFSET 5"
))
.unwrap()
.query_map([], |r| r.get::<_, String>(3))
.unwrap()
.flatten()
.collect();
let plan = plan.join(" | ");
assert!(
plan.contains("images_grid_order"),
"the window read is not using the ordering index: {plan}"
);
assert!(
!plan.contains("TEMP B-TREE"),
"the window read is still sorting the whole library: {plan}"
);
assert!(
!plan.to_lowercase().contains("remote"),
"the window read is joining `remote` again, which pages the whole \
library before it discards it: {plan}"
);
}
/// The file ids still arrive, now that they come from a second query.
#[test]
fn a_window_still_carries_the_server_file_ids() {
let catalog = with_images(20);
let page = read_cells(&catalog, 5, 4).unwrap();
assert_eq!(page.len(), 4);
assert!(
page.iter().all(|c| c.file_id.is_some()),
"a cell lost its file id when the join was split out"
);
}
/// TRACES: FR-CULL-5
/// A folded burst takes rows out of the cells, the count and the range a
/// shift-click resolves — all three, together.
///
/// This is the test that would fail if the clause were added to four of
/// the five queries that need it. That failure has no other symptom: the
/// header claims images the grid will not draw, the scrollbar sizes itself
/// for rows that are not there, and neither number looks wrong on its own.
#[test]
fn folding_a_burst_takes_the_same_rows_out_of_every_answer() {
use dr_catalog::bursts::{self, Rules, Signature};
let catalog = with_images(4);
// Three of the four are one burst: a second apart, one signature.
let ids = image_ids(&catalog);
for (n, id) in ids.iter().enumerate() {
let hash = if n < 3 { 0xFF00 } else { 0x00FF };
catalog
.connection()
.execute(
"UPDATE images SET captured_at = ?2, camera = 'Canon EOS R5',
perceptual_hash = ?3
WHERE id = ?1",
rusqlite::params![id.0 as i64, 1_000 + n as i64, Signature(hash).to_stored()],
)
.unwrap();
}
bursts::regroup(catalog.connection(), Rules::default()).unwrap();
let filter = RatingFilter::default();
// Open, as a new burst is: nothing has been taken away yet.
assert_eq!(read_cells(&catalog, 0, 50).unwrap().len(), 4);
assert_eq!(total_images_filtered(&catalog, &filter).unwrap(), 4);
bursts::set_expanded(catalog.connection(), ids[0], false).unwrap();
let cells = read_cells(&catalog, 0, 50).unwrap();
assert_eq!(cells.len(), 2, "the folded frames are still in the cells");
assert_eq!(
total_images_filtered(&catalog, &filter).unwrap(),
cells.len(),
"the header's count and the cells disagree"
);
assert_eq!(
read_ids_span(&catalog, None, &filter, false, 0, 49)
.unwrap()
.len(),
cells.len(),
"a shift-click over the whole grid would select frames it cannot show"
);
}
#[test]
fn cells_carry_the_file_id_the_thumbnail_store_keys_on() {
// Without this the store can never be hit: every launch would refetch
// every thumbnail over the network.
let catalog = Catalog::in_memory().unwrap();
let result = dr_sync::ScanResult {
images: vec![entry("PhotosRaw/a.CR2", 7777, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))],
progress: Default::default(),
sidecars: Vec::new(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
let cells = read_cells(&catalog, 0, 10).unwrap();
assert_eq!(cells[0].file_id, Some(7777));
}
#[test]
fn a_stored_thumbnail_survives_a_restart() {
// The end-to-end property the store exists for: encode, persist,
// reopen, decode. A second launch must not re-fetch.
let dir = std::env::temp_dir().join(format!("dr-ui-thumbs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let rgba: Vec<u8> = std::iter::repeat_n([90u8, 140, 200, 255], 32 * 32)
.flatten()
.collect();
{
let mut store = ThumbStore::open(&dir).unwrap();
let bytes = dr_thumbs::encode_rgba(32, 32, &rgba).unwrap();
store
.put(
4242,
dr_thumbs::ThumbSize::Grid,
&dr_thumbs::Thumbnail {
width: 32,
height: 32,
bytes,
},
)
.unwrap();
}
let store = ThumbStore::open(&dir).unwrap();
let stored = store
.get(4242, dr_thumbs::ThumbSize::Grid)
.unwrap()
.expect("persisted");
let (w, h, out) = dr_thumbs::decode_rgba(&stored.bytes).unwrap();
assert_eq!((w, h), (32, 32));
// Lossy, so compare approximately — a blue-ish pixel must stay blue.
assert!(out[2] > out[0], "channel order survived the round trip");
}
#[test]
fn a_store_hit_is_not_evidence_the_server_is_reachable() {
// The regression this guards: store hits were delivered as the same
// `Ready` the network path sends, and the UI took any `Ready` as proof
// of connectivity. A mostly-cached window then declared "back online"
// against a server that was down — clearing the banner and kicking off
// a sweep that immediately failed, on every scroll.
let dir = std::env::temp_dir().join(format!("dr-ui-provenance-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let rgba: Vec<u8> = std::iter::repeat_n([10u8, 20, 30, 255], 8 * 8)
.flatten()
.collect();
let mut store = ThumbStore::open(&dir).unwrap();
let bytes = dr_thumbs::encode_rgba(8, 8, &rgba).unwrap();
store
.put(
99,
dr_thumbs::ThumbSize::Grid,
&dr_thumbs::Thumbnail {
width: 8,
height: 8,
bytes,
},
)
.unwrap();
// The split in `spawn_thumbnails`: a hit decodes off local disk and is
// reported with `from_cache` set, which is what the reachability gate
// keys on.
let stored = store
.get(99, dr_thumbs::ThumbSize::Grid)
.unwrap()
.expect("stored");
let (width, height, rgba) = dr_thumbs::decode_rgba(&stored.bytes).unwrap();
let hit = ThumbnailReady {
row: 0,
width,
height,
rgba,
class: dr_thumbs::ThumbSize::Grid,
from_cache: true,
};
assert!(
hit.from_cache,
"a thumbnail read from the store must not be mistaken for a fetch"
);
// And the mechanism it feeds: an offline tracker must survive it.
let mut reach = dr_sync::Reachability::new();
let now = std::time::Instant::now();
reach.mark_unreachable("network error".into(), now);
if !hit.from_cache {
reach.mark_reachable(now);
}
assert!(
reach.is_offline(),
"replaying cached thumbnails must leave offline mode intact"
);
}
// --- the trash view (FR-CAT-15) ---------------------------------------
/// Mark one image trashed, at a given instant.
fn shaped(catalog: &Catalog, path: &str, w: i64, h: i64) {
let n = catalog
.connection()
.execute(
"UPDATE images SET w = ?2, h = ?3 WHERE source_ref = ?1",
rusqlite::params![path, w, h],
)
.unwrap();
assert_eq!(n, 1, "fixture should have shaped exactly {path}");
}
#[test]
fn the_wide_photographs_are_found_at_their_ordinals() {
let catalog = with_images(20);
assert!(
wide_ordinals(&catalog, None, &RatingFilter::default(), false)
.unwrap()
.is_empty()
);
shaped(&catalog, "PhotosRaw/img005.CR2", 4000, 1000);
shaped(&catalog, "PhotosRaw/img012.CR2", 3000, 1400);
shaped(&catalog, "PhotosRaw/img013.CR2", 3000, 2000);
let wide = wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
assert_eq!(
wide.iter().map(|(o, _)| *o).collect::<Vec<_>>(),
vec![5, 12],
"{wide:?}"
);
assert!((wide[0].1 - 4.0).abs() < 1e-6);
// The ordinals are the grid's: a frame trashed before them moves
// them up, and in the trash only the trash counts.
trash_at(&catalog, "PhotosRaw/img003.CR2", 1);
trash_at(&catalog, "PhotosRaw/img012.CR2", 2);
let wide = wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
assert_eq!(wide.iter().map(|(o, _)| *o).collect::<Vec<_>>(), vec![4]);
let trashed = wide_ordinals(&catalog, None, &RatingFilter::default(), true).unwrap();
// Newest deletion first: img012 then img003.
assert_eq!(trashed.iter().map(|(o, _)| *o).collect::<Vec<_>>(), vec![0]);
}
#[test]
fn a_library_with_no_panorama_is_told_so_by_the_index() {
let catalog = with_images(20);
wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
let plan: Vec<String> = catalog
.connection()
.prepare(&format!(
"EXPLAIN QUERY PLAN SELECT EXISTS(SELECT 1 FROM images WHERE {WIDE})"
))
.unwrap()
.query_map([], |r| r.get::<_, String>(3))
.unwrap()
.flatten()
.collect();
let plan = plan.join(" | ");
assert!(plan.contains("images_wide"), "{plan}");
}
#[test]
fn the_wide_predicate_is_the_layouts() {
use crate::library_ui::layout::natural_span;
let catalog = with_images(1);
for (w, h) in [
(19, 10),
(189, 100),
(190, 100),
(191, 100),
(6000, 4000),
(4000, 6000),
(22_993, 5_980),
(1_899, 1_000),
(1_900, 1_000),
] {
let sql: bool = catalog
.connection()
.query_row(
&format!("SELECT {}", WIDE.replace('w', "?1").replace('h', "?2")),
rusqlite::params![w, h],
|r| r.get(0),
)
.unwrap();
let rust = natural_span(Some(w as f32 / h as f32)) > 1;
assert_eq!(sql, rust, "{w}×{h}");
}
}
fn trash_at(catalog: &Catalog, path: &str, when: i64) {
let n = catalog
.connection()
.execute(
"UPDATE images SET trashed_at = ?1, trashed_from = source_ref
WHERE source_ref = ?2",
rusqlite::params![when, path],
)
.unwrap();
assert_eq!(n, 1, "fixture should have trashed exactly {path}");
}
#[test]
fn the_trash_lists_what_the_library_hides() {
// The whole point of the view: these rows exist and no other query in
// the application will show them.
let catalog = scanned(3);
trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 100);
let trashed = read_trashed_cells(&catalog, 0, 50).unwrap();
assert_eq!(trashed.len(), 1);
assert_eq!(trashed[0].remote_path, "PhotosRaw/IMG_0002.CR2");
// And it has left the library in the same move.
let live = read_cells_all(&catalog, &RatingFilter::default(), 0, 50).unwrap();
assert_eq!(live.len(), 2);
assert!(!live.iter().any(|c| c.remote_path.contains("IMG_0002")));
}
#[test]
fn an_untrashed_library_has_an_empty_trash() {
let catalog = scanned(3);
assert!(read_trashed_cells(&catalog, 0, 50).unwrap().is_empty());
assert_eq!(total_trashed(&catalog).unwrap(), 0);
}
#[test]
fn the_trash_count_agrees_with_the_cells_it_lists() {
// The header and the scrollbar are sized from the count while the grid
// draws the cells. Two predicates that drift leave the user scrolling
// through rows that are not there.
let catalog = scanned(5);
for (i, when) in [(1, 100), (3, 200), (5, 300)] {
trash_at(&catalog, &format!("PhotosRaw/IMG_{i:04}.CR2"), when);
}
assert_eq!(total_trashed(&catalog).unwrap(), 3);
assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 3);
}
#[test]
fn the_most_recently_trashed_image_is_listed_first() {
// A mistaken delete is corrected within seconds, so the row the user
// wants is the one they just made — not the oldest photograph.
let catalog = scanned(3);
trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100);
trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 300);
trash_at(&catalog, "PhotosRaw/IMG_0003.CR2", 200);
let order: Vec<String> = read_trashed_cells(&catalog, 0, 50)
.unwrap()
.into_iter()
.map(|c| c.remote_path)
.collect();
assert_eq!(
order,
vec![
"PhotosRaw/IMG_0002.CR2".to_string(),
"PhotosRaw/IMG_0003.CR2".to_string(),
"PhotosRaw/IMG_0001.CR2".to_string(),
]
);
}
#[test]
fn a_shadowed_jpeg_is_not_listed_beside_the_raw_it_belongs_to() {
// The inversion applies to `trashed_at` only. Trashing a RAW takes its
// sibling JPEG with it, and listing both would offer to restore the
// same frame twice.
let catalog = scanned(2);
let c = catalog.connection();
let raw: i64 = c
.query_row(
"SELECT id FROM images WHERE source_ref = 'PhotosRaw/IMG_0001.CR2'",
[],
|r| r.get(0),
)
.unwrap();
c.execute(
"UPDATE images SET shadowed_by = ?1 WHERE source_ref = 'PhotosRaw/IMG_0002.CR2'",
[raw],
)
.unwrap();
trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100);
trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 100);
let trashed = read_trashed_cells(&catalog, 0, 50).unwrap();
assert_eq!(trashed.len(), 1, "one photograph, not two");
assert_eq!(trashed[0].remote_path, "PhotosRaw/IMG_0001.CR2");
assert_eq!(total_trashed(&catalog).unwrap(), 1, "and the count agrees");
}
#[test]
fn the_trash_window_pages_like_the_grid_does() {
// The trash uses the same windowed read as the library, so a large one
// must not try to draw itself in a single query.
let catalog = scanned(6);
for i in 1..=6 {
trash_at(
&catalog,
&format!("PhotosRaw/IMG_{i:04}.CR2"),
100 + i as i64,
);
}
let first = read_trashed_cells(&catalog, 0, 2).unwrap();
let second = read_trashed_cells(&catalog, 2, 2).unwrap();
assert_eq!(first.len(), 2);
assert_eq!(second.len(), 2);
assert!(
first
.iter()
.all(|a| !second.iter().any(|b| b.image_id == a.image_id)),
"pages must not overlap"
);
}
#[test]
fn a_rating_filter_does_not_hide_anything_in_the_trash() {
// The filter narrows a culling pass. A trash that dropped rows because
// of a filter set elsewhere would look like it had lost them.
let catalog = scanned(3);
trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100);
// Nothing here is rated, so a four-star library filter would empty any
// view that honoured it.
let strict = RatingFilter {
min_rating: 4,
..Default::default()
};
assert!(read_cells_all(&catalog, &strict, 0, 50).unwrap().is_empty());
assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 1);
}
}