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,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