Skip to main content

videre_api/
faces.rs

1//! Facade over videre's faces-labeling read operations. Plain functions over
2//! an open `rusqlite::Connection`, returning serde types and a shared
3//! `Error`. Called by the axum `--faces` server and any other embedder.
4
5use crate::error::{Error, Result};
6use crate::types::*;
7use rusqlite::Connection;
8use std::collections::HashMap;
9
10/// People / unassigned clusters / singletons for the labeling page.
11pub fn faces_list(conn: &Connection) -> Result<FacesData> {
12    let mut people: HashMap<String, PersonData> = HashMap::new();
13    {
14        let mut stmt = conn.prepare(
15            // LEFT JOIN, not JOIN: a face labelled before the people table
16            // existed still has to appear, showing its raw label until the
17            // migration gives it a row.
18            "SELECT f.id, f.hash, f.person_label, COALESCE(p.full_name, f.person_label) \
19             FROM faces f LEFT JOIN people p ON p.name = f.person_label \
20             WHERE f.confirmed = 1 AND f.person_label IS NOT NULL \
21             ORDER BY f.person_label, f.is_primary DESC, f.id ASC",
22        )?;
23        let rows = stmt.query_map([], |r| {
24            Ok((
25                r.get::<_, i64>(0)?,
26                r.get::<_, String>(1)?,
27                r.get::<_, String>(2)?,
28                r.get::<_, String>(3)?,
29            ))
30        })?;
31        for row in rows {
32            let (id, hash, label, full_name) = row?;
33            let person = people.entry(label.clone()).or_insert(PersonData {
34                label: label.clone(),
35                full_name,
36                face_ids: vec![],
37                representative_id: id,
38                hashes: vec![],
39            });
40            person.face_ids.push(id);
41            if !person.hashes.contains(&hash) {
42                person.hashes.push(hash);
43            }
44        }
45    }
46
47    let mut cluster_map: HashMap<i64, ClusterData> = HashMap::new();
48    {
49        let mut stmt = conn.prepare(
50            "SELECT id, hash, cluster_id FROM faces \
51             WHERE cluster_id IS NOT NULL AND (confirmed = 0 OR person_label IS NULL) \
52             ORDER BY cluster_id, id",
53        )?;
54        let rows = stmt.query_map([], |r| {
55            Ok((
56                r.get::<_, i64>(0)?,
57                r.get::<_, String>(1)?,
58                r.get::<_, i64>(2)?,
59            ))
60        })?;
61        for row in rows {
62            let (id, hash, cid) = row?;
63            let cluster = cluster_map.entry(cid).or_insert(ClusterData {
64                cluster_id: cid,
65                face_ids: vec![],
66                hashes: vec![],
67            });
68            cluster.face_ids.push(id);
69            if !cluster.hashes.contains(&hash) {
70                cluster.hashes.push(hash);
71            }
72        }
73    }
74
75    let mut singletons: Vec<SingletonData> = vec![];
76    {
77        let mut stmt = conn.prepare(
78            "SELECT id, hash FROM faces \
79             WHERE cluster_id IS NULL AND (confirmed = 0 OR person_label IS NULL) \
80             ORDER BY id",
81        )?;
82        let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
83        for row in rows {
84            let (id, hash) = row?;
85            singletons.push(SingletonData { face_id: id, hash });
86        }
87    }
88
89    // Both maps are HashMaps, whose iteration order is arbitrary and differs
90    // between instances, so collecting straight from them threw away the
91    // ORDER BY the queries above establish. The labeling UI re-fetches this
92    // list after every assignment, so the effect was that people and clusters
93    // reshuffled on each drop: the cluster lined up next moved somewhere else,
94    // and so did the person being dragged onto. `singletons` never had the
95    // problem, and the difference is exactly that it is built as a Vec.
96    //
97    // Clusters are ordered largest first, which is the order people label in:
98    // the big clusters are worth the most and are the easiest to recognise.
99    // cluster_id breaks ties so the order is total, not merely sorted.
100    let mut people: Vec<PersonData> = people.into_values().collect();
101    people.sort_by(|a, b| a.full_name.to_lowercase().cmp(&b.full_name.to_lowercase()));
102    let mut clusters: Vec<ClusterData> = cluster_map.into_values().collect();
103    clusters.sort_by(|a, b| {
104        b.face_ids
105            .len()
106            .cmp(&a.face_ids.len())
107            .then(a.cluster_id.cmp(&b.cluster_id))
108    });
109
110    Ok(FacesData {
111        people,
112        clusters,
113        singletons,
114    })
115}
116
117/// Every face in one unassigned cluster (for the cluster detail page).
118pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
119    let mut stmt = conn.prepare(
120        "SELECT f.id, f.hash, fh.path FROM faces f \
121         JOIN file_hashes fh ON f.hash = fh.hash \
122         WHERE f.cluster_id = ?1 ORDER BY f.id",
123    )?;
124    let faces = stmt
125        .query_map([cluster_id], |r| {
126            Ok(ClusterFaceData {
127                face_id: r.get(0)?,
128                hash: r.get(1)?,
129                path: r.get(2)?,
130            })
131        })?
132        .collect::<rusqlite::Result<Vec<_>>>()?;
133    Ok(ClusterDetail { cluster_id, faces })
134}
135
136/// Every confirmed face for one person, primary first and flagged.
137pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
138    // Reads normalize too, so `/person/Erhan`, `/person/erhan` and the original
139    // spelling all reach the same person. That is what keeps existing links
140    // working across the migration without a redirect table.
141    let name = videre_core::person::normalize(name).unwrap_or_else(|| name.to_string());
142    let name = name.as_str();
143    let mut stmt = conn.prepare(
144        "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
145         JOIN file_hashes fh ON f.hash = fh.hash \
146         WHERE f.person_label = ?1 AND f.confirmed = 1 \
147         ORDER BY f.is_primary DESC, f.id",
148    )?;
149    let faces = stmt
150        .query_map([name], |r| {
151            Ok(PersonFaceData {
152                face_id: r.get(0)?,
153                hash: r.get(1)?,
154                path: r.get(2)?,
155                is_primary: r.get::<_, i64>(3)? != 0,
156            })
157        })?
158        .collect::<rusqlite::Result<Vec<_>>>()?;
159    // Falls back to the identity for a person with no row yet, so a library
160    // opened before the migration still shows something sensible.
161    let full_name: String = conn
162        .query_row(
163            "SELECT full_name FROM people WHERE name = ?1",
164            rusqlite::params![name],
165            |r| r.get(0),
166        )
167        .unwrap_or_else(|_| name.to_string());
168    Ok(PersonDetail {
169        label: name.to_string(),
170        full_name,
171        faces,
172    })
173}
174
175/// Image paths for confirmed faces of a person (prefix match), for the
176/// person-name autocomplete. Delegates to the existing core search.
177pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
178    Ok(videre_core::person_search::search_by_person(
179        conn, name, None,
180    )?)
181}
182
183/// Assign faces to an existing/new person: sets person_label + confirmed.
184/// Rejects an empty label after sanitizing.
185pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
186    // What was typed becomes the display name; its normalized form is the
187    // identity written to every face row. Upserting keeps `people` complete
188    // without a separate "create person" step.
189    let display = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
190    let label = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
191    conn.execute(
192        "INSERT INTO people (name, full_name) VALUES (?1, ?2) ON CONFLICT(name) DO NOTHING",
193        rusqlite::params![&label, &display],
194    )?;
195    for id in face_ids {
196        conn.execute(
197            "UPDATE faces SET person_label = ?1, confirmed = 1 WHERE id = ?2",
198            rusqlite::params![label, id],
199        )?;
200    }
201    Ok(())
202}
203
204/// Create a person from faces. Same effect as `assign`; kept as a distinct
205/// operation because callers treat "new person" and "assign to existing" as
206/// separate user intents.
207pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
208    assign(conn, face_ids, label)
209}
210
211/// Reset one face to fully unassigned (cluster, label, confirmed, primary).
212pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
213    conn.execute(
214        "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
215        [face_id],
216    )?;
217    Ok(())
218}
219
220/// Ungroup a bad cluster: its faces become unassigned singletons (not deleted).
221pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
222    conn.execute(
223        "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
224        [cluster_id],
225    )?;
226    Ok(())
227}
228
229/// Reset every face of a person back to unassigned. Deliberately does NOT touch
230/// cluster_id, so a face rejoins its cluster's unassigned group rather than
231/// scattering to singletons.
232/// Change only what a person is shown as, never their identity.
233///
234/// This is the only rename there is. Identity is permanent: `Erhan` to
235/// `Erhan Gündoğan` is a display correction even though its normalized form
236/// would change too, and there is no way to ask for the other reading. One row,
237/// no face touched, and `/person/<name>` keeps working, which is the whole
238/// reason identity and display are separate.
239pub fn set_full_name(conn: &Connection, name: &str, full_name: &str) -> Result<()> {
240    let display = crate::label::sanitize_person_label(full_name).ok_or(Error::Invalid)?;
241    let name = videre_core::person::normalize(name).ok_or(Error::Invalid)?;
242    let n = conn.execute(
243        "UPDATE people SET full_name = ?1 WHERE name = ?2",
244        rusqlite::params![display, name],
245    )?;
246    if n == 0 {
247        return Err(Error::NotFound);
248    }
249    Ok(())
250}
251
252pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
253    let label = videre_core::person::normalize(label).unwrap_or_else(|| label.to_string());
254    conn.execute(
255        "UPDATE faces SET person_label = NULL, confirmed = 0, is_primary = 0 WHERE person_label = ?1",
256        rusqlite::params![label],
257    )?;
258    Ok(())
259}
260
261/// Mark one face as the person's primary (their labeling-page thumbnail),
262/// clearing any previous primary in the same transaction so exactly one
263/// remains. The target update is guarded by person_label so it can't steal a
264/// face from another person.
265pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
266    let person_label =
267        videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
268    conn.execute_batch("BEGIN")?;
269    let result = (|| -> rusqlite::Result<()> {
270        conn.execute(
271            "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
272            rusqlite::params![person_label],
273        )?;
274        conn.execute(
275            "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
276            rusqlite::params![person_label, face_id],
277        )?;
278        Ok(())
279    })();
280    match result {
281        Ok(()) => {
282            conn.execute_batch("COMMIT")?;
283            Ok(())
284        }
285        Err(e) => {
286            let _ = conn.execute_batch("ROLLBACK");
287            Err(Error::Db(e))
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    /// In-memory db with the faces + file_hashes tables and a few rows:
297    /// - face 1: person "Alice", confirmed, is_primary
298    /// - face 2: person "Alice", confirmed
299    /// - face 3: cluster 7 (unassigned)
300    /// - face 4: cluster 7 (unassigned)
301    /// - face 5: singleton (no cluster, unassigned)
302    pub(super) fn seed() -> Connection {
303        let conn = Connection::open_in_memory().unwrap();
304        videre_core::face_db::create_faces_table(&conn).unwrap();
305        conn.execute_batch(
306            "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
307             INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
308                ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
309             -- Labels are stored in identity form, as `assign` writes them and
310             -- as the migration leaves them; `people` carries what a reader
311             -- sees. Seeding raw 'Alice' would test a state the application no
312             -- longer produces.
313             INSERT INTO people (name, full_name) VALUES ('alice','Alice');
314             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
315                (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
316                (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
317                (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
318                (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
319                (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
320        )
321        .unwrap();
322        videre_core::db::ensure_file_hashes_columns(&conn);
323        conn
324    }
325
326    #[test]
327    fn the_list_comes_back_in_the_same_order_every_time() {
328        // The labeling UI re-fetches after every assignment, so an unstable
329        // order means the cluster lined up next moves, and so does the person
330        // being dragged onto. Both lists were collected straight out of a
331        // HashMap, which discarded the ORDER BY in the queries above.
332        // `singletons` never had the bug, and the only difference is that it is
333        // built as a Vec.
334        let conn = seed();
335        // The seed has one cluster and one person, which cannot show an
336        // ordering problem. Add enough of both to have an order at all, with
337        // sizes deliberately not matching id order.
338        conn.execute_batch(
339            // Columns named explicitly: `seed` runs ensure_file_hashes_columns,
340            // so the table has more than the two it was created with.
341            "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
342                ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
343             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
344                (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
345                (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
346                (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
347                (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
348                (10,'h10','0,0,9,9',X'0000',NULL,'Bob',1,0);",
349        )
350        .unwrap();
351
352        // Two calls on one connection: each builds fresh HashMaps, and Rust
353        // seeds them differently, so an unstable order shows up here.
354        let a = faces_list(&conn).unwrap();
355        let b = faces_list(&conn).unwrap();
356
357        let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
358        let names =
359            |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
360        assert!(ids(&a).len() >= 3, "fixture must have several clusters");
361        assert_eq!(
362            ids(&a),
363            ids(&b),
364            "cluster order must not change between calls"
365        );
366        assert_eq!(
367            names(&a),
368            names(&b),
369            "people order must not change between calls"
370        );
371
372        // And the order is the useful one: biggest first, so the cluster worth
373        // the most labelling effort is where it is expected.
374        let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
375        let mut want = sizes.clone();
376        want.sort_unstable_by(|x, y| y.cmp(x));
377        assert_eq!(
378            sizes, want,
379            "clusters must be ordered largest first, got {sizes:?}"
380        );
381    }
382
383    #[test]
384    fn faces_list_splits_people_clusters_singletons() {
385        let conn = seed();
386        let d = faces_list(&conn).unwrap();
387        assert_eq!(d.people.len(), 1);
388        // Identity is the normalized form; what a reader sees is separate.
389        assert_eq!(d.people[0].label, "alice");
390        assert_eq!(d.people[0].full_name, "Alice");
391        assert_eq!(
392            d.people[0].representative_id, 1,
393            "primary face is representative"
394        );
395        assert_eq!(d.clusters.len(), 1);
396        assert_eq!(d.clusters[0].cluster_id, 7);
397        assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
398        assert_eq!(d.singletons.len(), 1);
399        assert_eq!(d.singletons[0].face_id, 5);
400    }
401
402    #[test]
403    fn person_detail_marks_primary() {
404        let conn = seed();
405        let p = person_detail(&conn, "Alice").unwrap();
406        assert_eq!(p.faces.len(), 2);
407        assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
408        assert!(!p.faces[1].is_primary);
409    }
410
411    #[test]
412    fn cluster_detail_lists_faces() {
413        let conn = seed();
414        let c = cluster_detail(&conn, 7).unwrap();
415        assert_eq!(c.cluster_id, 7);
416        assert_eq!(
417            c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
418            vec![3, 4]
419        );
420    }
421
422    #[test]
423    fn assign_labels_and_confirms() {
424        let conn = seed();
425        assign(&conn, &[3, 4], "Bob").unwrap();
426        let p = person_detail(&conn, "Bob").unwrap();
427        assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
428    }
429
430    #[test]
431    fn assign_rejects_empty_label() {
432        let conn = seed();
433        assert!(matches!(assign(&conn, &[3], "   "), Err(Error::Invalid)));
434    }
435
436    #[test]
437    fn remove_face_unassigns_everything() {
438        let conn = seed();
439        remove_face(&conn, 1).unwrap();
440        let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
441            .query_row(
442                "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
443                [],
444                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
445            )
446            .unwrap();
447        assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
448    }
449
450    #[test]
451    fn dissolve_cluster_nulls_cluster_id() {
452        let conn = seed();
453        dissolve_cluster(&conn, 7).unwrap();
454        assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
455        assert_eq!(
456            faces_list(&conn).unwrap().singletons.len(),
457            3,
458            "3,4 join 5 as singletons"
459        );
460    }
461
462    #[test]
463    fn delete_person_unassigns_without_touching_cluster() {
464        let conn = seed();
465        // Give one of Alice's faces a cluster_id so we can prove delete_person
466        // leaves cluster_id intact (it must, so the face rejoins its cluster's
467        // unassigned group rather than scattering to singletons).
468        conn.execute("UPDATE faces SET cluster_id = 42 WHERE id = 1", [])
469            .unwrap();
470        delete_person(&conn, "Alice").unwrap();
471        assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
472        let (cid, label, confirmed): (Option<i64>, Option<String>, i64) = conn
473            .query_row(
474                "SELECT cluster_id, person_label, confirmed FROM faces WHERE id = 1",
475                [],
476                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
477            )
478            .unwrap();
479        assert_eq!(cid, Some(42), "cluster_id must be preserved");
480        assert_eq!(label, None, "person_label cleared");
481        assert_eq!(confirmed, 0, "confirmed cleared");
482    }
483
484    #[test]
485    fn set_primary_is_exclusive_per_person() {
486        let conn = seed();
487        set_primary(&conn, 2, "Alice").unwrap();
488        let primaries: Vec<i64> = {
489            let mut s = conn
490                .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
491                .unwrap();
492            s.query_map([], |r| r.get(0))
493                .unwrap()
494                .collect::<rusqlite::Result<_>>()
495                .unwrap()
496        };
497        assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
498    }
499
500    #[test]
501    fn renaming_only_the_spelling_keeps_the_identity() {
502        // The common rename: correcting or extending what is shown, which must
503        // not change the URL or touch a single face row.
504        let conn = seed();
505        set_full_name(&conn, "alice", "Alice Smith").unwrap();
506        let (name, full): (String, String) = conn
507            .query_row("SELECT name, full_name FROM people", [], |r| {
508                Ok((r.get(0)?, r.get(1)?))
509            })
510            .unwrap();
511        assert_eq!(name, "alice", "identity is unchanged");
512        assert_eq!(full, "Alice Smith", "only the display name moved");
513        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
514    }
515}
516
517#[cfg(test)]
518mod identity_tests {
519    use super::tests::seed;
520    use super::*;
521
522    fn people(conn: &Connection) -> Vec<(String, String)> {
523        conn.prepare("SELECT name, full_name FROM people ORDER BY name")
524            .unwrap()
525            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
526            .unwrap()
527            .collect::<rusqlite::Result<_>>()
528            .unwrap()
529    }
530
531    #[test]
532    fn assign_stores_the_identity_and_records_the_display_name() {
533        let conn = seed();
534        assign(&conn, &[3], "Işıl Özyeğin").unwrap();
535
536        let label: String = conn
537            .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
538                r.get(0)
539            })
540            .unwrap();
541        assert_eq!(label, "isil_ozyegin", "faces hold the identity");
542        assert!(
543            people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
544            "and the spelling is kept for display"
545        );
546    }
547
548    #[test]
549    fn assigning_an_existing_name_in_another_case_joins_that_person() {
550        // The bug this whole change exists to fix: this used to create a second
551        // person.
552        let conn = seed();
553        assign(&conn, &[3], "ALICE").unwrap();
554        assert_eq!(people(&conn).len(), 1, "still one person, not two");
555        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
556        assert_eq!(
557            people(&conn)[0].1,
558            "Alice",
559            "the existing spelling is not overwritten by the new casing"
560        );
561    }
562
563    #[test]
564    fn assign_rejects_a_name_with_no_usable_identity() {
565        // Punctuation alone leaves nothing to identify a person by, and an
566        // empty identity would be a person nobody could address.
567        let conn = seed();
568        assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
569    }
570
571    #[test]
572    fn person_detail_resolves_every_form_of_the_name() {
573        let conn = seed();
574        for form in ["alice", "Alice", "ALICE", "  alice  "] {
575            assert_eq!(
576                person_detail(&conn, form).unwrap().faces.len(),
577                2,
578                "form {form:?}"
579            );
580        }
581    }
582
583    #[test]
584    fn person_detail_reports_the_display_name() {
585        let d = person_detail(&seed(), "alice").unwrap();
586        assert_eq!(d.label, "alice");
587        assert_eq!(d.full_name, "Alice");
588    }
589
590    #[test]
591    fn person_detail_falls_back_when_there_is_no_people_row() {
592        // A label written before the table existed still has to render.
593        let conn = seed();
594        conn.execute(
595            "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
596             VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
597            [],
598        )
599        .unwrap();
600        let d = person_detail(&conn, "orphan").unwrap();
601        assert_eq!(d.full_name, "orphan", "falls back to the identity");
602    }
603
604    #[test]
605    fn set_full_name_changes_only_the_display_name() {
606        let conn = seed();
607        set_full_name(&conn, "alice", "Alice Smith").unwrap();
608        assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
609        assert_eq!(
610            person_detail(&conn, "alice").unwrap().faces.len(),
611            2,
612            "no face was touched"
613        );
614    }
615
616    #[test]
617    fn set_full_name_accepts_any_form_of_the_identity() {
618        let conn = seed();
619        set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
620        assert_eq!(people(&conn)[0].1, "Alice Smith");
621    }
622
623    #[test]
624    fn set_full_name_on_a_missing_person_is_not_found() {
625        assert!(matches!(
626            set_full_name(&seed(), "nobody", "Someone"),
627            Err(Error::NotFound)
628        ));
629    }
630
631    #[test]
632    fn set_full_name_rejects_an_empty_display_name() {
633        // A person with no name to show is worse than one shown by identity.
634        assert!(matches!(
635            set_full_name(&seed(), "alice", "   "),
636            Err(Error::Invalid)
637        ));
638    }
639
640    #[test]
641    fn delete_person_accepts_any_form_of_the_name() {
642        let conn = seed();
643        delete_person(&conn, "Alice").unwrap();
644        let left: i64 = conn
645            .query_row(
646                "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
647                [],
648                |r| r.get(0),
649            )
650            .unwrap();
651        assert_eq!(left, 0, "faces are unassigned whichever form was passed");
652    }
653
654    #[test]
655    fn set_primary_accepts_any_form_of_the_name() {
656        let conn = seed();
657        set_primary(&conn, 2, "ALICE").unwrap();
658        let primary: i64 = conn
659            .query_row(
660                "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
661                [],
662                |r| r.get(0),
663            )
664            .unwrap();
665        assert_eq!(primary, 2);
666    }
667}