test: cover local backup export/import panel in Profil

Covers F1/F2 (#84): panel visibility in both profile states, export
success/failure snackbars, merge success, replace-all double
confirmation (including cancel), invalid file dialog, active-session
block, and silent cancellation of the file picker.
This commit is contained in:
2026-07-22 13:54:39 +02:00
parent 889f07cd1d
commit d2b7d59d17

View File

@ -1,8 +1,12 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart'; import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart'; import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/profile_screen.dart'; import 'package:gametime/presentation/profile_screen.dart';
import 'package:share_plus/share_plus.dart';
void main() { void main() {
testWidgets('le profil déconnecté invite sans bloquer le reste', ( testWidgets('le profil déconnecté invite sans bloquer le reste', (
@ -16,6 +20,8 @@ void main() {
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases, shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
), ),
), ),
); );
@ -39,6 +45,8 @@ void main() {
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases, shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
), ),
), ),
); );
@ -72,6 +80,8 @@ void main() {
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases, shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
), ),
), ),
); );
@ -116,6 +126,8 @@ void main() {
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases, shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
), ),
), ),
); );
@ -144,6 +156,8 @@ void main() {
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases, shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
), ),
), ),
); );
@ -171,6 +185,8 @@ void main() {
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases, shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
), ),
), ),
); );
@ -198,6 +214,8 @@ void main() {
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases, shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
), ),
), ),
); );
@ -222,6 +240,8 @@ void main() {
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases, shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
), ),
), ),
); );
@ -232,6 +252,341 @@ void main() {
expect(find.text('Aucun partage reçu'), findsOneWidget); expect(find.text('Aucun partage reçu'), findsOneWidget);
}); });
testWidgets(
'la section Sauvegarde locale est visible déconnecté et connecté',
(tester) async {
final harness = _AuthHarness();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
expect(find.text('Sauvegarde locale'), findsOneWidget);
expect(find.text('Exporter mes données'), findsOneWidget);
expect(find.text('Importer des données'), findsOneWidget);
final connectedHarness = _AuthHarness.connected();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: connectedHarness.useCases,
syncUseCases: connectedHarness.syncUseCases,
shareUseCases: connectedHarness.shareUseCases,
dataExportUseCase: connectedHarness.dataExportUseCase,
dataImportUseCase: connectedHarness.dataImportUseCase,
),
),
);
await tester.pump();
expect(find.text('Sauvegarde locale'), findsOneWidget);
expect(find.text('Exporter mes données'), findsOneWidget);
expect(find.text('Importer des données'), findsOneWidget);
},
);
testWidgets(
'export réussi affiche le message de sauvegarde exportée',
(tester) async {
final harness = _AuthHarness();
final exporter = _FakeBackupFileExporter(
status: ShareResultStatus.success,
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFileExporter: exporter,
),
),
);
await tester.pump();
await tester.tap(find.text('Exporter mes données'));
await tester.pumpAndSettle();
expect(find.text('Sauvegarde exportée.'), findsOneWidget);
expect(exporter.exportedFileName, isNotNull);
expect(exporter.exportedFileName, endsWith('.gametime'));
},
);
testWidgets('un échec dexport affiche un message neutre', (tester) async {
final harness = _AuthHarness();
final exporter = _FakeBackupFileExporter()..throwOnExport = true;
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFileExporter: exporter,
),
),
);
await tester.pump();
await tester.tap(find.text('Exporter mes données'));
await tester.pumpAndSettle();
expect(find.text('Export impossible pour le moment.'), findsOneWidget);
});
testWidgets('import fusion réussie affiche les données importées', (
tester,
) async {
final harness = _AuthHarness()..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
expect(find.text('Importer cette sauvegarde ?'), findsOneWidget);
expect(find.text('Fusionner'), findsOneWidget);
expect(find.text('Remplacer tout'), findsOneWidget);
await tester.tap(find.text('Fusionner'));
await tester.pumpAndSettle();
expect(find.text('Données importées.'), findsOneWidget);
expect(harness.backupRepository.appliedModes, [
LocalBackupImportMode.merge,
]);
});
testWidgets(
'remplacer tout exige une seconde confirmation destructive',
(tester) async {
final harness = _AuthHarness()..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
expect(
find.text('Remplacer toutes les données locales ?'),
findsOneWidget,
);
expect(harness.backupRepository.appliedModes, isEmpty);
await tester.tap(find.widgetWithText(TextButton, 'Annuler'));
await tester.pumpAndSettle();
expect(harness.backupRepository.appliedModes, isEmpty);
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remplacer tout'));
await tester.pumpAndSettle();
expect(find.text('Sauvegarde restaurée.'), findsOneWidget);
expect(harness.backupRepository.appliedModes, [
LocalBackupImportMode.replaceAll,
]);
},
);
testWidgets('un fichier invalide affiche le message dédié', (tester) async {
final harness = _AuthHarness();
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'notes.txt',
bytes: Uint8List.fromList(utf8.encode('{"kind":"something.else"}')),
),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
expect(find.text('Fichier invalide'), findsOneWidget);
expect(
find.text('Ce fichier nest pas une sauvegarde GameTime valide.'),
findsOneWidget,
);
});
testWidgets('une séance active bloque limport', (tester) async {
final harness = _AuthHarness()
..backupRepository.hasOpenSession = true
..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
expect(find.text('Importer cette sauvegarde ?'), findsOneWidget);
await tester.tap(find.text('Fusionner'));
await tester.pumpAndSettle();
expect(find.text('Séance en cours'), findsOneWidget);
expect(
find.text(
'Termine ou sauvegarde ta séance avant dimporter des données.',
),
findsOneWidget,
);
});
testWidgets('annuler la sélection de fichier reste silencieux', (
tester,
) async {
final harness = _AuthHarness();
final picker = _FakeBackupFilePicker();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(find.byType(SnackBar), findsNothing);
});
} }
final class _AuthHarness { final class _AuthHarness {
@ -247,7 +602,9 @@ final class _AuthHarness {
shareInbox = _FakeShareInboxRepository(), shareInbox = _FakeShareInboxRepository(),
pendingShares = _FakePendingShareActionRepository(), pendingShares = _FakePendingShareActionRepository(),
programRepository = _FakeProgramRepository(), programRepository = _FakeProgramRepository(),
templateRepository = _FakeWorkoutTemplateRepository() { templateRepository = _FakeWorkoutTemplateRepository(),
backupRepository = _FakeLocalDataBackupRepository(),
backupMediaStore = _FakeLocalBackupMediaStore() {
useCases = AuthUseCases( useCases = AuthUseCases(
tokenStore: tokenStore, tokenStore: tokenStore,
accountRepository: accountRepository, accountRepository: accountRepository,
@ -276,6 +633,16 @@ final class _AuthHarness {
ids: _FakeIds(), ids: _FakeIds(),
originDeviceId: 'device-1', originDeviceId: 'device-1',
); );
dataExportUseCase = DataExportUseCase(
repository: backupRepository,
mediaStore: backupMediaStore,
clock: const _FakeClock(),
);
dataImportUseCase = DataImportUseCase(
repository: backupRepository,
mediaStore: backupMediaStore,
clock: const _FakeClock(),
);
} }
factory _AuthHarness.connected() { factory _AuthHarness.connected() {
@ -305,9 +672,13 @@ final class _AuthHarness {
final _FakePendingShareActionRepository pendingShares; final _FakePendingShareActionRepository pendingShares;
final _FakeProgramRepository programRepository; final _FakeProgramRepository programRepository;
final _FakeWorkoutTemplateRepository templateRepository; final _FakeWorkoutTemplateRepository templateRepository;
final _FakeLocalDataBackupRepository backupRepository;
final _FakeLocalBackupMediaStore backupMediaStore;
late final AuthUseCases useCases; late final AuthUseCases useCases;
late final SyncUseCases syncUseCases; late final SyncUseCases syncUseCases;
late final ShareUseCases shareUseCases; late final ShareUseCases shareUseCases;
late final DataExportUseCase dataExportUseCase;
late final DataImportUseCase dataImportUseCase;
} }
final class _FakeClock implements Clock { final class _FakeClock implements Clock {
@ -621,3 +992,96 @@ final class _FakeWorkoutTemplateRepository
@override @override
Future<void> saveProgram(WorkoutTemplateProgram program) async {} Future<void> saveProgram(WorkoutTemplateProgram program) async {}
} }
final class _FakeLocalDataBackupRepository
implements LocalDataBackupRepository {
var hasOpenSession = false;
var hasData = false;
var throwOnApply = false;
final appliedModes = <LocalBackupImportMode>[];
@override
Future<LocalBackupImportResult> applyImportSnapshot({
required LocalDataExportSnapshot snapshot,
required LocalBackupImportMode mode,
required DateTime importedAt,
}) async {
if (throwOnApply) {
throw Exception('transaction failed');
}
appliedModes.add(mode);
return const LocalBackupImportResult(
insertedCount: 1,
updatedCount: 0,
ignoredOlderCount: 0,
deletedByReplaceCount: 0,
missingMediaCount: 0,
);
}
@override
Future<bool> hasAnyUserData() async => hasData;
@override
Future<bool> hasOpenActiveWorkoutSession() async => hasOpenSession;
@override
Future<LocalDataExportSnapshot> readExportSnapshot(
DateTime exportedAt,
) async {
return LocalDataExportSnapshot(
exportedAt: exportedAt,
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
);
}
}
final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore {
@override
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
List<LocalBackupResource> mediaAssets,
) async => const [];
@override
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
List<EmbeddedBackupMediaFile> files,
) async => const {};
}
final class _FakeBackupFileExporter implements LocalBackupFileExporter {
_FakeBackupFileExporter({this.status = ShareResultStatus.success});
ShareResultStatus status;
String? exportedFileName;
Uint8List? exportedBytes;
var throwOnExport = false;
@override
Future<ShareResultStatus> exportFile({
required String fileName,
required Uint8List bytes,
}) async {
if (throwOnExport) {
throw Exception('share failed');
}
exportedFileName = fileName;
exportedBytes = bytes;
return status;
}
}
final class _FakeBackupFilePicker implements LocalBackupFilePicker {
_FakeBackupFilePicker({this.file});
LocalBackupPickedFile? file;
@override
Future<LocalBackupPickedFile?> pickFile() async => file;
}