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