fonzarely/regine-photos-archiverpublic⑂ Fork 0
⑂ e849fcb
Commits
⬇ Clone ▾
git clone https://git.rickub.com/fonzarely/regine-photos-archiver.git
git clone ssh://git@rickub.com/fonzarely/regine-photos-archiver.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

implemente

Fabien Champigny committed 2026-09-19T16:37:04+02:00 Browse files
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
66
77 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).
88
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`
1010
1111 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).
1212
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+
1315 **Contrat de comportement** :
1416 - 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).
1618 - 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.
1820
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`
2022
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.
2224
23-## `regine_core.camera_profile.list_boitiers() -> list[Boitier]`
25+## `regine_core.camera_profile.list_boitiers(conn: sqlite3.Connection) -> list[Boitier]`
2426
2527 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.
2628
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`
2830
2931 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.
3032
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+
3138 ## Point d'intégration côté façade (futur, hors périmètre de ce plan)
3239
3340 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=...)`.
3744 4. Poursuivre l'import normalement pour tous les fichiers résolus automatiquement.
3845
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
2222
2323 ## Phase 1: Setup
2424
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
2929
3030 ---
3131
@@ -35,11 +35,11 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install
3535
3636 **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase.
3737
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)
4343
4444 **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer.
4545
@@ -53,11 +53,11 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install
5353
5454 ### Tests for User Story 1
5555
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`
5757
5858 ### Implementation for User Story 1
5959
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
6161
6262 **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP — garantit l'absence de friction par défaut).
6363
@@ -71,14 +71,14 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install
7171
7272 ### Tests for User Story 2
7373
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`
7777
7878 ### Implementation for User Story 2
7979
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)
8282
8383 **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment.
8484
@@ -92,16 +92,16 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install
9292
9393 ### Tests for User Story 3
9494
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`
9999
100100 ### Implementation for User Story 3
101101
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)
105105
106106 **Checkpoint**: Les trois user stories fonctionnelles indépendamment.
107107
@@ -109,9 +109,9 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install
109109
110110 ## Phase 6: Polish & Cross-Cutting Concerns
111111
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
115115
116116 ---
117117
@@ -22,10 +22,10 @@ Aucune autre dépendance externe : `exiftool` est un binaire système à install
22 22
23 ## Phase 1: Setup23 ## 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âches25+- [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 absent28+- [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 154 ### 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 158 ### 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 module60+- [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 272 ### 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 278 ### 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 393 ### 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 3100 ### 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 Concerns110 ## 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ésultat112+- [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ées114+- [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