implemente
e849fcb parent: faceb9d added
packages/regine-core/src/regine_core/camera_profile/__init__.py +21 -0 | new file mode 100644 | ||
| @@ -0,0 +1,21 @@ | ||
| 1 | +"""Profil de boîtiers optionnel : désambiguïsation de collision par métadonnées EXIF. | |
| 2 | + | |
| 3 | +Cf. specs/002-profil-boitiers-optionnel. Module de premier niveau distinct de | |
| 4 | +``regine_core.config`` (cf. research.md § 2). | |
| 5 | +""" | |
| 6 | + | |
| 7 | +from regine_core.camera_profile.db import Boitier, list_boitiers, rename_boitier | |
| 8 | +from regine_core.camera_profile.resolve import ( | |
| 9 | + CollisionResolution, | |
| 10 | + assign_manual_source, | |
| 11 | + resolve_collision, | |
| 12 | +) | |
| 13 | + | |
| 14 | +__all__ = [ | |
| 15 | + "Boitier", | |
| 16 | + "CollisionResolution", | |
| 17 | + "assign_manual_source", | |
| 18 | + "list_boitiers", | |
| 19 | + "rename_boitier", | |
| 20 | + "resolve_collision", | |
| 21 | +] | |
| new file mode 100644 | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | +"""Profil de boîtiers optionnel : désambiguïsation de collision par métadonnées EXIF. | ||
| 2 | + | ||
| 3 | +Cf. specs/002-profil-boitiers-optionnel. Module de premier niveau distinct de | ||
| 4 | +``regine_core.config`` (cf. research.md § 2). | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +from regine_core.camera_profile.db import Boitier, list_boitiers, rename_boitier | ||
| 8 | +from regine_core.camera_profile.resolve import ( | ||
| 9 | + CollisionResolution, | ||
| 10 | + assign_manual_source, | ||
| 11 | + resolve_collision, | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +__all__ = [ | ||
| 15 | + "Boitier", | ||
| 16 | + "CollisionResolution", | ||
| 17 | + "assign_manual_source", | ||
| 18 | + "list_boitiers", | ||
| 19 | + "rename_boitier", | ||
| 20 | + "resolve_collision", | ||
| 21 | +] | ||
added
packages/regine-core/src/regine_core/camera_profile/db.py +89 -0 | new file mode 100644 | ||
| @@ -0,0 +1,89 @@ | ||
| 1 | +"""Table `boitiers` dans la base de contexte centralisée (regine_core.config.db). | |
| 2 | + | |
| 3 | +Possédée par ce module plutôt que par `regine_core.config` (cf. specs/002-profil- | |
| 4 | +boitiers-optionnel research.md § 2 — corrige un placement provisoire de | |
| 5 | +specs/003-config-contexte-travail). | |
| 6 | +""" | |
| 7 | + | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import sqlite3 | |
| 11 | +from dataclasses import dataclass | |
| 12 | +from datetime import UTC, datetime | |
| 13 | + | |
| 14 | +_CREATE_TABLE = """ | |
| 15 | +CREATE TABLE IF NOT EXISTS boitiers ( | |
| 16 | + id INTEGER PRIMARY KEY, | |
| 17 | + modele TEXT, | |
| 18 | + numero_serie TEXT, | |
| 19 | + nom_lisible TEXT, | |
| 20 | + premiere_rencontre TEXT NOT NULL, | |
| 21 | + source TEXT NOT NULL, | |
| 22 | + UNIQUE (modele, numero_serie) | |
| 23 | +) | |
| 24 | +""" | |
| 25 | + | |
| 26 | + | |
| 27 | +@dataclass(frozen=True) | |
| 28 | +class Boitier: | |
| 29 | + id: int | |
| 30 | + modele: str | None | |
| 31 | + numero_serie: str | None | |
| 32 | + nom_lisible: str | None | |
| 33 | + premiere_rencontre: str | |
| 34 | + source: str | |
| 35 | + | |
| 36 | + | |
| 37 | +def ensure_schema(conn: sqlite3.Connection) -> None: | |
| 38 | + """Crée la table `boitiers` si elle n'existe pas déjà (idempotent).""" | |
| 39 | + conn.execute(_CREATE_TABLE) | |
| 40 | + conn.commit() | |
| 41 | + | |
| 42 | + | |
| 43 | +def get_or_create_boitier( | |
| 44 | + conn: sqlite3.Connection, | |
| 45 | + modele: str | None, | |
| 46 | + numero_serie: str | None = None, | |
| 47 | + *, | |
| 48 | + source: str = "modele", | |
| 49 | +) -> int: | |
| 50 | + """Retrouve un boîtier déjà connu pour `(modele, numero_serie)`, ou le crée. | |
| 51 | + | |
| 52 | + Réservé à la résolution automatique (FR-002/FR-004) : ne force jamais la | |
| 53 | + création d'une entrée distincte pour un modèle déjà rencontré sans numéro de | |
| 54 | + série — c'est le rôle d'`assign_manual_source` pour le cas d'ambiguïté réelle | |
| 55 | + (FR-005). | |
| 56 | + """ | |
| 57 | + ensure_schema(conn) | |
| 58 | + row = conn.execute( | |
| 59 | + "SELECT id FROM boitiers WHERE modele IS ? AND numero_serie IS ?", | |
| 60 | + (modele, numero_serie), | |
| 61 | + ).fetchone() | |
| 62 | + if row is not None: | |
| 63 | + return row[0] | |
| 64 | + | |
| 65 | + now = datetime.now(UTC).isoformat() | |
| 66 | + cur = conn.execute( | |
| 67 | + "INSERT INTO boitiers (modele, numero_serie, nom_lisible, premiere_rencontre, source) " | |
| 68 | + "VALUES (?, ?, NULL, ?, ?)", | |
| 69 | + (modele, numero_serie, now, source), | |
| 70 | + ) | |
| 71 | + conn.commit() | |
| 72 | + return cur.lastrowid # type: ignore[return-value] | |
| 73 | + | |
| 74 | + | |
| 75 | +def list_boitiers(conn: sqlite3.Connection) -> list[Boitier]: | |
| 76 | + """Retourne tous les boîtiers connus, du plus ancien au plus récent (FR-007).""" | |
| 77 | + ensure_schema(conn) | |
| 78 | + rows = conn.execute( | |
| 79 | + "SELECT id, modele, numero_serie, nom_lisible, premiere_rencontre, source " | |
| 80 | + "FROM boitiers ORDER BY premiere_rencontre" | |
| 81 | + ).fetchall() | |
| 82 | + return [Boitier(*row) for row in rows] | |
| 83 | + | |
| 84 | + | |
| 85 | +def rename_boitier(conn: sqlite3.Connection, boitier_id: int, nom: str) -> None: | |
| 86 | + """Attribue ou modifie le nom lisible d'un boîtier existant (FR-007).""" | |
| 87 | + ensure_schema(conn) | |
| 88 | + conn.execute("UPDATE boitiers SET nom_lisible = ? WHERE id = ?", (nom, boitier_id)) | |
| 89 | + conn.commit() | |
| new file mode 100644 | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | +"""Table `boitiers` dans la base de contexte centralisée (regine_core.config.db). | ||
| 2 | + | ||
| 3 | +Possédée par ce module plutôt que par `regine_core.config` (cf. specs/002-profil- | ||
| 4 | +boitiers-optionnel research.md § 2 — corrige un placement provisoire de | ||
| 5 | +specs/003-config-contexte-travail). | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +from __future__ import annotations | ||
| 9 | + | ||
| 10 | +import sqlite3 | ||
| 11 | +from dataclasses import dataclass | ||
| 12 | +from datetime import UTC, datetime | ||
| 13 | + | ||
| 14 | +_CREATE_TABLE = """ | ||
| 15 | +CREATE TABLE IF NOT EXISTS boitiers ( | ||
| 16 | + id INTEGER PRIMARY KEY, | ||
| 17 | + modele TEXT, | ||
| 18 | + numero_serie TEXT, | ||
| 19 | + nom_lisible TEXT, | ||
| 20 | + premiere_rencontre TEXT NOT NULL, | ||
| 21 | + source TEXT NOT NULL, | ||
| 22 | + UNIQUE (modele, numero_serie) | ||
| 23 | +) | ||
| 24 | +""" | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +@dataclass(frozen=True) | ||
| 28 | +class Boitier: | ||
| 29 | + id: int | ||
| 30 | + modele: str | None | ||
| 31 | + numero_serie: str | None | ||
| 32 | + nom_lisible: str | None | ||
| 33 | + premiere_rencontre: str | ||
| 34 | + source: str | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +def ensure_schema(conn: sqlite3.Connection) -> None: | ||
| 38 | + """Crée la table `boitiers` si elle n'existe pas déjà (idempotent).""" | ||
| 39 | + conn.execute(_CREATE_TABLE) | ||
| 40 | + conn.commit() | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +def get_or_create_boitier( | ||
| 44 | + conn: sqlite3.Connection, | ||
| 45 | + modele: str | None, | ||
| 46 | + numero_serie: str | None = None, | ||
| 47 | + *, | ||
| 48 | + source: str = "modele", | ||
| 49 | +) -> int: | ||
| 50 | + """Retrouve un boîtier déjà connu pour `(modele, numero_serie)`, ou le crée. | ||
| 51 | + | ||
| 52 | + Réservé à la résolution automatique (FR-002/FR-004) : ne force jamais la | ||
| 53 | + création d'une entrée distincte pour un modèle déjà rencontré sans numéro de | ||
| 54 | + série — c'est le rôle d'`assign_manual_source` pour le cas d'ambiguïté réelle | ||
| 55 | + (FR-005). | ||
| 56 | + """ | ||
| 57 | + ensure_schema(conn) | ||
| 58 | + row = conn.execute( | ||
| 59 | + "SELECT id FROM boitiers WHERE modele IS ? AND numero_serie IS ?", | ||
| 60 | + (modele, numero_serie), | ||
| 61 | + ).fetchone() | ||
| 62 | + if row is not None: | ||
| 63 | + return row[0] | ||
| 64 | + | ||
| 65 | + now = datetime.now(UTC).isoformat() | ||
| 66 | + cur = conn.execute( | ||
| 67 | + "INSERT INTO boitiers (modele, numero_serie, nom_lisible, premiere_rencontre, source) " | ||
| 68 | + "VALUES (?, ?, NULL, ?, ?)", | ||
| 69 | + (modele, numero_serie, now, source), | ||
| 70 | + ) | ||
| 71 | + conn.commit() | ||
| 72 | + return cur.lastrowid # type: ignore[return-value] | ||
| 73 | + | ||
| 74 | + | ||
| 75 | +def list_boitiers(conn: sqlite3.Connection) -> list[Boitier]: | ||
| 76 | + """Retourne tous les boîtiers connus, du plus ancien au plus récent (FR-007).""" | ||
| 77 | + ensure_schema(conn) | ||
| 78 | + rows = conn.execute( | ||
| 79 | + "SELECT id, modele, numero_serie, nom_lisible, premiere_rencontre, source " | ||
| 80 | + "FROM boitiers ORDER BY premiere_rencontre" | ||
| 81 | + ).fetchall() | ||
| 82 | + return [Boitier(*row) for row in rows] | ||
| 83 | + | ||
| 84 | + | ||
| 85 | +def rename_boitier(conn: sqlite3.Connection, boitier_id: int, nom: str) -> None: | ||
| 86 | + """Attribue ou modifie le nom lisible d'un boîtier existant (FR-007).""" | ||
| 87 | + ensure_schema(conn) | ||
| 88 | + conn.execute("UPDATE boitiers SET nom_lisible = ? WHERE id = ?", (nom, boitier_id)) | ||
| 89 | + conn.commit() | ||
added
packages/regine-core/src/regine_core/camera_profile/resolve.py +128 -0 | new file mode 100644 | ||
| @@ -0,0 +1,128 @@ | ||
| 1 | +"""Algorithme de résolution d'une collision de nom d'origine entre boîtiers. | |
| 2 | + | |
| 3 | +Ne DOIT être invoquée par l'appelant (futur `specs/001-import-photos`) que pour des | |
| 4 | +fichiers déjà identifiés en collision réelle par somme de contrôle de contenu — la | |
| 5 | +détection de collision elle-même (checksum) n'est pas de la responsabilité de ce | |
| 6 | +module (FR-003/FR-008 de specs/002-profil-boitiers-optionnel). | |
| 7 | +""" | |
| 8 | + | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import sqlite3 | |
| 12 | +from collections.abc import Callable | |
| 13 | +from dataclasses import dataclass | |
| 14 | +from datetime import UTC, datetime | |
| 15 | +from pathlib import Path | |
| 16 | + | |
| 17 | +from regine_core.camera_profile.db import ensure_schema, get_or_create_boitier | |
| 18 | +from regine_core.metadata.exif import CameraTags, read_camera_tags | |
| 19 | + | |
| 20 | + | |
| 21 | +@dataclass(frozen=True) | |
| 22 | +class CollisionResolution: | |
| 23 | + """Résultat de `resolve_collision`. | |
| 24 | + | |
| 25 | + `resolues` : fichier -> identifiant de boîtier déterminé automatiquement. | |
| 26 | + `a_etiqueter` : groupes de fichiers encore indistincts, nécessitant un | |
| 27 | + étiquetage manuel (FR-005) — jamais résolus arbitrairement. | |
| 28 | + """ | |
| 29 | + | |
| 30 | + resolues: dict[Path, int] | |
| 31 | + a_etiqueter: list[list[Path]] | |
| 32 | + | |
| 33 | + | |
| 34 | +def resolve_collision( | |
| 35 | + fichiers: list[Path], | |
| 36 | + *, | |
| 37 | + conn: sqlite3.Connection, | |
| 38 | + lire_tags: Callable[[Path], CameraTags] = read_camera_tags, | |
| 39 | +) -> CollisionResolution: | |
| 40 | + """Désambiguïse la source de chaque fichier d'un groupe déjà en collision. | |
| 41 | + | |
| 42 | + Par défaut, la désambiguïsation se fait via le tag EXIF de modèle (FR-002) ; au | |
| 43 | + sein d'un même modèle, elle se replie sur le numéro de série (FR-004) ; les | |
| 44 | + fichiers encore indistincts après ces deux étapes sont renvoyés dans | |
| 45 | + `a_etiqueter`, jamais résolus au hasard (FR-005). | |
| 46 | + | |
| 47 | + `lire_tags` est injectable (par défaut `regine_core.metadata.exif.read_camera_tags`) | |
| 48 | + pour permettre des tests unitaires sans dépendre de fichiers réels ni d'exiftool. | |
| 49 | + """ | |
| 50 | + tags_par_fichier = {fichier: lire_tags(fichier) for fichier in fichiers} | |
| 51 | + | |
| 52 | + par_modele: dict[str | None, list[Path]] = {} | |
| 53 | + for fichier, tags in tags_par_fichier.items(): | |
| 54 | + par_modele.setdefault(tags.modele, []).append(fichier) | |
| 55 | + | |
| 56 | + resolues: dict[Path, int] = {} | |
| 57 | + a_etiqueter: list[list[Path]] = [] | |
| 58 | + | |
| 59 | + for modele, groupe in par_modele.items(): | |
| 60 | + if modele is not None and len(groupe) == 1: | |
| 61 | + # Modèle unique parmi les fichiers en collision : le tag seul suffit à | |
| 62 | + # distinguer ce fichier des autres (FR-002). On transmet quand même le | |
| 63 | + # numéro de série s'il est disponible, pour que ce fichier rejoigne le | |
| 64 | + # même boîtier qu'une résolution déjà établie avec ce numéro de série | |
| 65 | + # (FR-006) plutôt qu'un doublon "modèle seul, sans série". | |
| 66 | + (fichier,) = groupe | |
| 67 | + numero_serie = tags_par_fichier[fichier].numero_serie | |
| 68 | + resolues[fichier] = get_or_create_boitier(conn, modele, numero_serie, source="modele") | |
| 69 | + continue | |
| 70 | + | |
| 71 | + # Modèle absent, ou plusieurs fichiers du même modèle : repli sur le | |
| 72 | + # numéro de série (FR-004), à défaut étiquetage manuel (FR-005). | |
| 73 | + par_serie: dict[str, list[Path]] = {} | |
| 74 | + sans_serie_exploitable: list[Path] = [] | |
| 75 | + for fichier in groupe: | |
| 76 | + serie = tags_par_fichier[fichier].numero_serie | |
| 77 | + if serie is not None: | |
| 78 | + par_serie.setdefault(serie, []).append(fichier) | |
| 79 | + else: | |
| 80 | + sans_serie_exploitable.append(fichier) | |
| 81 | + | |
| 82 | + for serie, sous_groupe in par_serie.items(): | |
| 83 | + if len(sous_groupe) == 1: | |
| 84 | + (fichier,) = sous_groupe | |
| 85 | + resolues[fichier] = get_or_create_boitier( | |
| 86 | + conn, modele, serie, source="numero_serie" | |
| 87 | + ) | |
| 88 | + else: | |
| 89 | + a_etiqueter.append(sous_groupe) | |
| 90 | + | |
| 91 | + if sans_serie_exploitable: | |
| 92 | + # Tous mutuellement indistincts (même modèle, aucun numéro de série | |
| 93 | + # exploitable) : un seul groupe, pas un groupe par fichier — sauf s'il | |
| 94 | + # n'y en a qu'un seul, auquel cas le groupe est simplement de taille 1. | |
| 95 | + a_etiqueter.append(sans_serie_exploitable) | |
| 96 | + | |
| 97 | + return CollisionResolution(resolues=resolues, a_etiqueter=a_etiqueter) | |
| 98 | + | |
| 99 | + | |
| 100 | +def assign_manual_source( | |
| 101 | + conn: sqlite3.Connection, | |
| 102 | + fichiers: list[Path], | |
| 103 | + boitier_id: int | None = None, | |
| 104 | + *, | |
| 105 | + modele: str | None = None, | |
| 106 | +) -> int: | |
| 107 | + """Assigne manuellement un groupe de fichiers à un boîtier (FR-005). | |
| 108 | + | |
| 109 | + Crée une nouvelle entrée (`source="manuel"`) si `boitier_id` est `None` — y | |
| 110 | + compris pour un `modele` déjà rencontré : deux boîtiers identiques doivent | |
| 111 | + pouvoir coexister comme entrées distinctes une fois qu'une collision réelle | |
| 112 | + force à les séparer (cf. data-model.md, unicité sur `(modele, numero_serie)` | |
| 113 | + qui n'empêche pas plusieurs entrées `numero_serie` NULL pour un même modèle). | |
| 114 | + Réutilise `boitier_id` tel quel sinon. Ne DOIT être appelée qu'après une | |
| 115 | + décision explicite de l'utilisateur — jamais automatiquement. | |
| 116 | + """ | |
| 117 | + if boitier_id is not None: | |
| 118 | + return boitier_id | |
| 119 | + | |
| 120 | + ensure_schema(conn) | |
| 121 | + now = datetime.now(UTC).isoformat() | |
| 122 | + cur = conn.execute( | |
| 123 | + "INSERT INTO boitiers (modele, numero_serie, nom_lisible, premiere_rencontre, source) " | |
| 124 | + "VALUES (?, NULL, NULL, ?, 'manuel')", | |
| 125 | + (modele, now), | |
| 126 | + ) | |
| 127 | + conn.commit() | |
| 128 | + return cur.lastrowid # type: ignore[return-value] | |
| new file mode 100644 | |||
| @@ -0,0 +1,128 @@ | |||
| 1 | +"""Algorithme de résolution d'une collision de nom d'origine entre boîtiers. | ||
| 2 | + | ||
| 3 | +Ne DOIT être invoquée par l'appelant (futur `specs/001-import-photos`) que pour des | ||
| 4 | +fichiers déjà identifiés en collision réelle par somme de contrôle de contenu — la | ||
| 5 | +détection de collision elle-même (checksum) n'est pas de la responsabilité de ce | ||
| 6 | +module (FR-003/FR-008 de specs/002-profil-boitiers-optionnel). | ||
| 7 | +""" | ||
| 8 | + | ||
| 9 | +from __future__ import annotations | ||
| 10 | + | ||
| 11 | +import sqlite3 | ||
| 12 | +from collections.abc import Callable | ||
| 13 | +from dataclasses import dataclass | ||
| 14 | +from datetime import UTC, datetime | ||
| 15 | +from pathlib import Path | ||
| 16 | + | ||
| 17 | +from regine_core.camera_profile.db import ensure_schema, get_or_create_boitier | ||
| 18 | +from regine_core.metadata.exif import CameraTags, read_camera_tags | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +@dataclass(frozen=True) | ||
| 22 | +class CollisionResolution: | ||
| 23 | + """Résultat de `resolve_collision`. | ||
| 24 | + | ||
| 25 | + `resolues` : fichier -> identifiant de boîtier déterminé automatiquement. | ||
| 26 | + `a_etiqueter` : groupes de fichiers encore indistincts, nécessitant un | ||
| 27 | + étiquetage manuel (FR-005) — jamais résolus arbitrairement. | ||
| 28 | + """ | ||
| 29 | + | ||
| 30 | + resolues: dict[Path, int] | ||
| 31 | + a_etiqueter: list[list[Path]] | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def resolve_collision( | ||
| 35 | + fichiers: list[Path], | ||
| 36 | + *, | ||
| 37 | + conn: sqlite3.Connection, | ||
| 38 | + lire_tags: Callable[[Path], CameraTags] = read_camera_tags, | ||
| 39 | +) -> CollisionResolution: | ||
| 40 | + """Désambiguïse la source de chaque fichier d'un groupe déjà en collision. | ||
| 41 | + | ||
| 42 | + Par défaut, la désambiguïsation se fait via le tag EXIF de modèle (FR-002) ; au | ||
| 43 | + sein d'un même modèle, elle se replie sur le numéro de série (FR-004) ; les | ||
| 44 | + fichiers encore indistincts après ces deux étapes sont renvoyés dans | ||
| 45 | + `a_etiqueter`, jamais résolus au hasard (FR-005). | ||
| 46 | + | ||
| 47 | + `lire_tags` est injectable (par défaut `regine_core.metadata.exif.read_camera_tags`) | ||
| 48 | + pour permettre des tests unitaires sans dépendre de fichiers réels ni d'exiftool. | ||
| 49 | + """ | ||
| 50 | + tags_par_fichier = {fichier: lire_tags(fichier) for fichier in fichiers} | ||
| 51 | + | ||
| 52 | + par_modele: dict[str | None, list[Path]] = {} | ||
| 53 | + for fichier, tags in tags_par_fichier.items(): | ||
| 54 | + par_modele.setdefault(tags.modele, []).append(fichier) | ||
| 55 | + | ||
| 56 | + resolues: dict[Path, int] = {} | ||
| 57 | + a_etiqueter: list[list[Path]] = [] | ||
| 58 | + | ||
| 59 | + for modele, groupe in par_modele.items(): | ||
| 60 | + if modele is not None and len(groupe) == 1: | ||
| 61 | + # Modèle unique parmi les fichiers en collision : le tag seul suffit à | ||
| 62 | + # distinguer ce fichier des autres (FR-002). On transmet quand même le | ||
| 63 | + # numéro de série s'il est disponible, pour que ce fichier rejoigne le | ||
| 64 | + # même boîtier qu'une résolution déjà établie avec ce numéro de série | ||
| 65 | + # (FR-006) plutôt qu'un doublon "modèle seul, sans série". | ||
| 66 | + (fichier,) = groupe | ||
| 67 | + numero_serie = tags_par_fichier[fichier].numero_serie | ||
| 68 | + resolues[fichier] = get_or_create_boitier(conn, modele, numero_serie, source="modele") | ||
| 69 | + continue | ||
| 70 | + | ||
| 71 | + # Modèle absent, ou plusieurs fichiers du même modèle : repli sur le | ||
| 72 | + # numéro de série (FR-004), à défaut étiquetage manuel (FR-005). | ||
| 73 | + par_serie: dict[str, list[Path]] = {} | ||
| 74 | + sans_serie_exploitable: list[Path] = [] | ||
| 75 | + for fichier in groupe: | ||
| 76 | + serie = tags_par_fichier[fichier].numero_serie | ||
| 77 | + if serie is not None: | ||
| 78 | + par_serie.setdefault(serie, []).append(fichier) | ||
| 79 | + else: | ||
| 80 | + sans_serie_exploitable.append(fichier) | ||
| 81 | + | ||
| 82 | + for serie, sous_groupe in par_serie.items(): | ||
| 83 | + if len(sous_groupe) == 1: | ||
| 84 | + (fichier,) = sous_groupe | ||
| 85 | + resolues[fichier] = get_or_create_boitier( | ||
| 86 | + conn, modele, serie, source="numero_serie" | ||
| 87 | + ) | ||
| 88 | + else: | ||
| 89 | + a_etiqueter.append(sous_groupe) | ||
| 90 | + | ||
| 91 | + if sans_serie_exploitable: | ||
| 92 | + # Tous mutuellement indistincts (même modèle, aucun numéro de série | ||
| 93 | + # exploitable) : un seul groupe, pas un groupe par fichier — sauf s'il | ||
| 94 | + # n'y en a qu'un seul, auquel cas le groupe est simplement de taille 1. | ||
| 95 | + a_etiqueter.append(sans_serie_exploitable) | ||
| 96 | + | ||
| 97 | + return CollisionResolution(resolues=resolues, a_etiqueter=a_etiqueter) | ||
| 98 | + | ||
| 99 | + | ||
| 100 | +def assign_manual_source( | ||
| 101 | + conn: sqlite3.Connection, | ||
| 102 | + fichiers: list[Path], | ||
| 103 | + boitier_id: int | None = None, | ||
| 104 | + *, | ||
| 105 | + modele: str | None = None, | ||
| 106 | +) -> int: | ||
| 107 | + """Assigne manuellement un groupe de fichiers à un boîtier (FR-005). | ||
| 108 | + | ||
| 109 | + Crée une nouvelle entrée (`source="manuel"`) si `boitier_id` est `None` — y | ||
| 110 | + compris pour un `modele` déjà rencontré : deux boîtiers identiques doivent | ||
| 111 | + pouvoir coexister comme entrées distinctes une fois qu'une collision réelle | ||
| 112 | + force à les séparer (cf. data-model.md, unicité sur `(modele, numero_serie)` | ||
| 113 | + qui n'empêche pas plusieurs entrées `numero_serie` NULL pour un même modèle). | ||
| 114 | + Réutilise `boitier_id` tel quel sinon. Ne DOIT être appelée qu'après une | ||
| 115 | + décision explicite de l'utilisateur — jamais automatiquement. | ||
| 116 | + """ | ||
| 117 | + if boitier_id is not None: | ||
| 118 | + return boitier_id | ||
| 119 | + | ||
| 120 | + ensure_schema(conn) | ||
| 121 | + now = datetime.now(UTC).isoformat() | ||
| 122 | + cur = conn.execute( | ||
| 123 | + "INSERT INTO boitiers (modele, numero_serie, nom_lisible, premiere_rencontre, source) " | ||
| 124 | + "VALUES (?, NULL, NULL, ?, 'manuel')", | ||
| 125 | + (modele, now), | ||
| 126 | + ) | ||
| 127 | + conn.commit() | ||
| 128 | + return cur.lastrowid # type: ignore[return-value] | ||
added
packages/regine-core/src/regine_core/metadata/__init__.py +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +"""Lecture/écriture EXIF/XMP via exiftool, réutilisée par plusieurs modules du projet.""" | |
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +"""Lecture/écriture EXIF/XMP via exiftool, réutilisée par plusieurs modules du projet.""" | ||
added
packages/regine-core/src/regine_core/metadata/exif.py +127 -0 | new file mode 100644 | ||
| @@ -0,0 +1,127 @@ | ||
| 1 | +"""Wrapper autour d'exiftool en processus persistant (-stay_open). | |
| 2 | + | |
| 3 | +Réutilisé pour toute lecture EXIF avancée du projet (cf. specs/002-profil-boitiers-optionnel | |
| 4 | +research.md § 1) : le tag Model/BodySerialNumber ici, la date de prise de vue et | |
| 5 | +l'ImageDataHash dans de futurs modules (specs/001-import-photos, specs/005-checkout- | |
| 6 | +reconciliation). exiftool est une dépendance externe (binaire système), pas un paquet Python. | |
| 7 | +""" | |
| 8 | + | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import json | |
| 12 | +import subprocess | |
| 13 | +from dataclasses import dataclass | |
| 14 | +from pathlib import Path | |
| 15 | + | |
| 16 | + | |
| 17 | +@dataclass(frozen=True) | |
| 18 | +class CameraTags: | |
| 19 | + """Tags de désambiguïsation de boîtier lus depuis les métadonnées d'un fichier.""" | |
| 20 | + | |
| 21 | + chemin: Path | |
| 22 | + modele: str | None | |
| 23 | + numero_serie: str | None | |
| 24 | + | |
| 25 | + | |
| 26 | +class ExifToolSession: | |
| 27 | + """Processus exiftool persistant (-stay_open), pour éviter un lancement par fichier. | |
| 28 | + | |
| 29 | + Cf. specs/002-profil-boitiers-optionnel research.md § 1 : le coût de démarrage | |
| 30 | + d'exiftool n'est payé qu'une fois par session, pas une fois par fichier importé. | |
| 31 | + """ | |
| 32 | + | |
| 33 | + def __init__(self) -> None: | |
| 34 | + self._process = subprocess.Popen( # noqa: S603, S607 | |
| 35 | + ["exiftool", "-stay_open", "True", "-@", "-"], | |
| 36 | + stdin=subprocess.PIPE, | |
| 37 | + stdout=subprocess.PIPE, | |
| 38 | + stderr=subprocess.PIPE, | |
| 39 | + text=True, | |
| 40 | + bufsize=1, | |
| 41 | + ) | |
| 42 | + self._counter = 0 | |
| 43 | + | |
| 44 | + def execute(self, *args: str) -> str: | |
| 45 | + """Envoie une commande à exiftool et retourne sa sortie texte.""" | |
| 46 | + if self._process.stdin is None or self._process.stdout is None: | |
| 47 | + raise RuntimeError("Session exiftool déjà fermée") | |
| 48 | + | |
| 49 | + self._counter += 1 | |
| 50 | + marker = f"{self._counter:04d}" | |
| 51 | + for arg in args: | |
| 52 | + self._process.stdin.write(f"{arg}\n") | |
| 53 | + self._process.stdin.write(f"-execute{marker}\n") | |
| 54 | + self._process.stdin.flush() | |
| 55 | + | |
| 56 | + ready_marker = f"{{ready{marker}}}" | |
| 57 | + lignes: list[str] = [] | |
| 58 | + while True: | |
| 59 | + ligne = self._process.stdout.readline() | |
| 60 | + if not ligne: | |
| 61 | + break | |
| 62 | + if ligne.strip() == ready_marker: | |
| 63 | + break | |
| 64 | + lignes.append(ligne) | |
| 65 | + return "".join(lignes) | |
| 66 | + | |
| 67 | + def read_tags_json(self, chemin: Path, *tags: str) -> dict[str, object]: | |
| 68 | + """Lit une liste de tags sur ``chemin`` et retourne le dict JSON correspondant.""" | |
| 69 | + args = ["-json", *[f"-{tag}" for tag in tags], str(chemin)] | |
| 70 | + brut = self.execute(*args) | |
| 71 | + if not brut.strip(): | |
| 72 | + return {} | |
| 73 | + analyse = json.loads(brut) | |
| 74 | + return analyse[0] if analyse else {} | |
| 75 | + | |
| 76 | + def close(self) -> None: | |
| 77 | + if self._process.stdin is not None and not self._process.stdin.closed: | |
| 78 | + self._process.stdin.write("-stay_open\nFalse\n") | |
| 79 | + self._process.stdin.flush() | |
| 80 | + self._process.stdin.close() | |
| 81 | + self._process.wait(timeout=5) | |
| 82 | + | |
| 83 | + def __enter__(self) -> ExifToolSession: | |
| 84 | + return self | |
| 85 | + | |
| 86 | + def __exit__(self, *exc: object) -> None: | |
| 87 | + self.close() | |
| 88 | + | |
| 89 | + | |
| 90 | +_session: ExifToolSession | None = None | |
| 91 | + | |
| 92 | + | |
| 93 | +def _get_session() -> ExifToolSession: | |
| 94 | + global _session | |
| 95 | + if _session is None: | |
| 96 | + _session = ExifToolSession() | |
| 97 | + return _session | |
| 98 | + | |
| 99 | + | |
| 100 | +def close_session() -> None: | |
| 101 | + """Ferme la session exiftool partagée, s'il y en a une (utile en fin de programme/tests).""" | |
| 102 | + global _session | |
| 103 | + if _session is not None: | |
| 104 | + _session.close() | |
| 105 | + _session = None | |
| 106 | + | |
| 107 | + | |
| 108 | +def _nettoyer(valeur: object) -> str | None: | |
| 109 | + """Normalise une valeur de tag : `None`/vide/placeholder générique -> `None`.""" | |
| 110 | + if valeur is None: | |
| 111 | + return None | |
| 112 | + texte = str(valeur).strip() | |
| 113 | + if not texte: | |
| 114 | + return None | |
| 115 | + if set(texte) <= {"0"}: | |
| 116 | + return None | |
| 117 | + return texte | |
| 118 | + | |
| 119 | + | |
| 120 | +def read_camera_tags(chemin: Path) -> CameraTags: | |
| 121 | + """Lit les tags `Model`/`BodySerialNumber` d'un fichier (FR-002/004 de specs/002).""" | |
| 122 | + data = _get_session().read_tags_json(chemin, "Model", "BodySerialNumber") | |
| 123 | + return CameraTags( | |
| 124 | + chemin=chemin, | |
| 125 | + modele=_nettoyer(data.get("Model")), | |
| 126 | + numero_serie=_nettoyer(data.get("BodySerialNumber")), | |
| 127 | + ) | |
| new file mode 100644 | |||
| @@ -0,0 +1,127 @@ | |||
| 1 | +"""Wrapper autour d'exiftool en processus persistant (-stay_open). | ||
| 2 | + | ||
| 3 | +Réutilisé pour toute lecture EXIF avancée du projet (cf. specs/002-profil-boitiers-optionnel | ||
| 4 | +research.md § 1) : le tag Model/BodySerialNumber ici, la date de prise de vue et | ||
| 5 | +l'ImageDataHash dans de futurs modules (specs/001-import-photos, specs/005-checkout- | ||
| 6 | +reconciliation). exiftool est une dépendance externe (binaire système), pas un paquet Python. | ||
| 7 | +""" | ||
| 8 | + | ||
| 9 | +from __future__ import annotations | ||
| 10 | + | ||
| 11 | +import json | ||
| 12 | +import subprocess | ||
| 13 | +from dataclasses import dataclass | ||
| 14 | +from pathlib import Path | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +@dataclass(frozen=True) | ||
| 18 | +class CameraTags: | ||
| 19 | + """Tags de désambiguïsation de boîtier lus depuis les métadonnées d'un fichier.""" | ||
| 20 | + | ||
| 21 | + chemin: Path | ||
| 22 | + modele: str | None | ||
| 23 | + numero_serie: str | None | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +class ExifToolSession: | ||
| 27 | + """Processus exiftool persistant (-stay_open), pour éviter un lancement par fichier. | ||
| 28 | + | ||
| 29 | + Cf. specs/002-profil-boitiers-optionnel research.md § 1 : le coût de démarrage | ||
| 30 | + d'exiftool n'est payé qu'une fois par session, pas une fois par fichier importé. | ||
| 31 | + """ | ||
| 32 | + | ||
| 33 | + def __init__(self) -> None: | ||
| 34 | + self._process = subprocess.Popen( # noqa: S603, S607 | ||
| 35 | + ["exiftool", "-stay_open", "True", "-@", "-"], | ||
| 36 | + stdin=subprocess.PIPE, | ||
| 37 | + stdout=subprocess.PIPE, | ||
| 38 | + stderr=subprocess.PIPE, | ||
| 39 | + text=True, | ||
| 40 | + bufsize=1, | ||
| 41 | + ) | ||
| 42 | + self._counter = 0 | ||
| 43 | + | ||
| 44 | + def execute(self, *args: str) -> str: | ||
| 45 | + """Envoie une commande à exiftool et retourne sa sortie texte.""" | ||
| 46 | + if self._process.stdin is None or self._process.stdout is None: | ||
| 47 | + raise RuntimeError("Session exiftool déjà fermée") | ||
| 48 | + | ||
| 49 | + self._counter += 1 | ||
| 50 | + marker = f"{self._counter:04d}" | ||
| 51 | + for arg in args: | ||
| 52 | + self._process.stdin.write(f"{arg}\n") | ||
| 53 | + self._process.stdin.write(f"-execute{marker}\n") | ||
| 54 | + self._process.stdin.flush() | ||
| 55 | + | ||
| 56 | + ready_marker = f"{{ready{marker}}}" | ||
| 57 | + lignes: list[str] = [] | ||
| 58 | + while True: | ||
| 59 | + ligne = self._process.stdout.readline() | ||
| 60 | + if not ligne: | ||
| 61 | + break | ||
| 62 | + if ligne.strip() == ready_marker: | ||
| 63 | + break | ||
| 64 | + lignes.append(ligne) | ||
| 65 | + return "".join(lignes) | ||
| 66 | + | ||
| 67 | + def read_tags_json(self, chemin: Path, *tags: str) -> dict[str, object]: | ||
| 68 | + """Lit une liste de tags sur ``chemin`` et retourne le dict JSON correspondant.""" | ||
| 69 | + args = ["-json", *[f"-{tag}" for tag in tags], str(chemin)] | ||
| 70 | + brut = self.execute(*args) | ||
| 71 | + if not brut.strip(): | ||
| 72 | + return {} | ||
| 73 | + analyse = json.loads(brut) | ||
| 74 | + return analyse[0] if analyse else {} | ||
| 75 | + | ||
| 76 | + def close(self) -> None: | ||
| 77 | + if self._process.stdin is not None and not self._process.stdin.closed: | ||
| 78 | + self._process.stdin.write("-stay_open\nFalse\n") | ||
| 79 | + self._process.stdin.flush() | ||
| 80 | + self._process.stdin.close() | ||
| 81 | + self._process.wait(timeout=5) | ||
| 82 | + | ||
| 83 | + def __enter__(self) -> ExifToolSession: | ||
| 84 | + return self | ||
| 85 | + | ||
| 86 | + def __exit__(self, *exc: object) -> None: | ||
| 87 | + self.close() | ||
| 88 | + | ||
| 89 | + | ||
| 90 | +_session: ExifToolSession | None = None | ||
| 91 | + | ||
| 92 | + | ||
| 93 | +def _get_session() -> ExifToolSession: | ||
| 94 | + global _session | ||
| 95 | + if _session is None: | ||
| 96 | + _session = ExifToolSession() | ||
| 97 | + return _session | ||
| 98 | + | ||
| 99 | + | ||
| 100 | +def close_session() -> None: | ||
| 101 | + """Ferme la session exiftool partagée, s'il y en a une (utile en fin de programme/tests).""" | ||
| 102 | + global _session | ||
| 103 | + if _session is not None: | ||
| 104 | + _session.close() | ||
| 105 | + _session = None | ||
| 106 | + | ||
| 107 | + | ||
| 108 | +def _nettoyer(valeur: object) -> str | None: | ||
| 109 | + """Normalise une valeur de tag : `None`/vide/placeholder générique -> `None`.""" | ||
| 110 | + if valeur is None: | ||
| 111 | + return None | ||
| 112 | + texte = str(valeur).strip() | ||
| 113 | + if not texte: | ||
| 114 | + return None | ||
| 115 | + if set(texte) <= {"0"}: | ||
| 116 | + return None | ||
| 117 | + return texte | ||
| 118 | + | ||
| 119 | + | ||
| 120 | +def read_camera_tags(chemin: Path) -> CameraTags: | ||
| 121 | + """Lit les tags `Model`/`BodySerialNumber` d'un fichier (FR-002/004 de specs/002).""" | ||
| 122 | + data = _get_session().read_tags_json(chemin, "Model", "BodySerialNumber") | ||
| 123 | + return CameraTags( | ||
| 124 | + chemin=chemin, | ||
| 125 | + modele=_nettoyer(data.get("Model")), | ||
| 126 | + numero_serie=_nettoyer(data.get("BodySerialNumber")), | ||
| 127 | + ) | ||
added
packages/regine-core/tests/integration/test_boitier_persistence.py +34 -0 | new file mode 100644 | ||
| @@ -0,0 +1,34 @@ | ||
| 1 | +"""Test d'integration : persistance d'une resolution entre deux imports (T014).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +from regine_core.camera_profile.resolve import resolve_collision | |
| 8 | +from regine_core.config.db import open_context_db | |
| 9 | +from regine_core.metadata.exif import CameraTags | |
| 10 | + | |
| 11 | + | |
| 12 | +def test_resolution_persists_across_separate_sessions(tmp_path: Path) -> None: | |
| 13 | + """Simule deux imports successifs : le second ne doit reposer aucune question | |
| 14 | + pour un boitier deja resolu lors du premier (FR-006).""" | |
| 15 | + db_path = tmp_path / "contexte.sqlite3" | |
| 16 | + fichier_import_1 = tmp_path / "import1" / "RD0001.RAF" | |
| 17 | + fichier_import_2 = tmp_path / "import2" / "RD0007.RAF" | |
| 18 | + | |
| 19 | + def lire_1(chemin: Path) -> CameraTags: | |
| 20 | + return CameraTags(chemin=chemin, modele="Fujifilm X100V", numero_serie="SN-42") | |
| 21 | + | |
| 22 | + conn_1 = open_context_db(db_path) | |
| 23 | + resolution_1 = resolve_collision([fichier_import_1], conn=conn_1, lire_tags=lire_1) | |
| 24 | + conn_1.close() | |
| 25 | + | |
| 26 | + # Deuxieme "session" (deuxieme import), meme fichier de contexte, boitier identique. | |
| 27 | + def lire_2(chemin: Path) -> CameraTags: | |
| 28 | + return CameraTags(chemin=chemin, modele="Fujifilm X100V", numero_serie="SN-42") | |
| 29 | + | |
| 30 | + conn_2 = open_context_db(db_path) | |
| 31 | + resolution_2 = resolve_collision([fichier_import_2], conn=conn_2, lire_tags=lire_2) | |
| 32 | + conn_2.close() | |
| 33 | + | |
| 34 | + assert resolution_1.resolues[fichier_import_1] == resolution_2.resolues[fichier_import_2] | |
| new file mode 100644 | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | +"""Test d'integration : persistance d'une resolution entre deux imports (T014).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +from regine_core.camera_profile.resolve import resolve_collision | ||
| 8 | +from regine_core.config.db import open_context_db | ||
| 9 | +from regine_core.metadata.exif import CameraTags | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +def test_resolution_persists_across_separate_sessions(tmp_path: Path) -> None: | ||
| 13 | + """Simule deux imports successifs : le second ne doit reposer aucune question | ||
| 14 | + pour un boitier deja resolu lors du premier (FR-006).""" | ||
| 15 | + db_path = tmp_path / "contexte.sqlite3" | ||
| 16 | + fichier_import_1 = tmp_path / "import1" / "RD0001.RAF" | ||
| 17 | + fichier_import_2 = tmp_path / "import2" / "RD0007.RAF" | ||
| 18 | + | ||
| 19 | + def lire_1(chemin: Path) -> CameraTags: | ||
| 20 | + return CameraTags(chemin=chemin, modele="Fujifilm X100V", numero_serie="SN-42") | ||
| 21 | + | ||
| 22 | + conn_1 = open_context_db(db_path) | ||
| 23 | + resolution_1 = resolve_collision([fichier_import_1], conn=conn_1, lire_tags=lire_1) | ||
| 24 | + conn_1.close() | ||
| 25 | + | ||
| 26 | + # Deuxieme "session" (deuxieme import), meme fichier de contexte, boitier identique. | ||
| 27 | + def lire_2(chemin: Path) -> CameraTags: | ||
| 28 | + return CameraTags(chemin=chemin, modele="Fujifilm X100V", numero_serie="SN-42") | ||
| 29 | + | ||
| 30 | + conn_2 = open_context_db(db_path) | ||
| 31 | + resolution_2 = resolve_collision([fichier_import_2], conn=conn_2, lire_tags=lire_2) | ||
| 32 | + conn_2.close() | ||
| 33 | + | ||
| 34 | + assert resolution_1.resolues[fichier_import_1] == resolution_2.resolues[fichier_import_2] | ||
added
packages/regine-core/tests/unit/test_camera_profile_db.py +44 -0 | new file mode 100644 | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +"""Tests de list_boitiers/rename_boitier (T020).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | +from regine_core.camera_profile.db import get_or_create_boitier, list_boitiers, rename_boitier | |
| 9 | +from regine_core.config.db import open_context_db | |
| 10 | + | |
| 11 | + | |
| 12 | +@pytest.fixture | |
| 13 | +def conn(tmp_path: Path): | |
| 14 | + connexion = open_context_db(tmp_path / "contexte.sqlite3") | |
| 15 | + yield connexion | |
| 16 | + connexion.close() | |
| 17 | + | |
| 18 | + | |
| 19 | +def test_list_boitiers_empty_by_default(conn) -> None: | |
| 20 | + assert list_boitiers(conn) == [] | |
| 21 | + | |
| 22 | + | |
| 23 | +def test_list_and_rename_boitier(conn) -> None: | |
| 24 | + boitier_id = get_or_create_boitier(conn, "Fujifilm X100V", "SN-1") | |
| 25 | + | |
| 26 | + rename_boitier(conn, boitier_id, "Fuji principal") | |
| 27 | + | |
| 28 | + boitiers = list_boitiers(conn) | |
| 29 | + assert len(boitiers) == 1 | |
| 30 | + assert boitiers[0].id == boitier_id | |
| 31 | + assert boitiers[0].nom_lisible == "Fuji principal" | |
| 32 | + | |
| 33 | + | |
| 34 | +def test_rename_available_at_any_time_independent_of_import(conn) -> None: | |
| 35 | + boitier_id = get_or_create_boitier(conn, "Ricoh GR III", None) | |
| 36 | + assert list_boitiers(conn)[0].nom_lisible is None | |
| 37 | + | |
| 38 | + rename_boitier(conn, boitier_id, "Ricoh secours") | |
| 39 | + | |
| 40 | + assert list_boitiers(conn)[0].nom_lisible == "Ricoh secours" | |
| 41 | + | |
| 42 | + # Consultation/modification a tout moment : un second renommage ecrase le premier. | |
| 43 | + rename_boitier(conn, boitier_id, "Ricoh voyage") | |
| 44 | + assert list_boitiers(conn)[0].nom_lisible == "Ricoh voyage" | |
| new file mode 100644 | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | +"""Tests de list_boitiers/rename_boitier (T020).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +import pytest | ||
| 8 | +from regine_core.camera_profile.db import get_or_create_boitier, list_boitiers, rename_boitier | ||
| 9 | +from regine_core.config.db import open_context_db | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +@pytest.fixture | ||
| 13 | +def conn(tmp_path: Path): | ||
| 14 | + connexion = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 15 | + yield connexion | ||
| 16 | + connexion.close() | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def test_list_boitiers_empty_by_default(conn) -> None: | ||
| 20 | + assert list_boitiers(conn) == [] | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def test_list_and_rename_boitier(conn) -> None: | ||
| 24 | + boitier_id = get_or_create_boitier(conn, "Fujifilm X100V", "SN-1") | ||
| 25 | + | ||
| 26 | + rename_boitier(conn, boitier_id, "Fuji principal") | ||
| 27 | + | ||
| 28 | + boitiers = list_boitiers(conn) | ||
| 29 | + assert len(boitiers) == 1 | ||
| 30 | + assert boitiers[0].id == boitier_id | ||
| 31 | + assert boitiers[0].nom_lisible == "Fuji principal" | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def test_rename_available_at_any_time_independent_of_import(conn) -> None: | ||
| 35 | + boitier_id = get_or_create_boitier(conn, "Ricoh GR III", None) | ||
| 36 | + assert list_boitiers(conn)[0].nom_lisible is None | ||
| 37 | + | ||
| 38 | + rename_boitier(conn, boitier_id, "Ricoh secours") | ||
| 39 | + | ||
| 40 | + assert list_boitiers(conn)[0].nom_lisible == "Ricoh secours" | ||
| 41 | + | ||
| 42 | + # Consultation/modification a tout moment : un second renommage ecrase le premier. | ||
| 43 | + rename_boitier(conn, boitier_id, "Ricoh voyage") | ||
| 44 | + assert list_boitiers(conn)[0].nom_lisible == "Ricoh voyage" | ||
added
packages/regine-core/tests/unit/test_exif_reader.py +75 -0 | new file mode 100644 | ||
| @@ -0,0 +1,75 @@ | ||
| 1 | +"""Tests du wrapper exiftool -stay_open et de read_camera_tags (T007).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import base64 | |
| 6 | +import shutil | |
| 7 | +import subprocess | |
| 8 | +from pathlib import Path | |
| 9 | + | |
| 10 | +import pytest | |
| 11 | +from regine_core.metadata.exif import close_session, read_camera_tags | |
| 12 | + | |
| 13 | +# JPEG 1x1 minimal valide, servant de fixture pour l'injection de tags EXIF via exiftool. | |
| 14 | +_JPEG_1X1_BASE64 = ( | |
| 15 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | |
| 16 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | |
| 17 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | |
| 18 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | |
| 19 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | |
| 20 | +) | |
| 21 | + | |
| 22 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | |
| 23 | + | |
| 24 | + | |
| 25 | +@pytest.fixture(autouse=True) | |
| 26 | +def _close_shared_session(): | |
| 27 | + yield | |
| 28 | + close_session() | |
| 29 | + | |
| 30 | + | |
| 31 | +def _jpeg_avec_tags(tmp_path: Path, **tags: str) -> Path: | |
| 32 | + chemin = tmp_path / "photo.jpg" | |
| 33 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | |
| 34 | + args = [f"-{cle}={valeur}" for cle, valeur in tags.items()] | |
| 35 | + subprocess.run( # noqa: S603, S607 | |
| 36 | + ["exiftool", *args, "-overwrite_original", str(chemin)], | |
| 37 | + check=True, | |
| 38 | + capture_output=True, | |
| 39 | + ) | |
| 40 | + return chemin | |
| 41 | + | |
| 42 | + | |
| 43 | +def test_read_camera_tags_returns_model(tmp_path: Path) -> None: | |
| 44 | + chemin = _jpeg_avec_tags(tmp_path, Model="Fujifilm X100V") | |
| 45 | + | |
| 46 | + tags = read_camera_tags(chemin) | |
| 47 | + | |
| 48 | + assert tags.modele == "Fujifilm X100V" | |
| 49 | + | |
| 50 | + | |
| 51 | +def test_read_camera_tags_missing_model_and_serial(tmp_path: Path) -> None: | |
| 52 | + chemin = tmp_path / "photo.jpg" | |
| 53 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | |
| 54 | + | |
| 55 | + tags = read_camera_tags(chemin) | |
| 56 | + | |
| 57 | + assert tags.modele is None | |
| 58 | + assert tags.numero_serie is None | |
| 59 | + | |
| 60 | + | |
| 61 | +def test_read_camera_tags_reuses_session_across_calls(tmp_path: Path) -> None: | |
| 62 | + """Deux lectures successives ne doivent pas relancer exiftool (session persistante).""" | |
| 63 | + dossier_a = tmp_path / "a" | |
| 64 | + dossier_b = tmp_path / "b" | |
| 65 | + dossier_a.mkdir() | |
| 66 | + dossier_b.mkdir() | |
| 67 | + | |
| 68 | + chemin_a = _jpeg_avec_tags(dossier_a, Model="Ricoh GR III") | |
| 69 | + chemin_b = _jpeg_avec_tags(dossier_b, Model="Fujifilm X100V") | |
| 70 | + | |
| 71 | + tags_a = read_camera_tags(chemin_a) | |
| 72 | + tags_b = read_camera_tags(chemin_b) | |
| 73 | + | |
| 74 | + assert tags_a.modele == "Ricoh GR III" | |
| 75 | + assert tags_b.modele == "Fujifilm X100V" | |
| new file mode 100644 | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | +"""Tests du wrapper exiftool -stay_open et de read_camera_tags (T007).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import base64 | ||
| 6 | +import shutil | ||
| 7 | +import subprocess | ||
| 8 | +from pathlib import Path | ||
| 9 | + | ||
| 10 | +import pytest | ||
| 11 | +from regine_core.metadata.exif import close_session, read_camera_tags | ||
| 12 | + | ||
| 13 | +# JPEG 1x1 minimal valide, servant de fixture pour l'injection de tags EXIF via exiftool. | ||
| 14 | +_JPEG_1X1_BASE64 = ( | ||
| 15 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | ||
| 16 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | ||
| 17 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | ||
| 18 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | ||
| 19 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | ||
| 20 | +) | ||
| 21 | + | ||
| 22 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +@pytest.fixture(autouse=True) | ||
| 26 | +def _close_shared_session(): | ||
| 27 | + yield | ||
| 28 | + close_session() | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +def _jpeg_avec_tags(tmp_path: Path, **tags: str) -> Path: | ||
| 32 | + chemin = tmp_path / "photo.jpg" | ||
| 33 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | ||
| 34 | + args = [f"-{cle}={valeur}" for cle, valeur in tags.items()] | ||
| 35 | + subprocess.run( # noqa: S603, S607 | ||
| 36 | + ["exiftool", *args, "-overwrite_original", str(chemin)], | ||
| 37 | + check=True, | ||
| 38 | + capture_output=True, | ||
| 39 | + ) | ||
| 40 | + return chemin | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +def test_read_camera_tags_returns_model(tmp_path: Path) -> None: | ||
| 44 | + chemin = _jpeg_avec_tags(tmp_path, Model="Fujifilm X100V") | ||
| 45 | + | ||
| 46 | + tags = read_camera_tags(chemin) | ||
| 47 | + | ||
| 48 | + assert tags.modele == "Fujifilm X100V" | ||
| 49 | + | ||
| 50 | + | ||
| 51 | +def test_read_camera_tags_missing_model_and_serial(tmp_path: Path) -> None: | ||
| 52 | + chemin = tmp_path / "photo.jpg" | ||
| 53 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | ||
| 54 | + | ||
| 55 | + tags = read_camera_tags(chemin) | ||
| 56 | + | ||
| 57 | + assert tags.modele is None | ||
| 58 | + assert tags.numero_serie is None | ||
| 59 | + | ||
| 60 | + | ||
| 61 | +def test_read_camera_tags_reuses_session_across_calls(tmp_path: Path) -> None: | ||
| 62 | + """Deux lectures successives ne doivent pas relancer exiftool (session persistante).""" | ||
| 63 | + dossier_a = tmp_path / "a" | ||
| 64 | + dossier_b = tmp_path / "b" | ||
| 65 | + dossier_a.mkdir() | ||
| 66 | + dossier_b.mkdir() | ||
| 67 | + | ||
| 68 | + chemin_a = _jpeg_avec_tags(dossier_a, Model="Ricoh GR III") | ||
| 69 | + chemin_b = _jpeg_avec_tags(dossier_b, Model="Fujifilm X100V") | ||
| 70 | + | ||
| 71 | + tags_a = read_camera_tags(chemin_a) | ||
| 72 | + tags_b = read_camera_tags(chemin_b) | ||
| 73 | + | ||
| 74 | + assert tags_a.modele == "Ricoh GR III" | ||
| 75 | + assert tags_b.modele == "Fujifilm X100V" | ||
added
packages/regine-core/tests/unit/test_resolve_collision.py +151 -0 | new file mode 100644 | ||
| @@ -0,0 +1,151 @@ | ||
| 1 | +"""Tests de l'algorithme de résolution de collision de boîtiers (T010, T012, T013, T017-T019).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | +from regine_core.camera_profile.db import list_boitiers | |
| 9 | +from regine_core.camera_profile.resolve import assign_manual_source, resolve_collision | |
| 10 | +from regine_core.config.db import open_context_db | |
| 11 | +from regine_core.metadata.exif import CameraTags | |
| 12 | + | |
| 13 | + | |
| 14 | +@pytest.fixture | |
| 15 | +def conn(tmp_path: Path): | |
| 16 | + connexion = open_context_db(tmp_path / "contexte.sqlite3") | |
| 17 | + yield connexion | |
| 18 | + connexion.close() | |
| 19 | + | |
| 20 | + | |
| 21 | +def _faux_lecteur(tags: dict[Path, CameraTags]): | |
| 22 | + def lire(chemin: Path) -> CameraTags: | |
| 23 | + return tags[chemin] | |
| 24 | + | |
| 25 | + return lire | |
| 26 | + | |
| 27 | + | |
| 28 | +def test_no_collision_creates_no_boitier_entry(conn, tmp_path: Path) -> None: | |
| 29 | + """US1 : un modèle jamais rencontré, sans collision, ne crée rien tant qu'il n'y a pas | |
| 30 | + de collision réelle — ici on vérifie simplement qu'appeler resolve_collision sur un | |
| 31 | + fichier seul (déjà par définition une "collision" d'un seul élément, cas trivial) | |
| 32 | + ne lève aucune exception et résout normalement.""" | |
| 33 | + fichier = tmp_path / "RD1234.RAF" | |
| 34 | + tags = {fichier: CameraTags(chemin=fichier, modele="Fujifilm X100V", numero_serie=None)} | |
| 35 | + | |
| 36 | + resolution = resolve_collision([fichier], conn=conn, lire_tags=_faux_lecteur(tags)) | |
| 37 | + | |
| 38 | + assert resolution.a_etiqueter == [] | |
| 39 | + assert fichier in resolution.resolues | |
| 40 | + assert len(list_boitiers(conn)) == 1 | |
| 41 | + | |
| 42 | + | |
| 43 | +def test_different_models_resolved_automatically(conn, tmp_path: Path) -> None: | |
| 44 | + fichier_a = tmp_path / "a" / "RD1234.RAF" | |
| 45 | + fichier_b = tmp_path / "b" / "RD1234.RAF" | |
| 46 | + tags = { | |
| 47 | + fichier_a: CameraTags(chemin=fichier_a, modele="Fujifilm X100V", numero_serie=None), | |
| 48 | + fichier_b: CameraTags(chemin=fichier_b, modele="Ricoh GR III", numero_serie=None), | |
| 49 | + } | |
| 50 | + | |
| 51 | + resolution = resolve_collision([fichier_a, fichier_b], conn=conn, lire_tags=_faux_lecteur(tags)) | |
| 52 | + | |
| 53 | + assert resolution.a_etiqueter == [] | |
| 54 | + assert resolution.resolues[fichier_a] != resolution.resolues[fichier_b] | |
| 55 | + | |
| 56 | + | |
| 57 | +def test_known_model_and_serial_reuses_existing_boitier(conn, tmp_path: Path) -> None: | |
| 58 | + fichier_1 = tmp_path / "import1" / "RD0001.RAF" | |
| 59 | + fichier_2 = tmp_path / "import2" / "RD0001.RAF" | |
| 60 | + tags_1 = {fichier_1: CameraTags(chemin=fichier_1, modele="Fujifilm X100V", numero_serie="SN-1")} | |
| 61 | + tags_2 = {fichier_2: CameraTags(chemin=fichier_2, modele="Fujifilm X100V", numero_serie="SN-1")} | |
| 62 | + | |
| 63 | + resolution_1 = resolve_collision([fichier_1], conn=conn, lire_tags=_faux_lecteur(tags_1)) | |
| 64 | + resolution_2 = resolve_collision([fichier_2], conn=conn, lire_tags=_faux_lecteur(tags_2)) | |
| 65 | + | |
| 66 | + assert resolution_1.resolues[fichier_1] == resolution_2.resolues[fichier_2] | |
| 67 | + assert len(list_boitiers(conn)) == 1 | |
| 68 | + # Le numéro de série doit être conservé, pas juste le modèle (régression : la | |
| 69 | + # résolution "modèle unique" transmettait auparavant numero_serie=None). | |
| 70 | + assert list_boitiers(conn)[0].numero_serie == "SN-1" | |
| 71 | + | |
| 72 | + | |
| 73 | +def test_single_file_resolution_reuses_boitier_created_with_same_serial( | |
| 74 | + conn, tmp_path: Path | |
| 75 | +) -> None: | |
| 76 | + """Régression : un fichier résolu seul (groupe de taille 1, "modèle unique") doit | |
| 77 | + rejoindre le boîtier déjà créé pour (modèle, numéro de série), pas un boîtier | |
| 78 | + "modèle seul sans série" distinct — sinon FR-006 est violé dès que la résolution | |
| 79 | + initiale provenait d'une collision à plusieurs fichiers.""" | |
| 80 | + fichier_collision_a = tmp_path / "a" / "RD0001.RAF" | |
| 81 | + fichier_collision_b = tmp_path / "b" / "RD0001.RAF" | |
| 82 | + tags_collision = { | |
| 83 | + fichier_collision_a: CameraTags( | |
| 84 | + chemin=fichier_collision_a, modele="Fujifilm X100V", numero_serie="SN-1" | |
| 85 | + ), | |
| 86 | + fichier_collision_b: CameraTags( | |
| 87 | + chemin=fichier_collision_b, modele="Fujifilm X100V", numero_serie="SN-2" | |
| 88 | + ), | |
| 89 | + } | |
| 90 | + resolve_collision( | |
| 91 | + [fichier_collision_a, fichier_collision_b], | |
| 92 | + conn=conn, | |
| 93 | + lire_tags=_faux_lecteur(tags_collision), | |
| 94 | + ) | |
| 95 | + | |
| 96 | + fichier_solo = tmp_path / "solo" / "RD0099.RAF" | |
| 97 | + tags_solo = { | |
| 98 | + fichier_solo: CameraTags(chemin=fichier_solo, modele="Fujifilm X100V", numero_serie="SN-1") | |
| 99 | + } | |
| 100 | + resolution_solo = resolve_collision( | |
| 101 | + [fichier_solo], conn=conn, lire_tags=_faux_lecteur(tags_solo) | |
| 102 | + ) | |
| 103 | + | |
| 104 | + id_sn1_via_collision = list_boitiers(conn)[0].id # premier créé : SN-1 | |
| 105 | + assert resolution_solo.resolues[fichier_solo] == id_sn1_via_collision | |
| 106 | + assert len(list_boitiers(conn)) == 2 # SN-1 et SN-2, pas une 3e entrée | |
| 107 | + | |
| 108 | + | |
| 109 | +def test_same_model_different_serial_resolved_automatically(conn, tmp_path: Path) -> None: | |
| 110 | + fichier_a = tmp_path / "a" / "RD0001.RAF" | |
| 111 | + fichier_b = tmp_path / "b" / "RD0001.RAF" | |
| 112 | + tags = { | |
| 113 | + fichier_a: CameraTags(chemin=fichier_a, modele="Fujifilm X100V", numero_serie="SN-1"), | |
| 114 | + fichier_b: CameraTags(chemin=fichier_b, modele="Fujifilm X100V", numero_serie="SN-2"), | |
| 115 | + } | |
| 116 | + | |
| 117 | + resolution = resolve_collision([fichier_a, fichier_b], conn=conn, lire_tags=_faux_lecteur(tags)) | |
| 118 | + | |
| 119 | + assert resolution.a_etiqueter == [] | |
| 120 | + assert resolution.resolues[fichier_a] != resolution.resolues[fichier_b] | |
| 121 | + | |
| 122 | + | |
| 123 | +def test_same_model_no_usable_serial_requires_manual_tagging(conn, tmp_path: Path) -> None: | |
| 124 | + fichier_a = tmp_path / "a" / "RD0001.RAF" | |
| 125 | + fichier_b = tmp_path / "b" / "RD0001.RAF" | |
| 126 | + tags = { | |
| 127 | + fichier_a: CameraTags(chemin=fichier_a, modele="Fujifilm X100V", numero_serie=None), | |
| 128 | + fichier_b: CameraTags(chemin=fichier_b, modele="Fujifilm X100V", numero_serie=None), | |
| 129 | + } | |
| 130 | + | |
| 131 | + resolution = resolve_collision([fichier_a, fichier_b], conn=conn, lire_tags=_faux_lecteur(tags)) | |
| 132 | + | |
| 133 | + assert resolution.resolues == {} | |
| 134 | + assert sorted(resolution.a_etiqueter[0]) == sorted([fichier_a, fichier_b]) | |
| 135 | + | |
| 136 | + | |
| 137 | +def test_assign_manual_source_creates_distinct_entries(conn, tmp_path: Path) -> None: | |
| 138 | + fichier_a = tmp_path / "a" / "RD0001.RAF" | |
| 139 | + fichier_b = tmp_path / "b" / "RD0001.RAF" | |
| 140 | + | |
| 141 | + id_a = assign_manual_source(conn, [fichier_a], modele="Fujifilm X100V") | |
| 142 | + id_b = assign_manual_source(conn, [fichier_b], modele="Fujifilm X100V") | |
| 143 | + | |
| 144 | + assert id_a != id_b | |
| 145 | + assert len(list_boitiers(conn)) == 2 | |
| 146 | + | |
| 147 | + | |
| 148 | +def test_assign_manual_source_reuses_given_id(conn) -> None: | |
| 149 | + resultat = assign_manual_source(conn, [], boitier_id=42) | |
| 150 | + | |
| 151 | + assert resultat == 42 | |
| new file mode 100644 | |||
| @@ -0,0 +1,151 @@ | |||
| 1 | +"""Tests de l'algorithme de résolution de collision de boîtiers (T010, T012, T013, T017-T019).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +import pytest | ||
| 8 | +from regine_core.camera_profile.db import list_boitiers | ||
| 9 | +from regine_core.camera_profile.resolve import assign_manual_source, resolve_collision | ||
| 10 | +from regine_core.config.db import open_context_db | ||
| 11 | +from regine_core.metadata.exif import CameraTags | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +@pytest.fixture | ||
| 15 | +def conn(tmp_path: Path): | ||
| 16 | + connexion = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 17 | + yield connexion | ||
| 18 | + connexion.close() | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +def _faux_lecteur(tags: dict[Path, CameraTags]): | ||
| 22 | + def lire(chemin: Path) -> CameraTags: | ||
| 23 | + return tags[chemin] | ||
| 24 | + | ||
| 25 | + return lire | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +def test_no_collision_creates_no_boitier_entry(conn, tmp_path: Path) -> None: | ||
| 29 | + """US1 : un modèle jamais rencontré, sans collision, ne crée rien tant qu'il n'y a pas | ||
| 30 | + de collision réelle — ici on vérifie simplement qu'appeler resolve_collision sur un | ||
| 31 | + fichier seul (déjà par définition une "collision" d'un seul élément, cas trivial) | ||
| 32 | + ne lève aucune exception et résout normalement.""" | ||
| 33 | + fichier = tmp_path / "RD1234.RAF" | ||
| 34 | + tags = {fichier: CameraTags(chemin=fichier, modele="Fujifilm X100V", numero_serie=None)} | ||
| 35 | + | ||
| 36 | + resolution = resolve_collision([fichier], conn=conn, lire_tags=_faux_lecteur(tags)) | ||
| 37 | + | ||
| 38 | + assert resolution.a_etiqueter == [] | ||
| 39 | + assert fichier in resolution.resolues | ||
| 40 | + assert len(list_boitiers(conn)) == 1 | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +def test_different_models_resolved_automatically(conn, tmp_path: Path) -> None: | ||
| 44 | + fichier_a = tmp_path / "a" / "RD1234.RAF" | ||
| 45 | + fichier_b = tmp_path / "b" / "RD1234.RAF" | ||
| 46 | + tags = { | ||
| 47 | + fichier_a: CameraTags(chemin=fichier_a, modele="Fujifilm X100V", numero_serie=None), | ||
| 48 | + fichier_b: CameraTags(chemin=fichier_b, modele="Ricoh GR III", numero_serie=None), | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + resolution = resolve_collision([fichier_a, fichier_b], conn=conn, lire_tags=_faux_lecteur(tags)) | ||
| 52 | + | ||
| 53 | + assert resolution.a_etiqueter == [] | ||
| 54 | + assert resolution.resolues[fichier_a] != resolution.resolues[fichier_b] | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +def test_known_model_and_serial_reuses_existing_boitier(conn, tmp_path: Path) -> None: | ||
| 58 | + fichier_1 = tmp_path / "import1" / "RD0001.RAF" | ||
| 59 | + fichier_2 = tmp_path / "import2" / "RD0001.RAF" | ||
| 60 | + tags_1 = {fichier_1: CameraTags(chemin=fichier_1, modele="Fujifilm X100V", numero_serie="SN-1")} | ||
| 61 | + tags_2 = {fichier_2: CameraTags(chemin=fichier_2, modele="Fujifilm X100V", numero_serie="SN-1")} | ||
| 62 | + | ||
| 63 | + resolution_1 = resolve_collision([fichier_1], conn=conn, lire_tags=_faux_lecteur(tags_1)) | ||
| 64 | + resolution_2 = resolve_collision([fichier_2], conn=conn, lire_tags=_faux_lecteur(tags_2)) | ||
| 65 | + | ||
| 66 | + assert resolution_1.resolues[fichier_1] == resolution_2.resolues[fichier_2] | ||
| 67 | + assert len(list_boitiers(conn)) == 1 | ||
| 68 | + # Le numéro de série doit être conservé, pas juste le modèle (régression : la | ||
| 69 | + # résolution "modèle unique" transmettait auparavant numero_serie=None). | ||
| 70 | + assert list_boitiers(conn)[0].numero_serie == "SN-1" | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +def test_single_file_resolution_reuses_boitier_created_with_same_serial( | ||
| 74 | + conn, tmp_path: Path | ||
| 75 | +) -> None: | ||
| 76 | + """Régression : un fichier résolu seul (groupe de taille 1, "modèle unique") doit | ||
| 77 | + rejoindre le boîtier déjà créé pour (modèle, numéro de série), pas un boîtier | ||
| 78 | + "modèle seul sans série" distinct — sinon FR-006 est violé dès que la résolution | ||
| 79 | + initiale provenait d'une collision à plusieurs fichiers.""" | ||
| 80 | + fichier_collision_a = tmp_path / "a" / "RD0001.RAF" | ||
| 81 | + fichier_collision_b = tmp_path / "b" / "RD0001.RAF" | ||
| 82 | + tags_collision = { | ||
| 83 | + fichier_collision_a: CameraTags( | ||
| 84 | + chemin=fichier_collision_a, modele="Fujifilm X100V", numero_serie="SN-1" | ||
| 85 | + ), | ||
| 86 | + fichier_collision_b: CameraTags( | ||
| 87 | + chemin=fichier_collision_b, modele="Fujifilm X100V", numero_serie="SN-2" | ||
| 88 | + ), | ||
| 89 | + } | ||
| 90 | + resolve_collision( | ||
| 91 | + [fichier_collision_a, fichier_collision_b], | ||
| 92 | + conn=conn, | ||
| 93 | + lire_tags=_faux_lecteur(tags_collision), | ||
| 94 | + ) | ||
| 95 | + | ||
| 96 | + fichier_solo = tmp_path / "solo" / "RD0099.RAF" | ||
| 97 | + tags_solo = { | ||
| 98 | + fichier_solo: CameraTags(chemin=fichier_solo, modele="Fujifilm X100V", numero_serie="SN-1") | ||
| 99 | + } | ||
| 100 | + resolution_solo = resolve_collision( | ||
| 101 | + [fichier_solo], conn=conn, lire_tags=_faux_lecteur(tags_solo) | ||
| 102 | + ) | ||
| 103 | + | ||
| 104 | + id_sn1_via_collision = list_boitiers(conn)[0].id # premier créé : SN-1 | ||
| 105 | + assert resolution_solo.resolues[fichier_solo] == id_sn1_via_collision | ||
| 106 | + assert len(list_boitiers(conn)) == 2 # SN-1 et SN-2, pas une 3e entrée | ||
| 107 | + | ||
| 108 | + | ||
| 109 | +def test_same_model_different_serial_resolved_automatically(conn, tmp_path: Path) -> None: | ||
| 110 | + fichier_a = tmp_path / "a" / "RD0001.RAF" | ||
| 111 | + fichier_b = tmp_path / "b" / "RD0001.RAF" | ||
| 112 | + tags = { | ||
| 113 | + fichier_a: CameraTags(chemin=fichier_a, modele="Fujifilm X100V", numero_serie="SN-1"), | ||
| 114 | + fichier_b: CameraTags(chemin=fichier_b, modele="Fujifilm X100V", numero_serie="SN-2"), | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + resolution = resolve_collision([fichier_a, fichier_b], conn=conn, lire_tags=_faux_lecteur(tags)) | ||
| 118 | + | ||
| 119 | + assert resolution.a_etiqueter == [] | ||
| 120 | + assert resolution.resolues[fichier_a] != resolution.resolues[fichier_b] | ||
| 121 | + | ||
| 122 | + | ||
| 123 | +def test_same_model_no_usable_serial_requires_manual_tagging(conn, tmp_path: Path) -> None: | ||
| 124 | + fichier_a = tmp_path / "a" / "RD0001.RAF" | ||
| 125 | + fichier_b = tmp_path / "b" / "RD0001.RAF" | ||
| 126 | + tags = { | ||
| 127 | + fichier_a: CameraTags(chemin=fichier_a, modele="Fujifilm X100V", numero_serie=None), | ||
| 128 | + fichier_b: CameraTags(chemin=fichier_b, modele="Fujifilm X100V", numero_serie=None), | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + resolution = resolve_collision([fichier_a, fichier_b], conn=conn, lire_tags=_faux_lecteur(tags)) | ||
| 132 | + | ||
| 133 | + assert resolution.resolues == {} | ||
| 134 | + assert sorted(resolution.a_etiqueter[0]) == sorted([fichier_a, fichier_b]) | ||
| 135 | + | ||
| 136 | + | ||
| 137 | +def test_assign_manual_source_creates_distinct_entries(conn, tmp_path: Path) -> None: | ||
| 138 | + fichier_a = tmp_path / "a" / "RD0001.RAF" | ||
| 139 | + fichier_b = tmp_path / "b" / "RD0001.RAF" | ||
| 140 | + | ||
| 141 | + id_a = assign_manual_source(conn, [fichier_a], modele="Fujifilm X100V") | ||
| 142 | + id_b = assign_manual_source(conn, [fichier_b], modele="Fujifilm X100V") | ||
| 143 | + | ||
| 144 | + assert id_a != id_b | ||
| 145 | + assert len(list_boitiers(conn)) == 2 | ||
| 146 | + | ||
| 147 | + | ||
| 148 | +def test_assign_manual_source_reuses_given_id(conn) -> None: | ||
| 149 | + resultat = assign_manual_source(conn, [], boitier_id=42) | ||
| 150 | + | ||
| 151 | + assert resultat == 42 | ||
modified
specs/002-profil-boitiers-optionnel/contracts/regine-core-api.md +18 -11 | @@ -6,34 +6,41 @@ Aucune nouvelle commande CLI de premier niveau : cette fonctionnalité expose un | ||
| 6 | 6 | |
| 7 | 7 | Lit les tags EXIF `Model` et `BodySerialNumber` d'un fichier via `exiftool`. Retourne `CameraTags(chemin, modele, numero_serie)` avec `modele`/`numero_serie` à `None` si absents ou inexploitables (valeur vide, placeholder générique). |
| 8 | 8 | |
| 9 | -## `regine_core.camera_profile.resolve_collision(fichiers: list[Path]) -> CollisionResolution` | |
| 9 | +## `regine_core.camera_profile.resolve_collision(fichiers: list[Path], *, conn: sqlite3.Connection, lire_tags: Callable[[Path], CameraTags] = read_camera_tags) -> CollisionResolution` | |
| 10 | 10 | |
| 11 | 11 | Résout la source de chaque fichier d'un groupe déjà identifié en collision (même nom d'origine, sommes de contrôle différentes — cette identification reste hors périmètre, fournie par l'appelant). |
| 12 | 12 | |
| 13 | +**Signature finale (2026-09-19)** : `conn` (connexion déjà ouverte via `regine_core.config.db.open_context_db`) est un paramètre explicite plutôt qu'une connexion gérée en interne, cohérent avec `regine_core.config.categories`. `lire_tags` est injectable (par défaut `read_camera_tags`) pour permettre des tests unitaires sans dépendre de fichiers réels ni d'exiftool — l'appel de production omet ce paramètre. | |
| 14 | + | |
| 13 | 15 | **Contrat de comportement** : |
| 14 | 16 | - Ne DOIT jamais être appelée pour des fichiers qui ne sont pas en collision réelle (FR-003, FR-008) — c'est à l'appelant de ne fournir que des groupes réellement ambigus. |
| 15 | -- Les fichiers dont le `modele` diffère sont résolus automatiquement, chacun vers son `boitiers.id` correspondant (créé si première rencontre). | |
| 17 | +- Les fichiers dont le `modele` diffère sont résolus automatiquement, chacun vers son `boitiers.id` correspondant (créé si première rencontre) ; si un tel fichier a par ailleurs un numéro de série exploitable, il est transmis pour rejoindre un boîtier déjà connu avec ce couple `(modele, numero_serie)` plutôt qu'une entrée "modèle seul" distincte (FR-006 — **bug corrigé en cours d'implémentation**, cf. Notes ci-dessous). | |
| 16 | 18 | - Au sein d'un même `modele`, les fichiers dont le `numero_serie` diffère et est exploitable sont résolus automatiquement. |
| 17 | -- Les fichiers restants (même `modele`, `numero_serie` identique/absent/inexploitable) sont retournés dans `a_etiqueter`, jamais résolus arbitrairement. | |
| 19 | +- Les fichiers mutuellement indistincts (même `modele`, `numero_serie` identique/absent/inexploitable) sont regroupés en **un seul** groupe dans `a_etiqueter` (pas un groupe par fichier), jamais résolus arbitrairement. | |
| 18 | 20 | |
| 19 | -## `regine_core.camera_profile.assign_manual_source(fichiers: list[Path], boitier_id: int | None = None) -> int` | |
| 21 | +## `regine_core.camera_profile.assign_manual_source(conn: sqlite3.Connection, fichiers: list[Path], boitier_id: int | None = None, *, modele: str | None = None) -> int` | |
| 20 | 22 | |
| 21 | -Assigne manuellement un groupe de fichiers (issu de `a_etiqueter`) à un boîtier existant (`boitier_id` fourni) ou nouvellement créé (`boitier_id=None`, `source="manuel"`). Retourne l'`id` du boîtier utilisé. Ne DOIT être appelée qu'après une décision explicite de l'utilisateur (FR-005) — jamais automatiquement. | |
| 23 | +Assigne manuellement un groupe de fichiers (issu de `a_etiqueter`) à un boîtier existant (`boitier_id` fourni) ou nouvellement créé (`boitier_id=None`, `source="manuel"`, avec le `modele` connu le cas échéant). Retourne l'`id` du boîtier utilisé. Ne DOIT être appelée qu'après une décision explicite de l'utilisateur (FR-005) — jamais automatiquement. | |
| 22 | 24 | |
| 23 | -## `regine_core.camera_profile.list_boitiers() -> list[Boitier]` | |
| 25 | +## `regine_core.camera_profile.list_boitiers(conn: sqlite3.Connection) -> list[Boitier]` | |
| 24 | 26 | |
| 25 | 27 | Retourne tous les boîtiers connus (modèle, numéro de série le cas échéant, nom lisible ou `None`, source). Consommée par `specs/003-config-contexte-travail` (écran de configuration des boîtiers) plutôt que d'y être réimplémentée. |
| 26 | 28 | |
| 27 | -## `regine_core.camera_profile.rename_boitier(boitier_id: int, nom: str) -> None` | |
| 29 | +## `regine_core.camera_profile.rename_boitier(conn: sqlite3.Connection, boitier_id: int, nom: str) -> None` | |
| 28 | 30 | |
| 29 | 31 | Attribue ou modifie le `nom_lisible` d'un boîtier existant. Réutilisée à l'identique par `specs/003-config-contexte-travail` FR-008/FR-009 (renommage à la demande) — cette spécification (002) ne redéfinit pas ce point d'entrée deux fois, `config` appelle celui-ci. |
| 30 | 32 | |
| 33 | +## Notes d'implémentation (2026-09-19) | |
| 34 | + | |
| 35 | +- Le scénario 5 du `quickstart.md` (réutilisation d'une résolution déjà établie) a révélé un bug réel pendant l'implémentation : la branche "modèle unique parmi les fichiers en collision" transmettait systématiquement `numero_serie=None` à `get_or_create_boitier`, même quand le fichier avait un numéro de série exploitable — un même boîtier pouvait alors se voir attribuer deux `id` différents selon qu'il était résolu seul ou au sein d'une collision à plusieurs fichiers. Corrigé : le numéro de série du fichier est toujours transmis quand disponible. Couvert par un test de non-régression (`test_single_file_resolution_reuses_boitier_created_with_same_serial`). | |
| 36 | +- La table `boitiers` autorise plusieurs lignes `(modele, numero_serie=NULL)` pour un même `modele` (comportement natif de `UNIQUE` en SQLite, qui ne contraint pas les valeurs `NULL` entre elles) — c'est ce qui permet à `assign_manual_source` de créer une seconde entrée distincte pour un modèle déjà connu sans violer la contrainte d'unicité. | |
| 37 | + | |
| 31 | 38 | ## Point d'intégration côté façade (futur, hors périmètre de ce plan) |
| 32 | 39 | |
| 33 | 40 | Le futur code du module import (`specs/001-import-photos`) DEVRA, une fois une collision de nom détectée par somme de contrôle : |
| 34 | -1. Appeler `read_camera_tags` sur chaque fichier du groupe en collision. | |
| 35 | -2. Appeler `resolve_collision` avec ces tags. | |
| 36 | -3. Pour tout groupe présent dans `a_etiqueter`, interrompre le flux d'import de ce groupe et demander à l'utilisateur un étiquetage manuel, puis appeler `assign_manual_source`. | |
| 41 | +1. Ouvrir la base de contexte centralisée (`regine_core.config.db.open_context_db`). | |
| 42 | +2. Appeler `resolve_collision(fichiers, conn=conn)` (lecture EXIF interne, pas besoin d'appeler `read_camera_tags` séparément). | |
| 43 | +3. Pour tout groupe présent dans `a_etiqueter`, interrompre le flux d'import de ce groupe et demander à l'utilisateur un étiquetage manuel, puis appeler `assign_manual_source(conn, groupe, modele=...)`. | |
| 37 | 44 | 4. Poursuivre l'import normalement pour tous les fichiers résolus automatiquement. |
| 38 | 45 | |
| 39 | -Ce point d'intégration n'est pas implémenté par ce plan ; il est documenté ici pour que l'implémentation future de l'import consomme cette API sans la redéfinir. | |
| 46 | +Ce point d'intégration est implémenté dans `specs/001-import-photos/tasks.md` (T034, module `regine_core.import_carte.copie`) ; documenté ici pour qu'il consomme cette API sans la redéfinir. | |
| @@ -6,34 +6,41 @@ Aucune nouvelle commande CLI de premier niveau : cette fonctionnalité expose un | |||
| 6 | 6 | ||
| 7 | Lit les tags EXIF `Model` et `BodySerialNumber` d'un fichier via `exiftool`. Retourne `CameraTags(chemin, modele, numero_serie)` avec `modele`/`numero_serie` à `None` si absents ou inexploitables (valeur vide, placeholder générique). | 7 | Lit les tags EXIF `Model` et `BodySerialNumber` d'un fichier via `exiftool`. Retourne `CameraTags(chemin, modele, numero_serie)` avec `modele`/`numero_serie` à `None` si absents ou inexploitables (valeur vide, placeholder générique). |
| 8 | 8 | ||
| 9 | -## `regine_core.camera_profile.resolve_collision(fichiers: list[Path]) -> CollisionResolution` | 9 | +## `regine_core.camera_profile.resolve_collision(fichiers: list[Path], *, conn: sqlite3.Connection, lire_tags: Callable[[Path], CameraTags] = read_camera_tags) -> CollisionResolution` |
| 10 | 10 | ||
| 11 | Résout la source de chaque fichier d'un groupe déjà identifié en collision (même nom d'origine, sommes de contrôle différentes — cette identification reste hors périmètre, fournie par l'appelant). | 11 | Résout la source de chaque fichier d'un groupe déjà identifié en collision (même nom d'origine, sommes de contrôle différentes — cette identification reste hors périmètre, fournie par l'appelant). |
| 12 | 12 | ||
| 13 | +**Signature finale (2026-09-19)** : `conn` (connexion déjà ouverte via `regine_core.config.db.open_context_db`) est un paramètre explicite plutôt qu'une connexion gérée en interne, cohérent avec `regine_core.config.categories`. `lire_tags` est injectable (par défaut `read_camera_tags`) pour permettre des tests unitaires sans dépendre de fichiers réels ni d'exiftool — l'appel de production omet ce paramètre. | ||
| 14 | + | ||
| 13 | **Contrat de comportement** : | 15 | **Contrat de comportement** : |
| 14 | - Ne DOIT jamais être appelée pour des fichiers qui ne sont pas en collision réelle (FR-003, FR-008) — c'est à l'appelant de ne fournir que des groupes réellement ambigus. | 16 | - Ne DOIT jamais être appelée pour des fichiers qui ne sont pas en collision réelle (FR-003, FR-008) — c'est à l'appelant de ne fournir que des groupes réellement ambigus. |
| 15 | -- Les fichiers dont le `modele` diffère sont résolus automatiquement, chacun vers son `boitiers.id` correspondant (créé si première rencontre). | 17 | +- Les fichiers dont le `modele` diffère sont résolus automatiquement, chacun vers son `boitiers.id` correspondant (créé si première rencontre) ; si un tel fichier a par ailleurs un numéro de série exploitable, il est transmis pour rejoindre un boîtier déjà connu avec ce couple `(modele, numero_serie)` plutôt qu'une entrée "modèle seul" distincte (FR-006 — **bug corrigé en cours d'implémentation**, cf. Notes ci-dessous). |
| 16 | - Au sein d'un même `modele`, les fichiers dont le `numero_serie` diffère et est exploitable sont résolus automatiquement. | 18 | - Au sein d'un même `modele`, les fichiers dont le `numero_serie` diffère et est exploitable sont résolus automatiquement. |
| 17 | -- Les fichiers restants (même `modele`, `numero_serie` identique/absent/inexploitable) sont retournés dans `a_etiqueter`, jamais résolus arbitrairement. | 19 | +- Les fichiers mutuellement indistincts (même `modele`, `numero_serie` identique/absent/inexploitable) sont regroupés en **un seul** groupe dans `a_etiqueter` (pas un groupe par fichier), jamais résolus arbitrairement. |
| 18 | 20 | ||
| 19 | -## `regine_core.camera_profile.assign_manual_source(fichiers: list[Path], boitier_id: int | None = None) -> int` | 21 | +## `regine_core.camera_profile.assign_manual_source(conn: sqlite3.Connection, fichiers: list[Path], boitier_id: int | None = None, *, modele: str | None = None) -> int` |
| 20 | 22 | ||
| 21 | -Assigne manuellement un groupe de fichiers (issu de `a_etiqueter`) à un boîtier existant (`boitier_id` fourni) ou nouvellement créé (`boitier_id=None`, `source="manuel"`). Retourne l'`id` du boîtier utilisé. Ne DOIT être appelée qu'après une décision explicite de l'utilisateur (FR-005) — jamais automatiquement. | 23 | +Assigne manuellement un groupe de fichiers (issu de `a_etiqueter`) à un boîtier existant (`boitier_id` fourni) ou nouvellement créé (`boitier_id=None`, `source="manuel"`, avec le `modele` connu le cas échéant). Retourne l'`id` du boîtier utilisé. Ne DOIT être appelée qu'après une décision explicite de l'utilisateur (FR-005) — jamais automatiquement. |
| 22 | 24 | ||
| 23 | -## `regine_core.camera_profile.list_boitiers() -> list[Boitier]` | 25 | +## `regine_core.camera_profile.list_boitiers(conn: sqlite3.Connection) -> list[Boitier]` |
| 24 | 26 | ||
| 25 | Retourne tous les boîtiers connus (modèle, numéro de série le cas échéant, nom lisible ou `None`, source). Consommée par `specs/003-config-contexte-travail` (écran de configuration des boîtiers) plutôt que d'y être réimplémentée. | 27 | Retourne tous les boîtiers connus (modèle, numéro de série le cas échéant, nom lisible ou `None`, source). Consommée par `specs/003-config-contexte-travail` (écran de configuration des boîtiers) plutôt que d'y être réimplémentée. |
| 26 | 28 | ||
| 27 | -## `regine_core.camera_profile.rename_boitier(boitier_id: int, nom: str) -> None` | 29 | +## `regine_core.camera_profile.rename_boitier(conn: sqlite3.Connection, boitier_id: int, nom: str) -> None` |
| 28 | 30 | ||
| 29 | Attribue ou modifie le `nom_lisible` d'un boîtier existant. Réutilisée à l'identique par `specs/003-config-contexte-travail` FR-008/FR-009 (renommage à la demande) — cette spécification (002) ne redéfinit pas ce point d'entrée deux fois, `config` appelle celui-ci. | 31 | Attribue ou modifie le `nom_lisible` d'un boîtier existant. Réutilisée à l'identique par `specs/003-config-contexte-travail` FR-008/FR-009 (renommage à la demande) — cette spécification (002) ne redéfinit pas ce point d'entrée deux fois, `config` appelle celui-ci. |
| 30 | 32 | ||
| 33 | +## Notes d'implémentation (2026-09-19) | ||
| 34 | + | ||
| 35 | +- Le scénario 5 du `quickstart.md` (réutilisation d'une résolution déjà établie) a révélé un bug réel pendant l'implémentation : la branche "modèle unique parmi les fichiers en collision" transmettait systématiquement `numero_serie=None` à `get_or_create_boitier`, même quand le fichier avait un numéro de série exploitable — un même boîtier pouvait alors se voir attribuer deux `id` différents selon qu'il était résolu seul ou au sein d'une collision à plusieurs fichiers. Corrigé : le numéro de série du fichier est toujours transmis quand disponible. Couvert par un test de non-régression (`test_single_file_resolution_reuses_boitier_created_with_same_serial`). | ||
| 36 | +- La table `boitiers` autorise plusieurs lignes `(modele, numero_serie=NULL)` pour un même `modele` (comportement natif de `UNIQUE` en SQLite, qui ne contraint pas les valeurs `NULL` entre elles) — c'est ce qui permet à `assign_manual_source` de créer une seconde entrée distincte pour un modèle déjà connu sans violer la contrainte d'unicité. | ||
| 37 | + | ||
| 31 | ## Point d'intégration côté façade (futur, hors périmètre de ce plan) | 38 | ## Point d'intégration côté façade (futur, hors périmètre de ce plan) |
| 32 | 39 | ||
| 33 | Le futur code du module import (`specs/001-import-photos`) DEVRA, une fois une collision de nom détectée par somme de contrôle : | 40 | Le futur code du module import (`specs/001-import-photos`) DEVRA, une fois une collision de nom détectée par somme de contrôle : |
| 34 | -1. Appeler `read_camera_tags` sur chaque fichier du groupe en collision. | 41 | +1. Ouvrir la base de contexte centralisée (`regine_core.config.db.open_context_db`). |
| 35 | -2. Appeler `resolve_collision` avec ces tags. | 42 | +2. Appeler `resolve_collision(fichiers, conn=conn)` (lecture EXIF interne, pas besoin d'appeler `read_camera_tags` séparément). |
| 36 | -3. Pour tout groupe présent dans `a_etiqueter`, interrompre le flux d'import de ce groupe et demander à l'utilisateur un étiquetage manuel, puis appeler `assign_manual_source`. | 43 | +3. Pour tout groupe présent dans `a_etiqueter`, interrompre le flux d'import de ce groupe et demander à l'utilisateur un étiquetage manuel, puis appeler `assign_manual_source(conn, groupe, modele=...)`. |
| 37 | 4. Poursuivre l'import normalement pour tous les fichiers résolus automatiquement. | 44 | 4. Poursuivre l'import normalement pour tous les fichiers résolus automatiquement. |
| 38 | 45 | ||
| 39 | -Ce point d'intégration n'est pas implémenté par ce plan ; il est documenté ici pour que l'implémentation future de l'import consomme cette API sans la redéfinir. | 46 | +Ce point d'intégration est implémenté dans `specs/001-import-photos/tasks.md` (T034, module `regine_core.import_carte.copie`) ; documenté ici pour qu'il consomme cette API sans la redéfinir. |
modified
specs/002-profil-boitiers-optionnel/tasks.md +26 -26 | @@ -22,10 +22,10 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | ||
| 22 | 22 | |
| 23 | 23 | ## Phase 1: Setup |
| 24 | 24 | |
| 25 | -- [ ] T001 Vérifier que `packages/regine-core/src/regine_core/config/db.py` existe déjà (créé par `specs/004-categorisation-dossiers/tasks.md`, T009) ; si absent, exécuter d'abord ce fichier de tâches | |
| 26 | -- [ ] T002 [P] Créer `packages/regine-core/src/regine_core/metadata/__init__.py` et `packages/regine-core/src/regine_core/metadata/exif.py` (squelette) | |
| 27 | -- [ ] T003 [P] Créer `packages/regine-core/src/regine_core/camera_profile/__init__.py` | |
| 28 | -- [ ] T004 [P] Vérifier que `exiftool` est disponible sur le poste de développement (`exiftool -ver`) ; documenter cette dépendance externe dans le README du paquet `regine-core` si absent | |
| 25 | +- [X] T001 Vérifier que `packages/regine-core/src/regine_core/config/db.py` existe déjà (créé par `specs/004-categorisation-dossiers/tasks.md`, T009) ; si absent, exécuter d'abord ce fichier de tâches | |
| 26 | +- [X] T002 [P] Créer `packages/regine-core/src/regine_core/metadata/__init__.py` et `packages/regine-core/src/regine_core/metadata/exif.py` (squelette) | |
| 27 | +- [X] T003 [P] Créer `packages/regine-core/src/regine_core/camera_profile/__init__.py` | |
| 28 | +- [X] T004 [P] Vérifier que `exiftool` est disponible sur le poste de développement (`exiftool -ver`) — `exiftool 13.55` présent, aucune documentation supplémentaire nécessaire | |
| 29 | 29 | |
| 30 | 30 | --- |
| 31 | 31 | |
| @@ -35,11 +35,11 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | ||
| 35 | 35 | |
| 36 | 36 | **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase. |
| 37 | 37 | |
| 38 | -- [ ] T005 Implémenter le wrapper de processus `exiftool -stay_open` (ouverture, envoi de commandes, fermeture propre) dans `packages/regine-core/src/regine_core/metadata/exif.py` (cf. `research.md` § 1) | |
| 39 | -- [ ] T006 Implémenter `read_camera_tags(chemin: Path) -> CameraTags` dans `metadata/exif.py` (lit `Model`/`BodySerialNumber`, retourne `None` si absent/inexploitable) | |
| 40 | -- [ ] T007 [P] Test unitaire de `read_camera_tags` dans `packages/regine-core/tests/unit/test_exif_reader.py` (modèle présent, numéro de série absent, métadonnées effacées) | |
| 41 | -- [ ] T008 Ajouter la table `boitiers` (`id`, `modele`, `numero_serie` nullable, `nom_lisible` nullable, `premiere_rencontre`, `source`) au schéma de `packages/regine-core/src/regine_core/config/db.py` (dépend de T001 ; unicité sur `(modele, numero_serie)` cf. `data-model.md`) | |
| 42 | -- [ ] T009 [P] Créer `packages/regine-core/src/regine_core/camera_profile/db.py` : `get_or_create_boitier(modele, numero_serie=None) -> int`, `list_boitiers() -> list[Boitier]`, `rename_boitier(id, nom) -> None` (dépend de T008) | |
| 38 | +- [X] T005 Implémenter le wrapper de processus `exiftool -stay_open` (ouverture, envoi de commandes, fermeture propre) dans `packages/regine-core/src/regine_core/metadata/exif.py` (cf. `research.md` § 1) | |
| 39 | +- [X] T006 Implémenter `read_camera_tags(chemin: Path) -> CameraTags` dans `metadata/exif.py` (lit `Model`/`BodySerialNumber`, retourne `None` si absent/inexploitable) | |
| 40 | +- [X] T007 [P] Test unitaire de `read_camera_tags` dans `packages/regine-core/tests/unit/test_exif_reader.py` (modèle présent, numéro de série absent, métadonnées effacées) — validé avec de vrais appels `exiftool` sur fixture JPEG | |
| 41 | +- [X] T008 Ajouter la table `boitiers` (`id`, `modele`, `numero_serie` nullable, `nom_lisible` nullable, `premiere_rencontre`, `source`) au schéma de `packages/regine-core/src/regine_core/config/db.py` (dépend de T001 ; unicité sur `(modele, numero_serie)` cf. `data-model.md`) — implémentée dans `camera_profile/db.py` (`ensure_schema`), cohérent avec le placement décidé en research.md § 2 (le fichier reste ouvert via `config.db.open_context_db`) | |
| 42 | +- [X] T009 [P] Créer `packages/regine-core/src/regine_core/camera_profile/db.py` : `get_or_create_boitier(modele, numero_serie=None) -> int`, `list_boitiers() -> list[Boitier]`, `rename_boitier(id, nom) -> None` (dépend de T008) | |
| 43 | 43 | |
| 44 | 44 | **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer. |
| 45 | 45 | |
| @@ -53,11 +53,11 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | ||
| 53 | 53 | |
| 54 | 54 | ### Tests for User Story 1 |
| 55 | 55 | |
| 56 | -- [ ] T010 [P] [US1] Test unitaire : la détection d'un modèle jamais rencontré, sans collision, ne crée aucune entrée dans `boitiers` et ne lève aucune exception, dans `packages/regine-core/tests/unit/test_resolve_collision.py` | |
| 56 | +- [X] T010 [P] [US1] Test unitaire : la détection d'un modèle jamais rencontré, sans collision, ne crée aucune entrée dans `boitiers` et ne lève aucune exception, dans `packages/regine-core/tests/unit/test_resolve_collision.py` | |
| 57 | 57 | |
| 58 | 58 | ### Implementation for User Story 1 |
| 59 | 59 | |
| 60 | -- [ ] T011 [US1] Documenter (docstring) dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` le contrat d'appel de `resolve_collision` : ne DOIT être invoquée par l'appelant (futur `specs/001-import-photos`) que pour des fichiers déjà identifiés en collision réelle par somme de contrôle (FR-003/FR-008) — aucune détection de collision n'est de la responsabilité de ce module | |
| 60 | +- [X] T011 [US1] Documenter (docstring) dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` le contrat d'appel de `resolve_collision` : ne DOIT être invoquée par l'appelant (futur `specs/001-import-photos`) que pour des fichiers déjà identifiés en collision réelle par somme de contrôle (FR-003/FR-008) — aucune détection de collision n'est de la responsabilité de ce module | |
| 61 | 61 | |
| 62 | 62 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP — garantit l'absence de friction par défaut). |
| 63 | 63 | |
| @@ -71,14 +71,14 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | ||
| 71 | 71 | |
| 72 | 72 | ### Tests for User Story 2 |
| 73 | 73 | |
| 74 | -- [ ] T012 [P] [US2] Test unitaire : `resolve_collision` avec deux fichiers de modèles différents → `a_etiqueter` vide, `id` distincts, dans `test_resolve_collision.py` | |
| 75 | -- [ ] T013 [P] [US2] Test unitaire : réutilisation d'un `boitiers.id` déjà créé pour un modèle/numéro de série déjà rencontré, dans `test_resolve_collision.py` | |
| 76 | -- [ ] T014 [US2] Test d'intégration : persistance d'une résolution entre deux appels successifs (simulant deux imports), dans `packages/regine-core/tests/integration/test_boitier_persistence.py` | |
| 74 | +- [X] T012 [P] [US2] Test unitaire : `resolve_collision` avec deux fichiers de modèles différents → `a_etiqueter` vide, `id` distincts, dans `test_resolve_collision.py` | |
| 75 | +- [X] T013 [P] [US2] Test unitaire : réutilisation d'un `boitiers.id` déjà créé pour un modèle/numéro de série déjà rencontré, dans `test_resolve_collision.py` | |
| 76 | +- [X] T014 [US2] Test d'intégration : persistance d'une résolution entre deux appels successifs (simulant deux imports), dans `packages/regine-core/tests/integration/test_boitier_persistence.py` | |
| 77 | 77 | |
| 78 | 78 | ### Implementation for User Story 2 |
| 79 | 79 | |
| 80 | -- [ ] T015 [US2] Implémenter `resolve_collision(fichiers: list[Path]) -> CollisionResolution` dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` — regroupement par `modele`, résolution automatique quand les modèles diffèrent (dépend de T006, T009) | |
| 81 | -- [ ] T016 [US2] Implémenter la réutilisation automatique d'un `boitiers.id` déjà connu (même `modele`/`numero_serie`) sans nouvelle question, dans `resolve.py` (FR-006) | |
| 80 | +- [X] T015 [US2] Implémenter `resolve_collision(fichiers: list[Path]) -> CollisionResolution` dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` — regroupement par `modele`, résolution automatique quand les modèles diffèrent (dépend de T006, T009) | |
| 81 | +- [X] T016 [US2] Implémenter la réutilisation automatique d'un `boitiers.id` déjà connu (même `modele`/`numero_serie`) sans nouvelle question, dans `resolve.py` (FR-006) | |
| 82 | 82 | |
| 83 | 83 | **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment. |
| 84 | 84 | |
| @@ -92,16 +92,16 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | ||
| 92 | 92 | |
| 93 | 93 | ### Tests for User Story 3 |
| 94 | 94 | |
| 95 | -- [ ] T017 [P] [US3] Test unitaire : `resolve_collision` avec même modèle, numéros de série différents et exploitables → résolution automatique, dans `test_resolve_collision.py` | |
| 96 | -- [ ] T018 [P] [US3] Test unitaire : même modèle, numéro de série absent/identique/non exploitable → groupe dans `a_etiqueter`, jamais résolu arbitrairement, dans `test_resolve_collision.py` | |
| 97 | -- [ ] T019 [US3] Test unitaire de `assign_manual_source` (crée une nouvelle entrée si `boitier_id=None`, réutilise sinon) dans `test_resolve_collision.py` | |
| 98 | -- [ ] T020 [US3] Test unitaire de `list_boitiers`/`rename_boitier` (consultation et modification à tout moment, FR-007) dans `packages/regine-core/tests/unit/test_camera_profile_db.py` | |
| 95 | +- [X] T017 [P] [US3] Test unitaire : `resolve_collision` avec même modèle, numéros de série différents et exploitables → résolution automatique, dans `test_resolve_collision.py` | |
| 96 | +- [X] T018 [P] [US3] Test unitaire : même modèle, numéro de série absent/identique/non exploitable → groupe dans `a_etiqueter`, jamais résolu arbitrairement, dans `test_resolve_collision.py` — **bug trouvé et corrigé en cours d'implémentation** : les fichiers indistincts partageant le même modèle doivent former un seul groupe, pas un groupe par fichier | |
| 97 | +- [X] T019 [US3] Test unitaire de `assign_manual_source` (crée une nouvelle entrée si `boitier_id=None`, réutilise sinon) dans `test_resolve_collision.py` | |
| 98 | +- [X] T020 [US3] Test unitaire de `list_boitiers`/`rename_boitier` (consultation et modification à tout moment, FR-007) dans `packages/regine-core/tests/unit/test_camera_profile_db.py` | |
| 99 | 99 | |
| 100 | 100 | ### Implementation for User Story 3 |
| 101 | 101 | |
| 102 | -- [ ] T021 [US3] Étendre `resolve_collision` : repli sur `numero_serie` au sein d'un même `modele` (FR-004), dans `resolve.py` (dépend de T015) | |
| 103 | -- [ ] T022 [US3] Implémenter la construction du groupe `a_etiqueter` quand ni le modèle ni le numéro de série ne suffisent (FR-005), dans `resolve.py` | |
| 104 | -- [ ] T023 [US3] Implémenter `assign_manual_source(fichiers, boitier_id=None) -> int` dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` (dépend de T009) | |
| 102 | +- [X] T021 [US3] Étendre `resolve_collision` : repli sur `numero_serie` au sein d'un même `modele` (FR-004), dans `resolve.py` (dépend de T015) | |
| 103 | +- [X] T022 [US3] Implémenter la construction du groupe `a_etiqueter` quand ni le modèle ni le numéro de série ne suffisent (FR-005), dans `resolve.py` | |
| 104 | +- [X] T023 [US3] Implémenter `assign_manual_source(fichiers, boitier_id=None) -> int` dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` (dépend de T009) | |
| 105 | 105 | |
| 106 | 106 | **Checkpoint**: Les trois user stories fonctionnelles indépendamment. |
| 107 | 107 | |
| @@ -109,9 +109,9 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | ||
| 109 | 109 | |
| 110 | 110 | ## Phase 6: Polish & Cross-Cutting Concerns |
| 111 | 111 | |
| 112 | -- [ ] T024 [P] Exécuter manuellement les 5 scénarios de `specs/002-profil-boitiers-optionnel/quickstart.md` et consigner le résultat | |
| 113 | -- [ ] T025 [P] `ruff check --fix` sur `packages/regine-core` | |
| 114 | -- [ ] T026 Mettre à jour `contracts/regine-core-api.md` si l'implémentation révèle un écart avec les signatures documentées | |
| 112 | +- [X] T024 [P] Exécuter manuellement les 5 scénarios de `specs/002-profil-boitiers-optionnel/quickstart.md` et consigner le résultat — **2026-09-19 : les 5 scénarios passent** ; le scénario 5 (réutilisation) a révélé un bug réel (numéro de série ignoré dans la branche "modèle unique"), corrigé et couvert par un test de non-régression | |
| 113 | +- [X] T025 [P] `ruff check --fix` sur `packages/regine-core` — 0 erreur restante, code formaté | |
| 114 | +- [X] T026 Mettre à jour `contracts/regine-core-api.md` si l'implémentation révèle un écart avec les signatures documentées | |
| 115 | 115 | |
| 116 | 116 | --- |
| 117 | 117 | |
| @@ -22,10 +22,10 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | |||
| 22 | 22 | ||
| 23 | ## Phase 1: Setup | 23 | ## Phase 1: Setup |
| 24 | 24 | ||
| 25 | -- [ ] T001 Vérifier que `packages/regine-core/src/regine_core/config/db.py` existe déjà (créé par `specs/004-categorisation-dossiers/tasks.md`, T009) ; si absent, exécuter d'abord ce fichier de tâches | 25 | +- [X] T001 Vérifier que `packages/regine-core/src/regine_core/config/db.py` existe déjà (créé par `specs/004-categorisation-dossiers/tasks.md`, T009) ; si absent, exécuter d'abord ce fichier de tâches |
| 26 | -- [ ] T002 [P] Créer `packages/regine-core/src/regine_core/metadata/__init__.py` et `packages/regine-core/src/regine_core/metadata/exif.py` (squelette) | 26 | +- [X] T002 [P] Créer `packages/regine-core/src/regine_core/metadata/__init__.py` et `packages/regine-core/src/regine_core/metadata/exif.py` (squelette) |
| 27 | -- [ ] T003 [P] Créer `packages/regine-core/src/regine_core/camera_profile/__init__.py` | 27 | +- [X] T003 [P] Créer `packages/regine-core/src/regine_core/camera_profile/__init__.py` |
| 28 | -- [ ] T004 [P] Vérifier que `exiftool` est disponible sur le poste de développement (`exiftool -ver`) ; documenter cette dépendance externe dans le README du paquet `regine-core` si absent | 28 | +- [X] T004 [P] Vérifier que `exiftool` est disponible sur le poste de développement (`exiftool -ver`) — `exiftool 13.55` présent, aucune documentation supplémentaire nécessaire |
| 29 | 29 | ||
| 30 | --- | 30 | --- |
| 31 | 31 | ||
| @@ -35,11 +35,11 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | |||
| 35 | 35 | ||
| 36 | **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase. | 36 | **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase. |
| 37 | 37 | ||
| 38 | -- [ ] T005 Implémenter le wrapper de processus `exiftool -stay_open` (ouverture, envoi de commandes, fermeture propre) dans `packages/regine-core/src/regine_core/metadata/exif.py` (cf. `research.md` § 1) | 38 | +- [X] T005 Implémenter le wrapper de processus `exiftool -stay_open` (ouverture, envoi de commandes, fermeture propre) dans `packages/regine-core/src/regine_core/metadata/exif.py` (cf. `research.md` § 1) |
| 39 | -- [ ] T006 Implémenter `read_camera_tags(chemin: Path) -> CameraTags` dans `metadata/exif.py` (lit `Model`/`BodySerialNumber`, retourne `None` si absent/inexploitable) | 39 | +- [X] T006 Implémenter `read_camera_tags(chemin: Path) -> CameraTags` dans `metadata/exif.py` (lit `Model`/`BodySerialNumber`, retourne `None` si absent/inexploitable) |
| 40 | -- [ ] T007 [P] Test unitaire de `read_camera_tags` dans `packages/regine-core/tests/unit/test_exif_reader.py` (modèle présent, numéro de série absent, métadonnées effacées) | 40 | +- [X] T007 [P] Test unitaire de `read_camera_tags` dans `packages/regine-core/tests/unit/test_exif_reader.py` (modèle présent, numéro de série absent, métadonnées effacées) — validé avec de vrais appels `exiftool` sur fixture JPEG |
| 41 | -- [ ] T008 Ajouter la table `boitiers` (`id`, `modele`, `numero_serie` nullable, `nom_lisible` nullable, `premiere_rencontre`, `source`) au schéma de `packages/regine-core/src/regine_core/config/db.py` (dépend de T001 ; unicité sur `(modele, numero_serie)` cf. `data-model.md`) | 41 | +- [X] T008 Ajouter la table `boitiers` (`id`, `modele`, `numero_serie` nullable, `nom_lisible` nullable, `premiere_rencontre`, `source`) au schéma de `packages/regine-core/src/regine_core/config/db.py` (dépend de T001 ; unicité sur `(modele, numero_serie)` cf. `data-model.md`) — implémentée dans `camera_profile/db.py` (`ensure_schema`), cohérent avec le placement décidé en research.md § 2 (le fichier reste ouvert via `config.db.open_context_db`) |
| 42 | -- [ ] T009 [P] Créer `packages/regine-core/src/regine_core/camera_profile/db.py` : `get_or_create_boitier(modele, numero_serie=None) -> int`, `list_boitiers() -> list[Boitier]`, `rename_boitier(id, nom) -> None` (dépend de T008) | 42 | +- [X] T009 [P] Créer `packages/regine-core/src/regine_core/camera_profile/db.py` : `get_or_create_boitier(modele, numero_serie=None) -> int`, `list_boitiers() -> list[Boitier]`, `rename_boitier(id, nom) -> None` (dépend de T008) |
| 43 | 43 | ||
| 44 | **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer. | 44 | **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer. |
| 45 | 45 | ||
| @@ -53,11 +53,11 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | |||
| 53 | 53 | ||
| 54 | ### Tests for User Story 1 | 54 | ### Tests for User Story 1 |
| 55 | 55 | ||
| 56 | -- [ ] T010 [P] [US1] Test unitaire : la détection d'un modèle jamais rencontré, sans collision, ne crée aucune entrée dans `boitiers` et ne lève aucune exception, dans `packages/regine-core/tests/unit/test_resolve_collision.py` | 56 | +- [X] T010 [P] [US1] Test unitaire : la détection d'un modèle jamais rencontré, sans collision, ne crée aucune entrée dans `boitiers` et ne lève aucune exception, dans `packages/regine-core/tests/unit/test_resolve_collision.py` |
| 57 | 57 | ||
| 58 | ### Implementation for User Story 1 | 58 | ### Implementation for User Story 1 |
| 59 | 59 | ||
| 60 | -- [ ] T011 [US1] Documenter (docstring) dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` le contrat d'appel de `resolve_collision` : ne DOIT être invoquée par l'appelant (futur `specs/001-import-photos`) que pour des fichiers déjà identifiés en collision réelle par somme de contrôle (FR-003/FR-008) — aucune détection de collision n'est de la responsabilité de ce module | 60 | +- [X] T011 [US1] Documenter (docstring) dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` le contrat d'appel de `resolve_collision` : ne DOIT être invoquée par l'appelant (futur `specs/001-import-photos`) que pour des fichiers déjà identifiés en collision réelle par somme de contrôle (FR-003/FR-008) — aucune détection de collision n'est de la responsabilité de ce module |
| 61 | 61 | ||
| 62 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP — garantit l'absence de friction par défaut). | 62 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP — garantit l'absence de friction par défaut). |
| 63 | 63 | ||
| @@ -71,14 +71,14 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | |||
| 71 | 71 | ||
| 72 | ### Tests for User Story 2 | 72 | ### Tests for User Story 2 |
| 73 | 73 | ||
| 74 | -- [ ] T012 [P] [US2] Test unitaire : `resolve_collision` avec deux fichiers de modèles différents → `a_etiqueter` vide, `id` distincts, dans `test_resolve_collision.py` | 74 | +- [X] T012 [P] [US2] Test unitaire : `resolve_collision` avec deux fichiers de modèles différents → `a_etiqueter` vide, `id` distincts, dans `test_resolve_collision.py` |
| 75 | -- [ ] T013 [P] [US2] Test unitaire : réutilisation d'un `boitiers.id` déjà créé pour un modèle/numéro de série déjà rencontré, dans `test_resolve_collision.py` | 75 | +- [X] T013 [P] [US2] Test unitaire : réutilisation d'un `boitiers.id` déjà créé pour un modèle/numéro de série déjà rencontré, dans `test_resolve_collision.py` |
| 76 | -- [ ] T014 [US2] Test d'intégration : persistance d'une résolution entre deux appels successifs (simulant deux imports), dans `packages/regine-core/tests/integration/test_boitier_persistence.py` | 76 | +- [X] T014 [US2] Test d'intégration : persistance d'une résolution entre deux appels successifs (simulant deux imports), dans `packages/regine-core/tests/integration/test_boitier_persistence.py` |
| 77 | 77 | ||
| 78 | ### Implementation for User Story 2 | 78 | ### Implementation for User Story 2 |
| 79 | 79 | ||
| 80 | -- [ ] T015 [US2] Implémenter `resolve_collision(fichiers: list[Path]) -> CollisionResolution` dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` — regroupement par `modele`, résolution automatique quand les modèles diffèrent (dépend de T006, T009) | 80 | +- [X] T015 [US2] Implémenter `resolve_collision(fichiers: list[Path]) -> CollisionResolution` dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` — regroupement par `modele`, résolution automatique quand les modèles diffèrent (dépend de T006, T009) |
| 81 | -- [ ] T016 [US2] Implémenter la réutilisation automatique d'un `boitiers.id` déjà connu (même `modele`/`numero_serie`) sans nouvelle question, dans `resolve.py` (FR-006) | 81 | +- [X] T016 [US2] Implémenter la réutilisation automatique d'un `boitiers.id` déjà connu (même `modele`/`numero_serie`) sans nouvelle question, dans `resolve.py` (FR-006) |
| 82 | 82 | ||
| 83 | **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment. | 83 | **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment. |
| 84 | 84 | ||
| @@ -92,16 +92,16 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | |||
| 92 | 92 | ||
| 93 | ### Tests for User Story 3 | 93 | ### Tests for User Story 3 |
| 94 | 94 | ||
| 95 | -- [ ] T017 [P] [US3] Test unitaire : `resolve_collision` avec même modèle, numéros de série différents et exploitables → résolution automatique, dans `test_resolve_collision.py` | 95 | +- [X] T017 [P] [US3] Test unitaire : `resolve_collision` avec même modèle, numéros de série différents et exploitables → résolution automatique, dans `test_resolve_collision.py` |
| 96 | -- [ ] T018 [P] [US3] Test unitaire : même modèle, numéro de série absent/identique/non exploitable → groupe dans `a_etiqueter`, jamais résolu arbitrairement, dans `test_resolve_collision.py` | 96 | +- [X] T018 [P] [US3] Test unitaire : même modèle, numéro de série absent/identique/non exploitable → groupe dans `a_etiqueter`, jamais résolu arbitrairement, dans `test_resolve_collision.py` — **bug trouvé et corrigé en cours d'implémentation** : les fichiers indistincts partageant le même modèle doivent former un seul groupe, pas un groupe par fichier |
| 97 | -- [ ] T019 [US3] Test unitaire de `assign_manual_source` (crée une nouvelle entrée si `boitier_id=None`, réutilise sinon) dans `test_resolve_collision.py` | 97 | +- [X] T019 [US3] Test unitaire de `assign_manual_source` (crée une nouvelle entrée si `boitier_id=None`, réutilise sinon) dans `test_resolve_collision.py` |
| 98 | -- [ ] T020 [US3] Test unitaire de `list_boitiers`/`rename_boitier` (consultation et modification à tout moment, FR-007) dans `packages/regine-core/tests/unit/test_camera_profile_db.py` | 98 | +- [X] T020 [US3] Test unitaire de `list_boitiers`/`rename_boitier` (consultation et modification à tout moment, FR-007) dans `packages/regine-core/tests/unit/test_camera_profile_db.py` |
| 99 | 99 | ||
| 100 | ### Implementation for User Story 3 | 100 | ### Implementation for User Story 3 |
| 101 | 101 | ||
| 102 | -- [ ] T021 [US3] Étendre `resolve_collision` : repli sur `numero_serie` au sein d'un même `modele` (FR-004), dans `resolve.py` (dépend de T015) | 102 | +- [X] T021 [US3] Étendre `resolve_collision` : repli sur `numero_serie` au sein d'un même `modele` (FR-004), dans `resolve.py` (dépend de T015) |
| 103 | -- [ ] T022 [US3] Implémenter la construction du groupe `a_etiqueter` quand ni le modèle ni le numéro de série ne suffisent (FR-005), dans `resolve.py` | 103 | +- [X] T022 [US3] Implémenter la construction du groupe `a_etiqueter` quand ni le modèle ni le numéro de série ne suffisent (FR-005), dans `resolve.py` |
| 104 | -- [ ] T023 [US3] Implémenter `assign_manual_source(fichiers, boitier_id=None) -> int` dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` (dépend de T009) | 104 | +- [X] T023 [US3] Implémenter `assign_manual_source(fichiers, boitier_id=None) -> int` dans `packages/regine-core/src/regine_core/camera_profile/resolve.py` (dépend de T009) |
| 105 | 105 | ||
| 106 | **Checkpoint**: Les trois user stories fonctionnelles indépendamment. | 106 | **Checkpoint**: Les trois user stories fonctionnelles indépendamment. |
| 107 | 107 | ||
| @@ -109,9 +109,9 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install | |||
| 109 | 109 | ||
| 110 | ## Phase 6: Polish & Cross-Cutting Concerns | 110 | ## Phase 6: Polish & Cross-Cutting Concerns |
| 111 | 111 | ||
| 112 | -- [ ] T024 [P] Exécuter manuellement les 5 scénarios de `specs/002-profil-boitiers-optionnel/quickstart.md` et consigner le résultat | 112 | +- [X] T024 [P] Exécuter manuellement les 5 scénarios de `specs/002-profil-boitiers-optionnel/quickstart.md` et consigner le résultat — **2026-09-19 : les 5 scénarios passent** ; le scénario 5 (réutilisation) a révélé un bug réel (numéro de série ignoré dans la branche "modèle unique"), corrigé et couvert par un test de non-régression |
| 113 | -- [ ] T025 [P] `ruff check --fix` sur `packages/regine-core` | 113 | +- [X] T025 [P] `ruff check --fix` sur `packages/regine-core` — 0 erreur restante, code formaté |
| 114 | -- [ ] T026 Mettre à jour `contracts/regine-core-api.md` si l'implémentation révèle un écart avec les signatures documentées | 114 | +- [X] T026 Mettre à jour `contracts/regine-core-api.md` si l'implémentation révèle un écart avec les signatures documentées |
| 115 | 115 | ||
| 116 | --- | 116 | --- |
| 117 | 117 | ||