1use crate::error::{Error, Result};
6use crate::types::*;
7use rusqlite::Connection;
8use std::collections::HashMap;
9
10fn faces_table_exists(conn: &Connection) -> bool {
12 conn.query_row(
13 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='faces'",
14 [],
15 |r| r.get::<_, i64>(0),
16 )
17 .map(|n| n > 0)
18 .unwrap_or(false)
19}
20
21pub fn faces_list(conn: &Connection) -> Result<FacesData> {
33 if !faces_table_exists(conn) {
34 return Ok(FacesData::default());
35 }
36 let mut people: HashMap<String, PersonData> = HashMap::new();
37 {
38 let mut stmt = conn.prepare(
39 "SELECT f.id, f.hash, f.person_label, COALESCE(p.full_name, f.person_label) \
43 FROM faces f LEFT JOIN people p ON p.name = f.person_label \
44 WHERE f.confirmed = 1 AND f.person_label IS NOT NULL \
45 ORDER BY f.person_label, f.is_primary DESC, f.id ASC",
46 )?;
47 let rows = stmt.query_map([], |r| {
48 Ok((
49 r.get::<_, i64>(0)?,
50 r.get::<_, String>(1)?,
51 r.get::<_, String>(2)?,
52 r.get::<_, String>(3)?,
53 ))
54 })?;
55 for row in rows {
56 let (id, hash, label, full_name) = row?;
57 let person = people.entry(label.clone()).or_insert(PersonData {
58 label: label.clone(),
59 full_name,
60 face_ids: vec![],
61 representative_id: id,
62 hashes: vec![],
63 });
64 person.face_ids.push(id);
65 if !person.hashes.contains(&hash) {
66 person.hashes.push(hash);
67 }
68 }
69 }
70
71 let mut cluster_map: HashMap<i64, ClusterData> = HashMap::new();
72 {
73 let mut stmt = conn.prepare(
74 "SELECT id, hash, cluster_id FROM faces \
75 WHERE cluster_id IS NOT NULL AND (confirmed = 0 OR person_label IS NULL) \
76 ORDER BY cluster_id, id",
77 )?;
78 let rows = stmt.query_map([], |r| {
79 Ok((
80 r.get::<_, i64>(0)?,
81 r.get::<_, String>(1)?,
82 r.get::<_, i64>(2)?,
83 ))
84 })?;
85 for row in rows {
86 let (id, hash, cid) = row?;
87 let cluster = cluster_map.entry(cid).or_insert(ClusterData {
88 cluster_id: cid,
89 face_ids: vec![],
90 hashes: vec![],
91 });
92 cluster.face_ids.push(id);
93 if !cluster.hashes.contains(&hash) {
94 cluster.hashes.push(hash);
95 }
96 }
97 }
98
99 let mut singletons: Vec<SingletonData> = vec![];
100 {
101 let mut stmt = conn.prepare(
102 "SELECT id, hash FROM faces \
103 WHERE cluster_id IS NULL AND (confirmed = 0 OR person_label IS NULL) \
104 ORDER BY id",
105 )?;
106 let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
107 for row in rows {
108 let (id, hash) = row?;
109 singletons.push(SingletonData { face_id: id, hash });
110 }
111 }
112
113 let mut people: Vec<PersonData> = people.into_values().collect();
125 people.sort_by_key(|a| a.full_name.to_lowercase());
126 let mut clusters: Vec<ClusterData> = cluster_map.into_values().collect();
127 clusters.sort_by(|a, b| {
128 b.face_ids
129 .len()
130 .cmp(&a.face_ids.len())
131 .then(a.cluster_id.cmp(&b.cluster_id))
132 });
133
134 Ok(FacesData {
135 people,
136 clusters,
137 singletons,
138 })
139}
140
141pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
143 let mut stmt = conn.prepare(
147 "SELECT f.id, f.hash, fh.path FROM faces f \
148 JOIN file_hashes fh ON f.hash = fh.hash \
149 WHERE f.cluster_id = ?1 AND (f.confirmed = 0 OR f.person_label IS NULL) \
150 ORDER BY f.id",
151 )?;
152 let faces = stmt
153 .query_map([cluster_id], |r| {
154 Ok(ClusterFaceData {
155 face_id: r.get(0)?,
156 hash: r.get(1)?,
157 path: r.get(2)?,
158 })
159 })?
160 .collect::<rusqlite::Result<Vec<_>>>()?;
161 Ok(ClusterDetail { cluster_id, faces })
162}
163
164pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
166 let name = videre_core::person::normalize(name).unwrap_or_else(|| name.to_string());
170 let name = name.as_str();
171 let mut stmt = conn.prepare(
172 "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
173 JOIN file_hashes fh ON f.hash = fh.hash \
174 WHERE f.person_label = ?1 AND f.confirmed = 1 \
175 ORDER BY f.is_primary DESC, f.id",
176 )?;
177 let faces = stmt
178 .query_map([name], |r| {
179 Ok(PersonFaceData {
180 face_id: r.get(0)?,
181 hash: r.get(1)?,
182 path: r.get(2)?,
183 is_primary: r.get::<_, i64>(3)? != 0,
184 })
185 })?
186 .collect::<rusqlite::Result<Vec<_>>>()?;
187 let full_name: String = conn
190 .query_row(
191 "SELECT full_name FROM people WHERE name = ?1",
192 rusqlite::params![name],
193 |r| r.get(0),
194 )
195 .unwrap_or_else(|_| name.to_string());
196 Ok(PersonDetail {
197 label: name.to_string(),
198 full_name,
199 faces,
200 })
201}
202
203pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
206 Ok(videre_core::person_search::search_by_person(
207 conn, name, None,
208 )?)
209}
210
211pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
214 let display = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
218 let label = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
219 if face_ids.is_empty() {
222 return Err(Error::Invalid);
223 }
224 conn.execute_batch("BEGIN")?;
229 let result = (|| -> Result<()> {
230 conn.execute(
231 "INSERT INTO people (name, full_name) VALUES (?1, ?2) ON CONFLICT(name) DO NOTHING",
232 rusqlite::params![&label, &display],
233 )?;
234 for id in face_ids {
235 let n = conn.execute(
239 "UPDATE faces SET person_label = ?1, confirmed = 1, cluster_id = NULL WHERE id = ?2",
240 rusqlite::params![label, id],
241 )?;
242 if n == 0 {
243 return Err(Error::NotFound);
244 }
245 }
246 Ok(())
247 })();
248 match result {
249 Ok(()) => {
250 conn.execute_batch("COMMIT")?;
251 Ok(())
252 }
253 Err(e) => {
254 let _ = conn.execute_batch("ROLLBACK");
255 Err(e)
256 }
257 }
258}
259
260pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
264 assign(conn, face_ids, label)
265}
266
267pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
269 let n = conn.execute(
273 "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
274 [face_id],
275 )?;
276 if n == 0 {
277 return Err(Error::NotFound);
278 }
279 Ok(())
280}
281
282pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
284 let n = conn.execute(
288 "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
289 [cluster_id],
290 )?;
291 if n == 0 {
292 return Err(Error::NotFound);
293 }
294 Ok(())
295}
296
297pub fn set_full_name(conn: &Connection, name: &str, full_name: &str) -> Result<()> {
308 let display = crate::label::sanitize_person_label(full_name).ok_or(Error::Invalid)?;
309 let name = videre_core::person::normalize(name).ok_or(Error::Invalid)?;
310 let n = conn.execute(
311 "UPDATE people SET full_name = ?1 WHERE name = ?2",
312 rusqlite::params![display, name],
313 )?;
314 if n == 0 {
315 return Err(Error::NotFound);
316 }
317 Ok(())
318}
319
320pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
321 let label = videre_core::person::normalize(label).unwrap_or_else(|| label.to_string());
322 conn.execute_batch("BEGIN")?;
325 let result = (|| -> Result<()> {
326 let n = conn.execute(
327 "UPDATE faces SET person_label = NULL, confirmed = 0, is_primary = 0, cluster_id = NULL WHERE person_label = ?1",
328 rusqlite::params![label],
329 )?;
330 if n > 0 {
331 videre_core::library_state::set(
338 conn,
339 videre_core::library_state::FACE_RECLUSTER_WATERMARK,
340 0,
341 )?;
342 }
343 Ok(())
344 })();
345 match result {
346 Ok(()) => {
347 conn.execute_batch("COMMIT")?;
348 Ok(())
349 }
350 Err(e) => {
351 let _ = conn.execute_batch("ROLLBACK");
352 Err(e)
353 }
354 }
355}
356
357pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
362 let person_label =
363 videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
364 conn.execute_batch("BEGIN")?;
365 let result = (|| -> Result<()> {
366 conn.execute(
367 "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
368 rusqlite::params![person_label],
369 )?;
370 let n = conn.execute(
375 "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
376 rusqlite::params![person_label, face_id],
377 )?;
378 if n == 0 {
379 return Err(Error::NotFound);
380 }
381 Ok(())
382 })();
383 match result {
384 Ok(()) => {
385 conn.execute_batch("COMMIT")?;
386 Ok(())
387 }
388 Err(e) => {
389 let _ = conn.execute_batch("ROLLBACK");
390 Err(e)
391 }
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn assign_detaches_the_face_from_its_cluster() {
401 let conn = seed();
402 assign(&conn, &[3], "Bob").unwrap();
405 let (label, confirmed, cid): (Option<String>, i64, Option<i64>) = conn
406 .query_row(
407 "SELECT person_label, confirmed, cluster_id FROM faces WHERE id = 3",
408 [],
409 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
410 )
411 .unwrap();
412 assert_eq!(label.as_deref(), Some("bob"));
413 assert_eq!(confirmed, 1);
414 assert_eq!(cid, None, "assignment must detach the machine grouping");
415 }
416
417 #[test]
418 fn cluster_detail_never_shows_labeled_faces() {
419 let conn = seed();
420 conn.execute(
424 "INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed) VALUES
425 (11,'h6','0,0,9,9',X'0000',7,'alice',1)",
426 [],
427 )
428 .unwrap();
429 conn.execute(
430 "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg')",
431 [],
432 )
433 .unwrap();
434 let detail = cluster_detail(&conn, 7).unwrap();
435 assert_eq!(
436 detail.faces.len(),
437 2,
438 "only the unlabeled faces of cluster 7 belong on the page"
439 );
440 }
441
442 pub(super) fn seed() -> Connection {
449 let conn = Connection::open_in_memory().unwrap();
450 videre_core::face_db::create_faces_table(&conn).unwrap();
451 conn.execute_batch(
452 "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
453 INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
454 ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
455 -- Labels are stored in identity form, as `assign` writes them and
456 -- as the migration leaves them; `people` carries what a reader
457 -- sees. Seeding raw 'Alice' would test a state the application no
458 -- longer produces.
459 INSERT INTO people (name, full_name) VALUES ('alice','Alice');
460 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
461 (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
462 (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
463 (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
464 (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
465 (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
466 )
467 .unwrap();
468 videre_core::db::ensure_file_hashes_columns(&conn);
469 conn
470 }
471
472 #[test]
473 fn the_list_comes_back_in_the_same_order_every_time() {
474 let conn = seed();
481 conn.execute_batch(
485 "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
488 ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
489 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
490 (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
491 (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
492 (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
493 (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
494 (10,'h10','0,0,9,9',X'0000',NULL,'Bob',1,0);",
495 )
496 .unwrap();
497
498 let a = faces_list(&conn).unwrap();
501 let b = faces_list(&conn).unwrap();
502
503 let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
504 let names =
505 |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
506 assert!(ids(&a).len() >= 3, "fixture must have several clusters");
507 assert_eq!(
508 ids(&a),
509 ids(&b),
510 "cluster order must not change between calls"
511 );
512 assert_eq!(
513 names(&a),
514 names(&b),
515 "people order must not change between calls"
516 );
517
518 let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
521 let mut want = sizes.clone();
522 want.sort_unstable_by(|x, y| y.cmp(x));
523 assert_eq!(
524 sizes, want,
525 "clusters must be ordered largest first, got {sizes:?}"
526 );
527 }
528
529 #[test]
530 fn faces_list_splits_people_clusters_singletons() {
531 let conn = seed();
532 let d = faces_list(&conn).unwrap();
533 assert_eq!(d.people.len(), 1);
534 assert_eq!(d.people[0].label, "alice");
536 assert_eq!(d.people[0].full_name, "Alice");
537 assert_eq!(
538 d.people[0].representative_id, 1,
539 "primary face is representative"
540 );
541 assert_eq!(d.clusters.len(), 1);
542 assert_eq!(d.clusters[0].cluster_id, 7);
543 assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
544 assert_eq!(d.singletons.len(), 1);
545 assert_eq!(d.singletons[0].face_id, 5);
546 }
547
548 #[test]
549 fn person_detail_marks_primary() {
550 let conn = seed();
551 let p = person_detail(&conn, "Alice").unwrap();
552 assert_eq!(p.faces.len(), 2);
553 assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
554 assert!(!p.faces[1].is_primary);
555 }
556
557 #[test]
558 fn cluster_detail_lists_faces() {
559 let conn = seed();
560 let c = cluster_detail(&conn, 7).unwrap();
561 assert_eq!(c.cluster_id, 7);
562 assert_eq!(
563 c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
564 vec![3, 4]
565 );
566 }
567
568 #[test]
569 fn assign_labels_and_confirms() {
570 let conn = seed();
571 assign(&conn, &[3, 4], "Bob").unwrap();
572 let p = person_detail(&conn, "Bob").unwrap();
573 assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
574 }
575
576 #[test]
577 fn assign_rejects_empty_label() {
578 let conn = seed();
579 assert!(matches!(assign(&conn, &[3], " "), Err(Error::Invalid)));
580 }
581
582 #[test]
583 fn remove_face_unassigns_everything() {
584 let conn = seed();
585 remove_face(&conn, 1).unwrap();
586 let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
587 .query_row(
588 "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
589 [],
590 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
591 )
592 .unwrap();
593 assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
594 }
595
596 #[test]
597 fn dissolve_cluster_nulls_cluster_id() {
598 let conn = seed();
599 dissolve_cluster(&conn, 7).unwrap();
600 assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
601 assert_eq!(
602 faces_list(&conn).unwrap().singletons.len(),
603 3,
604 "3,4 join 5 as singletons"
605 );
606 }
607
608 #[test]
609 fn deleting_a_missing_person_leaves_the_regrouping_gate_alone() {
610 let conn = seed();
613 videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
614 let before = videre_core::face_db::recluster_watermark(&conn).unwrap();
615 assert!(before > 0);
616 delete_person(&conn, "ghost").unwrap();
617 assert_eq!(
618 videre_core::face_db::recluster_watermark(&conn).unwrap(),
619 before,
620 "a no-op delete must not reopen the gated regroup"
621 );
622 }
623
624 #[test]
625 fn delete_person_returns_faces_to_the_unassigned_pool_and_reopens_regrouping() {
626 let conn = seed();
633 assign(&conn, &[1, 2], "Alice").unwrap();
634 assert_eq!(faces_list(&conn).unwrap().people.len(), 1);
635 videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
638 assert!(videre_core::face_db::recluster_watermark(&conn).unwrap() > 0);
639
640 delete_person(&conn, "Alice").unwrap();
641 assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
642 assert_eq!(
643 videre_core::face_db::recluster_watermark(&conn).unwrap(),
644 0,
645 "deleting a person must reopen the gated regroup for their faces"
646 );
647 let rows: Vec<(Option<i64>, Option<String>, i64)> = {
648 let mut s = conn
649 .prepare("SELECT cluster_id, person_label, confirmed FROM faces WHERE id IN (1, 2) ORDER BY id")
650 .unwrap();
651 s.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
652 .unwrap()
653 .collect::<rusqlite::Result<_>>()
654 .unwrap()
655 };
656 assert!(
657 rows.iter()
658 .all(|(cid, label, confirmed)| cid.is_none() && label.is_none() && *confirmed == 0),
659 "every face returns to the unassigned pool: {rows:?}"
660 );
661 }
662
663 #[test]
664 fn set_primary_is_exclusive_per_person() {
665 let conn = seed();
666 set_primary(&conn, 2, "Alice").unwrap();
667 let primaries: Vec<i64> = {
668 let mut s = conn
669 .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
670 .unwrap();
671 s.query_map([], |r| r.get(0))
672 .unwrap()
673 .collect::<rusqlite::Result<_>>()
674 .unwrap()
675 };
676 assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
677 }
678
679 #[test]
680 fn renaming_only_the_spelling_keeps_the_identity() {
681 let conn = seed();
684 set_full_name(&conn, "alice", "Alice Smith").unwrap();
685 let (name, full): (String, String) = conn
686 .query_row("SELECT name, full_name FROM people", [], |r| {
687 Ok((r.get(0)?, r.get(1)?))
688 })
689 .unwrap();
690 assert_eq!(name, "alice", "identity is unchanged");
691 assert_eq!(full, "Alice Smith", "only the display name moved");
692 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
693 }
694
695 #[test]
702 fn assign_a_missing_face_is_not_found() {
703 let conn = seed();
704 assert!(matches!(assign(&conn, &[999], "Bob"), Err(Error::NotFound)));
705 }
706
707 #[test]
708 fn assign_is_atomic_when_one_face_is_missing() {
709 let conn = seed();
713 assert!(matches!(
714 assign(&conn, &[3, 999], "Bob"),
715 Err(Error::NotFound)
716 ));
717 let (label, confirmed): (Option<String>, i64) = conn
718 .query_row(
719 "SELECT person_label, confirmed FROM faces WHERE id = 3",
720 [],
721 |r| Ok((r.get(0)?, r.get(1)?)),
722 )
723 .unwrap();
724 assert_eq!(label, None, "face 3 must not have been labelled");
725 assert_eq!(confirmed, 0, "face 3 must not have been confirmed");
726 let bob: i64 = conn
727 .query_row("SELECT COUNT(*) FROM people WHERE name = 'bob'", [], |r| {
728 r.get(0)
729 })
730 .unwrap();
731 assert_eq!(
732 bob, 0,
733 "no person may be created when the assign rolls back"
734 );
735 }
736
737 #[test]
738 fn assign_rejects_empty_face_ids() {
739 let conn = seed();
742 assert!(matches!(assign(&conn, &[], "Bob"), Err(Error::Invalid)));
743 }
744
745 #[test]
746 fn remove_face_missing_is_not_found() {
747 let conn = seed();
748 assert!(matches!(remove_face(&conn, 999), Err(Error::NotFound)));
749 }
750
751 #[test]
752 fn dissolve_cluster_missing_is_not_found() {
753 let conn = seed();
754 assert!(matches!(dissolve_cluster(&conn, 999), Err(Error::NotFound)));
755 }
756
757 #[test]
758 fn set_primary_missing_face_is_not_found() {
759 let conn = seed();
760 assert!(matches!(
761 set_primary(&conn, 999, "Alice"),
762 Err(Error::NotFound)
763 ));
764 }
765
766 #[test]
767 fn set_primary_face_of_another_person_is_not_found_and_rolls_back() {
768 let conn = seed();
772 assert!(matches!(
773 set_primary(&conn, 5, "Alice"),
774 Err(Error::NotFound)
775 ));
776 let primary: i64 = conn
777 .query_row(
778 "SELECT id FROM faces WHERE person_label = 'alice' AND is_primary = 1",
779 [],
780 |r| r.get(0),
781 )
782 .unwrap();
783 assert_eq!(
784 primary, 1,
785 "the original primary must be restored on rollback"
786 );
787 }
788
789 #[test]
790 fn delete_person_missing_is_idempotent_success() {
791 let conn = seed();
797 assert!(delete_person(&conn, "Nobody").is_ok());
798 }
799}
800
801#[cfg(test)]
802mod identity_tests {
803 use super::tests::seed;
804 use super::*;
805
806 fn people(conn: &Connection) -> Vec<(String, String)> {
807 conn.prepare("SELECT name, full_name FROM people ORDER BY name")
808 .unwrap()
809 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
810 .unwrap()
811 .collect::<rusqlite::Result<_>>()
812 .unwrap()
813 }
814
815 #[test]
816 fn assign_stores_the_identity_and_records_the_display_name() {
817 let conn = seed();
818 assign(&conn, &[3], "Işıl Özyeğin").unwrap();
819
820 let label: String = conn
821 .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
822 r.get(0)
823 })
824 .unwrap();
825 assert_eq!(label, "isil_ozyegin", "faces hold the identity");
826 assert!(
827 people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
828 "and the spelling is kept for display"
829 );
830 }
831
832 #[test]
833 fn assigning_an_existing_name_in_another_case_joins_that_person() {
834 let conn = seed();
837 assign(&conn, &[3], "ALICE").unwrap();
838 assert_eq!(people(&conn).len(), 1, "still one person, not two");
839 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
840 assert_eq!(
841 people(&conn)[0].1,
842 "Alice",
843 "the existing spelling is not overwritten by the new casing"
844 );
845 }
846
847 #[test]
848 fn assign_rejects_a_name_with_no_usable_identity() {
849 let conn = seed();
852 assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
853 }
854
855 #[test]
856 fn person_detail_resolves_every_form_of_the_name() {
857 let conn = seed();
858 for form in ["alice", "Alice", "ALICE", " alice "] {
859 assert_eq!(
860 person_detail(&conn, form).unwrap().faces.len(),
861 2,
862 "form {form:?}"
863 );
864 }
865 }
866
867 #[test]
868 fn person_detail_reports_the_display_name() {
869 let d = person_detail(&seed(), "alice").unwrap();
870 assert_eq!(d.label, "alice");
871 assert_eq!(d.full_name, "Alice");
872 }
873
874 #[test]
875 fn person_detail_falls_back_when_there_is_no_people_row() {
876 let conn = seed();
878 conn.execute(
879 "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
880 VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
881 [],
882 )
883 .unwrap();
884 let d = person_detail(&conn, "orphan").unwrap();
885 assert_eq!(d.full_name, "orphan", "falls back to the identity");
886 }
887
888 #[test]
889 fn set_full_name_changes_only_the_display_name() {
890 let conn = seed();
891 set_full_name(&conn, "alice", "Alice Smith").unwrap();
892 assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
893 assert_eq!(
894 person_detail(&conn, "alice").unwrap().faces.len(),
895 2,
896 "no face was touched"
897 );
898 }
899
900 #[test]
901 fn set_full_name_accepts_any_form_of_the_identity() {
902 let conn = seed();
903 set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
904 assert_eq!(people(&conn)[0].1, "Alice Smith");
905 }
906
907 #[test]
908 fn set_full_name_on_a_missing_person_is_not_found() {
909 assert!(matches!(
910 set_full_name(&seed(), "nobody", "Someone"),
911 Err(Error::NotFound)
912 ));
913 }
914
915 #[test]
916 fn set_full_name_rejects_an_empty_display_name() {
917 assert!(matches!(
919 set_full_name(&seed(), "alice", " "),
920 Err(Error::Invalid)
921 ));
922 }
923
924 #[test]
925 fn delete_person_accepts_any_form_of_the_name() {
926 let conn = seed();
927 delete_person(&conn, "Alice").unwrap();
928 let left: i64 = conn
929 .query_row(
930 "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
931 [],
932 |r| r.get(0),
933 )
934 .unwrap();
935 assert_eq!(left, 0, "faces are unassigned whichever form was passed");
936 }
937
938 #[test]
939 fn set_primary_accepts_any_form_of_the_name() {
940 let conn = seed();
941 set_primary(&conn, 2, "ALICE").unwrap();
942 let primary: i64 = conn
943 .query_row(
944 "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
945 [],
946 |r| r.get(0),
947 )
948 .unwrap();
949 assert_eq!(primary, 2);
950 }
951}
952
953#[cfg(test)]
954mod never_run_tests {
955 use super::*;
956
957 #[test]
969 fn a_library_that_never_ran_detection_is_empty_not_an_error() {
970 let conn = Connection::open_in_memory().unwrap();
971 conn.execute_batch(
972 "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);
973 CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT);",
974 )
975 .unwrap();
976
977 let data = faces_list(&conn).expect("a library with no faces table is not an error");
978 assert!(data.people.is_empty());
979 assert!(data.clusters.is_empty());
980 assert!(data.singletons.is_empty());
981 }
982}