Keywords are catalog state, and the catalog syncs. Without this, two devices keywording the same library would resolve to whichever synced last, and an afternoon of work would vanish with no sign it had ever happened. The vocabulary merges per row on the rule collections already use: revision first, timestamp only to break a tie, so a device with a skewed clock cannot win by having the wrong idea of the time. Assignments merge as a set union, which is FR-NC-9's principle applied to metadata instead of edit nodes — disjoint work survives on both sides. Three things needed care and are commented where they happen: A deletion travels *by name*, not by identity. Both devices may have minted their own uuid for one word before they ever synced, so deleting by uuid would tombstone a row nothing was assigned to and leave every photograph still carrying the word. The union then refuses to readmit a word a winning tombstone has just removed — without that filter the remote's live assignments would resurrect it on the very same pass. Images are resolved by the server's file id first and the content hash second. Membership has always used the hash alone, but the hash is computed only when import dedup or a reconnect asks for it, which for most libraries is never — so a hash-only union would have quietly done nothing for the ordinary photograph. A word lands on the local default version. Version uuids do not reconcile in the catalog at all: ensure_default_versions mints a fresh one per device, so a uuid-keyed join would have unioned nothing. Removal still does not propagate. That is the trade collection membership already makes, for the same reason — an unwanted keyword is removed again in a second, and a silently lost afternoon is not recoverable at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
734 lines
26 KiB
Rust
734 lines
26 KiB
Rust
//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CULL-4
|
|
//! Star ratings and pick/reject flags — the judgement a cull produces.
|
|
//!
|
|
//! # Why this hangs off `versions` rather than `images`
|
|
//!
|
|
//! The schema already carries `rating`, `label` and `flag` on `versions`, and
|
|
//! [`crate::query`] already compiles [`dr_types::Selector::Rating`] and
|
|
//! [`dr_types::Selector::Flag`] against the *default* version. What was
|
|
//! missing is that nothing ever created a version row: a scan inserts into
|
|
//! `images` and stops, so every image had no version, and therefore nowhere
|
|
//! to record a rating. The whole library sat permanently unrated with no way
|
|
//! out of that state.
|
|
//!
|
|
//! So this module's first job is [`ensure_default_versions`] — every image
|
|
//! gets exactly one default version, created at scan time and backfilled by
|
|
//! the v2 migration for libraries scanned before this existed.
|
|
//!
|
|
//! Keeping judgement on the version rather than the image is what makes
|
|
//! FR-CAT-12's virtual copies coherent: two crops of one frame are two
|
|
//! photographs to the photographer, and one may be a keeper while the other
|
|
//! is a reject. Hoisting the rating onto the image would force them to agree.
|
|
//!
|
|
//! # Unrated is a real state, not a zero
|
|
//!
|
|
//! `rating = 0` means *not yet judged*, and that is precisely what "filter to
|
|
//! unjudged" selects (FR-CULL-4). It is deliberately not conflated with "one
|
|
//! star" or with "rejected" — those are three different answers, and a cull
|
|
//! that cannot distinguish "I have not looked at this" from "I looked and it
|
|
//! is poor" cannot be resumed.
|
|
|
|
use rusqlite::{Connection, OptionalExtension};
|
|
|
|
use dr_types::{FlagState, ImageId};
|
|
|
|
use crate::error::CatalogError;
|
|
|
|
/// Highest star rating. Five, as every photo tool has settled on.
|
|
pub const MAX_RATING: u8 = 5;
|
|
|
|
/// Name given to the version created for an image that has none.
|
|
///
|
|
/// Matches what [`crate::collections`] and the sidecar both expect to see for
|
|
/// the original, unmodified frame.
|
|
pub const DEFAULT_VERSION_NAME: &str = "Default";
|
|
|
|
/// The judgement recorded against one image's default version.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub struct Judgement {
|
|
/// 0..=5. Zero means *unrated*, which is a state in its own right.
|
|
pub rating: u8,
|
|
pub flag: FlagState,
|
|
}
|
|
|
|
impl Judgement {
|
|
/// Whether this image has been judged at all.
|
|
///
|
|
/// Either axis counts: a photographer who flags without starring, or stars
|
|
/// without flagging, has still made a decision about the frame. "Filter to
|
|
/// unjudged" (FR-CULL-4) is the negation of this, and getting it wrong
|
|
/// means a resumed session re-presents work already done.
|
|
pub fn is_judged(self) -> bool {
|
|
self.rating > 0 || self.flag != FlagState::Unflagged
|
|
}
|
|
}
|
|
|
|
/// Give every image without one a default version.
|
|
///
|
|
/// Idempotent, and cheap on the common path: the `NOT EXISTS` sub-select is
|
|
/// answered by the `versions_image` index, so a library that already has its
|
|
/// versions costs one indexed scan and writes nothing.
|
|
///
|
|
/// Returns how many were created, so a scan can log the backfill rather than
|
|
/// silently doing thousands of inserts.
|
|
///
|
|
/// The UUID is per row and generated here — it is the merge identity across
|
|
/// devices (FR-NC-8), so two images must never share one.
|
|
pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError> {
|
|
// One transaction for the batch. A backfill over a 24k-image library is
|
|
// 24k inserts, and per-statement commits would make it minutes rather
|
|
// than seconds.
|
|
let tx = conn.unchecked_transaction()?;
|
|
let n = ensure_default_versions_within(&tx)?;
|
|
tx.commit()?;
|
|
Ok(n)
|
|
}
|
|
|
|
/// [`ensure_default_versions`] without opening a transaction.
|
|
///
|
|
/// Separate because SQLite has no nested `BEGIN`: [`crate::merge`] needs the
|
|
/// invariant restored *inside* the merge transaction — an incoming keyword
|
|
/// lands on a default version, so an image without one would silently drop it —
|
|
/// and calling the public form there fails at runtime with "cannot start a
|
|
/// transaction within a transaction". The same split, for the same reason, as
|
|
/// `collections::add_within`.
|
|
pub fn ensure_default_versions_within(conn: &Connection) -> Result<usize, CatalogError> {
|
|
let ids: Vec<i64> = {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT i.id FROM images i
|
|
WHERE NOT EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id)",
|
|
)?;
|
|
let found = stmt
|
|
.query_map([], |r| r.get(0))?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
found
|
|
};
|
|
if ids.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
|
|
{
|
|
let mut insert = conn.prepare(
|
|
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
|
VALUES (?1, ?2, ?3, 1, 0, 0)",
|
|
)?;
|
|
for id in &ids {
|
|
insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?;
|
|
}
|
|
}
|
|
|
|
Ok(ids.len())
|
|
}
|
|
|
|
/// The default version's row id for an image, creating one if it has none.
|
|
///
|
|
/// Every write path goes through this rather than assuming a version exists.
|
|
/// An image can arrive without one in two ways that are not worth trying to
|
|
/// prevent: a row inserted by a build predating this module, and a scan whose
|
|
/// version pass was interrupted between the image insert and the commit.
|
|
/// Failing a rating because of either would be the wrong answer — the user
|
|
/// pressed a key and expects a star.
|
|
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
|
|
let existing: Option<i64> = conn
|
|
.query_row(
|
|
"SELECT id FROM versions
|
|
WHERE image_id = ?1
|
|
ORDER BY is_default DESC, id ASC
|
|
LIMIT 1",
|
|
[image.0 as i64],
|
|
|r| r.get(0),
|
|
)
|
|
.optional()?;
|
|
|
|
if let Some(id) = existing {
|
|
return Ok(id);
|
|
}
|
|
|
|
conn.execute(
|
|
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
|
VALUES (?1, ?2, ?3, 1, 0, 0)",
|
|
rusqlite::params![image.0 as i64, new_uuid(), DEFAULT_VERSION_NAME],
|
|
)?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
/// Set the star rating for one image, clamped to 0..=[`MAX_RATING`].
|
|
///
|
|
/// Clamped rather than rejected: the value comes from a keystroke or a click
|
|
/// on a star strip, and there is no useful error to show a photographer who
|
|
/// pressed a key. Out of range can only mean a UI bug, and losing the
|
|
/// keystroke would be a worse symptom than recording five.
|
|
pub fn set_rating(conn: &Connection, image: ImageId, rating: u8) -> Result<(), CatalogError> {
|
|
let version = default_version_id(conn, image)?;
|
|
conn.execute(
|
|
"UPDATE versions SET rating = ?2 WHERE id = ?1",
|
|
rusqlite::params![version, rating.min(MAX_RATING) as i64],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the pick/reject flag for one image.
|
|
pub fn set_flag(conn: &Connection, image: ImageId, flag: FlagState) -> Result<(), CatalogError> {
|
|
let version = default_version_id(conn, image)?;
|
|
conn.execute(
|
|
"UPDATE versions SET flag = ?2 WHERE id = ?1",
|
|
rusqlite::params![version, flag_code(flag)],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply a rating to many images in one transaction.
|
|
///
|
|
/// The bulk path exists because rating a selection is one gesture: the user
|
|
/// selects forty frames and presses `3`. Forty separate transactions would be
|
|
/// forty fsyncs for what is conceptually a single edit, and a crash partway
|
|
/// through would leave the selection half-rated.
|
|
pub fn set_rating_many(
|
|
conn: &Connection,
|
|
images: &[ImageId],
|
|
rating: u8,
|
|
) -> Result<usize, CatalogError> {
|
|
apply_many(conn, images, |conn, id| set_rating(conn, id, rating))
|
|
}
|
|
|
|
/// Apply a flag to many images in one transaction. See [`set_rating_many`].
|
|
pub fn set_flag_many(
|
|
conn: &Connection,
|
|
images: &[ImageId],
|
|
flag: FlagState,
|
|
) -> Result<usize, CatalogError> {
|
|
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
|
|
}
|
|
|
|
/// Shared bulk wrapper, so the two axes cannot drift in their commit
|
|
/// behaviour — a partially-committed rating and a fully-committed flag from
|
|
/// the same keystroke would be hard to explain and harder to notice.
|
|
fn apply_many(
|
|
conn: &Connection,
|
|
images: &[ImageId],
|
|
mut one: impl FnMut(&Connection, ImageId) -> Result<(), CatalogError>,
|
|
) -> Result<usize, CatalogError> {
|
|
if images.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
let tx = conn.unchecked_transaction()?;
|
|
for id in images {
|
|
one(&tx, *id)?;
|
|
}
|
|
tx.commit()?;
|
|
Ok(images.len())
|
|
}
|
|
|
|
/// Read the judgement for one image.
|
|
///
|
|
/// An image with no version reads as unrated and unflagged rather than as an
|
|
/// error: that is exactly what it is.
|
|
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
|
|
let row: Option<(i64, i64)> = conn
|
|
.query_row(
|
|
"SELECT rating, flag FROM versions
|
|
WHERE image_id = ?1
|
|
ORDER BY is_default DESC, id ASC
|
|
LIMIT 1",
|
|
[image.0 as i64],
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
)
|
|
.optional()?;
|
|
|
|
Ok(match row {
|
|
Some((rating, flag)) => Judgement {
|
|
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
|
flag: flag_from_code(flag),
|
|
},
|
|
None => Judgement::default(),
|
|
})
|
|
}
|
|
|
|
/// Judgements for a window of images, in one statement.
|
|
///
|
|
/// The grid needs a star strip per cell, and one query per cell would be 120
|
|
/// round trips on every scroll — the same reasoning as
|
|
/// `collections_ui::sync_badges`. Images with no version simply do not appear
|
|
/// in the result, and the caller treats a miss as unrated.
|
|
pub fn judgements(
|
|
conn: &Connection,
|
|
images: &[ImageId],
|
|
) -> Result<std::collections::HashMap<ImageId, Judgement>, CatalogError> {
|
|
let mut out = std::collections::HashMap::new();
|
|
if images.is_empty() {
|
|
return Ok(out);
|
|
}
|
|
|
|
// Placeholders are generated from the *count* of ids, never from any text
|
|
// that came from outside — the same rule `read_cells_scoped` follows.
|
|
let placeholders = std::iter::repeat_n("?", images.len())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let sql = format!(
|
|
"SELECT image_id, rating, flag FROM versions
|
|
WHERE image_id IN ({placeholders}) AND is_default = 1"
|
|
);
|
|
|
|
let params: Vec<rusqlite::types::Value> = images
|
|
.iter()
|
|
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
|
.collect();
|
|
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
|
Ok((
|
|
r.get::<_, i64>(0)?,
|
|
r.get::<_, i64>(1)?,
|
|
r.get::<_, i64>(2)?,
|
|
))
|
|
})?;
|
|
|
|
for (image, rating, flag) in rows.flatten() {
|
|
out.insert(
|
|
ImageId(image as u64),
|
|
Judgement {
|
|
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
|
flag: flag_from_code(flag),
|
|
},
|
|
);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// How the library divides by rating, for the filter bar's counts.
|
|
///
|
|
/// Index `n` is the number of images rated `n`, so index 0 is the unrated
|
|
/// count. Shown beside each filter button so the user can see there is
|
|
/// something behind it before narrowing to it — a filter that silently
|
|
/// empties the grid reads as a broken filter.
|
|
pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
|
|
let mut out = [0usize; 6];
|
|
|
|
// LEFT JOIN, so an image whose version row is missing still counts as
|
|
// unrated rather than vanishing from the totals. The histogram has to sum
|
|
// to the library size or it is not believable.
|
|
let mut stmt = conn.prepare(
|
|
"SELECT coalesce(v.rating, 0) AS r, count(*)
|
|
FROM images i
|
|
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
|
GROUP BY r",
|
|
)?;
|
|
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
|
|
|
for (rating, count) in rows.flatten() {
|
|
if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) {
|
|
*slot += count as usize;
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// How many images carry each flag: `(picks, rejects)`.
|
|
pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> {
|
|
let picks: i64 = conn.query_row(
|
|
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)?;
|
|
let rejects: i64 = conn.query_row(
|
|
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 2",
|
|
[],
|
|
|r| r.get(0),
|
|
)?;
|
|
Ok((picks as usize, rejects as usize))
|
|
}
|
|
|
|
/// The stored integer for a flag. Matches [`crate::query::flag_code`]'s
|
|
/// mapping — the two must agree or a filter will not find what a write stored.
|
|
fn flag_code(f: FlagState) -> i64 {
|
|
match f {
|
|
FlagState::Unflagged => 0,
|
|
FlagState::Pick => 1,
|
|
FlagState::Reject => 2,
|
|
}
|
|
}
|
|
|
|
fn flag_from_code(v: i64) -> FlagState {
|
|
match v {
|
|
1 => FlagState::Pick,
|
|
2 => FlagState::Reject,
|
|
_ => FlagState::Unflagged,
|
|
}
|
|
}
|
|
|
|
/// A version UUID.
|
|
///
|
|
/// Hand-rolled rather than pulling in the `uuid` crate for one function — the
|
|
/// same reasoning as the date maths in `library_ui`. This needs to be unique
|
|
/// across devices, not cryptographically unguessable: it keys a merge, and an
|
|
/// attacker who can write to the sidecar has already won.
|
|
///
|
|
/// Seeded from the system clock and a per-process counter, so two versions
|
|
/// created inside the same nanosecond tick still differ.
|
|
fn new_uuid() -> String {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos() as u64)
|
|
.unwrap_or(0);
|
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
|
|
// Mixed so successive ids do not share a long common prefix, which makes
|
|
// them easier to tell apart when reading a sidecar by eye.
|
|
let a = nanos ^ (n.wrapping_mul(0x9E37_79B9_7F4A_7C15));
|
|
let b = nanos
|
|
.rotate_left(32)
|
|
.wrapping_add(n.wrapping_mul(0xBF58_476D_1CE4_E5B9));
|
|
|
|
format!(
|
|
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
|
|
(a >> 32) as u32,
|
|
(a >> 16) as u16,
|
|
(a & 0x0FFF) as u16,
|
|
// Variant bits, so this is a well-formed v4-shaped UUID rather than
|
|
// something that merely looks like one.
|
|
((b >> 48) as u16 & 0x3FFF) | 0x8000,
|
|
b & 0xFFFF_FFFF_FFFF,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::Catalog;
|
|
|
|
/// A catalog holding `n` images and nothing else — the state a scan
|
|
/// leaves behind before this module runs.
|
|
fn with_images(n: usize) -> Catalog {
|
|
let cat = Catalog::in_memory().unwrap();
|
|
let c = cat.connection();
|
|
c.execute(
|
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
for i in 0..n {
|
|
c.execute(
|
|
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
|
|
[format!("img{i:03}.CR3")],
|
|
)
|
|
.unwrap();
|
|
}
|
|
cat
|
|
}
|
|
|
|
fn ids(cat: &Catalog) -> Vec<ImageId> {
|
|
let mut stmt = cat
|
|
.connection()
|
|
.prepare("SELECT id FROM images ORDER BY id")
|
|
.unwrap();
|
|
stmt.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))
|
|
.unwrap()
|
|
.map(Result::unwrap)
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn every_scanned_image_gets_a_default_version() {
|
|
// The gap this module exists to close: a scan inserted images and no
|
|
// versions, so there was nowhere for a rating to go.
|
|
let cat = with_images(5);
|
|
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 5);
|
|
|
|
let n: i64 = cat
|
|
.connection()
|
|
.query_row(
|
|
"SELECT count(*) FROM versions WHERE is_default = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(n, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn images_enter_unrated_rather_than_at_one_star() {
|
|
// "Not yet judged" is the state a cull starts from and resumes to.
|
|
let cat = with_images(3);
|
|
ensure_default_versions(cat.connection()).unwrap();
|
|
|
|
for id in ids(&cat) {
|
|
let j = judgement(cat.connection(), id).unwrap();
|
|
assert_eq!(j.rating, 0);
|
|
assert_eq!(j.flag, FlagState::Unflagged);
|
|
assert!(!j.is_judged());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn backfilling_twice_creates_nothing_the_second_time() {
|
|
// Runs on every scan, so a second pass must not double every version.
|
|
let cat = with_images(4);
|
|
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 4);
|
|
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 0);
|
|
|
|
let n: i64 = cat
|
|
.connection()
|
|
.query_row("SELECT count(*) FROM versions", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(n, 4, "one version per image, not two");
|
|
}
|
|
|
|
#[test]
|
|
fn version_uuids_are_unique_across_a_batch() {
|
|
// The uuid is the cross-device merge identity: two images sharing one
|
|
// silently fuse their edits at the next sync.
|
|
let cat = with_images(200);
|
|
ensure_default_versions(cat.connection()).unwrap();
|
|
|
|
let distinct: i64 = cat
|
|
.connection()
|
|
.query_row("SELECT count(DISTINCT uuid) FROM versions", [], |r| {
|
|
r.get(0)
|
|
})
|
|
.unwrap();
|
|
assert_eq!(distinct, 200);
|
|
}
|
|
|
|
#[test]
|
|
fn a_rating_round_trips() {
|
|
let cat = with_images(1);
|
|
let id = ids(&cat)[0];
|
|
set_rating(cat.connection(), id, 4).unwrap();
|
|
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn rating_an_image_with_no_version_creates_one() {
|
|
// A library scanned by a build predating this module, or a scan that
|
|
// died between the image insert and the version pass. The keystroke
|
|
// must still land.
|
|
let cat = with_images(1);
|
|
let id = ids(&cat)[0];
|
|
// Deliberately *not* calling ensure_default_versions first.
|
|
set_rating(cat.connection(), id, 3).unwrap();
|
|
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn an_out_of_range_rating_is_clamped_rather_than_stored() {
|
|
// A stored 9 would sort above five stars forever and no filter would
|
|
// reach it.
|
|
let cat = with_images(1);
|
|
let id = ids(&cat)[0];
|
|
set_rating(cat.connection(), id, 99).unwrap();
|
|
assert_eq!(judgement(cat.connection(), id).unwrap().rating, MAX_RATING);
|
|
}
|
|
|
|
#[test]
|
|
fn rating_back_to_zero_returns_an_image_to_unrated() {
|
|
// Pressing 0 is how a mistake is undone, so it has to be reachable —
|
|
// not a floor at one star.
|
|
let cat = with_images(1);
|
|
let id = ids(&cat)[0];
|
|
set_rating(cat.connection(), id, 5).unwrap();
|
|
set_rating(cat.connection(), id, 0).unwrap();
|
|
|
|
let j = judgement(cat.connection(), id).unwrap();
|
|
assert_eq!(j.rating, 0);
|
|
assert!(!j.is_judged(), "back to unjudged, so a cull re-presents it");
|
|
}
|
|
|
|
#[test]
|
|
fn flags_and_stars_are_independent_axes() {
|
|
// Rejecting a four-star frame is a normal thing to do while culling,
|
|
// and one axis must not clear the other.
|
|
let cat = with_images(1);
|
|
let id = ids(&cat)[0];
|
|
set_rating(cat.connection(), id, 4).unwrap();
|
|
set_flag(cat.connection(), id, FlagState::Reject).unwrap();
|
|
|
|
let j = judgement(cat.connection(), id).unwrap();
|
|
assert_eq!(j.rating, 4);
|
|
assert_eq!(j.flag, FlagState::Reject);
|
|
}
|
|
|
|
#[test]
|
|
fn a_flag_alone_counts_as_judged() {
|
|
// Filter-to-unjudged must not re-present a frame the user already
|
|
// picked, merely because they did not also star it.
|
|
let cat = with_images(1);
|
|
let id = ids(&cat)[0];
|
|
set_flag(cat.connection(), id, FlagState::Pick).unwrap();
|
|
assert!(judgement(cat.connection(), id).unwrap().is_judged());
|
|
}
|
|
|
|
#[test]
|
|
fn a_bulk_rating_applies_to_the_whole_selection() {
|
|
// One gesture: select forty, press 3.
|
|
let cat = with_images(10);
|
|
let all = ids(&cat);
|
|
let chosen = &all[2..7];
|
|
|
|
assert_eq!(set_rating_many(cat.connection(), chosen, 3).unwrap(), 5);
|
|
|
|
for id in chosen {
|
|
assert_eq!(judgement(cat.connection(), *id).unwrap().rating, 3);
|
|
}
|
|
// And nothing outside the selection moved.
|
|
assert_eq!(judgement(cat.connection(), all[0]).unwrap().rating, 0);
|
|
assert_eq!(judgement(cat.connection(), all[9]).unwrap().rating, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_bulk_write_over_an_empty_selection_is_a_no_op() {
|
|
let cat = with_images(3);
|
|
assert_eq!(set_rating_many(cat.connection(), &[], 5).unwrap(), 0);
|
|
assert_eq!(
|
|
set_flag_many(cat.connection(), &[], FlagState::Pick).unwrap(),
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn judgements_reads_a_whole_window_in_one_query() {
|
|
// The grid draws a star strip per cell; one query per cell would be
|
|
// 120 round trips on every scroll.
|
|
let cat = with_images(6);
|
|
let all = ids(&cat);
|
|
set_rating(cat.connection(), all[1], 2).unwrap();
|
|
set_flag(cat.connection(), all[3], FlagState::Pick).unwrap();
|
|
|
|
let map = judgements(cat.connection(), &all).unwrap();
|
|
assert_eq!(map.get(&all[1]).unwrap().rating, 2);
|
|
assert_eq!(map.get(&all[3]).unwrap().flag, FlagState::Pick);
|
|
// Never rated, so it is either absent or explicitly unrated — both
|
|
// mean the same thing to the caller.
|
|
assert_eq!(
|
|
map.get(&all[5]).copied().unwrap_or_default(),
|
|
Judgement::default()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_histogram_sums_to_the_library_size() {
|
|
// A histogram that disagrees with the image count is not believable,
|
|
// and the unrated bucket is the one a fresh library lives in.
|
|
let cat = with_images(8);
|
|
ensure_default_versions(cat.connection()).unwrap();
|
|
let all = ids(&cat);
|
|
set_rating(cat.connection(), all[0], 5).unwrap();
|
|
set_rating(cat.connection(), all[1], 5).unwrap();
|
|
set_rating(cat.connection(), all[2], 3).unwrap();
|
|
|
|
let h = rating_histogram(cat.connection()).unwrap();
|
|
assert_eq!(h[5], 2);
|
|
assert_eq!(h[3], 1);
|
|
assert_eq!(h[0], 5, "the rest are still unrated");
|
|
assert_eq!(h.iter().sum::<usize>(), 8);
|
|
}
|
|
|
|
#[test]
|
|
fn the_histogram_counts_images_with_no_version_as_unrated() {
|
|
// They are unrated. Dropping them would make the counts disagree with
|
|
// the grid, which is the failure the LEFT JOIN exists to prevent.
|
|
let cat = with_images(4);
|
|
// No ensure_default_versions call at all.
|
|
let h = rating_histogram(cat.connection()).unwrap();
|
|
assert_eq!(h[0], 4);
|
|
assert_eq!(h.iter().sum::<usize>(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn flag_counts_separate_picks_from_rejects() {
|
|
let cat = with_images(5);
|
|
let all = ids(&cat);
|
|
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
|
|
set_flag(cat.connection(), all[1], FlagState::Pick).unwrap();
|
|
set_flag(cat.connection(), all[2], FlagState::Reject).unwrap();
|
|
|
|
assert_eq!(flag_counts(cat.connection()).unwrap(), (2, 1));
|
|
}
|
|
|
|
#[test]
|
|
fn unflagging_removes_an_image_from_both_counts() {
|
|
let cat = with_images(2);
|
|
let all = ids(&cat);
|
|
set_flag(cat.connection(), all[0], FlagState::Reject).unwrap();
|
|
set_flag(cat.connection(), all[0], FlagState::Unflagged).unwrap();
|
|
assert_eq!(flag_counts(cat.connection()).unwrap(), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_rating_survives_the_selector_that_queries_it() {
|
|
// The end-to-end property: what this module writes is what
|
|
// `dr_catalog::query` compiles `Selector::Rating` to find. These are
|
|
// two independent pieces of SQL and they must agree on where a rating
|
|
// lives, or rating an image would appear to do nothing.
|
|
use crate::Query;
|
|
use dr_types::Selector;
|
|
|
|
let cat = with_images(6);
|
|
let all = ids(&cat);
|
|
ensure_default_versions(cat.connection()).unwrap();
|
|
set_rating(cat.connection(), all[0], 5).unwrap();
|
|
set_rating(cat.connection(), all[1], 4).unwrap();
|
|
set_rating(cat.connection(), all[2], 1).unwrap();
|
|
|
|
let q = Query {
|
|
filter: Selector::Rating { min: 4 },
|
|
..Default::default()
|
|
};
|
|
assert_eq!(cat.count(&q, 0).unwrap(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn a_flag_survives_the_selector_that_queries_it() {
|
|
// Same contract for the other axis: `flag_code` here and in `query`
|
|
// are separate mappings and must not drift.
|
|
use crate::Query;
|
|
use dr_types::Selector;
|
|
|
|
let cat = with_images(4);
|
|
let all = ids(&cat);
|
|
ensure_default_versions(cat.connection()).unwrap();
|
|
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
|
|
set_flag(cat.connection(), all[1], FlagState::Reject).unwrap();
|
|
|
|
let picks = Query {
|
|
filter: Selector::Flag(FlagState::Pick),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(cat.count(&picks, 0).unwrap(), 1);
|
|
|
|
let rejects = Query {
|
|
filter: Selector::Flag(FlagState::Reject),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(cat.count(&rejects, 0).unwrap(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn unjudged_is_reachable_as_a_filter() {
|
|
// FR-CULL-4's "filter to unjudged", which is what lets a session
|
|
// resume where it stopped.
|
|
use crate::Query;
|
|
use dr_types::Selector;
|
|
|
|
let cat = with_images(5);
|
|
let all = ids(&cat);
|
|
ensure_default_versions(cat.connection()).unwrap();
|
|
set_rating(cat.connection(), all[0], 2).unwrap();
|
|
|
|
let q = Query {
|
|
filter: Selector::Rating { min: 0 },
|
|
..Default::default()
|
|
};
|
|
// `min: 0` matches everything, so unjudged needs the negation.
|
|
assert_eq!(cat.count(&q, 0).unwrap(), 5);
|
|
|
|
let unrated = Query {
|
|
filter: Selector::Not(Box::new(Selector::Rating { min: 1 })),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(cat.count(&unrated, 0).unwrap(), 4);
|
|
}
|
|
}
|