test: cover local backup import export
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user