fix: ajoute backup_service oublie lors du commit precedent
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
543f54dc4f
commit
0374a7611e
@@ -0,0 +1,426 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../data/models/maintenance.dart';
|
||||||
|
import '../data/models/session.dart';
|
||||||
|
import '../data/models/shot.dart';
|
||||||
|
import '../data/models/target_analysis.dart';
|
||||||
|
import '../data/models/weapon.dart';
|
||||||
|
import '../data/repositories/session_repository.dart';
|
||||||
|
import 'statistics_service.dart';
|
||||||
|
|
||||||
|
/// Résumé d'un fichier de sauvegarde, affiché avant de confirmer un import.
|
||||||
|
class BackupPreview {
|
||||||
|
final int sessionCount;
|
||||||
|
final int targetCount;
|
||||||
|
final int shotCount;
|
||||||
|
final int weaponCount;
|
||||||
|
final int maintenanceCount;
|
||||||
|
final bool hasImages;
|
||||||
|
final DateTime? exportedAt;
|
||||||
|
final Map<String, dynamic> raw;
|
||||||
|
|
||||||
|
const BackupPreview({
|
||||||
|
required this.sessionCount,
|
||||||
|
required this.targetCount,
|
||||||
|
required this.shotCount,
|
||||||
|
required this.weaponCount,
|
||||||
|
required this.maintenanceCount,
|
||||||
|
required this.hasImages,
|
||||||
|
required this.exportedAt,
|
||||||
|
required this.raw,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Résultat d'un import : ce qui a réellement été écrit en base.
|
||||||
|
class ImportResult {
|
||||||
|
final int sessions;
|
||||||
|
final int weapons;
|
||||||
|
final int maintenance;
|
||||||
|
final int images;
|
||||||
|
final List<String> errors;
|
||||||
|
|
||||||
|
const ImportResult({
|
||||||
|
required this.sessions,
|
||||||
|
required this.weapons,
|
||||||
|
required this.maintenance,
|
||||||
|
required this.images,
|
||||||
|
required this.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erreur « propre » d'import : message directement affichable à l'utilisateur.
|
||||||
|
class BackupFormatException implements Exception {
|
||||||
|
final String message;
|
||||||
|
BackupFormatException(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import / export de l'intégralité des données de l'app dans un fichier JSON :
|
||||||
|
/// sessions (avec cibles, impacts et calibration), armurerie (armes +
|
||||||
|
/// maintenance) et un instantané des statistiques calculées.
|
||||||
|
///
|
||||||
|
/// Les statistiques ne sont pas réimportées : elles sont recalculées à partir
|
||||||
|
/// des sessions. Elles figurent dans le fichier pour pouvoir être lues telles
|
||||||
|
/// quelles (analyse externe, IA, tableur).
|
||||||
|
class BackupService {
|
||||||
|
static const String formatId = 'impact.backup';
|
||||||
|
static const int formatVersion = 1;
|
||||||
|
|
||||||
|
final SessionRepository _repository;
|
||||||
|
final StatisticsService _statisticsService;
|
||||||
|
|
||||||
|
BackupService({
|
||||||
|
required SessionRepository repository,
|
||||||
|
StatisticsService? statisticsService,
|
||||||
|
}) : _repository = repository,
|
||||||
|
_statisticsService = statisticsService ?? StatisticsService();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- EXPORT
|
||||||
|
|
||||||
|
/// Construit le fichier de sauvegarde et renvoie le fichier écrit dans le
|
||||||
|
/// dossier temporaire, prêt à être passé à la feuille de partage du système.
|
||||||
|
Future<File> exportToFile({bool includeImages = false}) async {
|
||||||
|
final json = await buildBackupJson(includeImages: includeImages);
|
||||||
|
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final file = File(p.join(tempDir.path, _buildFileName()));
|
||||||
|
await file.writeAsString(
|
||||||
|
const JsonEncoder.withIndent(' ').convert(json),
|
||||||
|
flush: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildFileName() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
String two(int v) => v.toString().padLeft(2, '0');
|
||||||
|
return 'impact_sauvegarde_${now.year}-${two(now.month)}-${two(now.day)}'
|
||||||
|
'_${two(now.hour)}${two(now.minute)}.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<Map<String, dynamic>> buildBackupJson({
|
||||||
|
bool includeImages = false,
|
||||||
|
}) async {
|
||||||
|
final sessions = await _repository.getAllSessions();
|
||||||
|
final weapons = await _repository.getWeapons();
|
||||||
|
final maintenance = await _repository.getAllMaintenance();
|
||||||
|
|
||||||
|
// Maintenance regroupée par arme : une arme reste autonome dans le fichier.
|
||||||
|
final maintenanceByWeapon = <String, List<MaintenanceEntry>>{};
|
||||||
|
for (final entry in maintenance) {
|
||||||
|
(maintenanceByWeapon[entry.weaponId] ??= []).add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalTargets = 0;
|
||||||
|
var totalShots = 0;
|
||||||
|
final sessionsJson = <Map<String, dynamic>>[];
|
||||||
|
for (final session in sessions) {
|
||||||
|
final analysesJson = <Map<String, dynamic>>[];
|
||||||
|
for (final analysis in session.analyses) {
|
||||||
|
totalTargets++;
|
||||||
|
totalShots += analysis.shots.length;
|
||||||
|
analysesJson.add(await _analysisToJson(analysis, includeImages));
|
||||||
|
}
|
||||||
|
sessionsJson.add({
|
||||||
|
...session.toMap(),
|
||||||
|
'analyses': analysesJson,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'format': formatId,
|
||||||
|
'version': formatVersion,
|
||||||
|
'app': 'bully',
|
||||||
|
'exported_at': DateTime.now().toIso8601String(),
|
||||||
|
'includes_images': includeImages,
|
||||||
|
'counts': {
|
||||||
|
'sessions': sessions.length,
|
||||||
|
'targets': totalTargets,
|
||||||
|
'shots': totalShots,
|
||||||
|
'weapons': weapons.length,
|
||||||
|
'maintenance': maintenance.length,
|
||||||
|
},
|
||||||
|
'statistics': _statisticsToJson(sessions),
|
||||||
|
'weapons': weapons
|
||||||
|
.map((w) => {
|
||||||
|
...w.toMap(),
|
||||||
|
'maintenance': (maintenanceByWeapon[w.id] ?? [])
|
||||||
|
.map((e) => e.toMap())
|
||||||
|
.toList(),
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
'sessions': sessionsJson,
|
||||||
|
// Maintenance orpheline (arme supprimée) : conservée pour ne rien perdre.
|
||||||
|
'orphan_maintenance': maintenance
|
||||||
|
.where((e) => !weapons.any((w) => w.id == e.weaponId))
|
||||||
|
.map((e) => e.toMap())
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _analysisToJson(
|
||||||
|
TargetAnalysis analysis,
|
||||||
|
bool includeImages,
|
||||||
|
) async {
|
||||||
|
final json = <String, dynamic>{
|
||||||
|
...analysis.toMap(),
|
||||||
|
'shots': analysis.shots.map((s) => s.toMap()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeImages) {
|
||||||
|
try {
|
||||||
|
final file = File(analysis.imagePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
json['image_extension'] = p.extension(analysis.imagePath);
|
||||||
|
json['image_base64'] = base64Encode(await file.readAsBytes());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Une photo illisible ne doit pas faire échouer toute la sauvegarde.
|
||||||
|
debugPrint('Sauvegarde : image ignorée (${analysis.id}) : $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _statisticsToJson(List<Session> sessions) {
|
||||||
|
final stats = _statisticsService.calculateStatistics(
|
||||||
|
sessions,
|
||||||
|
period: StatsPeriod.all,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_shots': stats.totalShots,
|
||||||
|
'total_score': stats.totalScore,
|
||||||
|
'average_score': stats.avgScore,
|
||||||
|
'max_score': stats.maxScore,
|
||||||
|
'min_score': stats.minScore,
|
||||||
|
'precision': {
|
||||||
|
'avg_distance_from_center': stats.precision.avgDistanceFromCenter,
|
||||||
|
'grouping_diameter': stats.precision.groupingDiameter,
|
||||||
|
'precision_score': stats.precision.precisionScore,
|
||||||
|
'consistency_score': stats.precision.consistencyScore,
|
||||||
|
},
|
||||||
|
'std_dev': {
|
||||||
|
'x': stats.stdDev.stdDevX,
|
||||||
|
'y': stats.stdDev.stdDevY,
|
||||||
|
'radial': stats.stdDev.stdDevRadial,
|
||||||
|
'score': stats.stdDev.stdDevScore,
|
||||||
|
'mean_x': stats.stdDev.meanX,
|
||||||
|
'mean_y': stats.stdDev.meanY,
|
||||||
|
'mean_score': stats.stdDev.meanScore,
|
||||||
|
},
|
||||||
|
'regional': {
|
||||||
|
'quadrants': stats.regional.quadrantDistribution,
|
||||||
|
'sectors': stats.regional.sectorDistribution,
|
||||||
|
'dominant_direction': stats.regional.dominantDirection,
|
||||||
|
'bias_x': stats.regional.biasX,
|
||||||
|
'bias_y': stats.regional.biasY,
|
||||||
|
},
|
||||||
|
'heat_map': {
|
||||||
|
'grid_size': stats.heatMap.gridSize,
|
||||||
|
'max_shots_in_zone': stats.heatMap.maxShotsInZone,
|
||||||
|
'zones': [
|
||||||
|
for (final row in stats.heatMap.zones)
|
||||||
|
for (final zone in row)
|
||||||
|
{
|
||||||
|
'row': zone.row,
|
||||||
|
'col': zone.col,
|
||||||
|
'shot_count': zone.shotCount,
|
||||||
|
'intensity': zone.intensity,
|
||||||
|
'avg_score': zone.avgScore,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- IMPORT
|
||||||
|
|
||||||
|
/// Lit et valide un fichier de sauvegarde sans rien écrire en base.
|
||||||
|
Future<BackupPreview> readBackup(File file) async {
|
||||||
|
late final dynamic decoded;
|
||||||
|
try {
|
||||||
|
decoded = jsonDecode(await file.readAsString());
|
||||||
|
} catch (e) {
|
||||||
|
throw BackupFormatException(
|
||||||
|
'Fichier illisible : ce n\'est pas un JSON valide.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw BackupFormatException('Fichier illisible : format inattendu.');
|
||||||
|
}
|
||||||
|
if (decoded['format'] != formatId) {
|
||||||
|
throw BackupFormatException(
|
||||||
|
'Ce fichier n\'est pas une sauvegarde IMPACT.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final version = (decoded['version'] as num?)?.toInt() ?? 0;
|
||||||
|
if (version > formatVersion) {
|
||||||
|
throw BackupFormatException(
|
||||||
|
'Sauvegarde créée par une version plus récente de l\'application '
|
||||||
|
'(format $version). Mettez l\'app à jour pour l\'importer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final sessions = _asList(decoded['sessions']);
|
||||||
|
final weapons = _asList(decoded['weapons']);
|
||||||
|
|
||||||
|
var targets = 0;
|
||||||
|
var shots = 0;
|
||||||
|
var hasImages = false;
|
||||||
|
for (final session in sessions) {
|
||||||
|
for (final analysis in _asList(session['analyses'])) {
|
||||||
|
targets++;
|
||||||
|
shots += _asList(analysis['shots']).length;
|
||||||
|
if (analysis['image_base64'] != null) hasImages = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var maintenance = _asList(decoded['orphan_maintenance']).length;
|
||||||
|
for (final weapon in weapons) {
|
||||||
|
maintenance += _asList(weapon['maintenance']).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BackupPreview(
|
||||||
|
sessionCount: sessions.length,
|
||||||
|
targetCount: targets,
|
||||||
|
shotCount: shots,
|
||||||
|
weaponCount: weapons.length,
|
||||||
|
maintenanceCount: maintenance,
|
||||||
|
hasImages: hasImages,
|
||||||
|
exportedAt: DateTime.tryParse(decoded['exported_at'] as String? ?? ''),
|
||||||
|
raw: decoded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Écrit en base le contenu d'une sauvegarde déjà lue par [readBackup].
|
||||||
|
///
|
||||||
|
/// Fusion : les entrées existantes portant le même identifiant sont
|
||||||
|
/// remplacées, les autres sont conservées. Réimporter deux fois la même
|
||||||
|
/// sauvegarde ne crée donc pas de doublons.
|
||||||
|
Future<ImportResult> applyBackup(BackupPreview preview) async {
|
||||||
|
final errors = <String>[];
|
||||||
|
var importedSessions = 0;
|
||||||
|
var importedWeapons = 0;
|
||||||
|
var importedMaintenance = 0;
|
||||||
|
var importedImages = 0;
|
||||||
|
|
||||||
|
// 1. Armurerie d'abord : les sessions y font référence par weapon_id.
|
||||||
|
for (final weaponJson in _asList(preview.raw['weapons'])) {
|
||||||
|
try {
|
||||||
|
await _repository.saveWeapon(Weapon.fromMap(weaponJson));
|
||||||
|
importedWeapons++;
|
||||||
|
} catch (e) {
|
||||||
|
errors.add('Arme ignorée : $e');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (final entryJson in _asList(weaponJson['maintenance'])) {
|
||||||
|
try {
|
||||||
|
await _repository.saveMaintenanceEntry(
|
||||||
|
MaintenanceEntry.fromMap(entryJson),
|
||||||
|
);
|
||||||
|
importedMaintenance++;
|
||||||
|
} catch (e) {
|
||||||
|
errors.add('Entretien ignoré : $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sessions, cibles et impacts.
|
||||||
|
for (final sessionJson in _asList(preview.raw['sessions'])) {
|
||||||
|
try {
|
||||||
|
final analyses = <TargetAnalysis>[];
|
||||||
|
for (final analysisJson in _asList(sessionJson['analyses'])) {
|
||||||
|
final shots = _asList(analysisJson['shots'])
|
||||||
|
.map((s) => Shot.fromMap(s))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var map = _normalizeAnalysis(analysisJson);
|
||||||
|
final imagePath = await _restoreImage(analysisJson);
|
||||||
|
if (imagePath != null) {
|
||||||
|
map = {...map, 'image_path': imagePath};
|
||||||
|
importedImages++;
|
||||||
|
}
|
||||||
|
|
||||||
|
analyses.add(TargetAnalysis.fromMap(map, shots));
|
||||||
|
}
|
||||||
|
await _repository.saveSession(Session.fromMap(sessionJson, analyses));
|
||||||
|
importedSessions++;
|
||||||
|
} catch (e) {
|
||||||
|
errors.add('Session ignorée : $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Maintenance dont l'arme a été supprimée avant l'export.
|
||||||
|
for (final entryJson in _asList(preview.raw['orphan_maintenance'])) {
|
||||||
|
try {
|
||||||
|
await _repository.saveMaintenanceEntry(
|
||||||
|
MaintenanceEntry.fromMap(entryJson),
|
||||||
|
);
|
||||||
|
importedMaintenance++;
|
||||||
|
} catch (e) {
|
||||||
|
errors.add('Entretien ignoré : $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportResult(
|
||||||
|
sessions: importedSessions,
|
||||||
|
weapons: importedWeapons,
|
||||||
|
maintenance: importedMaintenance,
|
||||||
|
images: importedImages,
|
||||||
|
errors: errors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recrée la photo de cible si la sauvegarde l'embarque, et renvoie son
|
||||||
|
/// nouveau chemin local. `null` si la sauvegarde est sans photos : le chemin
|
||||||
|
/// d'origine est alors conservé (l'app affiche un placeholder s'il est mort).
|
||||||
|
Future<String?> _restoreImage(Map<String, dynamic> analysisJson) async {
|
||||||
|
final encoded = analysisJson['image_base64'] as String?;
|
||||||
|
if (encoded == null || encoded.isEmpty) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final bytes = base64Decode(encoded);
|
||||||
|
final extension = analysisJson['image_extension'] as String? ?? '.jpg';
|
||||||
|
return await _repository.saveImageBytes(bytes, extension);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Import : image ignorée : $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON ne distingue pas 1 de 1.0 : une valeur ronde relue devient un `int`
|
||||||
|
/// et casse les `as double?` des modèles. On reforce donc les doubles.
|
||||||
|
Map<String, dynamic> _normalizeAnalysis(Map<String, dynamic> json) {
|
||||||
|
const doubleKeys = [
|
||||||
|
'grouping_diameter',
|
||||||
|
'grouping_center_x',
|
||||||
|
'grouping_center_y',
|
||||||
|
'target_center_x',
|
||||||
|
'target_center_y',
|
||||||
|
'target_radius',
|
||||||
|
];
|
||||||
|
|
||||||
|
final map = Map<String, dynamic>.from(json);
|
||||||
|
for (final key in doubleKeys) {
|
||||||
|
map[key] = (map[key] as num?)?.toDouble();
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _asList(dynamic value) {
|
||||||
|
if (value is! List) return const [];
|
||||||
|
return value.whereType<Map<String, dynamic>>().toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user