1use std::collections::HashMap;
4
5use rusqlite::{params, params_from_iter, types::ToSql, Connection, Result as SqliteResult};
6
7use super::MAX_ROWS_PER_INSERT;
8
9#[derive(Debug, Clone)]
11pub struct BurstGroupRecord {
12 pub id: i64,
13 pub start_time: String,
14 pub end_time: String,
15 pub photo_count: i64,
16 pub cover_thumbnail_paths: Vec<String>,
20 pub cover_photo_ids: Vec<i64>,
24 pub member_photo_ids: Vec<i64>,
29}
30
31#[derive(Debug, Clone)]
33pub struct BurstGroupMemberRecord {
34 pub photo_id: i64,
35 pub sharpness_score: Option<f32>,
36 pub blur_score: Option<f32>,
37 pub is_suggested_best: bool,
38}
39
40pub struct BurstRepo<'a> {
42 conn: &'a Connection,
43}
44
45impl<'a> BurstRepo<'a> {
46 pub fn new(conn: &'a Connection) -> Self {
47 Self { conn }
48 }
49
50 pub fn sync_burst_groups(
56 &self,
57 groups: &[(String, String, Vec<i64>)], ) -> SqliteResult<()> {
59 use std::collections::{BTreeSet, HashMap, HashSet};
60
61 let tx = self.conn.unchecked_transaction()?;
62
63 let mut existing_sets: HashMap<BTreeSet<i64>, (i64, bool)> = HashMap::new();
65 {
66 let mut grp_stmt = self.conn.prepare("SELECT id, resolved FROM burst_groups")?;
67 let group_ids: Vec<(i64, bool)> = grp_stmt
68 .query_map([], |row| {
69 Ok((row.get::<_, i64>(0)?, row.get::<_, bool>(1)?))
70 })?
71 .collect::<SqliteResult<Vec<_>>>()?;
72
73 for (gid, resolved) in group_ids {
74 let mut mem_stmt = self
75 .conn
76 .prepare("SELECT photo_id FROM burst_group_members WHERE group_id = ?1")?;
77 let members: BTreeSet<i64> = mem_stmt
78 .query_map(params![gid], |row| row.get::<_, i64>(0))?
79 .collect::<SqliteResult<BTreeSet<_>>>()?;
80 existing_sets.insert(members, (gid, resolved));
81 }
82 }
83
84 let mut seen_sets: HashSet<BTreeSet<i64>> = HashSet::new();
85
86 for (start_time, end_time, photo_ids) in groups {
87 let set: BTreeSet<i64> = photo_ids.iter().copied().collect();
88 seen_sets.insert(set.clone());
89
90 if existing_sets.contains_key(&set) {
91 continue; }
93 create_group_in_conn(&tx, start_time, end_time, photo_ids)?;
96 }
97
98 for (set, (group_id, _)) in &existing_sets {
100 if !seen_sets.contains(set) {
101 delete_group_in_conn(&tx, *group_id)?;
102 }
103 }
104
105 tx.commit()
106 }
107
108 pub fn upsert_burst_groups(
113 &self,
114 groups: &[(String, String, Vec<i64>)],
115 ) -> SqliteResult<usize> {
116 Ok(self.upsert_burst_groups_collecting_inserted(groups)?.len())
117 }
118
119 pub fn upsert_burst_groups_collecting_inserted(
120 &self,
121 groups: &[(String, String, Vec<i64>)],
122 ) -> SqliteResult<Vec<Vec<i64>>> {
123 use std::collections::{BTreeSet, HashMap};
124
125 let mut existing_sets: HashMap<BTreeSet<i64>, (i64, bool)> = HashMap::new();
126 {
127 let mut grp_stmt = self.conn.prepare("SELECT id, resolved FROM burst_groups")?;
128 let group_ids: Vec<(i64, bool)> = grp_stmt
129 .query_map([], |row| {
130 Ok((row.get::<_, i64>(0)?, row.get::<_, bool>(1)?))
131 })?
132 .collect::<SqliteResult<Vec<_>>>()?;
133
134 for (gid, resolved) in group_ids {
135 let mut mem_stmt = self
136 .conn
137 .prepare("SELECT photo_id FROM burst_group_members WHERE group_id = ?1")?;
138 let members: BTreeSet<i64> = mem_stmt
139 .query_map(params![gid], |row| row.get::<_, i64>(0))?
140 .collect::<SqliteResult<BTreeSet<_>>>()?;
141 existing_sets.insert(members, (gid, resolved));
142 }
143 }
144
145 let tx = self.conn.unchecked_transaction()?;
146 let mut inserted_sets = Vec::new();
147 for (start_time, end_time, photo_ids) in groups {
148 let set: BTreeSet<i64> = photo_ids.iter().copied().collect();
149 if existing_sets.contains_key(&set) {
150 continue;
151 }
152 let group_id = create_group_in_conn(&tx, start_time, end_time, photo_ids)?;
153 inserted_sets.push(set.iter().copied().collect());
154 existing_sets.insert(set, (group_id, false));
155 }
156 tx.commit()?;
157 Ok(inserted_sets)
158 }
159
160 pub fn delete_unresolved_groups_by_member_sets(
161 &self,
162 groups: &[Vec<i64>],
163 ) -> SqliteResult<usize> {
164 use std::collections::{BTreeSet, HashMap, HashSet};
165
166 if groups.is_empty() {
167 return Ok(0);
168 }
169 let targets: HashSet<BTreeSet<i64>> = groups
170 .iter()
171 .map(|ids| ids.iter().copied().collect())
172 .collect();
173 let mut existing_sets: HashMap<BTreeSet<i64>, i64> = HashMap::new();
174 {
175 let mut grp_stmt = self
176 .conn
177 .prepare("SELECT id FROM burst_groups WHERE resolved = FALSE")?;
178 let group_ids: Vec<i64> = grp_stmt
179 .query_map([], |row| row.get::<_, i64>(0))?
180 .collect::<SqliteResult<Vec<_>>>()?;
181
182 for gid in group_ids {
183 let mut mem_stmt = self
184 .conn
185 .prepare("SELECT photo_id FROM burst_group_members WHERE group_id = ?1")?;
186 let members: BTreeSet<i64> = mem_stmt
187 .query_map(params![gid], |row| row.get::<_, i64>(0))?
188 .collect::<SqliteResult<BTreeSet<_>>>()?;
189 existing_sets.insert(members, gid);
190 }
191 }
192
193 let tx = self.conn.unchecked_transaction()?;
194 let mut deleted = 0usize;
195 for set in targets {
196 if let Some(group_id) = existing_sets.get(&set) {
197 delete_group_in_conn(&tx, *group_id)?;
198 deleted += 1;
199 }
200 }
201 tx.commit()?;
202 Ok(deleted)
203 }
204
205 pub fn ensure_suggested_best(&self, group_id: i64) -> SqliteResult<()> {
210 let tx = self.conn.unchecked_transaction()?;
211 let has_best: i64 = tx.query_row(
212 "SELECT COUNT(*) FROM burst_group_members bgm \
213 JOIN photos p ON p.id = bgm.photo_id \
214 WHERE bgm.group_id = ?1 \
215 AND bgm.is_suggested_best = TRUE \
216 AND p.is_trashed = FALSE",
217 params![group_id],
218 |r| r.get(0),
219 )?;
220 if has_best > 0 {
221 tx.commit()?;
222 return Ok(());
223 }
224 tx.execute(
225 "UPDATE burst_group_members SET is_suggested_best = FALSE WHERE group_id = ?1",
226 params![group_id],
227 )?;
228 tx.execute(
229 r#"
230 UPDATE burst_group_members
231 SET is_suggested_best = TRUE
232 WHERE group_id = ?1
233 AND photo_id = (
234 SELECT bgm.photo_id
235 FROM burst_group_members bgm
236 JOIN photos p ON p.id = bgm.photo_id
237 WHERE bgm.group_id = ?1
238 AND p.is_trashed = FALSE
239 ORDER BY p.date_taken ASC, bgm.rowid ASC
240 LIMIT 1
241 )
242 "#,
243 params![group_id],
244 )?;
245 tx.commit()
246 }
247
248 pub fn set_suggested_best(&self, group_id: i64, photo_id: i64) -> SqliteResult<()> {
250 let tx = self.conn.unchecked_transaction()?;
251
252 let exists: i64 = tx.query_row(
253 "SELECT COUNT(*)
254 FROM burst_group_members bgm
255 JOIN burst_groups bg ON bg.id = bgm.group_id
256 WHERE bgm.group_id = ?1
257 AND bgm.photo_id = ?2
258 AND bg.resolved = FALSE",
259 params![group_id, photo_id],
260 |row| row.get(0),
261 )?;
262 if exists == 0 {
263 return Err(rusqlite::Error::QueryReturnedNoRows);
264 }
265
266 tx.execute(
267 "UPDATE burst_group_members SET is_suggested_best = FALSE WHERE group_id = ?1",
268 params![group_id],
269 )?;
270
271 tx.execute(
272 "UPDATE burst_group_members SET is_suggested_best = TRUE WHERE group_id = ?1 AND photo_id = ?2",
273 params![group_id, photo_id],
274 )?;
275
276 tx.commit()
277 }
278
279 pub fn get_all_groups(&self) -> SqliteResult<Vec<BurstGroupRecord>> {
284 self.get_groups(i64::MAX, 0)
285 }
286
287 pub fn get_groups(&self, limit: i64, offset: i64) -> SqliteResult<Vec<BurstGroupRecord>> {
288 let mut stmt = self.conn.prepare(
289 r#"
290 SELECT bg.id, bg.start_time, bg.end_time, COUNT(p.id) AS live_photo_count
291 FROM burst_groups bg
292 JOIN burst_group_members bgm ON bgm.group_id = bg.id
293 JOIN photos p ON p.id = bgm.photo_id
294 WHERE bg.resolved = FALSE
295 AND p.is_trashed = FALSE
296 GROUP BY bg.id
297 HAVING COUNT(p.id) > 1
298 ORDER BY bg.start_time DESC
299 LIMIT ?1 OFFSET ?2
300 "#,
301 )?;
302
303 let rows = stmt.query_map(params![limit.max(0), offset.max(0)], |row| {
304 Ok(BurstGroupRecord {
305 id: row.get(0)?,
306 start_time: row.get(1)?,
307 end_time: row.get(2)?,
308 photo_count: row.get(3)?,
309 cover_thumbnail_paths: Vec::new(),
310 cover_photo_ids: Vec::new(),
311 member_photo_ids: Vec::new(),
312 })
313 })?;
314
315 let mut groups = Vec::new();
316 for row in rows {
317 groups.push(row?);
318 }
319
320 let placeholders = vec!["?"; groups.len()].join(",");
324 if !groups.is_empty() {
329 let sql = format!(
330 "SELECT m.group_id, m.photo_id, p.thumbnail_path
331 FROM burst_group_members m
332 JOIN photos p ON p.id = m.photo_id
333 WHERE m.group_id IN ({placeholders}) AND p.is_trashed = FALSE
334 ORDER BY m.group_id, m.is_suggested_best DESC, p.date_taken ASC, m.photo_id ASC"
335 );
336 let group_ids: Vec<i64> = groups.iter().map(|g| g.id).collect();
337 let mut members: HashMap<i64, Vec<i64>> = HashMap::with_capacity(groups.len());
338 let mut covers: HashMap<i64, Vec<(i64, String)>> = HashMap::with_capacity(groups.len());
339 let mut detail_stmt = self.conn.prepare(&sql)?;
340 let rows = detail_stmt.query_map(params_from_iter(group_ids), |row| {
341 Ok((
342 row.get::<_, i64>(0)?,
343 row.get::<_, i64>(1)?,
344 row.get::<_, Option<String>>(2)?,
345 ))
346 })?;
347 for row in rows {
348 let (group_id, photo_id, thumbnail) = row?;
349 members.entry(group_id).or_default().push(photo_id);
350 if let Some(path) = thumbnail {
351 let group_covers = covers.entry(group_id).or_default();
352 if group_covers.len() < 6 {
353 group_covers.push((photo_id, path));
354 }
355 }
356 }
357 for group in &mut groups {
358 group.member_photo_ids = members.remove(&group.id).unwrap_or_default();
359 let group_covers = covers.remove(&group.id).unwrap_or_default();
360 group.cover_photo_ids = group_covers.iter().map(|(id, _)| *id).collect();
361 group.cover_thumbnail_paths = group_covers.into_iter().map(|(_, p)| p).collect();
362 }
363 }
364
365 Ok(groups)
366 }
367
368 pub fn get_group_members(&self, group_id: i64) -> SqliteResult<Vec<BurstGroupMemberRecord>> {
370 let mut stmt = self.conn.prepare(
371 r#"
372 SELECT
373 bgm.photo_id,
374 bgm.sharpness_score,
375 bgm.blur_score,
376 bgm.face_count,
377 bgm.is_suggested_best,
378 p.file_path
379 FROM burst_group_members bgm
380 JOIN burst_groups bg ON bg.id = bgm.group_id
381 JOIN photos p ON bgm.photo_id = p.id
382 WHERE bgm.group_id = ?1
383 AND bg.resolved = FALSE
384 AND p.is_trashed = FALSE
385 ORDER BY bgm.is_suggested_best DESC, p.date_taken ASC
386 "#,
387 )?;
388
389 let rows = stmt.query_map(params![group_id], |row| {
390 Ok(BurstGroupMemberRecord {
391 photo_id: row.get(0)?,
392 sharpness_score: row.get(1)?,
393 blur_score: row.get(2)?,
394 is_suggested_best: row.get(4)?,
395 })
396 })?;
397
398 let mut members = Vec::new();
399 for row in rows {
400 members.push(row?);
401 }
402
403 Ok(members)
404 }
405
406 pub fn get_photos_to_trash(&self, group_id: i64) -> SqliteResult<Vec<i64>> {
408 let mut stmt = self.conn.prepare(
409 r#"
410 SELECT bgm.photo_id
411 FROM burst_group_members bgm
412 JOIN burst_groups bg ON bg.id = bgm.group_id
413 JOIN photos p ON p.id = bgm.photo_id
414 WHERE bgm.group_id = ?1
415 AND bgm.is_suggested_best = FALSE
416 AND bg.resolved = FALSE
417 AND p.is_trashed = FALSE
418 "#,
419 )?;
420
421 let rows = stmt.query_map(params![group_id], |row| row.get(0))?;
422
423 let mut photo_ids = Vec::new();
424 for row in rows {
425 photo_ids.push(row?);
426 }
427
428 Ok(photo_ids)
429 }
430
431 pub fn delete_group(&self, group_id: i64) -> SqliteResult<()> {
433 delete_group_in_conn(self.conn, group_id)
434 }
435
436 pub fn dismiss_group(&self, group_id: i64) -> SqliteResult<()> {
438 let updated = self.conn.execute(
439 "UPDATE burst_groups SET resolved = TRUE WHERE id = ?1",
440 params![group_id],
441 )?;
442 if updated == 0 {
443 return Err(rusqlite::Error::QueryReturnedNoRows);
444 }
445 Ok(())
446 }
447}
448
449fn delete_group_in_conn(conn: &Connection, group_id: i64) -> SqliteResult<()> {
450 conn.execute(
451 "DELETE FROM burst_group_members WHERE group_id = ?1",
452 params![group_id],
453 )?;
454 conn.execute("DELETE FROM burst_groups WHERE id = ?1", params![group_id])?;
455 Ok(())
456}
457
458fn create_group_in_conn(
462 conn: &Connection,
463 start_time: &str,
464 end_time: &str,
465 photo_ids: &[i64],
466) -> SqliteResult<i64> {
467 conn.execute(
468 r#"
469 INSERT INTO burst_groups (start_time, end_time, photo_count)
470 VALUES (?1, ?2, ?3)
471 "#,
472 params![start_time, end_time, photo_ids.len() as i64],
473 )?;
474 let group_id = conn.last_insert_rowid();
475 insert_group_members(conn, group_id, photo_ids)?;
476 if let Some(first) = photo_ids.first() {
480 conn.execute(
481 "UPDATE burst_group_members SET is_suggested_best = TRUE WHERE group_id = ?1 AND photo_id = ?2",
482 params![group_id, first],
483 )?;
484 }
485 Ok(group_id)
486}
487
488fn insert_group_members(conn: &Connection, group_id: i64, photo_ids: &[i64]) -> SqliteResult<()> {
492 if photo_ids.is_empty() {
493 return Ok(());
494 }
495 for chunk in photo_ids.chunks(MAX_ROWS_PER_INSERT) {
496 let placeholders: String = (0..chunk.len())
497 .map(|_| "(?, ?)")
498 .collect::<Vec<_>>()
499 .join(", ");
500 let sql = format!(
501 "INSERT INTO burst_group_members (group_id, photo_id) VALUES {}",
502 placeholders
503 );
504 let mut params_vec: Vec<Box<dyn ToSql>> = Vec::with_capacity(chunk.len() * 2);
505 for pid in chunk {
506 params_vec.push(Box::new(group_id));
507 params_vec.push(Box::new(*pid));
508 }
509 let params_refs: Vec<&dyn ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
510 conn.execute(&sql, params_refs.as_slice())?;
511 }
512 Ok(())
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use crate::db::create_schema;
519
520 #[test]
521 fn invalid_best_photo_does_not_clear_existing_best() {
522 let conn = Connection::open_in_memory().unwrap();
523 create_schema(&conn).unwrap();
524 conn.execute(
525 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken)
526 VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z'),
527 (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z')",
528 [],
529 )
530 .unwrap();
531
532 let repo = BurstRepo::new(&conn);
533 repo.upsert_burst_groups(&[(
534 "2026-01-01T00:00:00Z".into(),
535 "2026-01-01T00:00:01Z".into(),
536 vec![1, 2],
537 )])
538 .unwrap();
539 let group_id = repo.get_all_groups().unwrap()[0].id;
540
541 assert!(repo.set_suggested_best(group_id, 999).is_err());
542
543 let best_id: i64 = conn
544 .query_row(
545 "SELECT photo_id FROM burst_group_members
546 WHERE group_id = ?1 AND is_suggested_best = TRUE",
547 params![group_id],
548 |row| row.get(0),
549 )
550 .unwrap();
551 assert_eq!(best_id, 1);
552 }
553
554 #[test]
555 fn groups_ignore_trashed_members() {
556 let conn = Connection::open_in_memory().unwrap();
557 create_schema(&conn).unwrap();
558 conn.execute(
559 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
560 VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z', FALSE),
561 (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z', FALSE),
562 (3, 'c.jpg', 'c.jpg', 'c', 10, '2026-01-01T00:00:02Z', TRUE)",
563 [],
564 )
565 .unwrap();
566
567 let repo = BurstRepo::new(&conn);
568 repo.upsert_burst_groups(&[(
569 "2026-01-01T00:00:00Z".into(),
570 "2026-01-01T00:00:02Z".into(),
571 vec![1, 2, 3],
572 )])
573 .unwrap();
574
575 let group = repo.get_all_groups().unwrap().pop().unwrap();
576 assert_eq!(group.photo_count, 2);
577 assert_eq!(group.member_photo_ids, vec![1, 2]);
578 assert_eq!(
579 repo.get_group_members(group.id)
580 .unwrap()
581 .into_iter()
582 .map(|m| m.photo_id)
583 .collect::<Vec<_>>(),
584 vec![1, 2]
585 );
586 }
587
588 #[test]
589 fn ensure_suggested_best_replaces_trashed_best() {
590 let conn = Connection::open_in_memory().unwrap();
591 create_schema(&conn).unwrap();
592 conn.execute(
593 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
594 VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z', TRUE),
595 (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z', FALSE),
596 (3, 'c.jpg', 'c.jpg', 'c', 10, '2026-01-01T00:00:02Z', FALSE)",
597 [],
598 )
599 .unwrap();
600
601 let repo = BurstRepo::new(&conn);
602 repo.upsert_burst_groups(&[(
603 "2026-01-01T00:00:00Z".into(),
604 "2026-01-01T00:00:02Z".into(),
605 vec![1, 2, 3],
606 )])
607 .unwrap();
608 let group_id = conn
609 .query_row("SELECT id FROM burst_groups", [], |row| row.get(0))
610 .unwrap();
611
612 repo.ensure_suggested_best(group_id).unwrap();
613
614 assert_eq!(repo.get_photos_to_trash(group_id).unwrap(), vec![3]);
615 let best_ids = conn
616 .prepare(
617 "SELECT photo_id FROM burst_group_members
618 WHERE group_id = ?1 AND is_suggested_best = TRUE
619 ORDER BY photo_id",
620 )
621 .unwrap()
622 .query_map(params![group_id], |row| row.get::<_, i64>(0))
623 .unwrap()
624 .collect::<SqliteResult<Vec<_>>>()
625 .unwrap();
626 assert_eq!(best_ids, vec![2]);
627 }
628
629 #[test]
630 fn dismissed_group_does_not_reappear_on_next_detection() {
631 let conn = Connection::open_in_memory().unwrap();
632 create_schema(&conn).unwrap();
633 conn.execute(
634 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken)
635 VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z'),
636 (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z')",
637 [],
638 )
639 .unwrap();
640
641 let repo = BurstRepo::new(&conn);
642 let detected = [(
643 "2026-01-01T00:00:00Z".into(),
644 "2026-01-01T00:00:01Z".into(),
645 vec![1, 2],
646 )];
647 repo.sync_burst_groups(&detected).unwrap();
648 let group_id = repo.get_all_groups().unwrap()[0].id;
649
650 repo.dismiss_group(group_id).unwrap();
651 assert!(repo.get_all_groups().unwrap().is_empty());
652
653 assert_eq!(repo.upsert_burst_groups(&detected).unwrap(), 0);
654 assert!(repo.get_all_groups().unwrap().is_empty());
655
656 repo.sync_burst_groups(&detected).unwrap();
657 assert!(repo.get_all_groups().unwrap().is_empty());
658 }
659
660 #[test]
661 fn sync_prunes_stale_groups_inside_transaction() {
662 let conn = Connection::open_in_memory().unwrap();
663 create_schema(&conn).unwrap();
664 conn.execute(
665 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken)
666 VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z'),
667 (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z'),
668 (3, 'c.jpg', 'c.jpg', 'c', 10, '2026-01-01T00:00:02Z'),
669 (4, 'd.jpg', 'd.jpg', 'd', 10, '2026-01-01T00:00:03Z')",
670 [],
671 )
672 .unwrap();
673
674 let repo = BurstRepo::new(&conn);
675 repo.upsert_burst_groups(&[
676 (
677 "2026-01-01T00:00:00Z".into(),
678 "2026-01-01T00:00:01Z".into(),
679 vec![1, 2],
680 ),
681 (
682 "2026-01-01T00:00:02Z".into(),
683 "2026-01-01T00:00:03Z".into(),
684 vec![3, 4],
685 ),
686 ])
687 .unwrap();
688
689 repo.sync_burst_groups(&[(
690 "2026-01-01T00:00:00Z".into(),
691 "2026-01-01T00:00:01Z".into(),
692 vec![1, 2],
693 )])
694 .unwrap();
695
696 let group_count: i64 = conn
697 .query_row("SELECT COUNT(*) FROM burst_groups", [], |row| row.get(0))
698 .unwrap();
699 let member_count: i64 = conn
700 .query_row("SELECT COUNT(*) FROM burst_group_members", [], |row| {
701 row.get(0)
702 })
703 .unwrap();
704
705 assert_eq!(group_count, 1);
706 assert_eq!(member_count, 2);
707 }
708
709 #[test]
710 fn live_insert_cleanup_removes_only_new_unresolved_groups() {
711 let conn = Connection::open_in_memory().unwrap();
712 create_schema(&conn).unwrap();
713 conn.execute(
714 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken)
715 VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z'),
716 (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z'),
717 (3, 'c.jpg', 'c.jpg', 'c', 10, '2026-01-01T00:00:02Z'),
718 (4, 'd.jpg', 'd.jpg', 'd', 10, '2026-01-01T00:00:03Z')",
719 [],
720 )
721 .unwrap();
722
723 let repo = BurstRepo::new(&conn);
724 repo.upsert_burst_groups(&[(
725 "2026-01-01T00:00:00Z".into(),
726 "2026-01-01T00:00:01Z".into(),
727 vec![1, 2],
728 )])
729 .unwrap();
730
731 let inserted = repo
732 .upsert_burst_groups_collecting_inserted(&[
733 (
734 "2026-01-01T00:00:00Z".into(),
735 "2026-01-01T00:00:01Z".into(),
736 vec![1, 2],
737 ),
738 (
739 "2026-01-01T00:00:02Z".into(),
740 "2026-01-01T00:00:03Z".into(),
741 vec![3, 4],
742 ),
743 ])
744 .unwrap();
745
746 assert_eq!(inserted, vec![vec![3, 4]]);
747 assert_eq!(
748 repo.delete_unresolved_groups_by_member_sets(&inserted)
749 .unwrap(),
750 1
751 );
752 let remaining = repo.get_all_groups().unwrap();
753 assert_eq!(remaining.len(), 1);
754 assert_eq!(remaining[0].member_photo_ids, vec![1, 2]);
755 }
756
757 #[test]
758 fn dismissed_group_cannot_be_mutated_or_trashed_from_stale_detail() {
759 let conn = Connection::open_in_memory().unwrap();
760 create_schema(&conn).unwrap();
761 conn.execute(
762 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken)
763 VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z'),
764 (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z')",
765 [],
766 )
767 .unwrap();
768
769 let repo = BurstRepo::new(&conn);
770 repo.sync_burst_groups(&[(
771 "2026-01-01T00:00:00Z".into(),
772 "2026-01-01T00:00:01Z".into(),
773 vec![1, 2],
774 )])
775 .unwrap();
776 let group_id = repo.get_all_groups().unwrap()[0].id;
777
778 repo.dismiss_group(group_id).unwrap();
779
780 assert!(repo.set_suggested_best(group_id, 2).is_err());
781 assert!(repo.get_photos_to_trash(group_id).unwrap().is_empty());
782 }
783}