//! 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; #[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, Eq)] 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, /// 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, } /// 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, 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, filter: &RatingFilter, offset: usize, limit: usize, ) -> Result, dr_catalog::CatalogError> { let Some(scope) = scope else { return read_cells_all(catalog, filter, offset, limit); }; let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?; // Placeholders are generated from the *count* of ids, never from user text. let placeholders = std::iter::repeat_n("?", ids.len()) .collect::>() .join(","); let rated = filter.sql(); let folded = uncollapsed("i"); let (order, order_params) = grid_order_for(catalog, Some(scope)); let sql = format!( "SELECT {CELL_COLUMNS} FROM images i WHERE {VISIBLE}{rated}{folded} AND i.id IN (SELECT image_id FROM collection_members WHERE collection_id IN ({placeholders})) {order} LIMIT ? OFFSET ?" ); // Bound in the order the `?`s appear: the scope's ids in the WHERE, then // whatever the ORDER BY needs, then the window. let mut params: Vec = ids .iter() .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) .collect(); 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::, _>>()?; read }; attach_file_ids(catalog, &mut rows); Ok(rows) } pub(super) fn read_cells_all( catalog: &Catalog, filter: &RatingFilter, offset: usize, limit: usize, ) -> Result, 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::, _>>()?; 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, 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::, _>>()?; 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 { 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, filter: &RatingFilter, trash: bool, first: usize, last: usize, ) -> Result, 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::, _>>()?; 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, filter: &RatingFilter, trash: bool, path: &str, ) -> Result, 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), } } /// 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"; /// 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 { 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>(2)?.unwrap_or(0) as u64, metadata_state: r.get::<_, i64>(3)? as u8, captured_at: r.get(4)?, }) } /// 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::>() .join(","); let sql = format!("SELECT image_id, file_id FROM remote WHERE image_id IN ({placeholders})"); let params: Vec = 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>(1)?)) }); let found: std::collections::HashMap> = 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 { 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(coll), &filter, 0, 100).unwrap(); assert_eq!(cells.len(), 2); for (i, cell) in cells.iter().enumerate() { assert_eq!( ordinal_of_path(&catalog, Some(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(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 = (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 = 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 = 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 = 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, 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 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 = 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); } }