test: cover local backup import export

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 13:27:48 +02:00
parent c3d5273869
commit 4384797e25
3 changed files with 615 additions and 1 deletions

View File

@ -16,6 +16,7 @@ void main() {
late local.DriftWorkoutHistoryRepository historyRepository;
late local.DriftProgressionStatsRepository progressionStatsRepository;
late local.DriftLocalSyncChangeRepository syncChangeRepository;
late local.DriftLocalDataBackupRepository localDataBackupRepository;
late local.DriftExercisePerformanceReferenceRepository
performanceReferenceRepository;
@ -30,6 +31,7 @@ void main() {
database,
);
syncChangeRepository = local.DriftLocalSyncChangeRepository(database);
localDataBackupRepository = local.DriftLocalDataBackupRepository(database);
performanceReferenceRepository =
local.DriftExercisePerformanceReferenceRepository(database);
});
@ -262,6 +264,233 @@ void main() {
expect(template!.tags, isEmpty);
});
test('local backup export includes tags and full workout history', () async {
final now = DateTime.utc(2026, 7, 22, 10);
await exerciseRepository.save(
Exercise(
metadata: _metadata('backup-exercise', now),
name: 'Backup Shoot',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
tags: const ['match'],
),
);
await historyRepository.save(
WorkoutHistory(
metadata: _metadata('backup-history', now),
nameSnapshot: 'Backup Session',
startedAt: now,
endedAt: now.add(const Duration(minutes: 10)),
totalActiveMs: 600000,
completed: true,
historySnapshotJson: '{"name":"Backup Session"}',
results: [
_historySetResult(
id: 'backup-result',
historyId: 'backup-history',
sourceExerciseId: 'backup-exercise',
setIndex: 0,
startedAt: now,
actualReps: 12,
),
],
stepResults: [
_historyStepResult(
id: 'backup-step-result',
historyId: 'backup-history',
sourceExerciseId: 'backup-exercise',
startedAt: now,
),
],
),
);
final snapshot = await localDataBackupRepository.readExportSnapshot(
DateTime.utc(2026, 7, 22, 12),
);
final document =
jsonDecode(utf8.decode(const LocalBackupCodec().encode(snapshot)))
as Map<String, Object?>;
final data = document['data'] as Map<String, Object?>;
final exercises = data['exercises'] as List;
final histories = data['workoutHistories'] as List;
final history = histories.single as Map;
expect(document['kind'], 'gametime.localBackup');
expect(exercises.single, containsPair('tags', ['match']));
expect(history['results'], hasLength(1));
expect(history['stepResults'], hasLength(1));
});
test('local backup merge applies LWW by type and stable id', () async {
final localTime = DateTime.utc(2026, 7, 22, 10);
await exerciseRepository.save(
Exercise(
metadata: _metadata('merge-existing', localTime),
name: 'Local older',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 8,
),
);
await exerciseRepository.save(
Exercise(
metadata: _metadata(
'merge-local-newer',
localTime.add(const Duration(hours: 3)),
),
name: 'Local newer',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 8,
),
);
final snapshot = LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 12),
appSchemaVersion: 19,
originDeviceId: 'device-backup',
mediaAssets: const [],
exercises: [
_backupExerciseResource(
id: 'merge-existing',
name: 'Backup newer',
updatedAt: localTime.add(const Duration(hours: 2)),
),
_backupExerciseResource(
id: 'merge-local-newer',
name: 'Backup older',
updatedAt: localTime.add(const Duration(hours: 1)),
),
_backupExerciseResource(
id: 'merge-new',
name: 'Backup inserted',
updatedAt: localTime.add(const Duration(hours: 1)),
),
],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
);
final result = await localDataBackupRepository.applyImportSnapshot(
snapshot: snapshot,
mode: LocalBackupImportMode.merge,
importedAt: DateTime.utc(2026, 7, 22, 14),
);
expect(result.insertedCount, 1);
expect(result.updatedCount, 1);
expect(result.ignoredOlderCount, 1);
expect(
(await exerciseRepository.findById('merge-existing'))!.name,
'Backup newer',
);
expect(
(await exerciseRepository.findById('merge-local-newer'))!.name,
'Local newer',
);
expect(
(await exerciseRepository.findById('merge-new'))!.name,
'Backup inserted',
);
});
test(
'local backup replaceAll soft deletes absent data and imports file',
() async {
final now = DateTime.utc(2026, 7, 22, 10);
await exerciseRepository.save(
Exercise(
metadata: _metadata('replace-old', now),
name: 'Old',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 8,
),
);
final snapshot = LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 12),
appSchemaVersion: 19,
originDeviceId: 'device-backup',
mediaAssets: const [],
exercises: [
_backupExerciseResource(
id: 'replace-new',
name: 'New',
updatedAt: now.add(const Duration(hours: 1)),
),
],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
);
final result = await localDataBackupRepository.applyImportSnapshot(
snapshot: snapshot,
mode: LocalBackupImportMode.replaceAll,
importedAt: DateTime.utc(2026, 7, 22, 14),
);
expect(result.deletedByReplaceCount, 1);
expect(await exerciseRepository.listActive(), hasLength(1));
expect((await exerciseRepository.findById('replace-new'))!.name, 'New');
final tombstones =
await (database.select(database.changeLogEntries)..where(
(table) =>
table.entityId.equals('replace-old') &
table.operation.equals('softDelete'),
))
.get();
expect(tombstones, hasLength(1));
},
);
test('local backup import is blocked by open active workout', () async {
final now = DateTime.utc(2026, 7, 22, 10);
await activeRepository.save(
ActiveWorkoutSession(
metadata: _metadata('active-import-block', now),
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: '{"programs":[]}',
),
);
await expectLater(
localDataBackupRepository.applyImportSnapshot(
snapshot: LocalDataExportSnapshot(
exportedAt: now,
appSchemaVersion: 19,
originDeviceId: 'device-backup',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
),
mode: LocalBackupImportMode.merge,
importedAt: now.add(const Duration(hours: 1)),
),
throwsA(
isA<LocalBackupException>().having(
(error) => error.error,
'error',
LocalBackupValidationError.activeWorkoutInProgress,
),
),
);
});
test('program duplication persists copied children through Drift', () async {
final now = DateTime.utc(2026, 7, 22, 11, 15);
await exerciseRepository.save(
@ -1783,6 +2012,37 @@ EntityMetadata _metadata(String id, DateTime now, [int localRevision = 0]) {
);
}
LocalBackupResource _backupExerciseResource({
required String id,
required String name,
required DateTime updatedAt,
}) {
return LocalBackupResource(
id: id,
updatedAt: updatedAt,
payload: {
'id': id,
'metadata': {
'id': id,
'createdAt': updatedAt.toUtc().toIso8601String(),
'updatedAt': updatedAt.toUtc().toIso8601String(),
'deletedAt': null,
'schemaVersion': 1,
'syncState': 'synced',
'localRevision': 0,
'originDeviceId': 'device-backup',
},
'name': name,
'hasTimeMeasure': false,
'hasRepsMeasure': true,
'hasScoreMeasure': false,
'defaultTargetReps': 10,
'tags': const ['backup'],
'steps': const [],
},
);
}
WorkoutHistory _history({
required String id,
required DateTime startedAt,
@ -1803,6 +2063,35 @@ WorkoutHistory _history({
);
}
WorkoutHistoryStepResult _historyStepResult({
required String id,
required String historyId,
required String sourceExerciseId,
required DateTime startedAt,
}) {
return WorkoutHistoryStepResult(
metadata: _metadata(id, startedAt),
workoutHistoryId: historyId,
programSnapshotId: 'program-snapshot',
exerciseSnapshotId: 'exercise-snapshot-$sourceExerciseId',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
passageIndex: 0,
stepIndex: 0,
stepSnapshotId: 'step-snapshot',
stepNameSnapshot: 'Step',
stepTypeSnapshot: ExerciseStepType.reps,
targetValueSnapshot: 10,
hasScoreSnapshot: false,
status: SetResultStatus.completed,
startedAt: startedAt,
completedAt: startedAt.add(const Duration(seconds: 10)),
actualReps: 10,
sourceExerciseIdSnapshot: sourceExerciseId,
);
}
WorkoutHistorySetResult _historySetResult({
required String id,
required String historyId,