From d2b7d59d17c7e175b89ec59761f678134454c60d Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 22 Jul 2026 13:54:39 +0200 Subject: [PATCH] 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. --- test/presentation/profile_screen_test.dart | 466 ++++++++++++++++++++- 1 file changed, 465 insertions(+), 1 deletion(-) diff --git a/test/presentation/profile_screen_test.dart b/test/presentation/profile_screen_test.dart index a439324..ed1ad9a 100644 --- a/test/presentation/profile_screen_test.dart +++ b/test/presentation/profile_screen_test.dart @@ -1,8 +1,12 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; import 'package:gametime/domain/domain.dart'; import 'package:gametime/presentation/profile_screen.dart'; +import 'package:share_plus/share_plus.dart'; void main() { testWidgets('le profil déconnecté invite sans bloquer le reste', ( @@ -16,6 +20,8 @@ void main() { authUseCases: harness.useCases, syncUseCases: harness.syncUseCases, shareUseCases: harness.shareUseCases, + dataExportUseCase: harness.dataExportUseCase, + dataImportUseCase: harness.dataImportUseCase, ), ), ); @@ -39,6 +45,8 @@ void main() { authUseCases: harness.useCases, syncUseCases: harness.syncUseCases, shareUseCases: harness.shareUseCases, + dataExportUseCase: harness.dataExportUseCase, + dataImportUseCase: harness.dataImportUseCase, ), ), ); @@ -72,6 +80,8 @@ void main() { authUseCases: harness.useCases, syncUseCases: harness.syncUseCases, shareUseCases: harness.shareUseCases, + dataExportUseCase: harness.dataExportUseCase, + dataImportUseCase: harness.dataImportUseCase, ), ), ); @@ -116,6 +126,8 @@ void main() { authUseCases: harness.useCases, syncUseCases: harness.syncUseCases, shareUseCases: harness.shareUseCases, + dataExportUseCase: harness.dataExportUseCase, + dataImportUseCase: harness.dataImportUseCase, ), ), ); @@ -144,6 +156,8 @@ void main() { authUseCases: harness.useCases, syncUseCases: harness.syncUseCases, shareUseCases: harness.shareUseCases, + dataExportUseCase: harness.dataExportUseCase, + dataImportUseCase: harness.dataImportUseCase, ), ), ); @@ -171,6 +185,8 @@ void main() { authUseCases: harness.useCases, syncUseCases: harness.syncUseCases, shareUseCases: harness.shareUseCases, + dataExportUseCase: harness.dataExportUseCase, + dataImportUseCase: harness.dataImportUseCase, ), ), ); @@ -198,6 +214,8 @@ void main() { authUseCases: harness.useCases, syncUseCases: harness.syncUseCases, shareUseCases: harness.shareUseCases, + dataExportUseCase: harness.dataExportUseCase, + dataImportUseCase: harness.dataImportUseCase, ), ), ); @@ -222,6 +240,8 @@ void main() { authUseCases: harness.useCases, syncUseCases: harness.syncUseCases, shareUseCases: harness.shareUseCases, + dataExportUseCase: harness.dataExportUseCase, + dataImportUseCase: harness.dataImportUseCase, ), ), ); @@ -232,6 +252,341 @@ void main() { 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 d’export 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 n’est pas une sauvegarde GameTime valide.'), + findsOneWidget, + ); + }); + + testWidgets('une séance active bloque l’import', (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 d’importer 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 { @@ -247,7 +602,9 @@ final class _AuthHarness { shareInbox = _FakeShareInboxRepository(), pendingShares = _FakePendingShareActionRepository(), programRepository = _FakeProgramRepository(), - templateRepository = _FakeWorkoutTemplateRepository() { + templateRepository = _FakeWorkoutTemplateRepository(), + backupRepository = _FakeLocalDataBackupRepository(), + backupMediaStore = _FakeLocalBackupMediaStore() { useCases = AuthUseCases( tokenStore: tokenStore, accountRepository: accountRepository, @@ -276,6 +633,16 @@ final class _AuthHarness { ids: _FakeIds(), originDeviceId: 'device-1', ); + dataExportUseCase = DataExportUseCase( + repository: backupRepository, + mediaStore: backupMediaStore, + clock: const _FakeClock(), + ); + dataImportUseCase = DataImportUseCase( + repository: backupRepository, + mediaStore: backupMediaStore, + clock: const _FakeClock(), + ); } factory _AuthHarness.connected() { @@ -305,9 +672,13 @@ final class _AuthHarness { final _FakePendingShareActionRepository pendingShares; final _FakeProgramRepository programRepository; final _FakeWorkoutTemplateRepository templateRepository; + final _FakeLocalDataBackupRepository backupRepository; + final _FakeLocalBackupMediaStore backupMediaStore; late final AuthUseCases useCases; late final SyncUseCases syncUseCases; late final ShareUseCases shareUseCases; + late final DataExportUseCase dataExportUseCase; + late final DataImportUseCase dataImportUseCase; } final class _FakeClock implements Clock { @@ -621,3 +992,96 @@ final class _FakeWorkoutTemplateRepository @override Future saveProgram(WorkoutTemplateProgram program) async {} } + +final class _FakeLocalDataBackupRepository + implements LocalDataBackupRepository { + var hasOpenSession = false; + var hasData = false; + var throwOnApply = false; + final appliedModes = []; + + @override + Future 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 hasAnyUserData() async => hasData; + + @override + Future hasOpenActiveWorkoutSession() async => hasOpenSession; + + @override + Future 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> readEmbeddableFiles( + List mediaAssets, + ) async => const []; + + @override + Future> restoreEmbeddedFiles( + List files, + ) async => const {}; +} + +final class _FakeBackupFileExporter implements LocalBackupFileExporter { + _FakeBackupFileExporter({this.status = ShareResultStatus.success}); + + ShareResultStatus status; + String? exportedFileName; + Uint8List? exportedBytes; + var throwOnExport = false; + + @override + Future 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 pickFile() async => file; +}