Le commit 543f54dc livrait l'ecran Statistiques qui importe
services/backup_service.dart, mais le service et son test n'avaient
jamais ete ajoutes a l'index : la branche poussee ne compilait pas.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
12 KiB
Dart
373 lines
12 KiB
Dart
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<Session> sessions;
|
|
final List<Weapon> weapons;
|
|
final List<MaintenanceEntry> maintenance;
|
|
final List<List<int>> savedImages = [];
|
|
|
|
_FakeRepository({
|
|
List<Session>? sessions,
|
|
List<Weapon>? weapons,
|
|
List<MaintenanceEntry>? maintenance,
|
|
}) : sessions = sessions ?? [],
|
|
weapons = weapons ?? [],
|
|
maintenance = maintenance ?? [];
|
|
|
|
@override
|
|
Future<List<Session>> getAllSessions({int? limit, int? offset}) async =>
|
|
sessions;
|
|
|
|
@override
|
|
Future<List<Weapon>> getWeapons() async => weapons;
|
|
|
|
@override
|
|
Future<List<MaintenanceEntry>> getAllMaintenance() async => maintenance;
|
|
|
|
@override
|
|
Future<void> saveSession(Session session) async {
|
|
sessions.removeWhere((s) => s.id == session.id);
|
|
sessions.add(session);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveWeapon(Weapon weapon) async {
|
|
weapons.removeWhere((w) => w.id == weapon.id);
|
|
weapons.add(weapon);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveMaintenanceEntry(MaintenanceEntry entry) async {
|
|
maintenance.removeWhere((e) => e.id == entry.id);
|
|
maintenance.add(entry);
|
|
}
|
|
|
|
@override
|
|
Future<String> saveImageBytes(List<int> 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<BackupPreview> _roundTrip(
|
|
Map<String, dynamic> 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<String, dynamic>;
|
|
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<String, dynamic>;
|
|
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<String, dynamic>;
|
|
final analysis = (session['analyses'] as List).single as Map<String, dynamic>;
|
|
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<BackupFormatException>()),
|
|
);
|
|
|
|
final otherJson = File('${dir.path}/autre.json');
|
|
await otherJson.writeAsString('{"hello": "world"}');
|
|
expect(
|
|
() => service.readBackup(otherJson),
|
|
throwsA(isA<BackupFormatException>()),
|
|
);
|
|
});
|
|
|
|
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<BackupFormatException>()),
|
|
);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
}
|