diff --git a/Makefile b/Makefile index 8d144c4..8dd7b5b 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ CFLAGS = -std=c17 \ -MP \ $(shell $(PKG_CONFIG) --cflags gtk4 sqlite3) -LDFLAGS = $(shell $(PKG_CONFIG) --libs gtk4 sqlite3) +LDFLAGS = $(shell $(PKG_CONFIG) --libs gtk4 sqlite3) -ljpeg TEST_CFLAGS = -std=c17 \ -Wall \ @@ -125,6 +125,13 @@ TEST_EVIDENCE_RECLASSIFICATION := tests/test_evidence_reclassification TEST_SOCIAL_ACCOUNT_SERVICE := tests/test_social_account_service TEST_SOCIAL_PLATFORM := tests/test_social_platform TEST_PERSON_ENTITY_SERVICE := tests/test_person_entity_service +TEST_EVIDENCE_PREVIEW := tests/test_evidence_preview +TEST_EVIDENCE_SELECTION_MODEL := tests/test_evidence_selection_model +TEST_PERSON_CREATION_GUARD := tests/test_person_creation_guard +TEST_PERSON_DIALOG_LIFECYCLE := tests/test_person_dialog_lifecycle +TEST_CREATE_PERSON_DIALOG_GTK := tests/test_create_person_dialog_gtk +TEST_PERSON_CONFIRMATION_SUMMARY := tests/test_person_confirmation_summary +TEST_PERSON_ROLE_ASSIGNMENT_DAO := tests/test_person_role_assignment_dao TEST_EML_ANALYZER := tests/test_eml_analyzer TEST_EML_INTEGRATION := tests/test_eml_integration TEST_IBAN_ANALYZER := tests/test_iban_analyzer @@ -778,10 +785,12 @@ $(TEST_SOCIAL_PLATFORM): \ $(TEST_PERSON_ENTITY_SERVICE): \ tests/test_person_entity_service.c \ src/core/person_entity_service.c \ + src/dao/person_role_assignment_dao.c \ src/dao/entity_dao.c \ src/dao/evidence_entity_dao.c \ src/models/entity_record.c \ src/models/evidence_observation.c \ + src/models/person_role_assignment.c \ src/database/database.c \ src/database/schema.c \ src/database/statement.c \ @@ -789,6 +798,50 @@ $(TEST_PERSON_ENTITY_SERVICE): \ src/database/error.c $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 +$(TEST_EVIDENCE_PREVIEW): tests/test_evidence_preview.c \ + src/core/evidence_preview.c \ + src/core/evidence_preview_task.c src/core/background_task.c \ + src/core/task_manager.c \ + src/core/evidence_integrity_verifier.c \ + src/core/file_hash.c src/models/evidence_record.c + $(CC) $(CFLAGS) -Wpedantic $^ -o $@ $(LDFLAGS) -ljpeg + +$(TEST_EVIDENCE_SELECTION_MODEL): tests/test_evidence_selection_model.c \ + src/models/evidence_selection_model.c src/models/evidence_record.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) + +$(TEST_PERSON_CREATION_GUARD): tests/test_person_creation_guard.c \ + src/core/person_creation_guard.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) + +$(TEST_PERSON_DIALOG_LIFECYCLE): tests/test_person_dialog_lifecycle.c \ + src/core/person_dialog_lifecycle.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) + +$(TEST_CREATE_PERSON_DIALOG_GTK): tests/test_create_person_dialog_gtk.c \ + src/views/create_person_dialog.c src/core/person_dialog_lifecycle.c \ + src/core/person_confirmation_summary.c \ + src/core/evidence_preview_task.c src/core/evidence_preview.c \ + src/core/evidence_integrity_verifier.c src/core/file_hash.c \ + src/core/background_task.c src/core/task_manager.c \ + src/models/evidence_selection_model.c src/models/evidence_record.c \ + src/models/person_role_assignment.c + $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) + +$(TEST_PERSON_CONFIRMATION_SUMMARY): \ + tests/test_person_confirmation_summary.c \ + src/core/person_confirmation_summary.c src/models/evidence_record.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) + +$(TEST_PERSON_ROLE_ASSIGNMENT_DAO): \ + tests/test_person_role_assignment_dao.c \ + src/dao/person_role_assignment_dao.c src/models/person_role_assignment.c \ + src/dao/entity_dao.c src/models/entity_record.c \ + src/dao/evidence_dao.c src/models/evidence_record.c \ + src/database/database.c src/database/schema.c src/database/statement.c \ + src/database/transaction.c src/database/error.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 + $(TEST_EML_ANALYZER): tests/test_eml_analyzer.c src/core/eml_analyzer.c $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) @@ -895,6 +948,13 @@ test: \ $(TEST_SOCIAL_ACCOUNT_SERVICE) \ $(TEST_SOCIAL_PLATFORM) \ $(TEST_PERSON_ENTITY_SERVICE) \ + $(TEST_EVIDENCE_PREVIEW) \ + $(TEST_EVIDENCE_SELECTION_MODEL) \ + $(TEST_PERSON_CREATION_GUARD) \ + $(TEST_PERSON_DIALOG_LIFECYCLE) \ + $(TEST_CREATE_PERSON_DIALOG_GTK) \ + $(TEST_PERSON_CONFIRMATION_SUMMARY) \ + $(TEST_PERSON_ROLE_ASSIGNMENT_DAO) \ $(TEST_EML_ANALYZER) \ $(TEST_EML_INTEGRATION) \ $(TEST_IBAN_ANALYZER) \ @@ -969,6 +1029,13 @@ test: \ @$(TEST_SOCIAL_ACCOUNT_SERVICE) @$(TEST_SOCIAL_PLATFORM) @$(TEST_PERSON_ENTITY_SERVICE) + @$(TEST_EVIDENCE_PREVIEW) + @$(TEST_EVIDENCE_SELECTION_MODEL) + @$(TEST_PERSON_CREATION_GUARD) + @$(TEST_PERSON_DIALOG_LIFECYCLE) + @$(TEST_CREATE_PERSON_DIALOG_GTK) + @$(TEST_PERSON_CONFIRMATION_SUMMARY) + @$(TEST_PERSON_ROLE_ASSIGNMENT_DAO) @$(TEST_EML_ANALYZER) @$(TEST_EML_INTEGRATION) @$(TEST_IBAN_ANALYZER) @@ -1050,6 +1117,13 @@ clean: $(TEST_SOCIAL_ACCOUNT_SERVICE) \ $(TEST_SOCIAL_PLATFORM) \ $(TEST_PERSON_ENTITY_SERVICE) \ + $(TEST_EVIDENCE_PREVIEW) \ + $(TEST_EVIDENCE_SELECTION_MODEL) \ + $(TEST_PERSON_CREATION_GUARD) \ + $(TEST_PERSON_DIALOG_LIFECYCLE) \ + $(TEST_CREATE_PERSON_DIALOG_GTK) \ + $(TEST_PERSON_CONFIRMATION_SUMMARY) \ + $(TEST_PERSON_ROLE_ASSIGNMENT_DAO) \ $(TEST_EML_ANALYZER) \ $(TEST_EML_INTEGRATION) \ $(TEST_EXTRACTION_DROP_SERVICE) \ diff --git a/database/schema_current.sql b/database/schema_current.sql index 9e0d0c2..08d2ea8 100644 --- a/database/schema_current.sql +++ b/database/schema_current.sql @@ -238,3 +238,30 @@ CREATE INDEX IF NOT EXISTS idx_preuve_entite_sources_entity ON preuve_entite_sources(entite_id); CREATE INDEX IF NOT EXISTS idx_preuve_entite_sources_source ON preuve_entite_sources(source_kind, source_uuid); + +CREATE TABLE IF NOT EXISTS person_role_assignments +( + id TEXT PRIMARY KEY CHECK (length(trim(id)) > 0), + entity_id TEXT NOT NULL, + role_code TEXT NOT NULL CHECK (length(trim(role_code)) > 0), + evidence_id TEXT, + provenance_kind TEXT NOT NULL CHECK ( + provenance_kind IN ('manual', 'legacy_manual') + ), + confidence INTEGER CHECK ( + confidence IS NULL OR confidence BETWEEN 0 AND 100 + ), + notes TEXT, + created_at TEXT NOT NULL CHECK (length(created_at) = 20), + updated_at TEXT NOT NULL CHECK (length(updated_at) = 20), + FOREIGN KEY (entity_id) REFERENCES entites(id) ON DELETE CASCADE, + FOREIGN KEY (evidence_id) REFERENCES preuves(id) ON DELETE SET NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_role_assignments_semantic + ON person_role_assignments( + entity_id, role_code, COALESCE(evidence_id, ''), provenance_kind + ); +CREATE INDEX IF NOT EXISTS idx_person_role_assignments_entity + ON person_role_assignments(entity_id); +CREATE INDEX IF NOT EXISTS idx_person_role_assignments_evidence + ON person_role_assignments(evidence_id); diff --git a/database/schema_v14.sql b/database/schema_v14.sql new file mode 100644 index 0000000..27c1bae --- /dev/null +++ b/database/schema_v14.sql @@ -0,0 +1,46 @@ +/****************************************************************************** + * Schéma SQLite V14 — affectations contextuelles multiples des personnes. + * + * L'unicité porte sur (personne, rôle, preuve, provenance) : un même rôle + * peut donc être justifié par plusieurs preuves distinctes. + ******************************************************************************/ +CREATE TABLE person_role_assignments +( + id TEXT PRIMARY KEY CHECK (length(trim(id)) > 0), + entity_id TEXT NOT NULL, + role_code TEXT NOT NULL CHECK (length(trim(role_code)) > 0), + evidence_id TEXT, + provenance_kind TEXT NOT NULL CHECK ( + provenance_kind IN ('manual', 'legacy_manual') + ), + confidence INTEGER CHECK ( + confidence IS NULL OR confidence BETWEEN 0 AND 100 + ), + notes TEXT, + created_at TEXT NOT NULL CHECK (length(created_at) = 20), + updated_at TEXT NOT NULL CHECK (length(updated_at) = 20), + FOREIGN KEY (entity_id) REFERENCES entites(id) ON DELETE CASCADE, + FOREIGN KEY (evidence_id) REFERENCES preuves(id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX idx_person_role_assignments_semantic + ON person_role_assignments( + entity_id, role_code, COALESCE(evidence_id, ''), provenance_kind + ); +CREATE INDEX idx_person_role_assignments_entity + ON person_role_assignments(entity_id); +CREATE INDEX idx_person_role_assignments_evidence + ON person_role_assignments(evidence_id); + +/* Les codes historiques sont conservés littéralement : aucune équivalence + * approximative n'est appliquée pendant la migration. */ +INSERT INTO person_role_assignments( + id, entity_id, role_code, evidence_id, provenance_kind, + confidence, notes, created_at, updated_at +) +SELECT + lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || + substr(lower(hex(randomblob(2))),2) || '-a' || + substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), + entity_id, role, NULL, 'legacy_manual', NULL, NULL, updated_at, updated_at +FROM person_roles; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 874e77e..b9ee396 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,22 @@ > **Version :** 3.1 > **Dernière mise à jour :** 2026-07-28 -> **Schéma SQLite courant :** V13 +> **Schéma SQLite courant :** V14 + +## Personnes contextuelles — SQLite V14 + +`person_role_assignments` sépare les rôles contextuels des champs de l’entité. +Une personne peut porter plusieurs rôles avec preuve facultative, provenance, +confiance et notes. Les anciens codes sont copiés littéralement depuis +`person_roles` avec la provenance `legacy_manual`. Le service crée l’entité, +les rôles et le rattachement manuel dans une transaction unique. + +Le cœur `EvidencePreview` vérifie l’intégrité avant décodage PNG/JPEG, applique +des bornes et ne renvoie qu’un PNG mémoire réduit. Une `BackgroundTask` +travaille hors du thread GTK ; la texture est créée sur le contexte principal. +Le dialogue annule l’ancienne tâche et rejette les générations obsolètes. +L’interface conserve une génération de session et les chemins stables du +projet et de sa base. PDF, vidéo, EML avancé et OCR restent exclus. > **Statut :** architecture courante --- @@ -301,7 +316,7 @@ validation du chemin ↓ création de l'arborescence ↓ -initialisation transactionnelle de SQLite V13 +initialisation transactionnelle de SQLite V14 ↓ création de l'identité de l'enquête ↓ diff --git a/docs/DEPENDENCE.md b/docs/DEPENDENCE.md index 3096d1a..0953186 100644 --- a/docs/DEPENDENCE.md +++ b/docs/DEPENDENCE.md @@ -1,5 +1,9 @@ # Dépendances externes +`libjpeg` est utilisée pour le décodage borné des aperçus JPEG du ticket #109. +PNG est décodé avec Cairo. Aucun outil documentaire externe n’est lancé par +l’aperçu et aucun dérivé n’est persisté. + Ce fichier recense les outils utilisés par LabFy Investigation et leur installation. Il sera complété à chaque intégration d’un nouvel outil. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4a25ec2..121bbf1 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -405,16 +405,16 @@ tests/test_database.c ### 9.2 Nouvelle version de schéma -Pour créer une nouvelle version après V13, par exemple V14 : +Pour créer une nouvelle version après V14, par exemple V15 : -1. ajouter `database/schema_v13.sql` ; +1. ajouter le nouveau fichier `database/schema_v15.sql` ; 2. déclarer et implémenter `schema_install_v13()` ; 3. ajouter `database_migrate_v12_to_v13()` ; 4. raccorder la migration dans la boucle vers la version courante ; 5. mettre à jour les constantes de version ; -6. installer V13 lors de la création d'une base neuve ; +6. installer V15 lors de la création d'une base neuve ; 7. adapter `schema_current.sql` si nécessaire ; -8. ajouter une fixture V13 vers V14 ; +8. ajouter une fixture V14 vers V15 ; La fixture V12 vers V13 vérifie le backfill `legacy_manual`. Les tests EML couvrent aussi le retrait isolé d'une source face à un rattachement manuel. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7b5ea88..ff6ba73 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,7 +2,12 @@ > **Dernière mise à jour :** 2026-07-28 > **État du projet :** développement actif -> **Schéma SQLite courant :** V13 +> **Schéma SQLite courant :** V14 + +La tranche 1 du ticket #109 couvre les rôles contextuels multiples, la +sélection d’une preuve existante, la création transactionnelle et la garde de +session. Import intégré, PDF, vidéo, EML avancé, OCR et documents d’identité +restent hors périmètre. > **Usage opérationnel :** non prêt pour la production --- @@ -116,7 +121,7 @@ La branche `main` contient notamment : - création, validation et ouverture d'enquêtes ; - session d'enquête remplaçable proprement ; -- infrastructure SQLite et migrations jusqu'à V13 ; +- infrastructure SQLite et migrations jusqu'à V14 ; - couche Database, DAO et services métier ; - import de preuves avec copie contrôlée et SHA-256 ; - vérification d'intégrité et reclassement des preuves ; diff --git a/docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md b/docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md new file mode 100644 index 0000000..17e4220 --- /dev/null +++ b/docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md @@ -0,0 +1,29 @@ +# Test manuel — ticket #109, tranche 1 + +Utiliser uniquement une enquête de démonstration et des fichiers synthétiques +marqués `SPECIMEN`. + +1. Cliquer sur Annuler à chacune des quatre étapes doit fermer uniquement + l’assistant et laisser la fenêtre principale utilisable. Refaire le contrôle + avec la croix, puis pendant le chargement d’un aperçu. +2. Ouvrir une enquête synthétique, puis demander l’ajout d’une personne. +3. Vérifier les quatre étapes `Personne`, `Rôles`, `Preuve`, `Confirmation`. +4. Revenir en arrière : les champs, rôles multiples et la preuve choisie + doivent être conservés. Aucun rôle sensible ne doit être précoché. +5. Rechercher par nom, description et type, combiner avec le filtre métier, + puis revenir à `Tous les types`. +6. Choisir des PNG et JPEG synthétiques : vérifier chargement, aperçu et + métadonnées complètes, puis les états invalide et absent. +7. Sélectionner rapidement plusieurs preuves : seul le dernier aperçu doit + apparaître, puis fermer la fenêtre pendant un chargement. +8. Choisir zéro puis une preuve existante. Aucun import, OCR, PDF, vidéo ou + analyse EML avancée ne doit être proposé. +9. Confirmer et vérifier que personne, rôles, rattachement et source `manual` + apparaissent ensemble après actualisation. +10. Changer d’enquête pendant un aperçu puis avant confirmation : le résultat + tardif et la création doivent être refusés sans écriture ni rafraîchissement. + +L’aperçu de tranche 1 accepte uniquement PNG/JPEG intègres. Il refuse les +fichiers absents, modifiés, corrompus ou supérieurs aux limites de 25 Mio, +12 000 pixels par côté et 40 millions de pixels. Le rendu mémoire est borné à +1 024 × 1 024 et aucun dérivé n’est persisté. diff --git a/include/core/evidence_preview.h b/include/core/evidence_preview.h new file mode 100644 index 0000000..34abe10 --- /dev/null +++ b/include/core/evidence_preview.h @@ -0,0 +1,36 @@ +#ifndef LABFY_INVESTIGATION_EVIDENCE_PREVIEW_H +#define LABFY_INVESTIGATION_EVIDENCE_PREVIEW_H +#include +#include +G_BEGIN_DECLS +#define EVIDENCE_PREVIEW_MAX_FILE_BYTES (25U * 1024U * 1024U) +#define EVIDENCE_PREVIEW_MAX_DIMENSION 12000 +#define EVIDENCE_PREVIEW_MAX_PIXELS 40000000U +#define EVIDENCE_PREVIEW_MAX_EDGE 1024 +typedef struct { + char *investigation_root_path; + char *evidence_identifier; + char *relative_path; + char *expected_sha256; + char *mime_type; + guint64 request_generation; +} EvidencePreviewRequest; +typedef struct { + char *evidence_identifier; + guint64 request_generation; + GBytes *png_bytes; + gint width; + gint height; +} EvidencePreviewResult; +EvidencePreviewRequest *evidence_preview_request_new(const char *root, + const char *evidence_identifier, const char *relative_path, + const char *sha256, const char *mime_type, guint64 generation); +void evidence_preview_request_free(EvidencePreviewRequest *request); +EvidencePreviewResult *evidence_preview_load( + const EvidencePreviewRequest *request, GCancellable *cancellable, + GError **error); +void evidence_preview_result_free(EvidencePreviewResult *result); +gboolean evidence_preview_result_matches(const EvidencePreviewResult *result, + const char *evidence_identifier, guint64 generation); +G_END_DECLS +#endif diff --git a/include/core/evidence_preview_task.h b/include/core/evidence_preview_task.h new file mode 100644 index 0000000..8014940 --- /dev/null +++ b/include/core/evidence_preview_task.h @@ -0,0 +1,12 @@ +#ifndef LABFY_INVESTIGATION_EVIDENCE_PREVIEW_TASK_H +#define LABFY_INVESTIGATION_EVIDENCE_PREVIEW_TASK_H +#include "core/background_task.h" +#include "core/evidence_preview.h" +#include "core/task_manager.h" +G_BEGIN_DECLS +BackgroundTask *evidence_preview_task_start(TaskManager *manager, + const EvidencePreviewRequest *request, + BackgroundTaskCompletionCallback callback, gpointer callback_data, + GDestroyNotify callback_data_destroy, GError **error); +G_END_DECLS +#endif diff --git a/include/core/person_confirmation_summary.h b/include/core/person_confirmation_summary.h new file mode 100644 index 0000000..83e0a9a --- /dev/null +++ b/include/core/person_confirmation_summary.h @@ -0,0 +1,16 @@ +#ifndef LABFY_INVESTIGATION_PERSON_CONFIRMATION_SUMMARY_H +#define LABFY_INVESTIGATION_PERSON_CONFIRMATION_SUMMARY_H + +#include "models/evidence_record.h" +#include + +G_BEGIN_DECLS + +char *person_confirmation_summary_build(const char *designation, + const char *declared_name, const char *pseudonym, + const char *identification_status, gint confidence, const char *notes, + const GPtrArray *role_labels, const EvidenceRecord *evidence); + +G_END_DECLS + +#endif diff --git a/include/core/person_creation_guard.h b/include/core/person_creation_guard.h new file mode 100644 index 0000000..f2a2701 --- /dev/null +++ b/include/core/person_creation_guard.h @@ -0,0 +1,13 @@ +#ifndef LABFY_INVESTIGATION_PERSON_CREATION_GUARD_H +#define LABFY_INVESTIGATION_PERSON_CREATION_GUARD_H +#include +G_BEGIN_DECLS +typedef struct PersonCreationGuard PersonCreationGuard; +PersonCreationGuard *person_creation_guard_new(guint64 generation, + const char *root_path, const char *database_path); +void person_creation_guard_free(PersonCreationGuard *guard); +gboolean person_creation_guard_matches(const PersonCreationGuard *guard, + gboolean session_is_open, guint64 generation, const char *root_path, + const char *database_path); +G_END_DECLS +#endif diff --git a/include/core/person_dialog_lifecycle.h b/include/core/person_dialog_lifecycle.h new file mode 100644 index 0000000..650f2e5 --- /dev/null +++ b/include/core/person_dialog_lifecycle.h @@ -0,0 +1,29 @@ +#ifndef LABFY_INVESTIGATION_PERSON_DIALOG_LIFECYCLE_H +#define LABFY_INVESTIGATION_PERSON_DIALOG_LIFECYCLE_H + +#include + +G_BEGIN_DECLS + +typedef struct PersonDialogLifecycle PersonDialogLifecycle; + +PersonDialogLifecycle *person_dialog_lifecycle_new(void); +void person_dialog_lifecycle_free(PersonDialogLifecycle *lifecycle); +guint64 person_dialog_lifecycle_begin_preview( + PersonDialogLifecycle *lifecycle); +gboolean person_dialog_lifecycle_cancel(PersonDialogLifecycle *lifecycle); +gboolean person_dialog_lifecycle_complete(PersonDialogLifecycle *lifecycle); +gboolean person_dialog_lifecycle_accepts_preview( + const PersonDialogLifecycle *lifecycle, guint64 generation); +gboolean person_dialog_lifecycle_is_cancelled( + const PersonDialogLifecycle *lifecycle); +gboolean person_dialog_lifecycle_is_finished( + const PersonDialogLifecycle *lifecycle); +gboolean person_dialog_lifecycle_has_preview( + const PersonDialogLifecycle *lifecycle); +guint64 person_dialog_lifecycle_get_generation( + const PersonDialogLifecycle *lifecycle); + +G_END_DECLS + +#endif diff --git a/include/core/person_entity_service.h b/include/core/person_entity_service.h index df560e4..1c9156a 100644 --- a/include/core/person_entity_service.h +++ b/include/core/person_entity_service.h @@ -6,6 +6,7 @@ #define LABFY_INVESTIGATION_PERSON_ENTITY_SERVICE_H #include "database/database.h" #include "models/entity_record.h" +#include "models/person_role_assignment.h" #include G_BEGIN_DECLS /** @brief Données factuelles utilisées pour créer une personne. */ @@ -18,6 +19,8 @@ typedef struct const char *notes; gint confidence; const char *evidence_identifier; + /** PersonRoleAssignmentInput* empruntés, facultatif. */ + const GPtrArray *role_assignments; } PersonEntityInput; /** * @brief Crée une personne et son éventuelle liaison à une preuve. diff --git a/include/dao/person_role_assignment_dao.h b/include/dao/person_role_assignment_dao.h new file mode 100644 index 0000000..a9a38a2 --- /dev/null +++ b/include/dao/person_role_assignment_dao.h @@ -0,0 +1,20 @@ +#ifndef LABFY_INVESTIGATION_PERSON_ROLE_ASSIGNMENT_DAO_H +#define LABFY_INVESTIGATION_PERSON_ROLE_ASSIGNMENT_DAO_H +#include "database/database.h" +#include "models/person_role_assignment.h" +#include +G_BEGIN_DECLS +typedef struct PersonRoleAssignmentDao PersonRoleAssignmentDao; +PersonRoleAssignmentDao *person_role_assignment_dao_new( + Database *database, GError **error); +void person_role_assignment_dao_free(PersonRoleAssignmentDao *dao); +gboolean person_role_assignment_dao_insert(PersonRoleAssignmentDao *dao, + const char *entity_identifier, const PersonRoleAssignmentInput *input, + GError **error); +gboolean person_role_assignment_dao_insert_all(PersonRoleAssignmentDao *dao, + const char *entity_identifier, const GPtrArray *inputs, GError **error); +GPtrArray *person_role_assignment_dao_list_by_entity( + PersonRoleAssignmentDao *dao, const char *entity_identifier, + GError **error); +G_END_DECLS +#endif diff --git a/include/database/schema.h b/include/database/schema.h index 6e3e18e..5024a7b 100644 --- a/include/database/schema.h +++ b/include/database/schema.h @@ -94,6 +94,7 @@ bool schema_install_v10(Database *database); bool schema_install_v11(Database *database); bool schema_install_v12(Database *database); bool schema_install_v13(Database *database); +bool schema_install_v14(Database *database); /** * @brief Garantit la présence des extensions du schéma courant V2. diff --git a/include/models/evidence_record.h b/include/models/evidence_record.h index b292d0a..6ab7cd3 100644 --- a/include/models/evidence_record.h +++ b/include/models/evidence_record.h @@ -147,6 +147,13 @@ EvidenceRecord *evidence_record_new( GError **error ); +/** + * @brief Copie intégralement une preuve, métadonnées d’affichage incluses. + * + * @return Nouvelle preuve possédée par l’appelant, ou NULL. + */ +EvidenceRecord *evidence_record_copy(const EvidenceRecord *evidence_record); + /** * @brief Libère un modèle EvidenceRecord. * @@ -203,6 +210,16 @@ const char *evidence_record_get_type_identifier( const EvidenceRecord *evidence_record ); +/** @brief Enrichit les métadonnées d'affichage copiées par le modèle. */ +void evidence_record_set_display_metadata(EvidenceRecord *evidence_record, + const char *type_label, const char *mime_type); +/** @brief Retourne le libellé métier du type, ou son code à défaut. */ +const char *evidence_record_get_type_label( + const EvidenceRecord *evidence_record); +/** @brief Retourne le type MIME enregistré, éventuellement NULL. */ +const char *evidence_record_get_mime_type( + const EvidenceRecord *evidence_record); + /** * @brief Retourne la taille observée de la preuve. * diff --git a/include/models/evidence_selection_model.h b/include/models/evidence_selection_model.h new file mode 100644 index 0000000..408545f --- /dev/null +++ b/include/models/evidence_selection_model.h @@ -0,0 +1,23 @@ +#ifndef LABFY_INVESTIGATION_EVIDENCE_SELECTION_MODEL_H +#define LABFY_INVESTIGATION_EVIDENCE_SELECTION_MODEL_H +#include "models/evidence_record.h" +#include +G_BEGIN_DECLS +typedef struct EvidenceSelectionModel EvidenceSelectionModel; +/** Crée un modèle possédant des copies profondes des preuves fournies. */ +EvidenceSelectionModel *evidence_selection_model_new(const GPtrArray *records); +void evidence_selection_model_free(EvidenceSelectionModel *model); +void evidence_selection_model_set_query(EvidenceSelectionModel *model, + const char *query); +void evidence_selection_model_set_type(EvidenceSelectionModel *model, + const char *type_code); +GPtrArray *evidence_selection_model_list_visible( + const EvidenceSelectionModel *model); +gboolean evidence_selection_model_select(EvidenceSelectionModel *model, + const char *identifier); +const EvidenceRecord *evidence_selection_model_get_selected( + const EvidenceSelectionModel *model); +GPtrArray *evidence_selection_model_list_type_codes( + const EvidenceSelectionModel *model); +G_END_DECLS +#endif diff --git a/include/models/person_role_assignment.h b/include/models/person_role_assignment.h new file mode 100644 index 0000000..8600fc4 --- /dev/null +++ b/include/models/person_role_assignment.h @@ -0,0 +1,35 @@ +#ifndef LABFY_INVESTIGATION_PERSON_ROLE_ASSIGNMENT_H +#define LABFY_INVESTIGATION_PERSON_ROLE_ASSIGNMENT_H +#include +G_BEGIN_DECLS + +typedef struct +{ + char *role_code; + char *evidence_identifier; + char *provenance_kind; + gboolean has_confidence; + gint confidence; + char *notes; +} PersonRoleAssignmentInput; + +typedef struct +{ + char *identifier; + char *entity_identifier; + PersonRoleAssignmentInput assignment; + char *created_at; + char *updated_at; +} PersonRoleAssignmentRecord; + +gboolean person_role_assignment_role_is_known(const char *role_code); +const char *person_role_assignment_role_label(const char *role_code); +gboolean person_role_assignment_input_is_valid( + const PersonRoleAssignmentInput *input); +PersonRoleAssignmentInput *person_role_assignment_input_copy( + const PersonRoleAssignmentInput *input); +void person_role_assignment_input_free(PersonRoleAssignmentInput *input); +void person_role_assignment_record_free(PersonRoleAssignmentRecord *record); + +G_END_DECLS +#endif diff --git a/include/views/create_person_dialog.h b/include/views/create_person_dialog.h index f62857d..1bd6d3f 100644 --- a/include/views/create_person_dialog.h +++ b/include/views/create_person_dialog.h @@ -6,6 +6,7 @@ #define LABFY_INVESTIGATION_CREATE_PERSON_DIALOG_H #include "core/person_entity_service.h" #include "models/evidence_record.h" +#include "core/task_manager.h" #include G_BEGIN_DECLS /** @brief Résultat opaque possédé par le callback. */ @@ -13,6 +14,7 @@ typedef struct CreatePersonDialogResult CreatePersonDialogResult; /** @brief Callback appelé après validation ou annulation. */ typedef void (*CreatePersonDialogCallback)(CreatePersonDialogResult *result, gpointer user_data); +typedef gboolean (*CreatePersonDialogSessionCheck)(gpointer user_data); /** * @brief Présente le formulaire de création d'une personne. * @param parent Fenêtre parente. @@ -22,8 +24,10 @@ typedef void (*CreatePersonDialogCallback)(CreatePersonDialogResult *result, * @return TRUE si le dialogue a été présenté. */ gboolean create_person_dialog_present(GtkWindow *parent, - const GPtrArray *evidence_records, CreatePersonDialogCallback callback, - gpointer user_data); + const GPtrArray *evidence_records, const char *investigation_root_path, + TaskManager *task_manager, CreatePersonDialogSessionCheck session_check, + CreatePersonDialogCallback callback, gpointer user_data, + GDestroyNotify user_data_destroy); /** @brief Libère un résultat. */ void create_person_dialog_result_free(CreatePersonDialogResult *result); /** @brief Retourne les données empruntées du résultat. */ diff --git a/src/core/application.c b/src/core/application.c index 52cfb2b..55c6b43 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -56,6 +56,7 @@ #include "core/relation_service.h" #include "core/social_account_service.h" #include "core/person_entity_service.h" +#include "core/person_creation_guard.h" #include "core/eml_processing.h" #include "core/eml_integration.h" #include "core/eml_pipeline_task.h" @@ -142,6 +143,7 @@ struct Application InvestigationGraphModel *graph_model; InvestigationGraphLayout *graph_layout; guint64 graph_load_generation; + guint64 session_generation; gboolean graph_viewport_restored; guint graph_viewport_save_source_id; @@ -150,6 +152,20 @@ struct Application char *pending_entity_selection_identifier; }; +typedef struct +{ + Application *application; + PersonCreationGuard *guard; +} ApplicationPersonDialogContext; + +static void application_person_dialog_context_free( + ApplicationPersonDialogContext *context) +{ + if (context == NULL) return; + person_creation_guard_free(context->guard); + g_free(context); +} + static void application_on_evidence_selected( const char *evidence_identifier, gpointer user_data); @@ -2726,6 +2742,7 @@ static gboolean application_install_session( application->tree_model = new_tree_model; application->session = new_session; + application->session_generation++; application->graph_viewport_restored = FALSE; main_window_set_tree_model( @@ -5380,13 +5397,25 @@ static void application_on_add_social_account_requested(gpointer user_data) static void application_on_person_completed( CreatePersonDialogResult *result, gpointer user_data) { - Application *application = user_data; - const PersonEntityInput *input = create_person_dialog_result_get_input(result); + ApplicationPersonDialogContext *context = user_data; + Application *application = context != NULL ? context->application : NULL; + const PersonEntityInput *input = NULL; const InvestigationProject *project = NULL; GError *error = NULL; char *identifier = NULL; - if (result == NULL || application == NULL || application->session == NULL) - { create_person_dialog_result_free(result); return; } + const char *active_root = NULL, *active_database = NULL; + if (result == NULL) { + return; + } + input = create_person_dialog_result_get_input(result); + if (application == NULL || application->session == NULL) + goto changed_session; + project = investigation_session_get_project(application->session); + active_root = investigation_project_get_root_path(project); + active_database = investigation_project_get_database_path(project); + if (!person_creation_guard_matches(context->guard, TRUE, + application->session_generation, active_root, active_database)) + goto changed_session; if (!person_entity_service_create( investigation_session_get_database(application->session), input, &identifier, &error)) @@ -5402,6 +5431,24 @@ static void application_on_person_completed( } g_clear_error(&error); g_free(identifier); create_person_dialog_result_free(result); + return; +changed_session: + application_present_error(application, "Personne non créée", + "L'enquête active a changé depuis l'ouverture de l'assistant."); + create_person_dialog_result_free(result); +} + +static gboolean application_person_dialog_session_matches(gpointer user_data) +{ + ApplicationPersonDialogContext *context = user_data; + Application *application = context != NULL ? context->application : NULL; + const InvestigationProject *project; + if (application == NULL || application->session == NULL) return FALSE; + project = investigation_session_get_project(application->session); + return person_creation_guard_matches(context->guard, TRUE, + application->session_generation, + investigation_project_get_root_path(project), + investigation_project_get_database_path(project)); } /** @brief Charge les preuves puis ouvre le formulaire d'une personne. */ @@ -5410,6 +5457,8 @@ static void application_on_add_person_requested(gpointer user_data) Application *application = user_data; EvidenceDao *dao = NULL; GPtrArray *records = NULL; + ApplicationPersonDialogContext *context = NULL; + const InvestigationProject *project = NULL; GError *error = NULL; if (application == NULL || application->session == NULL) return; dao = evidence_dao_new( @@ -5418,9 +5467,21 @@ static void application_on_add_person_requested(gpointer user_data) if (records == NULL) application_present_error(application, "Formulaire indisponible", error != NULL ? error->message : "Impossible de charger les preuves."); - else + else { + project = investigation_session_get_project(application->session); + context = g_new0(ApplicationPersonDialogContext, 1); + context->application = application; + context->guard = person_creation_guard_new( + application->session_generation, + investigation_project_get_root_path(project), + investigation_project_get_database_path(project)); create_person_dialog_present(main_window_get_window(application->main_window), - records, application_on_person_completed, application); + records, investigation_project_get_root_path(project), + application->task_manager, + application_person_dialog_session_matches, + application_on_person_completed, context, + (GDestroyNotify) application_person_dialog_context_free); + } g_clear_pointer(&records, g_ptr_array_unref); evidence_dao_free(dao); g_clear_error(&error); } diff --git a/src/core/evidence_preview.c b/src/core/evidence_preview.c new file mode 100644 index 0000000..840b0ec --- /dev/null +++ b/src/core/evidence_preview.c @@ -0,0 +1,213 @@ +#include "core/evidence_preview.h" +#include "core/evidence_integrity_verifier.h" +#include +#include +#include +#include + +static GQuark preview_error(void) +{ return g_quark_from_static_string("evidence-preview-error"); } + +typedef struct { + struct jpeg_error_mgr base; + jmp_buf jump; +} PreviewJpegError; +static void jpeg_failed(j_common_ptr info) +{ + PreviewJpegError *error = (PreviewJpegError *) info->err; + longjmp(error->jump, 1); +} +static cairo_status_t write_png(void *closure, + const unsigned char *data, unsigned int length) +{ + g_byte_array_append(closure, data, length); + return CAIRO_STATUS_SUCCESS; +} +static gboolean surface_to_result(cairo_surface_t *source, gint source_width, + gint source_height, const EvidencePreviewRequest *request, + EvidencePreviewResult **out_result, GError **error) +{ + double scale = MIN(1.0, MIN( + (double) EVIDENCE_PREVIEW_MAX_EDGE / source_width, + (double) EVIDENCE_PREVIEW_MAX_EDGE / source_height)); + gint width = MAX(1, (gint) (source_width * scale)); + gint height = MAX(1, (gint) (source_height * scale)); + cairo_surface_t *target = cairo_image_surface_create( + CAIRO_FORMAT_ARGB32, width, height); + cairo_t *cr = cairo_create(target); + GByteArray *bytes = g_byte_array_new(); + cairo_scale(cr, scale, scale); + cairo_set_source_surface(cr, source, 0, 0); + cairo_paint(cr); + cairo_destroy(cr); + if (cairo_surface_write_to_png_stream(target, write_png, bytes) != + CAIRO_STATUS_SUCCESS) { + g_set_error_literal(error, preview_error(), 4, + "Impossible de produire l'aperçu en mémoire."); + g_byte_array_unref(bytes); cairo_surface_destroy(target); + return FALSE; + } + cairo_surface_destroy(target); + *out_result = g_new0(EvidencePreviewResult, 1); + (*out_result)->evidence_identifier = + g_strdup(request->evidence_identifier); + (*out_result)->request_generation = request->request_generation; + (*out_result)->width = width; (*out_result)->height = height; + (*out_result)->png_bytes = g_byte_array_free_to_bytes(bytes); + return TRUE; +} + +EvidencePreviewRequest *evidence_preview_request_new(const char *root, + const char *evidence_identifier, const char *relative_path, + const char *sha256, const char *mime_type, guint64 generation) +{ + if (root == NULL || !g_uuid_string_is_valid(evidence_identifier) || + relative_path == NULL || sha256 == NULL || mime_type == NULL) + return NULL; + EvidencePreviewRequest *r = g_new0(EvidencePreviewRequest, 1); + r->investigation_root_path = g_strdup(root); + r->evidence_identifier = g_strdup(evidence_identifier); + r->relative_path = g_strdup(relative_path); + r->expected_sha256 = g_strdup(sha256); + r->mime_type = g_strdup(mime_type); + r->request_generation = generation; + return r; +} +void evidence_preview_request_free(EvidencePreviewRequest *r) +{ + if (r == NULL) return; + g_free(r->investigation_root_path); g_free(r->evidence_identifier); + g_free(r->relative_path); g_free(r->expected_sha256); + g_free(r->mime_type); g_free(r); +} +void evidence_preview_result_free(EvidencePreviewResult *r) +{ + if (r == NULL) return; + g_free(r->evidence_identifier); + g_clear_pointer(&r->png_bytes, g_bytes_unref); + g_free(r); +} +gboolean evidence_preview_result_matches(const EvidencePreviewResult *r, + const char *identifier, guint64 generation) +{ + return r != NULL && r->request_generation == generation && + g_strcmp0(r->evidence_identifier, identifier) == 0; +} + +EvidencePreviewResult *evidence_preview_load( + const EvidencePreviewRequest *request, GCancellable *cancellable, + GError **error) +{ + EvidenceIntegrityVerificationResult *verification = NULL; + EvidencePreviewResult *result = NULL; + char *path = NULL; + gint width = 0, height = 0; + if (request == NULL || + !(g_str_equal(request->mime_type, "image/png") || + g_str_equal(request->mime_type, "image/jpeg"))) { + g_set_error_literal(error, preview_error(), 1, + "Aperçu indisponible pour ce format."); + return NULL; + } + verification = evidence_integrity_verifier_verify( + request->investigation_root_path, request->relative_path, + request->expected_sha256, cancellable, error); + if (verification == NULL) return NULL; + if (evidence_integrity_verification_result_get_status(verification) != + EVIDENCE_INTEGRITY_STATUS_VALID) { + g_set_error_literal(error, preview_error(), 2, + "Aperçu refusé : l'intégrité de la preuve est invalide."); + goto cleanup; + } + if (evidence_integrity_verification_result_get_size_bytes(verification) > + EVIDENCE_PREVIEW_MAX_FILE_BYTES) { + g_set_error_literal(error, preview_error(), 3, + "L'image dépasse la taille maximale autorisée."); + goto cleanup; + } + if (cancellable != NULL && + g_cancellable_set_error_if_cancelled(cancellable, error)) goto cleanup; + path = g_build_filename(request->investigation_root_path, + request->relative_path, NULL); + if (g_str_equal(request->mime_type, "image/png")) { + cairo_surface_t *surface = cairo_image_surface_create_from_png(path); + if (cairo_surface_status(surface) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(surface); + g_set_error_literal(error, preview_error(), 4, + "Le fichier image est illisible ou corrompu."); + goto cleanup; + } + width = cairo_image_surface_get_width(surface); + height = cairo_image_surface_get_height(surface); + if (width > EVIDENCE_PREVIEW_MAX_DIMENSION || + height > EVIDENCE_PREVIEW_MAX_DIMENSION || + (guint64) width * (guint64) height > EVIDENCE_PREVIEW_MAX_PIXELS) { + g_set_error_literal(error, preview_error(), 5, + "Les dimensions de l'image dépassent les limites sûres."); + cairo_surface_destroy(surface); + goto cleanup; + } + surface_to_result(surface, width, height, request, &result, error); + cairo_surface_destroy(surface); + goto cleanup; + } + FILE *jpeg_file = fopen(path, "rb"); + struct jpeg_decompress_struct jpeg = {0}; + PreviewJpegError jpeg_error; + cairo_surface_t *surface = NULL; + if (jpeg_file == NULL) { + g_set_error_literal(error, preview_error(), 4, + "Le fichier image est illisible ou corrompu."); + goto cleanup; + } + jpeg.err = jpeg_std_error(&jpeg_error.base); + jpeg_error.base.error_exit = jpeg_failed; + if (setjmp(jpeg_error.jump) != 0) { + jpeg_destroy_decompress(&jpeg); fclose(jpeg_file); + if (surface != NULL) cairo_surface_destroy(surface); + g_set_error_literal(error, preview_error(), 4, + "Le fichier JPEG est illisible ou corrompu."); + goto cleanup; + } + jpeg_create_decompress(&jpeg); jpeg_stdio_src(&jpeg, jpeg_file); + jpeg_read_header(&jpeg, TRUE); + width = (gint) jpeg.image_width; height = (gint) jpeg.image_height; + if (width > EVIDENCE_PREVIEW_MAX_DIMENSION || + height > EVIDENCE_PREVIEW_MAX_DIMENSION || + (guint64) width * (guint64) height > EVIDENCE_PREVIEW_MAX_PIXELS) { + g_set_error_literal(error, preview_error(), 5, + "Les dimensions de l'image dépassent les limites sûres."); + jpeg_destroy_decompress(&jpeg); fclose(jpeg_file); + goto cleanup; + } + jpeg.out_color_space = JCS_RGB; + jpeg_start_decompress(&jpeg); + surface = cairo_image_surface_create(CAIRO_FORMAT_RGB24, width, height); + unsigned char *pixels = cairo_image_surface_get_data(surface); + gint stride = cairo_image_surface_get_stride(surface); + JSAMPARRAY row = (*jpeg.mem->alloc_sarray)( + (j_common_ptr) &jpeg, JPOOL_IMAGE, (JDIMENSION) width * 3U, 1); + while (jpeg.output_scanline < jpeg.output_height) { + jpeg_read_scanlines(&jpeg, row, 1); + guint y = jpeg.output_scanline - 1; + guint32 *destination = (guint32 *) (pixels + y * stride); + for (gint x = 0; x < width; x++) + destination[x] = ((guint32) row[0][x * 3] << 16) | + ((guint32) row[0][x * 3 + 1] << 8) | row[0][x * 3 + 2]; + if (cancellable != NULL && g_cancellable_is_cancelled(cancellable)) + break; + } + cairo_surface_mark_dirty(surface); + jpeg_finish_decompress(&jpeg); jpeg_destroy_decompress(&jpeg); + fclose(jpeg_file); + if (cancellable != NULL && + g_cancellable_set_error_if_cancelled(cancellable, error)) { + cairo_surface_destroy(surface); goto cleanup; + } + surface_to_result(surface, width, height, request, &result, error); + cairo_surface_destroy(surface); +cleanup: + g_free(path); + evidence_integrity_verification_result_free(verification); + return result; +} diff --git a/src/core/evidence_preview_task.c b/src/core/evidence_preview_task.c new file mode 100644 index 0000000..a414591 --- /dev/null +++ b/src/core/evidence_preview_task.c @@ -0,0 +1,37 @@ +#include "core/evidence_preview_task.h" + +static EvidencePreviewRequest *copy_request(const EvidencePreviewRequest *r) +{ + return evidence_preview_request_new(r->investigation_root_path, + r->evidence_identifier, r->relative_path, r->expected_sha256, + r->mime_type, r->request_generation); +} +static gboolean worker(BackgroundTask *task, GCancellable *cancellable, + gpointer data, gpointer *result, GError **error) +{ + (void) task; + *result = evidence_preview_load(data, cancellable, error); + return *result != NULL; +} +BackgroundTask *evidence_preview_task_start(TaskManager *manager, + const EvidencePreviewRequest *request, + BackgroundTaskCompletionCallback callback, gpointer callback_data, + GDestroyNotify callback_data_destroy, GError **error) +{ + BackgroundTask *task = NULL; + EvidencePreviewRequest *copy = NULL; + if (manager == NULL || request == NULL) return NULL; + task = background_task_new("Aperçu de la preuve"); + copy = copy_request(request); + if (task == NULL || copy == NULL || + !task_manager_add(manager, task, error) || + !background_task_start(task, worker, copy, + (GDestroyNotify) evidence_preview_request_free, + (GDestroyNotify) evidence_preview_result_free, + callback, callback_data, callback_data_destroy, error)) { + evidence_preview_request_free(copy); + background_task_unref(task); + return NULL; + } + return task; +} diff --git a/src/core/person_confirmation_summary.c b/src/core/person_confirmation_summary.c new file mode 100644 index 0000000..dabe3bf --- /dev/null +++ b/src/core/person_confirmation_summary.c @@ -0,0 +1,78 @@ +#include "core/person_confirmation_summary.h" + +static const char *optional(const char *value, const char *fallback) +{ + return value != NULL && value[0] != '\0' ? value : fallback; +} + +static const char *integrity(EvidenceIntegrityStatus status) +{ + switch (status) { + case EVIDENCE_INTEGRITY_STATUS_VALID: return "Valide"; + case EVIDENCE_INTEGRITY_STATUS_MISSING: return "Fichier absent"; + case EVIDENCE_INTEGRITY_STATUS_MODIFIED: return "Invalide"; + case EVIDENCE_INTEGRITY_STATUS_ERROR: return "Erreur"; + default: return "Non vérifiée"; + } +} + +char *person_confirmation_summary_build(const char *designation, + const char *declared_name, const char *pseudonym, + const char *identification_status, gint confidence, const char *notes, + const GPtrArray *role_labels, const EvidenceRecord *evidence) +{ + GString *summary = g_string_new(NULL); + char *size = NULL, *short_sha = NULL; + g_string_append_printf(summary, + "PERSONNE\nDésignation : %s\nNom déclaré : %s\nPseudonyme : %s\n" + "Statut d’identification : %s\nConfiance : %d %%\n" + "Notes factuelles : %s\n\nRÔLES\n", + optional(designation, "Non renseignée"), + optional(declared_name, "Non renseigné"), + optional(pseudonym, "Non renseigné"), + optional(identification_status, "Non renseigné"), + confidence, optional(notes, "Aucune")); + if (role_labels == NULL || role_labels->len == 0) + g_string_append(summary, "Aucun rôle sélectionné\n"); + else + for (guint i = 0; i < role_labels->len; i++) + g_string_append_printf(summary, "• %s — preuve : %s\n", + optional(g_ptr_array_index((GPtrArray *) role_labels, i), + "Rôle non renseigné"), + evidence != NULL + ? optional(evidence_record_get_original_name(evidence), + "Non renseignée") + : "Aucune"); + g_string_append(summary, "\nPREUVE PRINCIPALE\n"); + if (evidence == NULL) + g_string_append(summary, "Aucune preuve principale associée.\n"); + else { + size = g_format_size(evidence_record_get_size_bytes(evidence)); + short_sha = g_strndup(evidence_record_get_sha256(evidence), 12); + g_string_append_printf(summary, + "Nom original : %s\nType métier : %s\nType MIME : %s\n" + "Taille : %s\nDate d’import : %s\nSHA-256 : %s…\n" + "Intégrité : %s\nDescription : %s\n", + optional(evidence_record_get_original_name(evidence), + "Non renseigné"), + optional(evidence_record_get_type_label(evidence), + evidence_record_get_type_identifier(evidence)), + optional(evidence_record_get_mime_type(evidence), + "Non renseigné"), + size, optional(evidence_record_get_imported_at(evidence), + "Non renseignée"), + optional(short_sha, "Non renseigné"), + integrity(evidence_record_get_integrity_status(evidence)), + optional(evidence_record_get_description(evidence), "Aucune")); + } + g_string_append(summary, + "\nAVERTISSEMENTS\n" + "Aucune écriture n’a encore été effectuée.\n" + "Les informations seront créées seulement après un clic sur " + "« Créer la personne ».\n" + "Aucune identité réelle ni relation avec un auteur n’est inférée " + "automatiquement."); + g_free(size); + g_free(short_sha); + return g_string_free(summary, FALSE); +} diff --git a/src/core/person_creation_guard.c b/src/core/person_creation_guard.c new file mode 100644 index 0000000..fd6b3c8 --- /dev/null +++ b/src/core/person_creation_guard.c @@ -0,0 +1,35 @@ +#include "core/person_creation_guard.h" +struct PersonCreationGuard { + guint64 generation; + char *root_path; + char *database_path; +}; +PersonCreationGuard *person_creation_guard_new(guint64 generation, + const char *root_path, const char *database_path) +{ + if (root_path == NULL || database_path == NULL) return NULL; + PersonCreationGuard *g = g_new0(PersonCreationGuard, 1); + g->generation = generation; + g->root_path = g_canonicalize_filename(root_path, NULL); + g->database_path = g_canonicalize_filename(database_path, NULL); + return g; +} +void person_creation_guard_free(PersonCreationGuard *g) +{ + if (g == NULL) return; + g_free(g->root_path); g_free(g->database_path); g_free(g); +} +gboolean person_creation_guard_matches(const PersonCreationGuard *g, + gboolean open, guint64 generation, const char *root, const char *database) +{ + char *canonical_root, *canonical_database; + gboolean matches; + if (g == NULL || !open || root == NULL || database == NULL || + generation != g->generation) return FALSE; + canonical_root = g_canonicalize_filename(root, NULL); + canonical_database = g_canonicalize_filename(database, NULL); + matches = g_strcmp0(canonical_root, g->root_path) == 0 && + g_strcmp0(canonical_database, g->database_path) == 0; + g_free(canonical_root); g_free(canonical_database); + return matches; +} diff --git a/src/core/person_dialog_lifecycle.c b/src/core/person_dialog_lifecycle.c new file mode 100644 index 0000000..26a76af --- /dev/null +++ b/src/core/person_dialog_lifecycle.c @@ -0,0 +1,78 @@ +#include "core/person_dialog_lifecycle.h" + +struct PersonDialogLifecycle +{ + guint64 generation; + gboolean preview_active; + gboolean finished; + gboolean cancelled; +}; + +PersonDialogLifecycle *person_dialog_lifecycle_new(void) +{ + return g_new0(PersonDialogLifecycle, 1); +} + +void person_dialog_lifecycle_free(PersonDialogLifecycle *lifecycle) +{ + g_free(lifecycle); +} + +guint64 person_dialog_lifecycle_begin_preview( + PersonDialogLifecycle *lifecycle) +{ + if (lifecycle == NULL || lifecycle->finished) return 0; + lifecycle->generation++; + lifecycle->preview_active = TRUE; + return lifecycle->generation; +} + +gboolean person_dialog_lifecycle_cancel(PersonDialogLifecycle *lifecycle) +{ + if (lifecycle == NULL || lifecycle->finished) return FALSE; + lifecycle->finished = TRUE; + lifecycle->cancelled = TRUE; + lifecycle->preview_active = FALSE; + lifecycle->generation++; + return TRUE; +} + +gboolean person_dialog_lifecycle_complete(PersonDialogLifecycle *lifecycle) +{ + if (lifecycle == NULL || lifecycle->finished) return FALSE; + lifecycle->finished = TRUE; + lifecycle->preview_active = FALSE; + lifecycle->generation++; + return TRUE; +} + +gboolean person_dialog_lifecycle_accepts_preview( + const PersonDialogLifecycle *lifecycle, guint64 generation) +{ + return lifecycle != NULL && !lifecycle->finished && + lifecycle->preview_active && generation == lifecycle->generation; +} + +gboolean person_dialog_lifecycle_is_cancelled( + const PersonDialogLifecycle *lifecycle) +{ + return lifecycle != NULL && lifecycle->cancelled; +} + +gboolean person_dialog_lifecycle_is_finished( + const PersonDialogLifecycle *lifecycle) +{ + return lifecycle != NULL && lifecycle->finished; +} + +gboolean person_dialog_lifecycle_has_preview( + const PersonDialogLifecycle *lifecycle) +{ + return lifecycle != NULL && lifecycle->preview_active; +} + +guint64 person_dialog_lifecycle_get_generation( + const PersonDialogLifecycle *lifecycle) +{ + return lifecycle != NULL ? lifecycle->generation : 0; +} diff --git a/src/core/person_entity_service.c b/src/core/person_entity_service.c index f60ca09..890ecf9 100644 --- a/src/core/person_entity_service.c +++ b/src/core/person_entity_service.c @@ -3,6 +3,7 @@ * @brief Création transactionnelle d'une personne observée. ******************************************************************************/ #include "core/person_entity_service.h" +#include "dao/person_role_assignment_dao.h" #include "dao/entity_dao.h" #include "dao/evidence_entity_dao.h" #include "database/transaction.h" @@ -188,6 +189,7 @@ gboolean person_entity_service_create(Database *database, { EntityDao *entity_dao = NULL; EvidenceEntityDao *link_dao = NULL; + PersonRoleAssignmentDao *role_dao = NULL; EntityRecord *record = NULL; GDateTime *now = NULL; char *timestamp = NULL; @@ -208,6 +210,16 @@ gboolean person_entity_service_create(Database *database, "Les informations de la personne sont invalides."); return FALSE; } + for (guint i = 0; input->role_assignments != NULL && + i < input->role_assignments->len; i++) + if (!person_role_assignment_input_is_valid(g_ptr_array_index( + (GPtrArray *) input->role_assignments, i))) + { + g_set_error_literal(error, g_quark_from_static_string( + "person-entity-service-error"), 1, + "Une affectation de rôle est invalide."); + return FALSE; + } value = input->declared_name != NULL && input->declared_name[0] != '\0' ? input->declared_name : input->pseudonym != NULL && input->pseudonym[0] != '\0' @@ -232,6 +244,10 @@ gboolean person_entity_service_create(Database *database, timestamp, timestamp, ENTITY_STATUS_ACTIVE, error); if (entity_dao == NULL || record == NULL || !entity_dao_insert(entity_dao, record, error)) goto cleanup; + role_dao = person_role_assignment_dao_new(database, error); + if (role_dao == NULL || + !person_role_assignment_dao_insert_all(role_dao, identifier, + input->role_assignments, error)) goto cleanup; if (input->evidence_identifier != NULL && input->evidence_identifier[0] != '\0') { link_dao = evidence_entity_dao_new(database, error); @@ -243,6 +259,7 @@ gboolean person_entity_service_create(Database *database, success = TRUE; if (out_identifier != NULL) *out_identifier = g_strdup(identifier); cleanup: + person_role_assignment_dao_free(role_dao); if (!success && active) database_transaction_rollback(database); evidence_entity_dao_free(link_dao); entity_record_free(record); diff --git a/src/dao/evidence_dao.c b/src/dao/evidence_dao.c index d6abd44..9d91710 100644 --- a/src/dao/evidence_dao.c +++ b/src/dao/evidence_dao.c @@ -84,7 +84,9 @@ static const char *const evidence_dao_find_by_identifier_sql = " preuves.collected_at," " preuves.source," " preuves.description," - " preuves.integrity_status " + " preuves.integrity_status," + " types_preuve.label," + " preuves.mime_type " "FROM preuves " "LEFT JOIN types_preuve " "ON types_preuve.id = preuves.type_id " @@ -140,7 +142,9 @@ static const char *const evidence_dao_list_all_sql = " preuves.collected_at," " preuves.source," " preuves.description," - " preuves.integrity_status " + " preuves.integrity_status," + " types_preuve.label," + " preuves.mime_type " "FROM preuves " "LEFT JOIN types_preuve " "ON types_preuve.id = preuves.type_id " @@ -456,6 +460,8 @@ static EvidenceRecord *evidence_dao_read_current_record( char *collected_at = NULL; char *source = NULL; char *description = NULL; + char *type_label = NULL; + char *mime_type = NULL; int64_t size_bytes = -1; int64_t integrity_status_value = -1; @@ -534,6 +540,9 @@ static EvidenceRecord *evidence_dao_read_current_record( statement, 11, &integrity_status_value + ) || + !database_statement_column_text(statement, 12, &type_label) || + !database_statement_column_text(statement, 13, &mime_type )) { evidence_dao_set_error_literal( @@ -608,6 +617,9 @@ static EvidenceRecord *evidence_dao_read_current_record( ); } } + else + evidence_record_set_display_metadata( + evidence_record, type_label, mime_type); cleanup: @@ -618,6 +630,8 @@ cleanup: g_free( description ); + g_free(type_label); + g_free(mime_type); g_free( source diff --git a/src/dao/person_role_assignment_dao.c b/src/dao/person_role_assignment_dao.c new file mode 100644 index 0000000..cdbda0a --- /dev/null +++ b/src/dao/person_role_assignment_dao.c @@ -0,0 +1,142 @@ +#include "dao/person_role_assignment_dao.h" +#include "database/statement.h" + +struct PersonRoleAssignmentDao { Database *database; }; +static GQuark role_dao_error(void) +{ return g_quark_from_static_string("person-role-assignment-dao-error"); } + +PersonRoleAssignmentDao *person_role_assignment_dao_new( + Database *database, GError **error) +{ + if (database == NULL) { + g_set_error_literal(error, role_dao_error(), 1, + "La connexion SQLite est invalide."); + return NULL; + } + PersonRoleAssignmentDao *dao = g_new0(PersonRoleAssignmentDao, 1); + dao->database = database; + return dao; +} +void person_role_assignment_dao_free(PersonRoleAssignmentDao *dao) +{ g_free(dao); } + +gboolean person_role_assignment_dao_insert(PersonRoleAssignmentDao *dao, + const char *entity_identifier, const PersonRoleAssignmentInput *input, + GError **error) +{ + static const char sql[] = + "INSERT INTO person_role_assignments(id,entity_id,role_code," + "evidence_id,provenance_kind,confidence,notes,created_at,updated_at) " + "SELECT ?,?,?,?,?,?,?,?,? WHERE EXISTS(SELECT 1 FROM entites e " + "JOIN types_entite t ON t.id=e.type_id WHERE e.id=? AND t.code='person') " + "AND (? IS NULL OR EXISTS(SELECT 1 FROM preuves WHERE id=?)) " + "RETURNING id;"; + DatabaseStatement *statement = NULL; + GDateTime *now = NULL; + char *timestamp = NULL, *identifier = NULL; + gboolean success = FALSE; + if (dao == NULL || !g_uuid_string_is_valid(entity_identifier) || + !person_role_assignment_input_is_valid(input)) goto invalid; + now = g_date_time_new_now_utc(); + timestamp = now != NULL ? g_date_time_format(now, + "%Y-%m-%dT%H:%M:%SZ") : NULL; + identifier = g_uuid_string_random(); + statement = database_statement_prepare(dao->database, sql); + if (statement == NULL || timestamp == NULL || identifier == NULL || + !database_statement_bind_text(statement, 1, identifier) || + !database_statement_bind_text(statement, 2, entity_identifier) || + !database_statement_bind_text(statement, 3, input->role_code) || + !(input->evidence_identifier != NULL + ? database_statement_bind_text(statement, 4, input->evidence_identifier) + : database_statement_bind_null(statement, 4)) || + !database_statement_bind_text(statement, 5, input->provenance_kind) || + !(input->has_confidence + ? database_statement_bind_int64(statement, 6, input->confidence) + : database_statement_bind_null(statement, 6)) || + !(input->notes != NULL + ? database_statement_bind_text(statement, 7, input->notes) + : database_statement_bind_null(statement, 7)) || + !database_statement_bind_text(statement, 8, timestamp) || + !database_statement_bind_text(statement, 9, timestamp) || + !database_statement_bind_text(statement, 10, entity_identifier) || + !(input->evidence_identifier != NULL + ? database_statement_bind_text(statement, 11, input->evidence_identifier) + : database_statement_bind_null(statement, 11)) || + !(input->evidence_identifier != NULL + ? database_statement_bind_text(statement, 12, input->evidence_identifier) + : database_statement_bind_null(statement, 12)) || + database_statement_step(statement) != DATABASE_STATEMENT_STEP_ROW) + goto failed; + success = TRUE; + goto cleanup; +invalid: + g_set_error_literal(error, role_dao_error(), 1, + "L'affectation de rôle est invalide."); + goto cleanup; +failed: + g_set_error_literal(error, role_dao_error(), 2, + "La personne, la preuve ou l'affectation de rôle est invalide."); +cleanup: + database_statement_finalize(statement); + g_free(identifier); g_free(timestamp); + g_clear_pointer(&now, g_date_time_unref); + return success; +} + +gboolean person_role_assignment_dao_insert_all(PersonRoleAssignmentDao *dao, + const char *entity_identifier, const GPtrArray *inputs, GError **error) +{ + for (guint i = 0; inputs != NULL && i < inputs->len; i++) + if (!person_role_assignment_dao_insert(dao, entity_identifier, + g_ptr_array_index((GPtrArray *) inputs, i), error)) + return FALSE; + return TRUE; +} + +GPtrArray *person_role_assignment_dao_list_by_entity( + PersonRoleAssignmentDao *dao, const char *entity_identifier, GError **error) +{ + static const char sql[] = "SELECT id,role_code,evidence_id,provenance_kind," + "confidence,notes,created_at,updated_at FROM person_role_assignments " + "WHERE entity_id=? ORDER BY created_at,id;"; + DatabaseStatement *s = NULL; + GPtrArray *result = NULL; + if (dao == NULL || !g_uuid_string_is_valid(entity_identifier)) goto failed; + s = database_statement_prepare(dao->database, sql); + result = g_ptr_array_new_with_free_func( + (GDestroyNotify) person_role_assignment_record_free); + if (s == NULL || result == NULL || + !database_statement_bind_text(s, 1, entity_identifier)) goto failed; + while (database_statement_step(s) == DATABASE_STATEMENT_STEP_ROW) { + PersonRoleAssignmentRecord *r = g_new0(PersonRoleAssignmentRecord, 1); + int64_t confidence = 0; + bool confidence_is_null = true; + if (!database_statement_column_text(s, 0, &r->identifier) || + !database_statement_column_text(s, 1, &r->assignment.role_code) || + !database_statement_column_text( + s, 2, &r->assignment.evidence_identifier) || + !database_statement_column_text( + s, 3, &r->assignment.provenance_kind) || + !database_statement_column_is_null(s, 4, &confidence_is_null) || + !database_statement_column_text(s, 5, &r->assignment.notes) || + !database_statement_column_text(s, 6, &r->created_at) || + !database_statement_column_text(s, 7, &r->updated_at)) { + person_role_assignment_record_free(r); + goto failed; + } + r->entity_identifier = g_strdup(entity_identifier); + r->assignment.has_confidence = !confidence_is_null; + if (r->assignment.has_confidence && + database_statement_column_int64(s, 4, &confidence)) + r->assignment.confidence = (gint) confidence; + g_ptr_array_add(result, r); + } + database_statement_finalize(s); + return result; +failed: + database_statement_finalize(s); + g_clear_pointer(&result, g_ptr_array_unref); + g_set_error_literal(error, role_dao_error(), 2, + "Impossible de lire les affectations de rôle."); + return NULL; +} diff --git a/src/database/database.c b/src/database/database.c index 9346b46..d96d2fc 100644 --- a/src/database/database.c +++ b/src/database/database.c @@ -16,12 +16,12 @@ /** * @brief Version actuelle du schéma SQLite. */ -#define DATABASE_SCHEMA_VERSION_CURRENT 13 +#define DATABASE_SCHEMA_VERSION_CURRENT 14 /** * @brief Version actuelle sous forme textuelle pour metadata. */ -#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "13" +#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "14" /** * @brief Nom de l'application enregistré dans les métadonnées. @@ -857,6 +857,21 @@ rollback: return false; } +static bool database_migrate_v13_to_v14(Database *database) +{ + bool transaction_started = false; + if (database == NULL || !database_transaction_begin(database)) return false; + transaction_started = true; + if (!schema_install_v14(database) || + !database_update_schema_version(database, "14") || + !database_transaction_commit(database)) goto rollback; + return true; +rollback: + if (transaction_started && !database_transaction_rollback(database)) + g_warning("Impossible d’annuler la migration SQLite V13 vers V14."); + return false; +} + /** * @brief Garantit atomiquement la présence des extensions du schéma courant. */ @@ -1098,6 +1113,10 @@ bool database_migrate_to_latest( if (!database_migrate_v12_to_v13(database)) return false; schema_version = 13; break; + case 13: + if (!database_migrate_v13_to_v14(database)) return false; + schema_version = 14; + break; default: database_set_error( diff --git a/src/database/schema.c b/src/database/schema.c index 1e66945..1c31acb 100644 --- a/src/database/schema.c +++ b/src/database/schema.c @@ -371,6 +371,12 @@ bool schema_install_v13(Database *database) "la migration SQLite V13"); } +bool schema_install_v14(Database *database) +{ + return schema_execute_file(database, "database/schema_v14.sql", + "la migration SQLite V14"); +} + bool schema_ensure_current( Database *database ) diff --git a/src/models/evidence_record.c b/src/models/evidence_record.c index 5b44e49..eade068 100644 --- a/src/models/evidence_record.c +++ b/src/models/evidence_record.c @@ -18,6 +18,8 @@ struct EvidenceRecord char *internal_name; char *relative_path; char *type_identifier; + char *type_label; + char *mime_type; guint64 size_bytes; @@ -30,6 +32,28 @@ struct EvidenceRecord EvidenceIntegrityStatus integrity_status; }; +EvidenceRecord *evidence_record_copy(const EvidenceRecord *evidence_record) +{ + EvidenceRecord *copy; + if (evidence_record == NULL) return NULL; + copy = g_new0(EvidenceRecord, 1); + copy->identifier = g_strdup(evidence_record->identifier); + copy->original_name = g_strdup(evidence_record->original_name); + copy->internal_name = g_strdup(evidence_record->internal_name); + copy->relative_path = g_strdup(evidence_record->relative_path); + copy->type_identifier = g_strdup(evidence_record->type_identifier); + copy->type_label = g_strdup(evidence_record->type_label); + copy->mime_type = g_strdup(evidence_record->mime_type); + copy->size_bytes = evidence_record->size_bytes; + copy->sha256 = g_strdup(evidence_record->sha256); + copy->imported_at = g_strdup(evidence_record->imported_at); + copy->collected_at = g_strdup(evidence_record->collected_at); + copy->source = g_strdup(evidence_record->source); + copy->description = g_strdup(evidence_record->description); + copy->integrity_status = evidence_record->integrity_status; + return copy; +} + /** * @brief Produit une erreur du domaine EvidenceRecord. */ @@ -800,6 +824,8 @@ void evidence_record_free( g_free( evidence_record->type_identifier ); + g_free(evidence_record->type_label); + g_free(evidence_record->mime_type); g_free( evidence_record->relative_path @@ -882,6 +908,28 @@ const char *evidence_record_get_type_identifier( return evidence_record->type_identifier; } +void evidence_record_set_display_metadata(EvidenceRecord *record, + const char *type_label, const char *mime_type) +{ + if (record == NULL) return; + g_free(record->type_label); + g_free(record->mime_type); + record->type_label = g_strdup(type_label); + record->mime_type = g_strdup(mime_type); +} + +const char *evidence_record_get_type_label(const EvidenceRecord *record) +{ + if (record == NULL) return NULL; + return record->type_label != NULL + ? record->type_label : record->type_identifier; +} + +const char *evidence_record_get_mime_type(const EvidenceRecord *record) +{ + return record != NULL ? record->mime_type : NULL; +} + guint64 evidence_record_get_size_bytes( const EvidenceRecord *evidence_record ) diff --git a/src/models/evidence_selection_model.c b/src/models/evidence_selection_model.c new file mode 100644 index 0000000..fc8131d --- /dev/null +++ b/src/models/evidence_selection_model.c @@ -0,0 +1,138 @@ +#include "models/evidence_selection_model.h" + +struct EvidenceSelectionModel { + GPtrArray *records; + char *query; + char *type_code; + char *selected_identifier; +}; +static gint compare_strings(gconstpointer left, gconstpointer right) +{ + return g_strcmp0(*(char * const *) left, *(char * const *) right); +} + +static char *fold(const char *text) +{ + char *normalized, *result; + if (text == NULL) return g_strdup(""); + normalized = g_utf8_normalize(text, -1, G_NORMALIZE_ALL_COMPOSE); + if (normalized == NULL) return g_strdup(""); + result = g_utf8_casefold(normalized, -1); + g_free(normalized); + return result != NULL ? result : g_strdup(""); +} +static gboolean matches(const EvidenceSelectionModel *m, + const EvidenceRecord *r) +{ + gboolean found = FALSE; + char *needle, *fields[5]; + if (m->type_code != NULL && g_strcmp0(m->type_code, + evidence_record_get_type_identifier(r)) != 0) return FALSE; + needle = fold(m->query); + if (needle[0] == '\0') found = TRUE; + fields[0] = fold(evidence_record_get_original_name(r)); + fields[1] = fold(evidence_record_get_description(r)); + fields[2] = fold(evidence_record_get_type_identifier(r)); + fields[3] = fold(evidence_record_get_type_label(r)); + fields[4] = fold(evidence_record_get_mime_type(r)); + for (guint i = 0; !found && i < G_N_ELEMENTS(fields); i++) + found = strstr(fields[i], needle) != NULL; + for (guint i = 0; i < G_N_ELEMENTS(fields); i++) g_free(fields[i]); + g_free(needle); + return found; +} +EvidenceSelectionModel *evidence_selection_model_new(const GPtrArray *records) +{ + EvidenceSelectionModel *m = g_new0(EvidenceSelectionModel, 1); + m->records = g_ptr_array_new_with_free_func( + (GDestroyNotify) evidence_record_free); + for (guint i = 0; records != NULL && i < records->len; i++) { + EvidenceRecord *copy = evidence_record_copy( + g_ptr_array_index((GPtrArray *) records, i)); + if (copy != NULL) g_ptr_array_add(m->records, copy); + } + return m; +} +void evidence_selection_model_free(EvidenceSelectionModel *m) +{ + if (m == NULL) return; + g_ptr_array_unref(m->records); g_free(m->query); + g_free(m->type_code); g_free(m->selected_identifier); g_free(m); +} +static void invalidate_hidden(EvidenceSelectionModel *m) +{ + if (m->selected_identifier == NULL) return; + for (guint i = 0; i < m->records->len; i++) { + EvidenceRecord *r = g_ptr_array_index(m->records, i); + if (g_strcmp0(evidence_record_get_identifier(r), + m->selected_identifier) == 0 && matches(m, r)) return; + } + g_clear_pointer(&m->selected_identifier, g_free); +} +void evidence_selection_model_set_query(EvidenceSelectionModel *m, + const char *query) +{ + if (m == NULL) return; + g_free(m->query); m->query = g_strdup(query); invalidate_hidden(m); +} +void evidence_selection_model_set_type(EvidenceSelectionModel *m, + const char *type_code) +{ + if (m == NULL) return; + g_free(m->type_code); + m->type_code = type_code != NULL && type_code[0] != '\0' + ? g_strdup(type_code) : NULL; + invalidate_hidden(m); +} +GPtrArray *evidence_selection_model_list_visible(const EvidenceSelectionModel *m) +{ + GPtrArray *result = g_ptr_array_new(); + for (guint i = 0; m != NULL && i < m->records->len; i++) { + EvidenceRecord *r = g_ptr_array_index(m->records, i); + if (matches(m, r)) g_ptr_array_add(result, r); + } + return result; +} +gboolean evidence_selection_model_select(EvidenceSelectionModel *m, + const char *identifier) +{ + if (m == NULL) return FALSE; + g_clear_pointer(&m->selected_identifier, g_free); + if (identifier == NULL) return TRUE; + for (guint i = 0; i < m->records->len; i++) { + EvidenceRecord *r = g_ptr_array_index(m->records, i); + if (matches(m, r) && g_strcmp0( + evidence_record_get_identifier(r), identifier) == 0) { + m->selected_identifier = g_strdup(identifier); + return TRUE; + } + } + return FALSE; +} +const EvidenceRecord *evidence_selection_model_get_selected( + const EvidenceSelectionModel *m) +{ + for (guint i = 0; m != NULL && m->selected_identifier != NULL && + i < m->records->len; i++) { + EvidenceRecord *r = g_ptr_array_index(m->records, i); + if (g_strcmp0(evidence_record_get_identifier(r), + m->selected_identifier) == 0) return r; + } + return NULL; +} +GPtrArray *evidence_selection_model_list_type_codes( + const EvidenceSelectionModel *m) +{ + GPtrArray *types = g_ptr_array_new_with_free_func(g_free); + for (guint i = 0; m != NULL && i < m->records->len; i++) { + const char *code = evidence_record_get_type_identifier( + g_ptr_array_index(m->records, i)); + gboolean exists = FALSE; + for (guint j = 0; code != NULL && j < types->len; j++) + if (g_strcmp0(code, g_ptr_array_index(types, j)) == 0) + exists = TRUE; + if (code != NULL && !exists) g_ptr_array_add(types, g_strdup(code)); + } + g_ptr_array_sort(types, compare_strings); + return types; +} diff --git a/src/models/person_role_assignment.c b/src/models/person_role_assignment.c new file mode 100644 index 0000000..61999fa --- /dev/null +++ b/src/models/person_role_assignment.c @@ -0,0 +1,89 @@ +#include "models/person_role_assignment.h" +#include + +typedef struct { const char *code; const char *label; } PersonRoleDefinition; +static const PersonRoleDefinition roles[] = { + {"alleged_author", "Auteur présumé"}, + {"presented_identity", "Identité présentée"}, + {"potentially_impersonated_identity", "Identité potentiellement usurpée"}, + {"victim", "Victime"}, + {"witness", "Témoin"}, + {"declared_bank_holder", "Titulaire bancaire déclaré"}, + {"intermediary", "Intermédiaire"}, + {"mentioned_person", "Personne citée"}, + {"other", "Autre"}, + /* Codes V6 conservés pour la lecture et la migration historique. */ + {"uncategorized", "Non catégorisée"}, + {"alleged_scammer", "Escroc présumé (historique)"}, + {"suspect", "Suspect (historique)"}, + {"related_person", "Personne liée (historique)"}, + {"impersonated_identity", "Identité usurpée (historique)"} +}; + +gboolean person_role_assignment_role_is_known(const char *role_code) +{ + if (role_code == NULL) return FALSE; + for (guint i = 0; i < G_N_ELEMENTS(roles); i++) + if (g_str_equal(role_code, roles[i].code)) return TRUE; + return FALSE; +} + +const char *person_role_assignment_role_label(const char *role_code) +{ + for (guint i = 0; role_code != NULL && i < G_N_ELEMENTS(roles); i++) + if (g_str_equal(role_code, roles[i].code)) return roles[i].label; + return NULL; +} + +gboolean person_role_assignment_input_is_valid( + const PersonRoleAssignmentInput *input) +{ + return input != NULL && + person_role_assignment_role_is_known(input->role_code) && + (input->evidence_identifier == NULL || + g_uuid_string_is_valid(input->evidence_identifier)) && + input->provenance_kind != NULL && + (g_str_equal(input->provenance_kind, "manual") || + g_str_equal(input->provenance_kind, "legacy_manual")) && + (!input->has_confidence || + (input->confidence >= 0 && input->confidence <= 100)); +} + +PersonRoleAssignmentInput *person_role_assignment_input_copy( + const PersonRoleAssignmentInput *input) +{ + PersonRoleAssignmentInput *copy; + if (input == NULL) return NULL; + copy = g_new0(PersonRoleAssignmentInput, 1); + copy->role_code = g_strdup(input->role_code); + copy->evidence_identifier = g_strdup(input->evidence_identifier); + copy->provenance_kind = g_strdup(input->provenance_kind); + copy->has_confidence = input->has_confidence; + copy->confidence = input->confidence; + copy->notes = g_strdup(input->notes); + return copy; +} + +void person_role_assignment_input_free(PersonRoleAssignmentInput *input) +{ + if (input == NULL) return; + g_free(input->role_code); + g_free(input->evidence_identifier); + g_free(input->provenance_kind); + g_free(input->notes); + g_free(input); +} + +void person_role_assignment_record_free(PersonRoleAssignmentRecord *record) +{ + if (record == NULL) return; + g_free(record->identifier); + g_free(record->entity_identifier); + g_free(record->assignment.role_code); + g_free(record->assignment.evidence_identifier); + g_free(record->assignment.provenance_kind); + g_free(record->assignment.notes); + g_free(record->created_at); + g_free(record->updated_at); + g_free(record); +} diff --git a/src/views/create_person_dialog.c b/src/views/create_person_dialog.c index 7fa1ca4..5ba5017 100644 --- a/src/views/create_person_dialog.c +++ b/src/views/create_person_dialog.c @@ -3,6 +3,10 @@ * @brief Formulaire GTK de création d'une personne observée. ******************************************************************************/ #include "views/create_person_dialog.h" +#include "core/evidence_preview_task.h" +#include "core/person_confirmation_summary.h" +#include "core/person_dialog_lifecycle.h" +#include "models/evidence_selection_model.h" struct CreatePersonDialogResult { @@ -13,6 +17,7 @@ struct CreatePersonDialogResult char *status; char *notes; char *evidence_identifier; + GPtrArray *role_assignments; }; typedef struct { @@ -23,14 +28,44 @@ typedef struct GtkDropDown *status; GtkSpinButton *confidence; GtkDropDown *evidence; + GtkDropDown *type_filter; + GtkEntry *search; GtkTextView *notes; GtkLabel *error; + GtkLabel *metadata; + GtkLabel *preview_status; + GtkPicture *preview_picture; + GtkLabel *progress; + GtkLabel *summary; + GtkStack *stack; + GtkButton *previous; + GtkButton *next; + GtkButton *create; + GtkCheckButton *roles[9]; + guint step; GPtrArray *evidence_identifiers; + GPtrArray *visible_records; + GPtrArray *type_codes; + EvidenceSelectionModel *selection_model; + char *investigation_root_path; + TaskManager *task_manager; + BackgroundTask *preview_task; + PersonDialogLifecycle *lifecycle; CreatePersonDialogCallback callback; + CreatePersonDialogSessionCheck session_check; gpointer user_data; - gboolean completed; + GDestroyNotify user_data_destroy; } CreatePersonDialogState; + +typedef struct { + GWeakRef window; + char *evidence_identifier; + guint64 generation; +} CreatePersonPreviewContext; static const char *const status_codes[] = {"unknown", "suspected", "confirmed"}; +static void create_person_dialog_clear_preview(CreatePersonDialogState *state); +static void create_person_dialog_select_record(CreatePersonDialogState *state, + const EvidenceRecord *record); /** @brief Copie et nettoie un texte facultatif. */ static char *create_person_dialog_copy(const char *text) @@ -54,16 +89,230 @@ static void create_person_dialog_state_free(gpointer data) { CreatePersonDialogState *state = data; if (state == NULL) return; + if (state->preview_task != NULL) { + background_task_cancel(state->preview_task); + background_task_unref(state->preview_task); + } + person_dialog_lifecycle_cancel(state->lifecycle); g_clear_pointer(&state->evidence_identifiers, g_ptr_array_unref); + g_clear_pointer(&state->visible_records, g_ptr_array_unref); + g_clear_pointer(&state->type_codes, g_ptr_array_unref); + g_clear_pointer(&state->selection_model, evidence_selection_model_free); + g_clear_pointer(&state->lifecycle, person_dialog_lifecycle_free); + g_free(state->investigation_root_path); + if (state->user_data_destroy != NULL) + state->user_data_destroy(state->user_data); g_free(state); } + +static void create_person_dialog_rebuild_evidence(CreatePersonDialogState *state) +{ + GtkStringList *labels = gtk_string_list_new(NULL); + const EvidenceRecord *selected = + evidence_selection_model_get_selected(state->selection_model); + const char *selected_id = selected != NULL + ? evidence_record_get_identifier(selected) : NULL; + guint selected_index = 0; + gtk_string_list_append(labels, "Aucune preuve associée"); + g_clear_pointer(&state->visible_records, g_ptr_array_unref); + state->visible_records = + evidence_selection_model_list_visible(state->selection_model); + for (guint i = 0; i < state->visible_records->len; i++) { + EvidenceRecord *record = g_ptr_array_index(state->visible_records, i); + gtk_string_list_append(labels, + evidence_record_get_original_name(record)); + if (g_strcmp0(selected_id, + evidence_record_get_identifier(record)) == 0) + selected_index = i + 1; + } + gtk_drop_down_set_model(state->evidence, G_LIST_MODEL(labels)); + gtk_drop_down_set_selected(state->evidence, selected_index); + g_object_unref(labels); + if (selected_index == 0) { + evidence_selection_model_select(state->selection_model, NULL); + create_person_dialog_clear_preview(state); + } +} + +static void create_person_dialog_on_search_changed( + GtkEditable *editable, gpointer data) +{ + CreatePersonDialogState *state = data; + evidence_selection_model_set_query(state->selection_model, + gtk_editable_get_text(editable)); + create_person_dialog_rebuild_evidence(state); +} + +static void create_person_dialog_on_type_changed( + GtkDropDown *dropdown, GParamSpec *pspec, gpointer data) +{ + CreatePersonDialogState *state = data; + guint selected = gtk_drop_down_get_selected(dropdown); + (void) pspec; + evidence_selection_model_set_type(state->selection_model, + selected > 0 && selected - 1 < state->type_codes->len + ? g_ptr_array_index(state->type_codes, selected - 1) : NULL); + create_person_dialog_rebuild_evidence(state); +} + +static void create_person_dialog_on_evidence_changed( + GtkDropDown *dropdown, GParamSpec *pspec, gpointer data) +{ + CreatePersonDialogState *state = data; + guint selected = gtk_drop_down_get_selected(dropdown); + (void) pspec; + create_person_dialog_select_record(state, + selected > 0 && selected - 1 < state->visible_records->len + ? g_ptr_array_index(state->visible_records, selected - 1) : NULL); +} /** @brief Termine le dialogue comme une annulation. */ static void create_person_dialog_cancel(CreatePersonDialogState *state) { - if (state == NULL || state->completed) return; - state->completed = TRUE; + if (state == NULL || + !person_dialog_lifecycle_cancel(state->lifecycle)) return; + if (state->preview_task != NULL) background_task_cancel(state->preview_task); if (state->callback != NULL) state->callback(NULL, state->user_data); } + +static void preview_context_free(gpointer data) +{ + CreatePersonPreviewContext *context = data; + if (context == NULL) return; + g_weak_ref_clear(&context->window); + g_free(context->evidence_identifier); + g_free(context); +} + +static const char *integrity_label(EvidenceIntegrityStatus status) +{ + switch (status) { + case EVIDENCE_INTEGRITY_STATUS_VALID: return "Valide"; + case EVIDENCE_INTEGRITY_STATUS_MISSING: return "Fichier absent"; + case EVIDENCE_INTEGRITY_STATUS_MODIFIED: return "Invalide"; + case EVIDENCE_INTEGRITY_STATUS_ERROR: return "Erreur de vérification"; + default: return "Non vérifiée"; + } +} + +static void create_person_dialog_clear_preview(CreatePersonDialogState *state) +{ + if (state->preview_task != NULL) { + background_task_cancel(state->preview_task); + background_task_unref(state->preview_task); + state->preview_task = NULL; + } + person_dialog_lifecycle_begin_preview(state->lifecycle); + gtk_picture_set_paintable(state->preview_picture, NULL); + gtk_label_set_text(state->preview_status, "Aucune preuve sélectionnée."); + gtk_label_set_text(state->metadata, ""); +} + +static void create_person_dialog_preview_completed( + BackgroundTask *task, gpointer data) +{ + CreatePersonPreviewContext *context = data; + GtkWindow *window = g_weak_ref_get(&context->window); + CreatePersonDialogState *state = window != NULL + ? g_object_get_data(G_OBJECT(window), "person-dialog-state") : NULL; + EvidencePreviewResult *result = background_task_get_result(task); + if (state != NULL && state->session_check != NULL && + !state->session_check(state->user_data)) { + person_dialog_lifecycle_cancel(state->lifecycle); + gtk_label_set_text(state->preview_status, + "Aperçu annulé : l'enquête active a changé."); + gtk_widget_set_sensitive(GTK_WIDGET(state->create), FALSE); + } else if (state != NULL && + person_dialog_lifecycle_accepts_preview( + state->lifecycle, context->generation) && + evidence_preview_result_matches(result, context->evidence_identifier, + context->generation)) { + GError *error = NULL; + GdkTexture *texture = gdk_texture_new_from_bytes( + result->png_bytes, &error); + if (texture != NULL) { + gtk_picture_set_paintable(state->preview_picture, + GDK_PAINTABLE(texture)); + gtk_label_set_text(state->preview_status, "Aperçu intègre."); + g_object_unref(texture); + } else { + gtk_label_set_text(state->preview_status, + error != NULL ? error->message : "Aperçu illisible."); + } + g_clear_error(&error); + } else if (state != NULL && + person_dialog_lifecycle_accepts_preview( + state->lifecycle, context->generation)) { + GError *error = background_task_dup_error(task); + gtk_label_set_text(state->preview_status, + error != NULL ? error->message : "Aperçu annulé."); + g_clear_error(&error); + } + if (state != NULL && state->preview_task == task) { + background_task_unref(state->preview_task); + state->preview_task = NULL; + } + g_clear_object(&window); +} + +static void create_person_dialog_select_record(CreatePersonDialogState *state, + const EvidenceRecord *record) +{ + char *size = NULL, *sha = NULL, *text = NULL; + const char *mime; + CreatePersonPreviewContext *context; + EvidencePreviewRequest *request; + GError *error = NULL; + create_person_dialog_clear_preview(state); + if (record == NULL) return; + evidence_selection_model_select(state->selection_model, + evidence_record_get_identifier(record)); + size = g_format_size(evidence_record_get_size_bytes(record)); + sha = g_strndup(evidence_record_get_sha256(record), 12); + mime = evidence_record_get_mime_type(record); + text = g_strdup_printf( + "Nom : %s\nType : %s (%s)\nMIME : %s\nTaille : %s\n" + "Import : %s\nSHA-256 : %s…\nIntégrité : %s\nDescription : %s", + evidence_record_get_original_name(record), + evidence_record_get_type_label(record), + evidence_record_get_type_identifier(record), + mime != NULL ? mime : "Non renseigné", size, + evidence_record_get_imported_at(record), sha, + integrity_label(evidence_record_get_integrity_status(record)), + evidence_record_get_description(record) != NULL + ? evidence_record_get_description(record) : "Aucune"); + gtk_label_set_text(state->metadata, text); + if (mime == NULL || !(g_str_equal(mime, "image/png") || + g_str_equal(mime, "image/jpeg"))) { + gtk_label_set_text(state->preview_status, + "Aperçu indisponible pour ce format."); + goto cleanup; + } + gtk_label_set_text(state->preview_status, + "Vérification et chargement en cours…"); + request = evidence_preview_request_new(state->investigation_root_path, + evidence_record_get_identifier(record), + evidence_record_get_relative_path(record), + evidence_record_get_sha256(record), mime, + person_dialog_lifecycle_get_generation(state->lifecycle)); + context = g_new0(CreatePersonPreviewContext, 1); + g_weak_ref_init(&context->window, G_OBJECT(state->window)); + context->evidence_identifier = + g_strdup(evidence_record_get_identifier(record)); + context->generation = + person_dialog_lifecycle_get_generation(state->lifecycle); + state->preview_task = evidence_preview_task_start(state->task_manager, + request, create_person_dialog_preview_completed, context, + preview_context_free, &error); + evidence_preview_request_free(request); + if (state->preview_task == NULL) { + preview_context_free(context); + gtk_label_set_text(state->preview_status, + error != NULL ? error->message : "Aperçu impossible."); + } + g_clear_error(&error); +cleanup: + g_free(size); g_free(sha); g_free(text); +} /** @brief Traite la fermeture native. */ static gboolean create_person_dialog_on_close(GtkWindow *window, gpointer data) { @@ -82,8 +331,16 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) CreatePersonDialogResult *result = NULL; char *notes = NULL; guint status = gtk_drop_down_get_selected(state->status); - guint evidence = gtk_drop_down_get_selected(state->evidence); + const EvidenceRecord *selected_record = + evidence_selection_model_get_selected(state->selection_model); (void) button; + if (state->session_check != NULL && + !state->session_check(state->user_data)) { + gtk_label_set_text(state->error, + "L'enquête active a changé : création refusée."); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + return; + } if (status >= G_N_ELEMENTS(status_codes) || gtk_editable_get_text(GTK_EDITABLE(state->designation))[0] == '\0') { @@ -100,9 +357,9 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) gtk_editable_get_text(GTK_EDITABLE(state->pseudonym))); result->status = g_strdup(status_codes[status]); result->notes = create_person_dialog_copy(notes); - if (evidence > 0 && evidence - 1 < state->evidence_identifiers->len) - result->evidence_identifier = g_strdup(g_ptr_array_index( - state->evidence_identifiers, evidence - 1)); + if (selected_record != NULL) + result->evidence_identifier = g_strdup( + evidence_record_get_identifier(selected_record)); result->input.designation = result->designation; result->input.declared_name = result->declared_name; result->input.pseudonym = result->pseudonym; @@ -110,11 +367,103 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) result->input.notes = result->notes; result->input.confidence = gtk_spin_button_get_value_as_int(state->confidence); result->input.evidence_identifier = result->evidence_identifier; - state->completed = TRUE; + result->role_assignments = g_ptr_array_new_with_free_func( + (GDestroyNotify) person_role_assignment_input_free); + static const char *const role_codes[] = { + "alleged_author", "presented_identity", + "potentially_impersonated_identity", "victim", "witness", + "declared_bank_holder", "intermediary", "mentioned_person", "other"}; + for (guint i = 0; i < G_N_ELEMENTS(role_codes); i++) + if (gtk_check_button_get_active(state->roles[i])) { + PersonRoleAssignmentInput assignment = { + .role_code = (char *) role_codes[i], + .evidence_identifier = result->evidence_identifier, + .provenance_kind = "manual", + .has_confidence = FALSE + }; + g_ptr_array_add(result->role_assignments, + person_role_assignment_input_copy(&assignment)); + } + result->input.role_assignments = result->role_assignments; + if (!person_dialog_lifecycle_complete(state->lifecycle)) { + create_person_dialog_result_free(result); + g_free(notes); + return; + } if (state->callback != NULL) state->callback(result, state->user_data); else create_person_dialog_result_free(result); g_free(notes); gtk_window_close(state->window); } + +static void create_person_dialog_update_navigation(CreatePersonDialogState *state) +{ + static const char *const pages[] = {"person", "roles", "evidence", "summary"}; + static const char *const steps[] = { + "1 Personne", "2 Rôles", "3 Preuve", "4 Confirmation"}; + GString *progress = g_string_new(NULL); + gtk_stack_set_visible_child_name(state->stack, pages[state->step]); + for (guint i = 0; i < G_N_ELEMENTS(steps); i++) { + char *escaped = g_markup_escape_text(steps[i], -1); + if (i > 0) g_string_append(progress, " → "); + if (i == state->step) + g_string_append_printf(progress, "%s", escaped); + else + g_string_append(progress, escaped); + g_free(escaped); + } + gtk_label_set_markup(state->progress, progress->str); + g_string_free(progress, TRUE); + if (state->step == 3) { + static const char *const status_labels[] = { + "Inconnu", "Présumé", "Confirmé"}; + GPtrArray *role_labels = g_ptr_array_new(); + const EvidenceRecord *evidence = + evidence_selection_model_get_selected(state->selection_model); + char *notes = create_person_dialog_get_notes(state); + char *text; + for (guint i = 0; i < G_N_ELEMENTS(state->roles); i++) + if (gtk_check_button_get_active(state->roles[i])) + g_ptr_array_add(role_labels, (gpointer) + gtk_check_button_get_label(state->roles[i])); + text = person_confirmation_summary_build( + gtk_editable_get_text(GTK_EDITABLE(state->designation)), + gtk_editable_get_text(GTK_EDITABLE(state->name)), + gtk_editable_get_text(GTK_EDITABLE(state->pseudonym)), + gtk_drop_down_get_selected(state->status) < + G_N_ELEMENTS(status_labels) + ? status_labels[gtk_drop_down_get_selected(state->status)] + : "Non renseigné", + gtk_spin_button_get_value_as_int(state->confidence), notes, + role_labels, evidence); + gtk_label_set_text(state->summary, text); + g_free(text); + g_free(notes); + g_ptr_array_unref(role_labels); + } + gtk_widget_set_sensitive(GTK_WIDGET(state->previous), state->step > 0); + gtk_widget_set_visible(GTK_WIDGET(state->next), state->step < 3); + gtk_widget_set_visible(GTK_WIDGET(state->create), state->step == 3); +} +static void create_person_dialog_on_previous(GtkButton *button, gpointer data) +{ + CreatePersonDialogState *state = data; (void) button; + if (state->step > 0) state->step--; + create_person_dialog_update_navigation(state); +} +static void create_person_dialog_on_next(GtkButton *button, gpointer data) +{ + CreatePersonDialogState *state = data; (void) button; + if (state->step == 0 && + gtk_editable_get_text(GTK_EDITABLE(state->designation))[0] == '\0') { + gtk_label_set_text(state->error, + "La désignation de la personne est obligatoire."); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + return; + } + gtk_widget_set_visible(GTK_WIDGET(state->error), FALSE); + if (state->step < 3) state->step++; + create_person_dialog_update_navigation(state); +} /** @brief Ajoute une ligne libellée au formulaire. */ static void create_person_dialog_add_row(GtkGrid *grid, int row, const char *label, GtkWidget *widget) @@ -126,17 +475,33 @@ static void create_person_dialog_add_row(GtkGrid *grid, int row, gtk_grid_attach(grid, widget, 1, row, 1, 1); } gboolean create_person_dialog_present(GtkWindow *parent, - const GPtrArray *records, CreatePersonDialogCallback callback, gpointer data) + const GPtrArray *records, const char *investigation_root_path, + TaskManager *task_manager, CreatePersonDialogSessionCheck session_check, + CreatePersonDialogCallback callback, gpointer data, + GDestroyNotify data_destroy) { static const char *const statuses[] = {"Inconnu", "Présumé", "Confirmé", NULL}; CreatePersonDialogState *state = NULL; - GtkWidget *box = NULL, *grid = NULL, *actions = NULL, *cancel = NULL, *create = NULL; - GtkStringList *labels = NULL; - if (parent == NULL) return FALSE; + GtkWidget *box = NULL, *grid = NULL, *actions = NULL, *cancel = NULL; + GtkWidget *roles_box = NULL, *evidence_box = NULL, *summary = NULL; + GtkStringList *labels = NULL, *type_labels = NULL; + if (parent == NULL || investigation_root_path == NULL || + task_manager == NULL) return FALSE; state = g_new0(CreatePersonDialogState, 1); - state->callback = callback; state->user_data = data; + state->callback = callback; state->session_check = session_check; + state->user_data = data; state->user_data_destroy = data_destroy; state->evidence_identifiers = g_ptr_array_new_with_free_func(g_free); + state->selection_model = evidence_selection_model_new(records); + state->visible_records = + evidence_selection_model_list_visible(state->selection_model); + state->type_codes = + evidence_selection_model_list_type_codes(state->selection_model); + state->investigation_root_path = g_strdup(investigation_root_path); + state->task_manager = task_manager; + state->lifecycle = person_dialog_lifecycle_new(); state->window = GTK_WINDOW(gtk_window_new()); + gtk_window_set_application(state->window, + gtk_window_get_application(parent)); gtk_window_set_title(state->window, "Ajouter une personne"); gtk_window_set_transient_for(state->window, parent); gtk_window_set_modal(state->window, TRUE); @@ -154,10 +519,11 @@ gboolean create_person_dialog_present(GtkWindow *parent, gtk_drop_down_set_selected(state->status, 1); state->confidence = GTK_SPIN_BUTTON(gtk_spin_button_new_with_range(0, 100, 5)); gtk_spin_button_set_value(state->confidence, 30); - labels = gtk_string_list_new(NULL); gtk_string_list_append(labels, "Aucune preuve associée"); - for (guint i = 0; records != NULL && i < records->len; i++) + labels = gtk_string_list_new(NULL); + gtk_string_list_append(labels, "Aucune preuve associée"); + for (guint i = 0; i < state->visible_records->len; i++) { - EvidenceRecord *record = g_ptr_array_index((GPtrArray *) records, i); + EvidenceRecord *record = g_ptr_array_index(state->visible_records, i); const char *id = evidence_record_get_identifier(record); const char *name = evidence_record_get_original_name(record); if (id == NULL || name == NULL) continue; @@ -166,6 +532,22 @@ gboolean create_person_dialog_present(GtkWindow *parent, } state->evidence = GTK_DROP_DOWN(gtk_drop_down_new(G_LIST_MODEL(labels), NULL)); labels = NULL; + state->search = GTK_ENTRY(gtk_entry_new()); + gtk_entry_set_placeholder_text(state->search, + "Rechercher par nom, description ou type"); + type_labels = gtk_string_list_new(NULL); + gtk_string_list_append(type_labels, "Tous les types"); + for (guint i = 0; i < state->type_codes->len; i++) + gtk_string_list_append(type_labels, + g_ptr_array_index(state->type_codes, i)); + state->type_filter = GTK_DROP_DOWN( + gtk_drop_down_new(G_LIST_MODEL(type_labels), NULL)); + /* + * gtk_drop_down_new() prend la propriété complète du modèle. Une + * libération ici laisserait les vues internes du GtkDropDown avec un + * GListModel détruit et ferait échouer leur nettoyage à la fermeture. + */ + type_labels = NULL; state->notes = GTK_TEXT_VIEW(gtk_text_view_new()); gtk_text_view_set_wrap_mode(state->notes, GTK_WRAP_WORD_CHAR); gtk_widget_set_size_request(GTK_WIDGET(state->notes), -1, 90); @@ -174,18 +556,80 @@ gboolean create_person_dialog_present(GtkWindow *parent, create_person_dialog_add_row(GTK_GRID(grid), 2, "Pseudonyme", GTK_WIDGET(state->pseudonym)); create_person_dialog_add_row(GTK_GRID(grid), 3, "Identification", GTK_WIDGET(state->status)); create_person_dialog_add_row(GTK_GRID(grid), 4, "Confiance (%)", GTK_WIDGET(state->confidence)); - create_person_dialog_add_row(GTK_GRID(grid), 5, "Preuve associée", GTK_WIDGET(state->evidence)); - create_person_dialog_add_row(GTK_GRID(grid), 6, "Notes factuelles", GTK_WIDGET(state->notes)); + create_person_dialog_add_row(GTK_GRID(grid), 5, "Notes factuelles", GTK_WIDGET(state->notes)); + state->stack = GTK_STACK(gtk_stack_new()); + gtk_stack_set_transition_type(state->stack, + GTK_STACK_TRANSITION_TYPE_SLIDE_LEFT_RIGHT); + gtk_stack_add_titled(state->stack, grid, "person", "1 — Personne"); + roles_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + static const char *const role_codes[] = { + "alleged_author", "presented_identity", + "potentially_impersonated_identity", "victim", "witness", + "declared_bank_holder", "intermediary", "mentioned_person", "other"}; + for (guint i = 0; i < G_N_ELEMENTS(role_codes); i++) { + state->roles[i] = GTK_CHECK_BUTTON(gtk_check_button_new_with_label( + person_role_assignment_role_label(role_codes[i]))); + gtk_box_append(GTK_BOX(roles_box), GTK_WIDGET(state->roles[i])); + } + gtk_stack_add_titled(state->stack, roles_box, "roles", "2 — Rôles"); + evidence_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_box_append(GTK_BOX(evidence_box), gtk_label_new( + "Sélectionnez une preuve existante (aucun import dans cette version).")); + gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->search)); + gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->type_filter)); + gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->evidence)); + state->metadata = GTK_LABEL(gtk_label_new("")); + gtk_label_set_xalign(state->metadata, 0.0f); + gtk_label_set_selectable(state->metadata, TRUE); + state->preview_status = GTK_LABEL(gtk_label_new( + "Aucune preuve sélectionnée.")); + state->preview_picture = GTK_PICTURE(gtk_picture_new()); + gtk_picture_set_can_shrink(state->preview_picture, TRUE); + gtk_picture_set_content_fit(state->preview_picture, + GTK_CONTENT_FIT_CONTAIN); + gtk_widget_set_size_request(GTK_WIDGET(state->preview_picture), 480, 260); + gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->metadata)); + gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->preview_status)); + gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->preview_picture)); + gtk_stack_add_titled(state->stack, evidence_box, "evidence", "3 — Preuve"); + state->summary = GTK_LABEL(gtk_label_new("")); + summary = GTK_WIDGET(state->summary); + gtk_label_set_wrap(state->summary, TRUE); + gtk_label_set_xalign(state->summary, 0.0f); + gtk_label_set_selectable(state->summary, TRUE); + gtk_stack_add_titled(state->stack, summary, "summary", "4 — Confirmation"); state->error = GTK_LABEL(gtk_label_new(NULL)); gtk_widget_set_visible(GTK_WIDGET(state->error), FALSE); actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_widget_set_halign(actions, GTK_ALIGN_END); - cancel = gtk_button_new_with_label("Annuler"); create = gtk_button_new_with_label("Ajouter au graphe"); - gtk_widget_add_css_class(create, "suggested-action"); - gtk_box_append(GTK_BOX(actions), cancel); gtk_box_append(GTK_BOX(actions), create); - gtk_box_append(GTK_BOX(box), grid); gtk_box_append(GTK_BOX(box), GTK_WIDGET(state->error)); + cancel = gtk_button_new_with_label("Annuler"); + state->previous = GTK_BUTTON(gtk_button_new_with_label("Précédent")); + state->next = GTK_BUTTON(gtk_button_new_with_label("Suivant")); + state->create = GTK_BUTTON(gtk_button_new_with_label("Créer la personne")); + gtk_widget_add_css_class(GTK_WIDGET(state->create), "suggested-action"); + gtk_box_append(GTK_BOX(actions), cancel); + gtk_box_append(GTK_BOX(actions), GTK_WIDGET(state->previous)); + gtk_box_append(GTK_BOX(actions), GTK_WIDGET(state->next)); + gtk_box_append(GTK_BOX(actions), GTK_WIDGET(state->create)); + state->progress = GTK_LABEL(gtk_label_new(NULL)); + gtk_label_set_xalign(state->progress, 0.0f); + gtk_box_append(GTK_BOX(box), GTK_WIDGET(state->progress)); + gtk_box_append(GTK_BOX(box), GTK_WIDGET(state->stack)); + gtk_box_append(GTK_BOX(box), GTK_WIDGET(state->error)); gtk_box_append(GTK_BOX(box), actions); gtk_window_set_child(state->window, box); g_signal_connect(state->window, "close-request", G_CALLBACK(create_person_dialog_on_close), state); g_signal_connect(cancel, "clicked", G_CALLBACK(create_person_dialog_on_cancel), state); - g_signal_connect(create, "clicked", G_CALLBACK(create_person_dialog_on_create), state); + g_signal_connect(state->previous, "clicked", + G_CALLBACK(create_person_dialog_on_previous), state); + g_signal_connect(state->next, "clicked", + G_CALLBACK(create_person_dialog_on_next), state); + g_signal_connect(state->create, "clicked", + G_CALLBACK(create_person_dialog_on_create), state); + g_signal_connect(state->search, "changed", + G_CALLBACK(create_person_dialog_on_search_changed), state); + g_signal_connect(state->type_filter, "notify::selected", + G_CALLBACK(create_person_dialog_on_type_changed), state); + g_signal_connect(state->evidence, "notify::selected", + G_CALLBACK(create_person_dialog_on_evidence_changed), state); + create_person_dialog_update_navigation(state); g_object_set_data_full(G_OBJECT(state->window), "person-dialog-state", state, create_person_dialog_state_free); gtk_window_present(state->window); return TRUE; @@ -195,7 +639,9 @@ void create_person_dialog_result_free(CreatePersonDialogResult *result) if (result == NULL) return; g_free(result->designation); g_free(result->declared_name); g_free(result->pseudonym); g_free(result->status); g_free(result->notes); - g_free(result->evidence_identifier); g_free(result); + g_free(result->evidence_identifier); + g_clear_pointer(&result->role_assignments, g_ptr_array_unref); + g_free(result); } const PersonEntityInput *create_person_dialog_result_get_input( const CreatePersonDialogResult *result) diff --git a/tests/test_create_person_dialog_gtk.c b/tests/test_create_person_dialog_gtk.c new file mode 100644 index 0000000..467087b --- /dev/null +++ b/tests/test_create_person_dialog_gtk.c @@ -0,0 +1,157 @@ +#include "views/create_person_dialog.h" +#include +#include + +typedef struct +{ + GtkApplication *application; + GtkWindow *main_window; + TaskManager *task_manager; + guint completion_count; + gboolean passed; +} TestContext; + +static GtkWidget *find_button(GtkWidget *widget, const char *label) +{ + if (GTK_IS_BUTTON(widget) && + g_strcmp0(gtk_button_get_label(GTK_BUTTON(widget)), label) == 0) + return widget; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkWidget *match = find_button(child, label); + if (match != NULL) return match; + } + return NULL; +} + +static GtkWidget *find_entry(GtkWidget *widget, const char *placeholder) +{ + if (GTK_IS_ENTRY(widget) && g_strcmp0( + gtk_entry_get_placeholder_text(GTK_ENTRY(widget)), + placeholder) == 0) + return widget; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkWidget *match = find_entry(child, placeholder); + if (match != NULL) return match; + } + return NULL; +} + +static GtkWidget *find_label_containing(GtkWidget *widget, const char *text) +{ + if (GTK_IS_LABEL(widget) && + strstr(gtk_label_get_text(GTK_LABEL(widget)), text) != NULL) + return widget; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkWidget *match = find_label_containing(child, text); + if (match != NULL) return match; + } + return NULL; +} + +static void completed(CreatePersonDialogResult *result, gpointer data) +{ + TestContext *context = data; + g_assert_null(result); + context->completion_count++; +} + +static gboolean check_after_cancel(gpointer data) +{ + TestContext *context = data; + GList *windows = gtk_application_get_windows(context->application); + g_assert_cmpuint(context->completion_count, ==, 1); + g_assert_true(gtk_widget_get_visible(GTK_WIDGET(context->main_window))); + g_assert_cmpuint(g_list_length(windows), ==, 1); + g_assert_true(windows->data == context->main_window); + context->passed = TRUE; + g_application_quit(G_APPLICATION(context->application)); + return G_SOURCE_REMOVE; +} + +static gboolean click_cancel(gpointer data) +{ + TestContext *context = data; + GList *windows = gtk_application_get_windows(context->application); + GtkWindow *dialog = windows != NULL && windows->data != context->main_window + ? windows->data : windows != NULL ? windows->next->data : NULL; + GtkWidget *button, *designation, *search; + g_assert_nonnull(dialog); + designation = find_entry(GTK_WIDGET(dialog), + "Personne présumée liée aux comptes"); + g_assert_nonnull(designation); + gtk_editable_set_text(GTK_EDITABLE(designation), "SPECIMEN"); + button = find_button(GTK_WIDGET(dialog), "Suivant"); + g_assert_nonnull(button); + g_signal_emit_by_name(button, "clicked"); + g_signal_emit_by_name(button, "clicked"); + search = find_entry(GTK_WIDGET(dialog), + "Rechercher par nom, description ou type"); + g_assert_nonnull(search); + gtk_editable_set_text(GTK_EDITABLE(search), "SPECIMEN"); + button = find_button(GTK_WIDGET(dialog), "Suivant"); + g_signal_emit_by_name(button, "clicked"); + g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), + "Désignation : SPECIMEN")); + g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), + "SPECIMEN-recherche.png")); + g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), + "Aucune écriture n’a encore été effectuée")); + button = find_button(GTK_WIDGET(dialog), "Annuler"); + g_assert_nonnull(button); + g_signal_emit_by_name(button, "clicked"); + g_idle_add(check_after_cancel, context); + return G_SOURCE_REMOVE; +} + +static void activate(GtkApplication *application, gpointer data) +{ + TestContext *context = data; + GError *error = NULL; + GPtrArray *records = g_ptr_array_new_with_free_func( + (GDestroyNotify) evidence_record_free); + EvidenceRecord *record = evidence_record_new( + "10000000-0000-4000-8000-000000000001", + "SPECIMEN-recherche.png", "specimen.png", + "01_Preuves_Originales/specimen.png", "screenshot", 12, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "2026-07-28T10:00:00Z", NULL, NULL, + "Description synthétique", EVIDENCE_INTEGRITY_STATUS_UNKNOWN, + &error); + g_assert_no_error(error); + evidence_record_set_display_metadata( + record, "Capture d’écran", NULL); + g_ptr_array_add(records, record); + context->main_window = GTK_WINDOW( + gtk_application_window_new(application)); + gtk_window_present(context->main_window); + g_assert_true(create_person_dialog_present(context->main_window, + records, "/tmp", context->task_manager, NULL, completed, + context, NULL)); + g_ptr_array_unref(records); + g_idle_add(click_cancel, context); +} + +int main(int argc, char **argv) +{ + TestContext context = {0}; + int status; + if (!gtk_init_check()) { + g_print("SKIP: aucun affichage GTK disponible.\n"); + return 0; + } + context.application = gtk_application_new( + "org.labfy.Investigation.DialogTest", + G_APPLICATION_NON_UNIQUE); + context.task_manager = task_manager_new(); + g_signal_connect(context.application, "activate", + G_CALLBACK(activate), &context); + status = g_application_run( + G_APPLICATION(context.application), argc, argv); + g_assert_true(context.passed); + task_manager_free(context.task_manager); + g_object_unref(context.application); + return status; +} diff --git a/tests/test_database.c b/tests/test_database.c index 7527f3f..9f3ff10 100644 --- a/tests/test_database.c +++ b/tests/test_database.c @@ -525,7 +525,8 @@ static void test_database_initialize_valid_database(void) "FROM investigation;" ); - assert(strcmp(schema_version, "13") == 0); + assert(strcmp(schema_version, "14") == 0); + test_database_assert_table_exists(database, "person_role_assignments"); test_database_assert_table_exists(database, "bank_account_entities"); test_database_assert_table_exists(database, "relation_types"); test_database_assert_table_exists(database, "graph_viewport"); @@ -992,7 +993,7 @@ static void test_database_migrate_v1_to_v2(void) assert( strcmp( schema_version, - "13" + "14" ) == 0 ); @@ -1412,7 +1413,17 @@ static void test_database_migrate_v12_to_v13_preserves_legacy_link(void) assert(database_initialize(path, "Migration V13 synthétique", directory)); assert(sqlite3_open(path, &sqlite_database) == SQLITE_OK); test_database_execute_sql(sqlite_database, + "INSERT INTO entites(id,type_id,valeur,label,confiance,created_at," + "updated_at,status) VALUES(" + "'20000000-0000-4000-8000-000000000014'," + "(SELECT id FROM types_entite WHERE code='person')," + "'Personne historique','Personne historique',50," + "'2026-07-28T08:00:00Z','2026-07-28T08:00:00Z','active');" + "INSERT INTO person_roles(entity_id,role,updated_at) VALUES(" + "'20000000-0000-4000-8000-000000000014','alleged_scammer'," + "'2026-07-28T08:00:00Z');" "DROP TABLE preuve_entite_sources;" + "DROP TABLE person_role_assignments;" "UPDATE metadata SET value='12' WHERE key='schema_version';" "INSERT INTO preuves(id,name,relative_path,type_id,size_bytes,sha256," "imported_at,updated_at,status,locked,original_name) VALUES(" @@ -1437,11 +1448,17 @@ static void test_database_migrate_v12_to_v13_preserves_legacy_link(void) legacy_sources = test_database_read_single_text(sqlite_database, "SELECT COUNT(*) FROM preuve_entite_sources " "WHERE source_kind='legacy_manual';"); - assert(strcmp(version, "13") == 0); + assert(strcmp(version, "14") == 0); assert(strcmp(legacy_sources, "1") == 0); + char *legacy_role = test_database_read_single_text(sqlite_database, + "SELECT role_code || ':' || provenance_kind " + "FROM person_role_assignments WHERE entity_id=" + "'20000000-0000-4000-8000-000000000014';"); + assert(strcmp(legacy_role, "alleged_scammer:legacy_manual") == 0); assert(sqlite3_close(sqlite_database) == SQLITE_OK); assert(g_remove(path) == 0 && g_rmdir(directory) == 0); g_free(legacy_sources); + g_free(legacy_role); g_free(version); g_free(path); g_free(directory); diff --git a/tests/test_evidence_preview.c b/tests/test_evidence_preview.c new file mode 100644 index 0000000..c4ff8d7 --- /dev/null +++ b/tests/test_evidence_preview.c @@ -0,0 +1,122 @@ +#include "core/evidence_preview.h" +#include "core/file_hash.h" +#include +#include +#include +#include "core/evidence_preview_task.h" +#include + +static void test_preview_png_and_guards(void) +{ + GError *error = NULL; + char *root = g_dir_make_tmp("labfy-preview-test-XXXXXX", &error); + char *path = g_build_filename(root, "SPECIMEN.png", NULL); + cairo_surface_t *fixture = cairo_image_surface_create( + CAIRO_FORMAT_RGB24, 32, 20); + cairo_t *cr = cairo_create(fixture); + char *sha256 = NULL; + guint64 size_bytes = 0; + EvidencePreviewRequest *request; + EvidencePreviewResult *result; + g_assert_nonnull(root); + cairo_set_source_rgb(cr, 0.2, 0.4, 0.6); + cairo_paint(cr); + g_assert_cmpint(cairo_surface_write_to_png(fixture, path), ==, + CAIRO_STATUS_SUCCESS); + g_assert_true(file_hash_compute_sha256( + path, NULL, &sha256, &size_bytes, &error)); + request = evidence_preview_request_new(root, + "10000000-0000-4000-8000-000000000109", "SPECIMEN.png", + sha256, "image/png", 7); + result = evidence_preview_load(request, NULL, &error); + g_assert_no_error(error); + g_assert_nonnull(result); + g_assert_true(evidence_preview_result_matches(result, + "10000000-0000-4000-8000-000000000109", 7)); + g_assert_false(evidence_preview_result_matches(result, + "10000000-0000-4000-8000-000000000109", 8)); + evidence_preview_result_free(result); + g_free(request->expected_sha256); + request->expected_sha256 = g_strdup( + "0000000000000000000000000000000000000000000000000000000000000000"); + result = evidence_preview_load(request, NULL, &error); + g_assert_null(result); + g_assert_error(error, g_quark_from_static_string( + "evidence-preview-error"), 2); + g_clear_error(&error); + evidence_preview_request_free(request); + cairo_destroy(cr); + cairo_surface_destroy(fixture); + g_assert_cmpint(g_remove(path), ==, 0); + g_assert_cmpint(g_rmdir(root), ==, 0); + g_free(sha256); g_free(path); g_free(root); +} + +static void write_jpeg(const char *path) +{ + struct jpeg_compress_struct compressor; + struct jpeg_error_mgr errors; + FILE *output = fopen(path, "wb"); + JSAMPLE row[3 * 32]; + g_assert_nonnull(output); + memset(row, 0x80, sizeof(row)); + compressor.err = jpeg_std_error(&errors); + jpeg_create_compress(&compressor); + jpeg_stdio_dest(&compressor, output); + compressor.image_width = 32; compressor.image_height = 20; + compressor.input_components = 3; compressor.in_color_space = JCS_RGB; + jpeg_set_defaults(&compressor); jpeg_start_compress(&compressor, TRUE); + while (compressor.next_scanline < compressor.image_height) { + JSAMPROW rows[] = {row}; + jpeg_write_scanlines(&compressor, rows, 1); + } + jpeg_finish_compress(&compressor); jpeg_destroy_compress(&compressor); + fclose(output); +} + +typedef struct { GMainLoop *loop; gboolean completed; } AsyncState; +static void preview_done(BackgroundTask *task, gpointer data) +{ + AsyncState *state = data; + state->completed = background_task_get_state(task) == + BACKGROUND_TASK_STATE_COMPLETED; + g_main_loop_quit(state->loop); +} +static void test_preview_jpeg_async(void) +{ + GError *error = NULL; + char *root = g_dir_make_tmp("labfy-preview-jpeg-XXXXXX", &error); + char *path = g_build_filename(root, "SPECIMEN.jpg", NULL); + char *sha = NULL; guint64 size = 0; + TaskManager *manager = task_manager_new(); + AsyncState state = {.loop = g_main_loop_new(NULL, FALSE)}; + write_jpeg(path); + g_assert_true(file_hash_compute_sha256(path, NULL, &sha, &size, &error)); + EvidencePreviewRequest *request = evidence_preview_request_new(root, + "10000000-0000-4000-8000-000000000110", "SPECIMEN.jpg", + sha, "image/jpeg", 9); + BackgroundTask *task = evidence_preview_task_start(manager, request, + preview_done, &state, NULL, &error); + g_assert_nonnull(task); + g_main_loop_run(state.loop); + g_assert_true(state.completed); + EvidencePreviewResult *result = background_task_get_result(task); + g_assert_nonnull(result); + g_assert_cmpint(result->width, ==, 32); + g_assert_cmpint(result->height, ==, 20); + background_task_unref(task); + evidence_preview_request_free(request); + task_manager_free(manager); g_main_loop_unref(state.loop); + g_assert_cmpint(g_remove(path), ==, 0); g_assert_cmpint(g_rmdir(root), ==, 0); + g_free(sha); g_free(path); g_free(root); +} + +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/evidence-preview/png-and-guards", + test_preview_png_and_guards); + g_test_add_func("/evidence-preview/jpeg-async", + test_preview_jpeg_async); + return g_test_run(); +} diff --git a/tests/test_evidence_selection_model.c b/tests/test_evidence_selection_model.c new file mode 100644 index 0000000..fe89b0b --- /dev/null +++ b/tests/test_evidence_selection_model.c @@ -0,0 +1,109 @@ +#include "models/evidence_selection_model.h" +#include + +static EvidenceRecord *record(const char *id, const char *name, + const char *type, const char *label, const char *description) +{ + GError *error = NULL; + EvidenceRecord *r = evidence_record_new(id, name, "synthetic.bin", + "01_Preuves_Originales/synthetic.bin", type, 12, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "2026-07-28T10:00:00Z", NULL, NULL, description, + EVIDENCE_INTEGRITY_STATUS_UNKNOWN, &error); + g_assert_no_error(error); + evidence_record_set_display_metadata(r, label, "image/png"); + return r; +} +static void test_search_filter_selection(void) +{ + GPtrArray *records = g_ptr_array_new_with_free_func( + (GDestroyNotify) evidence_record_free); + g_ptr_array_add(records, record( + "10000000-0000-4000-8000-000000000001", "SPECIMEN Alpha.PNG", + "screenshot", "Capture d'écran", "Écran de démonstration")); + g_ptr_array_add(records, record( + "10000000-0000-4000-8000-000000000002", "specimen-beta.jpg", + "photo", "Photographie", "Portrait entièrement synthétique")); + EvidenceSelectionModel *model = evidence_selection_model_new(records); + GPtrArray *visible = evidence_selection_model_list_visible(model); + g_assert_cmpuint(visible->len, ==, 2); g_ptr_array_unref(visible); + g_assert_true(evidence_selection_model_select(model, + "10000000-0000-4000-8000-000000000001")); + evidence_selection_model_set_query(model, "ÉCRAN"); + visible = evidence_selection_model_list_visible(model); + g_assert_cmpuint(visible->len, ==, 1); g_ptr_array_unref(visible); + g_assert_nonnull(evidence_selection_model_get_selected(model)); + evidence_selection_model_set_type(model, "photo"); + g_assert_null(evidence_selection_model_get_selected(model)); + visible = evidence_selection_model_list_visible(model); + g_assert_cmpuint(visible->len, ==, 0); g_ptr_array_unref(visible); + evidence_selection_model_set_query(model, ""); + visible = evidence_selection_model_list_visible(model); + g_assert_cmpuint(visible->len, ==, 1); g_ptr_array_unref(visible); + evidence_selection_model_set_type(model, NULL); + evidence_selection_model_set_query(model, "capture"); + visible = evidence_selection_model_list_visible(model); + g_assert_cmpuint(visible->len, ==, 1); g_ptr_array_unref(visible); + evidence_selection_model_free(model); + g_ptr_array_unref(records); +} + +static void test_owned_records_survive_source(void) +{ + GPtrArray *records = g_ptr_array_new_with_free_func( + (GDestroyNotify) evidence_record_free); + g_ptr_array_add(records, record( + "10000000-0000-4000-8000-000000000003", + "SPECIMEN durée de vie.png", "screenshot", + "Capture d’écran", NULL)); + EvidenceSelectionModel *model = evidence_selection_model_new(records); + g_ptr_array_unref(records); + evidence_selection_model_set_query(model, "d"); + GPtrArray *visible = evidence_selection_model_list_visible(model); + g_assert_cmpuint(visible->len, ==, 1); + g_ptr_array_unref(visible); + evidence_selection_model_free(model); +} + +static void test_null_utf8_and_successive_queries(void) +{ + GPtrArray *records = g_ptr_array_new_with_free_func( + (GDestroyNotify) evidence_record_free); + EvidenceRecord *without_optional = record( + "10000000-0000-4000-8000-000000000004", + "SPECIMEN été.png", "screenshot", "Capture française", NULL); + evidence_record_set_display_metadata( + without_optional, "Capture française", NULL); + g_ptr_array_add(records, without_optional); + EvidenceSelectionModel *model = evidence_selection_model_new(records); + const char *queries[] = { + NULL, "", "é", "ÉTÉ", "capture", "screenshot", + "image/png", "不存在", "a", "ab", "abc", NULL + }; + for (guint i = 0; i < G_N_ELEMENTS(queries); i++) { + evidence_selection_model_set_query(model, queries[i]); + GPtrArray *visible = evidence_selection_model_list_visible(model); + g_assert_nonnull(visible); + g_ptr_array_unref(visible); + } + char *long_query = g_strnfill(4096, 'x'); + evidence_selection_model_set_query(model, long_query); + GPtrArray *visible = evidence_selection_model_list_visible(model); + g_assert_cmpuint(visible->len, ==, 0); + g_ptr_array_unref(visible); + g_free(long_query); + evidence_selection_model_free(model); + g_ptr_array_unref(records); +} + +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/evidence-selection/search-filter", + test_search_filter_selection); + g_test_add_func("/evidence-selection/owned-records", + test_owned_records_survive_source); + g_test_add_func("/evidence-selection/null-utf8-successive", + test_null_utf8_and_successive_queries); + return g_test_run(); +} diff --git a/tests/test_person_confirmation_summary.c b/tests/test_person_confirmation_summary.c new file mode 100644 index 0000000..675074d --- /dev/null +++ b/tests/test_person_confirmation_summary.c @@ -0,0 +1,60 @@ +#include "core/person_confirmation_summary.h" +#include + +static EvidenceRecord *record(void) +{ + EvidenceRecord *evidence = evidence_record_new( + "10000000-0000-4000-8000-000000000010", + "SPECIMEN-confirmation.png", "specimen.png", + "01_Preuves_Originales/specimen.png", "screenshot", 42, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "2026-07-28T10:00:00Z", NULL, NULL, "Description SPECIMEN", + EVIDENCE_INTEGRITY_STATUS_VALID, NULL); + evidence_record_set_display_metadata( + evidence, "Capture d’écran", "image/png"); + return evidence; +} + +static void test_full_summary(void) +{ + EvidenceRecord *evidence = record(); + GPtrArray *roles = g_ptr_array_new(); + g_ptr_array_add(roles, "Victime"); + g_ptr_array_add(roles, "Témoin"); + char *summary = person_confirmation_summary_build( + "Personne SPECIMEN", "Nom SPECIMEN", "Pseudo", "Présumé", 55, + "Notes synthétiques", roles, evidence); + const char *values[] = { + "Personne SPECIMEN", "Nom SPECIMEN", "Pseudo", "Présumé", "55 %", + "Victime", "Témoin", "SPECIMEN-confirmation.png", + "Capture d’écran", "image/png", "42", "2026-07-28T10:00:00Z", + "aaaaaaaaaaaa…", "Valide", "Description SPECIMEN", + "Aucune écriture n’a encore été effectuée" + }; + for (guint i = 0; i < G_N_ELEMENTS(values); i++) + g_assert_nonnull(strstr(summary, values[i])); + g_free(summary); + g_ptr_array_unref(roles); + evidence_record_free(evidence); +} + +static void test_optional_summary(void) +{ + char *summary = person_confirmation_summary_build( + "SPECIMEN", "", NULL, "Inconnu", 0, "", NULL, NULL); + g_assert_nonnull(strstr(summary, "Nom déclaré : Non renseigné")); + g_assert_nonnull(strstr(summary, "Pseudonyme : Non renseigné")); + g_assert_nonnull(strstr(summary, "Notes factuelles : Aucune")); + g_assert_nonnull(strstr(summary, "Aucun rôle sélectionné")); + g_assert_nonnull(strstr(summary, + "Aucune preuve principale associée")); + g_free(summary); +} + +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/person-confirmation/full", test_full_summary); + g_test_add_func("/person-confirmation/optional", test_optional_summary); + return g_test_run(); +} diff --git a/tests/test_person_creation_guard.c b/tests/test_person_creation_guard.c new file mode 100644 index 0000000..d5b214e --- /dev/null +++ b/tests/test_person_creation_guard.c @@ -0,0 +1,24 @@ +#include "core/person_creation_guard.h" +#include +static void test_guard(void) +{ + PersonCreationGuard *guard = person_creation_guard_new(3, + "/tmp/synthetic-a", "/tmp/synthetic-a/Enquete.sqlite"); + g_assert_true(person_creation_guard_matches(guard, TRUE, 3, + "/tmp/synthetic-a", "/tmp/synthetic-a/Enquete.sqlite")); + g_assert_false(person_creation_guard_matches(guard, TRUE, 4, + "/tmp/synthetic-a", "/tmp/synthetic-a/Enquete.sqlite")); + g_assert_false(person_creation_guard_matches(guard, TRUE, 3, + "/tmp/synthetic-a", "/tmp/synthetic-b/Enquete.sqlite")); + g_assert_false(person_creation_guard_matches(guard, FALSE, 3, + "/tmp/synthetic-a", "/tmp/synthetic-a/Enquete.sqlite")); + g_assert_false(person_creation_guard_matches(guard, TRUE, 3, + NULL, NULL)); + person_creation_guard_free(guard); +} +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/person-creation-guard/session", test_guard); + return g_test_run(); +} diff --git a/tests/test_person_dialog_lifecycle.c b/tests/test_person_dialog_lifecycle.c new file mode 100644 index 0000000..dd51153 --- /dev/null +++ b/tests/test_person_dialog_lifecycle.c @@ -0,0 +1,117 @@ +#include "core/person_dialog_lifecycle.h" +#include + +typedef struct +{ + guint callback_count; + guint person_count; + guint role_count; + guint attachment_count; + guint manual_source_count; + guint graph_refresh_count; + guint quit_count; + gboolean main_window_active; +} CancellationEffects; + +static void cancel_once(PersonDialogLifecycle *lifecycle, + CancellationEffects *effects) +{ + if (!person_dialog_lifecycle_cancel(lifecycle)) return; + effects->callback_count++; +} + +static void assert_no_business_effect(const CancellationEffects *effects) +{ + g_assert_cmpuint(effects->person_count, ==, 0); + g_assert_cmpuint(effects->role_count, ==, 0); + g_assert_cmpuint(effects->attachment_count, ==, 0); + g_assert_cmpuint(effects->manual_source_count, ==, 0); + g_assert_cmpuint(effects->graph_refresh_count, ==, 0); + g_assert_cmpuint(effects->quit_count, ==, 0); + g_assert_true(effects->main_window_active); +} + +static void test_cancel_on_each_step(void) +{ + for (guint step = 0; step < 4; step++) { + PersonDialogLifecycle *lifecycle = person_dialog_lifecycle_new(); + CancellationEffects effects = {.main_window_active = TRUE}; + cancel_once(lifecycle, &effects); + g_assert_true(person_dialog_lifecycle_is_finished(lifecycle)); + g_assert_true(person_dialog_lifecycle_is_cancelled(lifecycle)); + g_assert_cmpuint(effects.callback_count, ==, 1); + assert_no_business_effect(&effects); + person_dialog_lifecycle_free(lifecycle); + } +} + +static void test_close_and_double_cancel_are_idempotent(void) +{ + PersonDialogLifecycle *lifecycle = person_dialog_lifecycle_new(); + CancellationEffects effects = {.main_window_active = TRUE}; + cancel_once(lifecycle, &effects); + cancel_once(lifecycle, &effects); + g_assert_false(person_dialog_lifecycle_cancel(lifecycle)); + g_assert_false(person_dialog_lifecycle_complete(lifecycle)); + g_assert_cmpuint(effects.callback_count, ==, 1); + assert_no_business_effect(&effects); + person_dialog_lifecycle_free(lifecycle); +} + +static void test_preview_is_cancelled_and_late_result_ignored(void) +{ + PersonDialogLifecycle *lifecycle = person_dialog_lifecycle_new(); + CancellationEffects effects = {.main_window_active = TRUE}; + guint64 generation = + person_dialog_lifecycle_begin_preview(lifecycle); + g_assert_true(person_dialog_lifecycle_has_preview(lifecycle)); + g_assert_true(person_dialog_lifecycle_accepts_preview( + lifecycle, generation)); + cancel_once(lifecycle, &effects); + g_assert_false(person_dialog_lifecycle_has_preview(lifecycle)); + g_assert_false(person_dialog_lifecycle_accepts_preview( + lifecycle, generation)); + g_assert_cmpuint(person_dialog_lifecycle_get_generation(lifecycle), + >, generation); + assert_no_business_effect(&effects); + person_dialog_lifecycle_free(lifecycle); +} + +static void test_complete_once(void) +{ + PersonDialogLifecycle *lifecycle = person_dialog_lifecycle_new(); + g_assert_true(person_dialog_lifecycle_complete(lifecycle)); + g_assert_true(person_dialog_lifecycle_is_finished(lifecycle)); + g_assert_false(person_dialog_lifecycle_is_cancelled(lifecycle)); + g_assert_false(person_dialog_lifecycle_complete(lifecycle)); + g_assert_false(person_dialog_lifecycle_cancel(lifecycle)); + person_dialog_lifecycle_free(lifecycle); +} + +static void test_null_and_full_destruction(void) +{ + g_assert_false(person_dialog_lifecycle_cancel(NULL)); + g_assert_false(person_dialog_lifecycle_complete(NULL)); + g_assert_false(person_dialog_lifecycle_accepts_preview(NULL, 1)); + person_dialog_lifecycle_free(NULL); + for (guint i = 0; i < 128; i++) { + PersonDialogLifecycle *lifecycle = person_dialog_lifecycle_new(); + person_dialog_lifecycle_begin_preview(lifecycle); + person_dialog_lifecycle_cancel(lifecycle); + person_dialog_lifecycle_free(lifecycle); + } +} + +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/person-dialog/cancel-each-step", + test_cancel_on_each_step); + g_test_add_func("/person-dialog/close-idempotent", + test_close_and_double_cancel_are_idempotent); + g_test_add_func("/person-dialog/late-preview", + test_preview_is_cancelled_and_late_result_ignored); + g_test_add_func("/person-dialog/complete-once", test_complete_once); + g_test_add_func("/person-dialog/destruction", test_null_and_full_destruction); + return g_test_run(); +} diff --git a/tests/test_person_entity_service.c b/tests/test_person_entity_service.c index 3b4fb63..470ac69 100644 --- a/tests/test_person_entity_service.c +++ b/tests/test_person_entity_service.c @@ -5,7 +5,9 @@ #include "core/person_entity_service.h" #include "database/database.h" #include "dao/entity_dao.h" +#include "dao/person_role_assignment_dao.h" #include "models/entity_record.h" +#include "database/statement.h" #include #include #include @@ -25,6 +27,15 @@ static void test_person_entity_create(void) .identification_status = "suspected", .notes = "Identité non confirmée.", .confidence = 30, .evidence_identifier = NULL}; + PersonRoleAssignmentInput victim = { + .role_code = "victim", .provenance_kind = "manual", + .has_confidence = TRUE, .confidence = 70}; + PersonRoleAssignmentInput witness = { + .role_code = "witness", .provenance_kind = "manual"}; + GPtrArray *roles = g_ptr_array_new(); + g_ptr_array_add(roles, &victim); + g_ptr_array_add(roles, &witness); + input.role_assignments = roles; directory = g_dir_make_tmp("labfy-person-service-test-XXXXXX", &error); assert(directory != NULL && error == NULL); path = g_build_filename(directory, "Enquete.sqlite", NULL); @@ -37,6 +48,13 @@ static void test_person_entity_create(void) assert(record != NULL && error == NULL); assert(strcmp(entity_record_get_type_identifier(record), "person") == 0); assert(entity_record_get_confidence(record) == 30); + PersonRoleAssignmentDao *role_dao = + person_role_assignment_dao_new(database, &error); + GPtrArray *saved_roles = person_role_assignment_dao_list_by_entity( + role_dao, identifier, &error); + assert(saved_roles != NULL && saved_roles->len == 2); + g_ptr_array_unref(saved_roles); + person_role_assignment_dao_free(role_dao); assert(entity_record_get_person_role(record) == PERSON_ROLE_UNCATEGORIZED); entity_record_free(record); record = NULL; assert(person_entity_service_update_role(database, identifier, @@ -63,10 +81,72 @@ static void test_person_entity_create(void) assert(strcmp(entity_record_get_label(record), "Vraie identité") == 0); entity_record_free(record); entity_dao_free(dao); database_close(database); assert(g_remove(path) == 0); assert(g_rmdir(directory) == 0); + g_ptr_array_unref(roles); g_free(identifier); g_free(path); g_free(directory); } + +static gint64 count_rows(Database *database, const char *sql) +{ + DatabaseStatement *statement = database_statement_prepare(database, sql); + int64_t count = -1; + assert(statement != NULL); + assert(database_statement_step(statement) == DATABASE_STATEMENT_STEP_ROW); + assert(database_statement_column_int64(statement, 0, &count)); + database_statement_finalize(statement); + return count; +} + +static void test_person_entity_rollbacks(void) +{ + GError *error = NULL; + char *directory = g_dir_make_tmp("labfy-person-rollback-XXXXXX", &error); + char *path = g_build_filename(directory, "Enquete.sqlite", NULL); + Database *database; + PersonRoleAssignmentInput first = { + .role_code="victim", .provenance_kind="manual"}; + PersonRoleAssignmentInput duplicate = { + .role_code="victim", .provenance_kind="manual"}; + GPtrArray *roles = g_ptr_array_new(); + PersonEntityInput input = { + .designation="Personne rollback SPECIMEN", + .identification_status="unknown", .confidence=20, + .role_assignments=roles}; + g_assert_true(database_initialize(path, "Rollback synthétique", directory)); + database = database_open(path); + g_assert_true(database_migrate_to_latest(database)); + g_ptr_array_add(roles, &first); g_ptr_array_add(roles, &duplicate); + g_assert_false(person_entity_service_create( + database, &input, NULL, &error)); + g_clear_error(&error); + g_assert_cmpint(count_rows(database, + "SELECT COUNT(*) FROM entites WHERE label='Personne rollback SPECIMEN';"), + ==, 0); + g_assert_cmpint(count_rows(database, + "SELECT COUNT(*) FROM person_role_assignments;"), ==, 0); + g_ptr_array_set_size(roles, 1); + input.evidence_identifier = + "10000000-0000-4000-8000-000000000999"; + first.evidence_identifier = NULL; + g_assert_false(person_entity_service_create( + database, &input, NULL, &error)); + g_clear_error(&error); + g_assert_cmpint(count_rows(database, + "SELECT COUNT(*) FROM entites WHERE label='Personne rollback SPECIMEN';"), + ==, 0); + g_assert_cmpint(count_rows(database, + "SELECT COUNT(*) FROM person_role_assignments;"), ==, 0); + g_assert_cmpint(count_rows(database, + "SELECT COUNT(*) FROM preuve_entites;"), ==, 0); + g_assert_cmpint(count_rows(database, + "SELECT COUNT(*) FROM preuve_entite_sources;"), ==, 0); + g_ptr_array_unref(roles); database_close(database); + g_assert_cmpint(g_remove(path), ==, 0); + g_assert_cmpint(g_rmdir(directory), ==, 0); + g_free(path); g_free(directory); +} int main(void) { test_person_entity_create(); + test_person_entity_rollbacks(); puts("PersonEntityService : tous les tests sont valides."); return 0; } diff --git a/tests/test_person_role_assignment_dao.c b/tests/test_person_role_assignment_dao.c new file mode 100644 index 0000000..25edd26 --- /dev/null +++ b/tests/test_person_role_assignment_dao.c @@ -0,0 +1,113 @@ +#include "dao/person_role_assignment_dao.h" +#include "dao/entity_dao.h" +#include "dao/evidence_dao.h" +#include "database/transaction.h" +#include +#include + +static EntityRecord *make_person(const char *id) +{ + GError *error = NULL; + EntityRecord *r = entity_record_new(id, "person", id, + "Personne SPECIMEN", NULL, 50, "2026-07-28T10:00:00Z", + "2026-07-28T10:00:00Z", ENTITY_STATUS_ACTIVE, &error); + g_assert_no_error(error); return r; +} +static EvidenceRecord *make_evidence(const char *id, const char *name) +{ + GError *error = NULL; + EvidenceRecord *r = evidence_record_new(id, name, name, + name, "document", 1, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "2026-07-28T10:00:00Z", NULL, NULL, NULL, + EVIDENCE_INTEGRITY_STATUS_UNKNOWN, &error); + g_assert_no_error(error); return r; +} +static void test_dao_roles(void) +{ + GError *error = NULL; + char *dir = g_dir_make_tmp("labfy-role-dao-XXXXXX", &error); + char *path = g_build_filename(dir, "Enquete.sqlite", NULL); + Database *db; EntityDao *entities; EvidenceDao *evidences; + PersonRoleAssignmentDao *roles; EntityRecord *person; + EvidenceRecord *evidence; + const char *person_id = "20000000-0000-4000-8000-000000000109"; + const char *evidence_id = "10000000-0000-4000-8000-000000000109"; + const char *evidence_id_2 = "10000000-0000-4000-8000-000000000110"; + g_assert_true(database_initialize(path, "Rôles synthétiques", dir)); + db = database_open(path); g_assert_true(database_migrate_to_latest(db)); + entities = entity_dao_new(db, &error); evidences = evidence_dao_new(db, &error); + roles = person_role_assignment_dao_new(db, &error); + person = make_person(person_id); g_assert_true(entity_dao_insert(entities, person, &error)); + evidence = make_evidence(evidence_id, "specimen.bin"); + g_assert_true(evidence_dao_insert(evidences, evidence, &error)); + EvidenceRecord *evidence_2 = make_evidence( + evidence_id_2, "specimen-2.bin"); + g_assert_true(evidence_dao_insert(evidences, evidence_2, &error)); + PersonRoleAssignmentInput without = { + .role_code="victim", .provenance_kind="manual", + .has_confidence=TRUE, .confidence=0}; + PersonRoleAssignmentInput with = { + .role_code="witness", .evidence_identifier=(char *) evidence_id, + .provenance_kind="manual", .has_confidence=TRUE, .confidence=100}; + g_assert_true(person_role_assignment_dao_insert( + roles, person_id, &without, &error)); + g_assert_true(person_role_assignment_dao_insert( + roles, person_id, &with, &error)); + PersonRoleAssignmentInput same_other_proof = with; + same_other_proof.evidence_identifier = (char *) evidence_id_2; + g_assert_true(person_role_assignment_dao_insert( + roles, person_id, &same_other_proof, &error)); + GPtrArray *saved = person_role_assignment_dao_list_by_entity( + roles, person_id, &error); + g_assert_cmpuint(saved->len, ==, 3); + g_ptr_array_unref(saved); + PersonRoleAssignmentInput invalid = { + .role_code="unknown-code", .provenance_kind="manual"}; + g_assert_false(person_role_assignment_dao_insert( + roles, person_id, &invalid, &error)); g_clear_error(&error); + invalid.role_code="victim"; invalid.has_confidence=TRUE; invalid.confidence=-1; + g_assert_false(person_role_assignment_dao_insert( + roles, person_id, &invalid, &error)); g_clear_error(&error); + invalid.confidence=101; + g_assert_false(person_role_assignment_dao_insert( + roles, person_id, &invalid, &error)); g_clear_error(&error); + with.evidence_identifier="10000000-0000-4000-8000-000000000999"; + g_assert_false(person_role_assignment_dao_insert( + roles, person_id, &with, &error)); g_clear_error(&error); + with.evidence_identifier=(char *) evidence_id; + g_assert_false(person_role_assignment_dao_insert(roles, + "20000000-0000-4000-8000-000000000999", &with, &error)); + g_clear_error(&error); + const char *rollback_person = + "20000000-0000-4000-8000-000000000110"; + EntityRecord *person_2 = make_person(rollback_person); + g_assert_true(entity_dao_insert(entities, person_2, &error)); + GPtrArray *batch = g_ptr_array_new(); + PersonRoleAssignmentInput batch_valid = { + .role_code="intermediary", .provenance_kind="manual"}; + PersonRoleAssignmentInput batch_invalid = { + .role_code="not-known", .provenance_kind="manual"}; + g_ptr_array_add(batch, &batch_valid); g_ptr_array_add(batch, &batch_invalid); + g_assert_true(database_transaction_begin(db)); + g_assert_false(person_role_assignment_dao_insert_all( + roles, rollback_person, batch, &error)); + g_clear_error(&error); + g_assert_true(database_transaction_rollback(db)); + saved = person_role_assignment_dao_list_by_entity( + roles, rollback_person, &error); + g_assert_cmpuint(saved->len, ==, 0); + g_ptr_array_unref(saved); g_ptr_array_unref(batch); + evidence_record_free(evidence_2); entity_record_free(person_2); + evidence_record_free(evidence); entity_record_free(person); + person_role_assignment_dao_free(roles); evidence_dao_free(evidences); + entity_dao_free(entities); database_close(db); + g_assert_cmpint(g_remove(path), ==, 0); g_assert_cmpint(g_rmdir(dir), ==, 0); + g_free(path); g_free(dir); +} +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/person-role-assignment-dao/direct", test_dao_roles); + return g_test_run(); +}