Files
lightcraft/crates/catalog/src/stacks.rs
T
Dylan M. Taylor bfb23db2ca Use chrono instead of hand-rolled UTC conversion (#2457)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled Howard Hinnant civil/days-from-civil copies and the
leap-year/days-in-month table with chrono as a workspace dependency:
- apps/lightcraft/src/logging.rs: timestamp (log line time)
- crates/catalog/src/dates.rs: civil + weekday
- crates/catalog/src/stacks.rs: iso_seconds (date -> seconds)
- crates/engine/src/cmd/face_recognize.rs: capture_secs (native only)
- crates/meta/src/datetime.rs: DateTime::unix_seconds and the valid() day check
Output formats and semantics are unchanged (pre-epoch clamp, day rollover that
normalize_iso relies on, leap-second tolerance, EXIF/XMP parsing). chrono uses
only std, so wasm stays safe. AGENTS.md/CLAUDE.md now say time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. timestamp now uses the fallible DateTime::from_timestamp
and reads such a clock as the epoch, keeping the old pre-epoch clamp.

timestamp and catalog's civil write years past 9999 with {:04} like the old
{y:04} did, because %Y would add a '+'. Tests cover the out-of-range and
5-digit-year cases.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve old out-of-range date conversion (no epoch fallback)

`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any instant
past chrono's year range to 1970-01-01, changing output the old hand-rolled
Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s was
318857-05-20 17:46:40, not the epoch). Restore the old conversion domain with a
cycle-based helper that routes the calendar through chrono (the calendar
repeats every 400 years = 146_097 days, so month/day/time come from chrono and
only the year is carried in i128, keeping every i64 exact).

- apps/lightcraft/src/logging.rs: `timestamp` via `utc_parts`; keep the
  pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch.
- crates/catalog/src/dates.rs: `civil` cycle-based (the callers stay inside
  0000-9999, but a far clock no longer resets to the epoch).

Pinned old outputs in tests:
  logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch),
          10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  civil 10_000_000_000_000 -> 318857-05-20T17:46:40,
        -10_000_000_000_000 -> -314918-08-13T06:13:20, i64 extremes

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-21 19:36:36 -04:00

471 lines
22 KiB
Rust

//! Stacks: photos grouped under a top photo.
//!
//! A stack is catalog data ([`Stack`], changed by `AddStack` / `RemoveStack` / `SetStack` ops), so
//! grouping is undoable and journaled like everything else. The helpers here build the ops for the
//! usual gestures (group, ungroup, remove from stack, set top, collapse) and for programmatic
//! grouping such as merge results stacked with their sources ([`Catalog::stack_with_ops`]).
//! [`Catalog::arrange_stacks`] turns a query result into display order (members together,
//! collapsed stacks reduced to one photo).
use std::collections::{HashMap, HashSet};
use crate::query::cmp_name;
use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId};
impl Catalog {
pub fn stack(&self, id: StackId) -> Option<&Stack> {
self.stacks.get(&id)
}
pub fn stacks(&self) -> impl Iterator<Item = &Stack> {
self.stacks.values()
}
/// The stack a photo belongs to.
pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> {
self.stack_id_of(photo).and_then(|id| self.stacks.get(&id))
}
/// Photo → (stack, position in the stack), for every stacked photo.
pub fn stack_index(&self) -> HashMap<PhotoId, (StackId, usize)> {
self.stacks.iter().flat_map(|(id, s)| s.photos.iter().enumerate().map(move |(i, p)| (*p, (*id, i)))).collect()
}
pub(crate) fn validate_stack(&self, id: StackId, photos: &[PhotoId]) -> Result<()> {
if photos.len() < 2 {
return Err(CatalogError::Invalid("a stack needs at least two photos".into()));
}
let mut seen = HashSet::new();
for p in photos {
if !seen.insert(*p) {
return Err(CatalogError::Invalid(format!("photo {p:?} twice in a stack")));
}
if !self.photos.contains_key(p) {
return Err(CatalogError::NoPhoto(*p));
}
}
if photos.iter().any(|p| self.stack_id_of(*p).is_some_and(|s| s != id)) {
return Err(CatalogError::Invalid("a photo can be in only one stack".into()));
}
Ok(())
}
/// Ops that put `top` and `members` into one stack with `top` on top. Stacks they already
/// belong to are merged into it (their other members follow, in order). `None` if fewer than
/// two photos would be in the stack.
pub fn group_ops(&mut self, top: PhotoId, members: &[PhotoId], collapsed: bool) -> Option<Op> {
let mut order: Vec<PhotoId> = Vec::new();
let push = |order: &mut Vec<PhotoId>, p: PhotoId| {
if !order.contains(&p) && self.photos.contains_key(&p) {
order.push(p);
}
};
push(&mut order, top);
for m in members {
push(&mut order, *m);
}
let mut touched: Vec<StackId> = Vec::new();
for p in order.clone() {
if let Some(s) = self.stack_of(p)
&& !touched.contains(&s.id)
{
touched.push(s.id);
}
}
for sid in &touched {
for p in self.stacks.get(sid).map(|s| s.photos.clone()).unwrap_or_default() {
push(&mut order, p);
}
}
if order.len() < 2 || order.first() != Some(&top) {
return None;
}
let mut ops: Vec<Op> = touched.iter().skip(1).map(|id| Op::RemoveStack { id: *id }).collect();
match touched.first() {
Some(keep) => ops.push(Op::SetStack { id: *keep, photos: order, collapsed }),
None => {
let id = self.alloc_stack_id();
ops.push(Op::AddStack { stack: Stack { id, photos: order, collapsed } });
}
}
Some(Op::Batch { ops })
}
/// Stack `sources` under `result` (e.g. an HDR or panorama merge with the photos it was made
/// from), collapsed so the grid shows the result.
pub fn stack_with_ops(&mut self, result: PhotoId, sources: &[PhotoId]) -> Option<Op> {
self.group_ops(result, sources, true)
}
/// Move `photo` `delta` places within its stack (towards the top for negative; index 0 is the
/// top). `None` when it isn't stacked or can't move.
pub fn move_in_stack_ops(&self, photo: PhotoId, delta: isize) -> Option<Op> {
let st = self.stack_of(photo)?;
let i = st.photos.iter().position(|p| *p == photo)?;
let to = (i as isize + delta).clamp(0, st.photos.len() as isize - 1) as usize;
if to == i {
return None;
}
let mut photos = st.photos.clone();
let p = photos.remove(i);
photos.insert(to, p);
Some(Op::SetStack { id: st.id, photos, collapsed: st.collapsed })
}
/// Split `photo`'s stack in two: the photos before it stay, it and the ones after it form a
/// new stack (topped by it). A part with a single photo is no longer stacked.
pub fn split_stack_ops(&mut self, photo: PhotoId) -> Option<Op> {
let st = self.stack_of(photo)?.clone();
let i = st.photos.iter().position(|p| *p == photo)?;
if i == 0 {
return None;
}
let (head, tail) = st.photos.split_at(i);
let mut ops = vec![if head.len() >= 2 {
Op::SetStack { id: st.id, photos: head.to_vec(), collapsed: st.collapsed }
} else {
Op::RemoveStack { id: st.id }
}];
if tail.len() >= 2 {
let id = self.alloc_stack_id();
ops.push(Op::AddStack { stack: Stack { id, photos: tail.to_vec(), collapsed: st.collapsed } });
}
Some(Op::Batch { ops })
}
/// Ops that dissolve every stack containing one of `photos`.
pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec<Op> {
self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect()
}
/// Ops that take `photos` out of their stacks (a stack left with one photo is dissolved).
pub fn remove_from_stacks_ops(&self, photos: &[PhotoId]) -> Vec<Op> {
self.stacks
.values()
.filter(|s| s.photos.iter().any(|p| photos.contains(p)))
.map(|s| {
let rest: Vec<PhotoId> = s.photos.iter().copied().filter(|p| !photos.contains(p)).collect();
if rest.len() < 2 { Op::RemoveStack { id: s.id } } else { Op::SetStack { id: s.id, photos: rest, collapsed: s.collapsed } }
})
.collect()
}
/// The op that moves `photo` to the top of its stack (`None` if unstacked or already on top).
pub fn set_top_ops(&self, photo: PhotoId) -> Option<Op> {
let s = self.stack_of(photo)?;
if s.top() == photo {
return None;
}
let mut photos = vec![photo];
photos.extend(s.photos.iter().copied().filter(|p| *p != photo));
Some(Op::SetStack { id: s.id, photos, collapsed: s.collapsed })
}
/// Ops that collapse or expand stacks.
pub fn set_collapsed_ops(&self, stacks: &[StackId], collapsed: bool) -> Vec<Op> {
stacks
.iter()
.filter_map(|id| self.stacks.get(id))
.filter(|s| s.collapsed != collapsed)
.map(|s| Op::SetStack { id: s.id, photos: s.photos.clone(), collapsed })
.collect()
}
/// Display order for a query result: a stack's members appear together (in stack order) where
/// its first member appears; a collapsed stack shows only its highest-ranked member in `ids` (the top,
/// unless the filter hides it).
pub fn arrange_stacks(&self, ids: &[PhotoId]) -> Vec<PhotoId> {
if self.stacks.is_empty() {
return ids.to_vec();
}
let index = self.stack_index();
let present: HashSet<PhotoId> = ids.iter().copied().collect();
let mut done: HashSet<StackId> = HashSet::new();
let mut out = Vec::with_capacity(ids.len());
for id in ids {
match index.get(id) {
None => out.push(*id),
Some((sid, _)) => {
if !done.insert(*sid) {
continue;
}
let Some(s) = self.stacks.get(sid) else {
out.push(*id);
continue;
};
let members = s.photos.iter().copied().filter(|p| present.contains(p));
if s.collapsed {
out.extend(members.take(1));
} else {
out.extend(members);
}
}
}
}
out
}
/// Auto-stack by capture time: runs of `ids` (not deleted, not already stacked, with a
/// capture time) whose consecutive captures are at most `gap_secs` apart, in time order.
/// Only runs of two or more are returned.
pub fn auto_stack_groups(&self, ids: &[PhotoId], gap_secs: f64) -> Vec<Vec<PhotoId>> {
let index = self.stack_index();
let mut timed: Vec<(i64, &str, PhotoId)> = ids
.iter()
.filter_map(|id| self.photos.get(id))
.filter(|p| !p.deleted && !index.contains_key(&p.id))
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.file_name.as_str(), p.id)))
.collect();
// a burst within one second stays in file name order (the first file is the stack's cover)
timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2)));
// equal ids are the same photo, so same time and name: adjacent after the sort
timed.dedup_by_key(|(_, _, id)| *id);
let mut groups: Vec<Vec<PhotoId>> = Vec::new();
let mut last: Option<i64> = None;
for (t, _, id) in timed {
match (last, groups.last_mut()) {
(Some(l), Some(g)) if (t - l) as f64 <= gap_secs => g.push(id),
_ => groups.push(vec![id]),
}
last = Some(t);
}
groups.retain(|g| g.len() >= 2);
groups
}
}
impl Catalog {
/// The bracket sets among `ids`, an HDR merge each: every stack one of them is in (its photos, less earlier merge
/// results), then the unstacked ones in capture order, a set running while each starts within about a second of the
/// previous one's end (its shutter time; capture times are whole seconds). A run is a set when its exposures span at
/// least ½ EV: a burst at one exposure is no bracket. Photos without a capture time or shutter speed are left out.
pub fn bracket_sets(&self, ids: &[PhotoId]) -> Vec<Vec<PhotoId>> {
let merged = |p: &crate::Photo| crate::query::merged_kind(&p.file_name).is_some();
let mut sets: Vec<Vec<PhotoId>> = Vec::new();
let mut stacks = HashSet::new();
let mut loose = Vec::new();
for id in ids {
match self.stack_of(*id) {
Some(s) if stacks.insert(s.id) => {
let set: Vec<PhotoId> = s.photos.iter().copied().filter(|p| self.photos.get(p).is_some_and(|p| !merged(p))).collect();
if set.len() >= 2 {
sets.push(set);
}
}
Some(_) => {}
None => loose.push(*id),
}
}
// (capture second, file name, id, shutter seconds, EV)
let mut timed: Vec<(i64, &str, PhotoId, f64, f64)> = loose
.iter()
.filter_map(|id| self.photos.get(id))
.filter(|p| !p.deleted && !merged(p))
.filter_map(|p| {
let t = iso_seconds(p.captured.as_deref()?)?;
let s = crate::model::parse_shutter_seconds(&p.meta.shutter)?;
let n = p.meta.aperture.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0) as f64;
let iso = p.meta.iso.filter(|i| *i > 0).unwrap_or(100) as f64;
Some((t, p.file_name.as_str(), p.id, s, (s * iso / (n * n)).log2()))
})
.collect();
timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2)));
timed.dedup_by_key(|x| x.2);
let mut runs: Vec<Vec<(i64, &str, PhotoId, f64, f64)>> = Vec::new();
for x in timed {
match runs.last_mut() {
Some(r) if r.last().is_some_and(|l| (x.0 - l.0) as f64 <= l.3.ceil() + 1.0) => r.push(x),
_ => runs.push(vec![x]),
}
}
for r in runs {
let (lo, hi) = r.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x.4), hi.max(x.4)));
if r.len() >= 2 && hi - lo >= 0.5 {
sets.push(r.iter().map(|x| x.2).collect());
}
}
sets
}
}
/// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`,
/// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse.
pub fn iso_seconds(s: &str) -> Option<i64> {
let s = s.trim();
let num = |a: usize, b: usize| -> Option<i64> { s.get(a..b).filter(|x| x.bytes().all(|c| c.is_ascii_digit())).and_then(|x| x.parse().ok()) };
let (y, m, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
let (hh, mm, ss) =
if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) };
if hh >= 24 || mm >= 60 || ss >= 60 {
return None;
}
// Days since 1970-01-01 (proleptic Gregorian). A day past the month's length rolls over the
// way the old Hinnant arithmetic did (e.g. 2026-02-30 → 2026-03-02), which `normalize_iso`
// relies on to reject impossible dates.
let first = chrono::NaiveDate::from_ymd_opt(i32::try_from(y).ok()?, u32::try_from(m).ok()?, 1)?;
let date = first.checked_add_days(chrono::Days::new(u64::try_from(d - 1).ok()?))?;
let days = (date - chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?).num_days();
Some(days * 86_400 + hh * 3600 + mm * 60 + ss)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Photo, Source};
fn cat(n: usize, times: &[&str]) -> (Catalog, Vec<PhotoId>) {
let mut c = Catalog::new();
let mut ids = Vec::new();
for i in 0..n {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, &format!("{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
p.captured = times.get(i).map(|t| t.to_string());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
(c, ids)
}
#[test]
fn auto_stack_keeps_a_burst_in_file_order() {
let mut c = Catalog::new();
let mut by_name = Vec::new();
for (n, name) in ["DSC_0001.ARW", "dsc_0002.ARW", "DSC_0003.ARW"].iter().enumerate() {
let id = PhotoId(1000 - n as u64);
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00");
p.captured = Some("2026-04-01T10:00:00".into());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
by_name.push(id);
}
let groups = c.auto_stack_groups(&[by_name[2], by_name[0], by_name[1]], 1.0);
assert_eq!(groups, vec![by_name.clone()]);
assert_eq!(groups[0][0], by_name[0], "the first file is the stack's cover");
}
#[test]
fn iso_seconds_parses() {
assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0));
assert_eq!(iso_seconds("1970-01-02"), Some(86_400));
assert_eq!(iso_seconds("2026-04-01T10:00:05.123+02:00").unwrap() - iso_seconds("2026-04-01T10:00:00").unwrap(), 5);
assert_eq!(iso_seconds("2024-03-01").unwrap() - iso_seconds("2024-02-28").unwrap(), 2 * 86_400, "leap year");
assert_eq!(iso_seconds("2000-01-01T00:00:00"), Some(946_684_800));
assert_eq!(iso_seconds("garbage"), None);
}
#[test]
fn group_merge_set_top_remove_and_arrange() {
let (mut c, ids) = cat(6, &[]);
let [a, b, x, d, e, f] = ids[..] else { panic!("six ids") };
let op = c.group_ops(b, &[a, b, x], false).unwrap();
let inv = c.apply(op).unwrap();
let s = c.stack_of(a).unwrap().clone();
assert_eq!(s.photos, vec![b, a, x]);
// query order a b x d e f → stack shown where its first member appears, in stack order
assert_eq!(c.arrange_stacks(&ids), vec![b, a, x, d, e, f]);
// collapse: only the top
let ops = c.set_collapsed_ops(&[s.id], true);
c.apply(Op::Batch { ops }).unwrap();
assert_eq!(c.arrange_stacks(&ids), vec![b, d, e, f]);
// filtered-out top: first visible member stands in
assert_eq!(c.arrange_stacks(&[x, d, a]), vec![a, d]);
// grouping with another stack merges both
let op = c.group_ops(d, &[e], false).unwrap();
c.apply(op).unwrap();
let op = c.group_ops(f, &[a, e], true).unwrap();
c.apply(op).unwrap();
assert_eq!(c.stacks().count(), 1);
assert_eq!(c.stack_of(f).unwrap().photos, vec![f, a, e, b, x, d]);
// set top, remove from stack, ungroup
c.apply(c.set_top_ops(x).unwrap()).unwrap();
assert_eq!(c.stack_of(f).unwrap().top(), x);
assert!(c.set_top_ops(x).is_none());
c.apply(Op::Batch { ops: c.remove_from_stacks_ops(&[x, a, e, b]) }).unwrap();
assert_eq!(c.stack_of(f).unwrap().photos, vec![f, d]);
c.apply(Op::Batch { ops: c.remove_from_stacks_ops(&[d]) }).unwrap();
assert_eq!(c.stacks().count(), 0, "a one-photo stack dissolves");
// undo everything back to the first group's inverse state
let (mut c2, _) = cat(6, &[]);
let op = c2.group_ops(a, &[b], false).unwrap();
c2.apply(op).unwrap();
c2.apply(Op::Batch { ops: c2.ungroup_ops(&[b]) }).unwrap();
assert_eq!(c2.stacks().count(), 0);
let _ = inv;
}
#[test]
fn stack_validation_and_permanent_delete() {
let (mut c, ids) = cat(3, &[]);
let id = c.alloc_stack_id();
assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0]], collapsed: false } }).is_err(), "one photo");
assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], ids[0]], collapsed: false } }).is_err(), "duplicate");
assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], PhotoId(99)], collapsed: false } }).is_err(), "missing");
c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], ids[1]], collapsed: true } }).unwrap();
let id2 = c.alloc_stack_id();
assert!(c.apply(Op::AddStack { stack: Stack { id: id2, photos: vec![ids[1], ids[2]], collapsed: false } }).is_err(), "one stack per photo");
// deleting a member permanently dissolves the 2-photo stack; undo restores it
let before = c.to_snapshot();
let inv = c.apply(c.delete_permanently_ops(ids[1])).unwrap();
assert!(c.stack_of(ids[0]).is_none());
c.apply(inv).unwrap();
assert_eq!(c.to_snapshot(), before);
// snapshot round trip keeps stacks and the id allocator
let back = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
assert_eq!(back.stack(id), c.stack(id));
assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id());
}
#[test]
fn bracket_sets_by_time_and_exposure() {
// (time, shutter, ISO, file name)
let shots = [
("2026-08-01T09:20:36", "1/125", 640, "A1.ARW"), // a handheld bracket
("2026-08-01T09:20:36", "1/125", 200, "A2.ARW"),
("2026-08-01T09:20:37", "1/125", 2000, "A3.ARW"),
("2026-08-01T09:20:37", "", 0, "A3-HDR.dng"), // an earlier merge of it
("2026-08-01T09:20:44", "1/125", 800, "B1.ARW"), // 7 s later: the next bracket
("2026-08-01T09:20:44", "1/125", 250, "B2.ARW"),
("2026-08-01T09:20:50", "1/1000", 160, "C1.ARW"), // a burst at one exposure
("2026-08-01T09:20:50", "1/1000", 160, "C2.ARW"),
("2026-06-27T23:34:59", "2", 320, "N1.ARW"), // 2 s night frames, 2.1 s apart
("2026-06-27T23:35:01", "2", 100, "N2.ARW"),
("2026-06-27T23:35:04", "2", 800, "N3.ARW"),
("2026-06-27T23:36:00", "1/40", 250, "S1.ARW"), // stacked by hand: merged whatever its times
("2026-06-27T23:39:00", "1/40", 250, "S2.ARW"),
];
let mut c = Catalog::new();
let mut ids = Vec::new();
for (t, sh, iso, name) in shots {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00");
p.captured = Some(t.into());
p.meta.shutter = sh.into();
p.meta.iso = Some(iso);
p.meta.aperture = Some(5.6);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
let op = c.group_ops(ids[11], &[ids[12]], true).unwrap();
c.apply(op).unwrap();
let sets = c.bracket_sets(&ids);
assert_eq!(sets, vec![vec![ids[11], ids[12]], vec![ids[8], ids[9], ids[10]], vec![ids[0], ids[1], ids[2]], vec![ids[4], ids[5]]]);
// a stack's merge result on top is left out; a stack selected twice is merged once
let op = c.stack_with_ops(ids[3], &[ids[0], ids[1], ids[2]]).unwrap();
c.apply(op).unwrap();
assert_eq!(c.bracket_sets(&[ids[3], ids[0]]), vec![vec![ids[0], ids[1], ids[2]]]);
}
#[test]
fn auto_stack_by_capture_time() {
let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"];
let (mut c, ids) = cat(6, &times);
let g = c.auto_stack_groups(&ids, 5.0);
assert_eq!(g, vec![vec![ids[0], ids[1]], vec![ids[3], ids[4]]]);
assert_eq!(c.auto_stack_groups(&ids, 10.0), vec![vec![ids[0], ids[1], ids[2]], vec![ids[3], ids[4]]]);
assert_eq!(c.auto_stack_groups(&ids, 3600.0).len(), 1);
// already stacked photos are skipped
let op = c.group_ops(ids[0], &[ids[1]], true).unwrap();
c.apply(op).unwrap();
assert_eq!(c.auto_stack_groups(&ids, 10.0), vec![vec![ids[3], ids[4]]]);
}
}