diff --git a/README.md b/README.md index 9692b6360..29c28a5f5 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick **Folders**, and **Recently Deleted**. Rate with 0–5, flag with P / X / U, colour-label with 6–9. Search understands fields: `rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. Every view sorts by capture date, -import date, edit date, name, rating or size. The justified **Photo Grid** and **Square Grid** views are virtualized, +import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized, so they stay smooth whether you have forty photos or forty thousand. diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index da4debf23..6f933252f 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -224,11 +224,14 @@ fn select_by(s: &mut Session, p: &Value) -> Result { Ok(json!({"selected": n})) } -/// `seed` as a non-negative integer, or `default` when absent; anything else is an error. +/// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote. +const MAX_SEED: u64 = 1 << 53; + +/// `seed` as an integer in `0..2^53`, or `default` when absent or null; anything else is an error. fn seed_param(p: &Value, cmd: &str, default: u64) -> Result { match p.get("seed") { None | Some(Value::Null) => Ok(default), - Some(v) => v.as_u64().ok_or_else(|| bad(cmd, "seed must be a non-negative integer")), + Some(v) => v.as_u64().filter(|n| *n < MAX_SEED).ok_or_else(|| bad(cmd, "seed must be an integer from 0 to 2^53 - 1")), } } @@ -237,7 +240,7 @@ fn seed_param(p: &Value, cmd: &str, default: u64) -> Result { /// clients that read numbers as doubles (web, MCP agents) get the same seed back. fn next_seed(prev: u64, now: &str) -> u64 { let t = now.bytes().fold(0u64, |h, b| lightcraft_catalog::mix64(h ^ u64::from(b))); - (lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & ((1 << 53) - 1) + (lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & (MAX_SEED - 1) } pub fn specs() -> Vec { @@ -318,11 +321,11 @@ pub fn specs() -> Vec { "Reshuffle", ["View", "Sort"], None, - "{seed?: u64} — sort at random; without `seed` a new shuffle each time", + "{seed?: 0..2^53-1} — sort at random; without `seed` a new shuffle each time", always, |s, p| { let seed = match seed_param(p, "library.shuffle", s.sort.seed)? { - given if p.get("seed").is_some() => given, + given if p.get("seed").is_some_and(|v| !v.is_null()) => given, // derived from the previous seed and the session clock: reproducible under a test // clock, and no RNG needed prev => next_seed(prev, &(s.clock)()), diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index 9bc6f0551..f9837c50e 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -494,8 +494,8 @@ fn random_sort_is_stable_until_reshuffled() { assert_eq!(s.sort.key, SortKey::Random); assert_ne!(seed1, 11); assert_ne!(s.visible_cloned(), a); - s.execute("library.shuffle", &json!({})).unwrap(); - assert_ne!(s.sort.seed, seed1, "every reshuffle is new"); + s.execute("library.shuffle", &json!({"seed": null})).unwrap(); + assert_ne!(s.sort.seed, seed1, "every reshuffle is new, and a null seed means none given"); assert!(s.sort.seed < 1 << 53, "seeds survive a round trip through a JSON double"); // an explicit seed is reproducible s.execute("library.shuffle", &json!({"seed": 11})).unwrap(); @@ -509,14 +509,18 @@ fn random_sort_rejects_bad_params() { assert!(s.execute("library.sort", &json!({"seed": "x"})).is_err()); assert!(s.execute("library.sort", &json!({"seed": -1})).is_err()); assert!(s.execute("library.shuffle", &json!({"seed": 1.5})).is_err()); - assert_eq!(s.sort, before, "a rejected command leaves the sort alone"); + assert!(s.execute("library.sort", &json!({"seed": 1u64 << 53})).is_err(), "beyond what a JSON double holds exactly"); + assert!(s.execute("library.sort", &json!({"seed": (1u64 << 53) - 1})).is_ok()); + assert_eq!(s.sort.key, before.key, "a rejected command leaves the sort alone"); } #[test] -fn random_sort_applies_to_albums_and_folders_too() { +fn random_sort_applies_to_albums_too() { let mut s = Session::with_demo(); s.execute("library.sort", &json!({"key": "random", "seed": 5})).unwrap(); let all = s.visible_cloned(); + // manual order shows newest-first (descending), i.e. the reverse of this insertion order, so it + // differs from the shuffle unless Random really overrides it let ids: Vec = all.iter().take(12).map(|i| i.0).collect(); let album = s.execute("album.create", &json!({"name": "Shuffled"})).unwrap()["id"].as_u64().unwrap(); s.execute("album.addPhotos", &json!({"id": album, "ids": ids})).unwrap(); @@ -526,12 +530,3 @@ fn random_sort_applies_to_albums_and_folders_too() { let expect: Vec<_> = all.iter().filter(|i| in_album.contains(i)).copied().collect(); assert_eq!(in_album, expect, "the album shows its photos in the shuffle's order, not manual order"); } - -/// The shuffle comes back after a restart (the sort is part of the saved view state). -#[test] -fn random_sort_survives_view_state_round_trip() { - use lightcraft_catalog::{Sort, SortKey}; - let sort = Sort { key: SortKey::Random, seed: u64::MAX, ..Default::default() }; - let back: Sort = serde_json::from_str(&serde_json::to_string(&sort).unwrap()).unwrap(); - assert_eq!(back, sort); -} diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index f23ae9bde..7fb24806b 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -242,3 +242,17 @@ fn filter_chips_clear_one_filter_and_total_ignores_the_filter() { } assert_eq!(s.filter, Default::default()); } + +/// A shuffle is part of the saved view: the same seed (and so the same grid) is back on the next launch. +#[test] +fn random_sort_persists_with_the_view() { + let dir = temp_dir("random-view"); + let mut s = open(&dir); + s.execute("library.sort", &json!({"key": "random", "seed": 123_456_789})).unwrap(); + s.close_library().unwrap(); + drop(s); + let s2 = open(&dir); + assert_eq!(s2.sort.key, lightcraft_catalog::SortKey::Random); + assert_eq!(s2.sort.seed, 123_456_789); + let _ = std::fs::remove_dir_all(&dir); +}