workout_app: cover export/import to 100%

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 20:07:56 +01:00
parent ba3a8a293d
commit 6cd21296a9
5 changed files with 208 additions and 3 deletions

View File

@ -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<ImportReport> importExportText(String source) async {
final decoded = jsonDecode(source);
@ -17,6 +14,9 @@ Future<ImportReport> 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<bool> exportUserData() async {
final json = const JsonEncoder.withIndent(

View File

@ -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<void>.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<DataTransferButtons>(buttons).onImported!();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 200)),
);
await tester.pump();
expect(find.byType(DataTransferButtons), findsOneWidget);
});
}

View File

@ -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<FormatException>()),
);
});
test('text that is not JSON is rejected with a FormatException', () async {
await expectLater(
importExportText('not json'),
throwsA(isA<FormatException>()),
);
});
}

View File

@ -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<Log?> read() async {
if (throwOnRead) throw StateError('network down');
return <String, Record>{};
}
@override
Future<bool> push(Log records) async {
pushed = mergeLogs(pushed, records);
return true;
}
}
Future<void> _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': <Object>[],
'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': <Object>[],
'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);
});
}

View File

@ -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<ManualWorkoutPort>());
});
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);
});
}