import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:bully/data/models/maintenance.dart'; import 'package:bully/data/models/session.dart'; import 'package:bully/data/models/shot.dart'; import 'package:bully/data/models/target_analysis.dart'; import 'package:bully/data/models/target_type.dart'; import 'package:bully/data/models/weapon.dart'; import 'package:bully/data/repositories/session_repository.dart'; import 'package:bully/services/backup_service.dart'; /// Dépôt en mémoire : évite d'ouvrir une vraie base SQLite dans les tests. class _FakeRepository extends SessionRepository { final List sessions; final List weapons; final List maintenance; final List> savedImages = []; _FakeRepository({ List? sessions, List? weapons, List? maintenance, }) : sessions = sessions ?? [], weapons = weapons ?? [], maintenance = maintenance ?? []; @override Future> getAllSessions({int? limit, int? offset}) async => sessions; @override Future> getWeapons() async => weapons; @override Future> getAllMaintenance() async => maintenance; @override Future saveSession(Session session) async { sessions.removeWhere((s) => s.id == session.id); sessions.add(session); } @override Future saveWeapon(Weapon weapon) async { weapons.removeWhere((w) => w.id == weapon.id); weapons.add(weapon); } @override Future saveMaintenanceEntry(MaintenanceEntry entry) async { maintenance.removeWhere((e) => e.id == entry.id); maintenance.add(entry); } @override Future saveImageBytes(List bytes, String extension) async { savedImages.add(bytes); return '/imported/image_${savedImages.length}$extension'; } } Session _session({String id = 's1', String weapon = 'Glock 17'}) { return Session( id: id, weapon: weapon, weaponId: 'w1', maxShotsPerTarget: 5, createdAt: DateTime(2026, 3, 14, 10, 30), notes: 'Entraînement', distance: 25, analyses: [ TargetAnalysis( id: '$id-a1', sessionId: id, targetType: TargetType.concentric, imagePath: '/photos/$id.jpg', totalScore: 18, groupingDiameter: 0.12, groupingCenterX: 0.5, groupingCenterY: 0.48, createdAt: DateTime(2026, 3, 14, 10, 35), targetCenterX: 0.5, targetCenterY: 0.5, targetRadius: 0.4, shots: [ Shot(id: '$id-t1', x: 0.5, y: 0.5, score: 10, analysisId: '$id-a1'), Shot(id: '$id-t2', x: 0.55, y: 0.52, score: 8, analysisId: '$id-a1'), ], ), ], ); } Weapon _weapon() => Weapon( id: 'w1', name: 'Glock 17', type: WeaponType.handgun, caliber: '9mm', magazineCount: 3, magazineCapacity: 17, createdAt: DateTime(2025, 1, 5), optic: 'Point rouge', customName: 'La bleue', ); MaintenanceEntry _maintenance() => MaintenanceEntry( id: 'm1', weaponId: 'w1', type: MaintenanceType.cleaning, description: 'Nettoyage complet', date: DateTime(2026, 2, 1), roundsSinceLastMaintenance: 500, ); /// Sérialise puis relit la sauvegarde comme le ferait un vrai fichier partagé. Future _roundTrip( Map json, BackupService service, ) async { final dir = await Directory.systemTemp.createTemp('impact_backup_test'); addTearDown(() => dir.delete(recursive: true)); final file = File('${dir.path}/backup.json'); await file.writeAsString(jsonEncode(json)); return service.readBackup(file); } void main() { group('export', () { test('la sauvegarde contient sessions, armurerie et stats', () async { final source = _FakeRepository( sessions: [_session()], weapons: [_weapon()], maintenance: [_maintenance()], ); final json = await BackupService(repository: source).buildBackupJson(); expect(json['format'], BackupService.formatId); expect(json['counts'], { 'sessions': 1, 'targets': 1, 'shots': 2, 'weapons': 1, 'maintenance': 1, }); // Les stats sont recalculées et écrites telles quelles dans le fichier. final stats = json['statistics'] as Map; expect(stats['total_shots'], 2); expect(stats['total_score'], 18); // L'entretien voyage avec son arme. final weapon = (json['weapons'] as List).single as Map; expect((weapon['maintenance'] as List), hasLength(1)); expect(json['orphan_maintenance'], isEmpty); }); test('sans photos, aucune image n\'est encodée', () async { final source = _FakeRepository(sessions: [_session()]); final json = await BackupService(repository: source).buildBackupJson(); final session = (json['sessions'] as List).single as Map; final analysis = (session['analyses'] as List).single as Map; expect(analysis.containsKey('image_base64'), isFalse); }); test('l\'entretien d\'une arme supprimée n\'est pas perdu', () async { final source = _FakeRepository(maintenance: [_maintenance()]); final json = await BackupService(repository: source).buildBackupJson(); expect(json['orphan_maintenance'], hasLength(1)); }); }); group('import', () { test('aller-retour complet : tout est restauré à l\'identique', () async { final source = _FakeRepository( sessions: [_session()], weapons: [_weapon()], maintenance: [_maintenance()], ); final json = await BackupService(repository: source).buildBackupJson(); final target = _FakeRepository(); final service = BackupService(repository: target); final preview = await _roundTrip(json, service); expect(preview.sessionCount, 1); expect(preview.shotCount, 2); expect(preview.weaponCount, 1); expect(preview.maintenanceCount, 1); expect(preview.hasImages, isFalse); final result = await service.applyBackup(preview); expect(result.errors, isEmpty); expect(result.sessions, 1); expect(result.weapons, 1); expect(result.maintenance, 1); final session = target.sessions.single; expect(session.id, 's1'); expect(session.weapon, 'Glock 17'); expect(session.distance, 25); expect(session.createdAt, DateTime(2026, 3, 14, 10, 30)); expect(session.totalShots, 2); expect(session.totalScore, 18); expect(session.analyses.single.targetRadius, 0.4); expect(session.analyses.single.shots.first.score, 10); expect(target.weapons.single.customName, 'La bleue'); expect(target.weapons.single.magazineCapacity, 17); expect(target.maintenance.single.roundsSinceLastMaintenance, 500); }); test('réimporter deux fois ne crée pas de doublon', () async { final source = _FakeRepository( sessions: [_session()], weapons: [_weapon()], ); final json = await BackupService(repository: source).buildBackupJson(); final target = _FakeRepository(); final service = BackupService(repository: target); await service.applyBackup(await _roundTrip(json, service)); await service.applyBackup(await _roundTrip(json, service)); expect(target.sessions, hasLength(1)); expect(target.weapons, hasLength(1)); }); test('les photos embarquées sont réécrites sur le disque', () async { final bytes = utf8.encode('fausse-image'); final json = { 'format': BackupService.formatId, 'version': 1, 'sessions': [ { 'id': 's1', 'weapon': 'Glock 17', 'max_shots_per_target': 5, 'created_at': '2026-03-14T10:30:00.000', 'distance': 25, 'analyses': [ { 'id': 'a1', 'session_id': 's1', 'target_type': 'concentric', 'image_path': '/ancien/chemin.jpg', 'total_score': 10, 'created_at': '2026-03-14T10:35:00.000', 'image_extension': '.jpg', 'image_base64': base64Encode(bytes), 'shots': [ {'id': 't1', 'x': 0.5, 'y': 0.5, 'score': 10, 'analysis_id': 'a1'}, ], }, ], }, ], }; final target = _FakeRepository(); final service = BackupService(repository: target); final preview = await _roundTrip(json, service); expect(preview.hasImages, isTrue); final result = await service.applyBackup(preview); expect(result.images, 1); expect(target.savedImages.single, bytes); expect(target.sessions.single.analyses.single.imagePath, '/imported/image_1.jpg'); }); test('un nombre entier là où un décimal est attendu ne casse rien', () async { // JSON ne distingue pas 1 de 1.0 : un fichier édité à la main peut // livrer des entiers là où les modèles attendent des doubles. final json = { 'format': BackupService.formatId, 'version': 1, 'sessions': [ { 'id': 's1', 'weapon': 'Glock 17', 'max_shots_per_target': 5, 'created_at': '2026-03-14T10:30:00.000', 'analyses': [ { 'id': 'a1', 'session_id': 's1', 'target_type': 'concentric', 'image_path': '/photo.jpg', 'total_score': 10, 'created_at': '2026-03-14T10:35:00.000', 'target_radius': 1, 'grouping_diameter': 0, 'shots': const [], }, ], }, ], }; final target = _FakeRepository(); final service = BackupService(repository: target); final result = await service.applyBackup(await _roundTrip(json, service)); expect(result.errors, isEmpty); expect(target.sessions.single.analyses.single.targetRadius, 1.0); expect(target.sessions.single.analyses.single.groupingDiameter, 0.0); }); test('un fichier étranger est refusé avec un message clair', () async { final service = BackupService(repository: _FakeRepository()); final dir = await Directory.systemTemp.createTemp('impact_backup_test'); addTearDown(() => dir.delete(recursive: true)); final notJson = File('${dir.path}/photo.jpg'); await notJson.writeAsString('pas du json du tout'); expect( () => service.readBackup(notJson), throwsA(isA()), ); final otherJson = File('${dir.path}/autre.json'); await otherJson.writeAsString('{"hello": "world"}'); expect( () => service.readBackup(otherJson), throwsA(isA()), ); }); test('une sauvegarde plus récente que l\'app est refusée', () async { final service = BackupService(repository: _FakeRepository()); final dir = await Directory.systemTemp.createTemp('impact_backup_test'); addTearDown(() => dir.delete(recursive: true)); final file = File('${dir.path}/futur.json'); await file.writeAsString(jsonEncode({ 'format': BackupService.formatId, 'version': BackupService.formatVersion + 1, })); expect( () => service.readBackup(file), throwsA(isA()), ); }); test('une session corrompue est ignorée sans bloquer les autres', () async { final valid = await BackupService( repository: _FakeRepository(sessions: [_session()]), ).buildBackupJson(); // On injecte une session sans date : elle doit être la seule écartée. (valid['sessions'] as List).add({ 'id': 'corrompue', 'weapon': 'X', 'max_shots_per_target': 5, 'analyses': const [], }); final target = _FakeRepository(); final service = BackupService(repository: target); final result = await service.applyBackup(await _roundTrip(valid, service)); expect(result.sessions, 1); expect(result.errors, hasLength(1)); expect(target.sessions.single.id, 's1'); }); }); }