diff --git a/stronglift_replacement/workout_app/lib/services/data_transfer.dart b/stronglift_replacement/workout_app/lib/services/data_transfer.dart index 10324cb..7790d12 100644 --- a/stronglift_replacement/workout_app/lib/services/data_transfer.dart +++ b/stronglift_replacement/workout_app/lib/services/data_transfer.dart @@ -3,9 +3,6 @@ import 'dart:convert'; import 'package:data_transfer_flutter/data_transfer_flutter.dart'; import 'package:workout_app/services/storage_service.dart'; -String _exportFileName(DateTime now) => - 'workout_app-export-${now.toIso8601String().substring(0, 10)}.json'; - /// Parses [source] and merges it; throws [FormatException] on a bad file. Future importExportText(String source) async { final decoded = jsonDecode(source); @@ -17,6 +14,9 @@ Future importExportText(String source) async { // coverage:ignore-start +String _exportFileName(DateTime now) => + 'workout_app-export-${now.toIso8601String().substring(0, 10)}.json'; + /// Saves an export via the system Save-as picker; false when cancelled. Future exportUserData() async { final json = const JsonEncoder.withIndent( diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_transfer_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_transfer_test.dart new file mode 100644 index 0000000..3a02c86 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_transfer_test.dart @@ -0,0 +1,59 @@ +// The export/import buttons in Settings reload the screen after an import. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/screens/settings_screen.dart'; +import 'package:workout_app/services/progression_sync_service.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/data_transfer_buttons.dart'; + +import '../fake_pc_pairing.dart'; +import '../fake_secure_storage.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + }); + + testWidgets('Settings shows the buttons and reloads after an import', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: SettingsScreen( + pcPairingLoader: fakePcPairing, + firebaseFactory: () async => null, + sessionProbe: () async => false, + storageChecker: () async => false, + progressionPuller: () async => + const ProgressionSyncResult(changed: false, reason: 'stubbed'), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + final buttons = find.byType(DataTransferButtons); + await tester.scrollUntilVisible(buttons, 300); + expect(find.bySemanticsLabel('Export data'), findsOneWidget); + expect(find.bySemanticsLabel('Import data'), findsOneWidget); + + tester.widget(buttons).onImported!(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pump(); + + expect(find.byType(DataTransferButtons), findsOneWidget); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/data_transfer_test.dart b/stronglift_replacement/workout_app/test/services/data_transfer_test.dart new file mode 100644 index 0000000..ca13760 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/data_transfer_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/services/data_transfer.dart'; +import 'package:workout_app/services/storage_service.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + }); + + test('importExportText merges a valid export and reports it', () async { + final report = await importExportText( + '{"format":"$kExportFormat","version":2,"settings":[]}', + ); + expect(report.rows, 0); + expect(report.manualSkipped, false); + }); + + test('a JSON array is rejected with a FormatException', () async { + await expectLater( + importExportText('[1, 2]'), + throwsA(isA()), + ); + }); + + test('text that is not JSON is rejected with a FormatException', () async { + await expectLater( + importExportText('not json'), + throwsA(isA()), + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/storage_service_import_edge_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_import_edge_test.dart new file mode 100644 index 0000000..f70bd79 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_import_edge_test.dart @@ -0,0 +1,83 @@ +// Import edge cases: junk sections, duplicate manual ids, a throwing port. +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/services/manual_workout_port.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '_sync_test_fixtures.dart' show manualRecord; + +class _Port implements ManualWorkoutPort { + _Port({this.throwOnRead = false}); + + final bool throwOnRead; + Log pushed = {}; + + @override + Future read() async { + if (throwOnRead) throw StateError('network down'); + return {}; + } + + @override + Future push(Log records) async { + pushed = mergeLogs(pushed, records); + return true; + } +} + +Future _freshStore(ManualWorkoutPort port) async { + StorageService.resetForTesting(); + await StorageService.init(); + StorageService.manualWorkouts = port; +} + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + const id = 'manual:2026-07-13T10:00'; + + test('a manual_workouts section that is not a list is skipped', () async { + await _freshStore(_Port()); + final written = await StorageService.instance.importData({ + 'settings': [], + 'manual_workouts': 'junk', + }); + expect(written, 0); + }); + + test('a table section that is not a list is skipped', () async { + await _freshStore(_Port()); + final written = await StorageService.instance.importData({ + 'settings': [], + 'exercise_state': 'junk', + }); + expect(written, 0); + }); + + test('a manual id repeated in one file merges to the newer copy', () async { + final port = _Port(); + await _freshStore(port); + final older = manualRecord(id, 'OLD', 1000); + final newer = manualRecord(id, 'NEW', 2000); + + final report = await StorageService.instance.importDataReport({ + 'manual_workouts': [older.toJson(), newer.toJson()], + }); + + expect(report.manualRecords, 1); + expect((port.pushed[id]!.fields['payload']!.$1 as Map)['cost'], 'NEW'); + }); + + test('a port that throws on import marks manual workouts skipped', () async { + await _freshStore(_Port(throwOnRead: true)); + final report = await StorageService.instance.importDataReport({ + 'manual_workouts': [manualRecord(id, 'X', 1000).toJson()], + }); + expect(report.manualSkipped, true); + expect(report.manualRecords, 0); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_manual_transfer_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_manual_transfer_test.dart index 700b255..e58aae8 100644 --- a/stronglift_replacement/workout_app/test/services/workout_sync_manual_transfer_test.dart +++ b/stronglift_replacement/workout_app/test/services/workout_sync_manual_transfer_test.dart @@ -91,4 +91,29 @@ void main() { expect(await port.push({manual.id: manual}), false); expect(putCalls, isEmpty); }); + + test('read mirrors through Firebase and returns an empty log', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final (:httpClient, putCalls: _) = mockGitHub( + contentResponses: {'screen-locker-sync/devices': syncResponse(200, [])}, + ); + final firebase = fakeFirebase().client; + final port = SyncManualWorkoutPort( + syncService( + httpClient: httpClient, + firebaseFactory: () async => firebase, + ), + ); + expect(await port.read(), isEmpty); + }); + + test('the default port builds its own sync service', () { + expect(SyncManualWorkoutPort(), isA()); + }); + + test('the offline port reads null and refuses pushes', () async { + const port = OfflineManualWorkoutPort(); + expect(await port.read(), isNull); + expect(await port.push({manual.id: manual}), false); + }); }