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