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:
@ -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<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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user