Complement #183 : fusion de la strategie Health Services au niveau seance pour eviter que le premier exercice fige tout le choix de strategie sur la seance. Fix #185 : nullification defensive de sourceExerciseId au pull quand l'exercice source est absent, pour ne plus casser le chargement des programmes apres suppression d'un exercice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3660 lines
117 KiB
Dart
3660 lines
117 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:drift/drift.dart' as drift;
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:gametime/application/application.dart';
|
|
import 'package:gametime/domain/domain.dart';
|
|
import 'package:gametime/infrastructure/local/local.dart' as local;
|
|
|
|
void main() {
|
|
late local.AppDatabase database;
|
|
late local.DriftMediaAssetRepository mediaAssetRepository;
|
|
late local.DriftExerciseRepository exerciseRepository;
|
|
late local.DriftProgramRepository programRepository;
|
|
late local.DriftActiveSessionRepository activeRepository;
|
|
late local.DriftWorkoutTemplateRepository templateRepository;
|
|
late local.DriftWorkoutHistoryRepository historyRepository;
|
|
late local.DriftProgressionStatsRepository progressionStatsRepository;
|
|
late local.DriftLocalSyncChangeRepository syncChangeRepository;
|
|
late local.DriftLocalDataBackupRepository localDataBackupRepository;
|
|
late local.DriftWorkoutTelemetryRepository telemetryRepository;
|
|
late local.DriftExercisePerformanceReferenceRepository
|
|
performanceReferenceRepository;
|
|
|
|
setUp(() {
|
|
database = local.AppDatabase(NativeDatabase.memory());
|
|
mediaAssetRepository = local.DriftMediaAssetRepository(database);
|
|
exerciseRepository = local.DriftExerciseRepository(database);
|
|
programRepository = local.DriftProgramRepository(database);
|
|
activeRepository = local.DriftActiveSessionRepository(database);
|
|
templateRepository = local.DriftWorkoutTemplateRepository(database);
|
|
historyRepository = local.DriftWorkoutHistoryRepository(database);
|
|
progressionStatsRepository = local.DriftProgressionStatsRepository(
|
|
database,
|
|
);
|
|
syncChangeRepository = local.DriftLocalSyncChangeRepository(database);
|
|
localDataBackupRepository = local.DriftLocalDataBackupRepository(database);
|
|
telemetryRepository = local.DriftWorkoutTelemetryRepository(database);
|
|
performanceReferenceRepository =
|
|
local.DriftExercisePerformanceReferenceRepository(database);
|
|
});
|
|
|
|
tearDown(() async {
|
|
await database.close();
|
|
});
|
|
|
|
test('exercise mutations are written to change log', () async {
|
|
final now = DateTime.utc(2026, 7, 17, 12);
|
|
final exercise = Exercise(
|
|
metadata: _metadata('exercise-1', now),
|
|
name: 'Squat',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
);
|
|
|
|
await exerciseRepository.save(exercise);
|
|
await exerciseRepository.save(
|
|
exercise.copyWith(
|
|
metadata: _metadata(
|
|
'exercise-1',
|
|
now.add(const Duration(seconds: 1)),
|
|
1,
|
|
),
|
|
name: 'Front squat',
|
|
),
|
|
);
|
|
await exerciseRepository.save(
|
|
exercise
|
|
.copyWith(
|
|
metadata: _metadata(
|
|
'exercise-1',
|
|
now.add(const Duration(seconds: 2)),
|
|
2,
|
|
),
|
|
name: 'Front squat',
|
|
)
|
|
.archive(now.add(const Duration(seconds: 2))),
|
|
);
|
|
|
|
final changes =
|
|
await (database.select(database.changeLogEntries)
|
|
..where((table) => table.entityId.equals('exercise-1'))
|
|
..orderBy([
|
|
(table) => drift.OrderingTerm.asc(table.localRevision),
|
|
]))
|
|
.get();
|
|
|
|
expect(changes.map((change) => change.operation), [
|
|
'insert',
|
|
'update',
|
|
'update',
|
|
]);
|
|
expect(changes.every((change) => change.entityType == 'Exercise'), isTrue);
|
|
});
|
|
|
|
test('exercise repository accepts zero manual default score', () async {
|
|
final exercise = Exercise(
|
|
metadata: _metadata('exercise-score-zero', DateTime.utc(2026, 7, 17, 12)),
|
|
name: 'Score nul',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreLabel: 'Score',
|
|
scoreUnit: 'pts',
|
|
defaultTargetScore: 0,
|
|
);
|
|
|
|
await exerciseRepository.save(exercise);
|
|
|
|
final restored = await exerciseRepository.findById(exercise.metadata.id);
|
|
expect(restored, isNotNull);
|
|
expect(restored!.defaultTargetScore, 0);
|
|
});
|
|
|
|
test('taggable tables expose tags json columns on fresh schema', () async {
|
|
Future<List<String>> columnNames(String tableName) async {
|
|
final rows = await database
|
|
.customSelect('PRAGMA table_info($tableName)')
|
|
.get();
|
|
return rows.map((row) => row.data['name'] as String).toList();
|
|
}
|
|
|
|
expect(await columnNames('exercises'), contains('tags_json'));
|
|
expect(await columnNames('exercises'), contains('business_types_json'));
|
|
expect(
|
|
await columnNames('program_exercises'),
|
|
contains('health_services_exercise_type_strategy_snapshot_json'),
|
|
);
|
|
expect(await columnNames('programs'), contains('tags_json'));
|
|
expect(await columnNames('workout_templates'), contains('tags_json'));
|
|
expect(
|
|
await columnNames('workout_history'),
|
|
contains('min_heart_rate_bpm'),
|
|
);
|
|
expect(
|
|
await columnNames('workout_history'),
|
|
contains('total_distance_meters'),
|
|
);
|
|
expect(
|
|
await columnNames('workout_history'),
|
|
contains('total_calories_kcal'),
|
|
);
|
|
expect(await columnNames('workout_telemetry_samples'), contains('id'));
|
|
expect(
|
|
await columnNames('workout_telemetry_aggregates'),
|
|
contains('sample_count'),
|
|
);
|
|
expect(database.schemaVersion, 25);
|
|
});
|
|
|
|
test('exercise business types persist with category fallback', () async {
|
|
final now = DateTime.utc(2026, 7, 30, 12);
|
|
final exercise = Exercise(
|
|
metadata: _metadata('exercise-business-types', now),
|
|
name: 'Dribble intense',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
category: ExerciseCategory.shoot,
|
|
businessTypes: const [
|
|
BusinessExerciseType.dribble,
|
|
BusinessExerciseType.highIntensity,
|
|
BusinessExerciseType.dribble,
|
|
],
|
|
);
|
|
await exerciseRepository.save(exercise);
|
|
|
|
final restoredExercise = await exerciseRepository.findById(
|
|
exercise.metadata.id,
|
|
);
|
|
expect(restoredExercise?.businessTypes, [
|
|
BusinessExerciseType.dribble,
|
|
BusinessExerciseType.highIntensity,
|
|
]);
|
|
expect(restoredExercise?.effectiveBusinessTypes, [
|
|
BusinessExerciseType.dribble,
|
|
BusinessExerciseType.highIntensity,
|
|
]);
|
|
expect(restoredExercise?.healthServicesExerciseTypeStrategy, [
|
|
HealthServicesExerciseType.running,
|
|
HealthServicesExerciseType.highIntensityIntervalTraining,
|
|
HealthServicesExerciseType.workout,
|
|
]);
|
|
|
|
final program = Program(
|
|
metadata: _metadata('program-business-types', now),
|
|
name: 'Programme types',
|
|
defaultRestSeconds: 30,
|
|
exercises: [
|
|
ProgramExercise.snapshotFromExercise(
|
|
metadata: _metadata('program-exercise-business-types', now),
|
|
programId: 'program-business-types',
|
|
exercise: restoredExercise!,
|
|
position: 0,
|
|
setsCount: 1,
|
|
enabledMeasures: const {WorkoutMeasure.time},
|
|
),
|
|
],
|
|
);
|
|
await programRepository.save(program);
|
|
|
|
final restoredProgram = await programRepository.findById(
|
|
program.metadata.id,
|
|
);
|
|
expect(
|
|
restoredProgram
|
|
?.exercises
|
|
.single
|
|
.healthServicesExerciseTypeStrategySnapshot,
|
|
[
|
|
HealthServicesExerciseType.running,
|
|
HealthServicesExerciseType.highIntensityIntervalTraining,
|
|
HealthServicesExerciseType.workout,
|
|
],
|
|
);
|
|
expect(
|
|
restoredProgram?.exercises.single
|
|
.toSnapshotJson()['healthServicesExerciseTypeStrategy'],
|
|
['RUNNING', 'HIGH_INTENSITY_INTERVAL_TRAINING', 'WORKOUT'],
|
|
);
|
|
|
|
final legacyExercise = Exercise(
|
|
metadata: _metadata('exercise-business-types-legacy', now),
|
|
name: 'Shoot',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
category: ExerciseCategory.shoot,
|
|
);
|
|
await exerciseRepository.save(legacyExercise);
|
|
|
|
final restoredLegacy = await exerciseRepository.findById(
|
|
legacyExercise.metadata.id,
|
|
);
|
|
expect(restoredLegacy?.businessTypes, isEmpty);
|
|
expect(restoredLegacy?.effectiveBusinessTypes, [
|
|
BusinessExerciseType.shoot,
|
|
]);
|
|
expect(restoredLegacy?.healthServicesExerciseTypeStrategy, [
|
|
HealthServicesExerciseType.highIntensityIntervalTraining,
|
|
HealthServicesExerciseType.running,
|
|
HealthServicesExerciseType.walking,
|
|
HealthServicesExerciseType.workout,
|
|
]);
|
|
});
|
|
|
|
test(
|
|
'telemetry repository persists samples and replaces aggregates',
|
|
() async {
|
|
final first = WorkoutTelemetrySample(
|
|
id: 'sample-1',
|
|
sessionId: 'session-1',
|
|
capturedAt: DateTime.utc(2026, 7, 28, 10),
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
stepIndex: 0,
|
|
heartRateBpm: 120,
|
|
distanceMeters: 500,
|
|
caloriesKcal: 42,
|
|
);
|
|
final second = WorkoutTelemetrySample(
|
|
id: 'sample-2',
|
|
sessionId: 'session-1',
|
|
capturedAt: DateTime.utc(2026, 7, 28, 10, 1),
|
|
programIndex: 0,
|
|
exerciseIndex: 1,
|
|
heartRateBpm: 150,
|
|
distanceMeters: 620,
|
|
caloriesKcal: 48,
|
|
);
|
|
final replacement = WorkoutTelemetrySample(
|
|
id: 'sample-1',
|
|
sessionId: 'session-1',
|
|
capturedAt: DateTime.utc(2026, 7, 28, 10, 0, 10),
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
stepIndex: 0,
|
|
heartRateBpm: 130,
|
|
distanceMeters: 530,
|
|
caloriesKcal: 45,
|
|
);
|
|
final olderReplacement = WorkoutTelemetrySample(
|
|
id: 'sample-1',
|
|
sessionId: 'session-1',
|
|
capturedAt: DateTime.utc(2026, 7, 28, 10, 0, 5),
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
stepIndex: 0,
|
|
heartRateBpm: 125,
|
|
distanceMeters: 510,
|
|
caloriesKcal: 43,
|
|
);
|
|
|
|
expect(await telemetryRepository.saveSample(first), isTrue);
|
|
expect(await telemetryRepository.saveSample(first), isFalse);
|
|
expect(await telemetryRepository.saveSample(replacement), isTrue);
|
|
expect(await telemetryRepository.saveSample(olderReplacement), isFalse);
|
|
expect(await telemetryRepository.saveSample(second), isTrue);
|
|
|
|
final samples = await telemetryRepository.listSamples('session-1');
|
|
expect(samples.map((sample) => sample.id), ['sample-1', 'sample-2']);
|
|
expect(samples.first.heartRateBpm, 130);
|
|
final stepSamples = await telemetryRepository.listSamplesForScope(
|
|
sessionId: 'session-1',
|
|
scope: WorkoutTelemetryAggregateScope.step,
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
stepIndex: 0,
|
|
);
|
|
expect(stepSamples, hasLength(1));
|
|
expect(stepSamples.single.distanceMeters, 530);
|
|
|
|
await telemetryRepository.replaceAggregatesForSession(
|
|
sessionId: 'session-1',
|
|
aggregates: [
|
|
WorkoutTelemetryAggregate(
|
|
sessionId: 'session-1',
|
|
scope: WorkoutTelemetryAggregateScope.session,
|
|
sampleCount: 2,
|
|
minHeartRateBpm: 120,
|
|
averageHeartRateBpm: 135,
|
|
maxHeartRateBpm: 150,
|
|
totalDistanceMeters: 620,
|
|
totalCaloriesKcal: 48,
|
|
),
|
|
],
|
|
);
|
|
|
|
final aggregate = await telemetryRepository.findAggregate(
|
|
sessionId: 'session-1',
|
|
scope: WorkoutTelemetryAggregateScope.session,
|
|
);
|
|
expect(aggregate?.sampleCount, 2);
|
|
expect(aggregate?.minHeartRateBpm, 120);
|
|
expect(aggregate?.averageHeartRateBpm, 135);
|
|
expect(aggregate?.maxHeartRateBpm, 150);
|
|
expect(aggregate?.totalDistanceMeters, 620);
|
|
expect(aggregate?.totalCaloriesKcal, 48);
|
|
|
|
await telemetryRepository.replaceAggregatesForSession(
|
|
sessionId: 'session-1',
|
|
aggregates: const [],
|
|
);
|
|
expect(await telemetryRepository.listAggregates('session-1'), isEmpty);
|
|
},
|
|
);
|
|
|
|
test('share inbox and pending actions persist workout packs', () async {
|
|
final now = DateTime.utc(2026, 7, 28, 10);
|
|
final inboxRepository = local.DriftShareInboxRepository(database);
|
|
final pendingRepository = local.DriftPendingShareActionRepository(database);
|
|
|
|
await inboxRepository.upsert(
|
|
ShareInboxItem(
|
|
shareId: 'share-pack-1',
|
|
senderUserId: 'sender-1',
|
|
resourceType: ShareResourceType.pack,
|
|
payloadJson: jsonEncode({
|
|
'kind': 'pack',
|
|
'name': 'Pack reprise',
|
|
'workouts': const [],
|
|
}),
|
|
status: ShareInboxStatus.pending,
|
|
createdAt: now,
|
|
),
|
|
);
|
|
await pendingRepository.add(
|
|
PendingShareAction(
|
|
id: 'pending-pack-1',
|
|
actionType: PendingShareActionType.send,
|
|
resourceType: ShareResourceType.pack,
|
|
payloadJson: jsonEncode({
|
|
'kind': 'pack',
|
|
'name': 'Pack reprise',
|
|
'workouts': const [],
|
|
}),
|
|
recipientEmailsJson: jsonEncode(['coach@example.com']),
|
|
createdAt: now,
|
|
),
|
|
);
|
|
|
|
final inboxItem = await inboxRepository.findByShareId('share-pack-1');
|
|
final pendingActions = await pendingRepository.listPending();
|
|
|
|
expect(inboxItem, isNotNull);
|
|
expect(inboxItem!.resourceType, ShareResourceType.pack);
|
|
expect(pendingActions.single.resourceType, ShareResourceType.pack);
|
|
});
|
|
|
|
test('migration 22 to 24 preserves share rows and accepts packs', () async {
|
|
final file = File(
|
|
'${Directory.systemTemp.path}/gametime_schema24_${DateTime.now().microsecondsSinceEpoch}.sqlite',
|
|
);
|
|
addTearDown(() async {
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
});
|
|
final seedDatabase = local.AppDatabase(NativeDatabase(file));
|
|
await seedDatabase.customSelect('SELECT 1').getSingle();
|
|
await seedDatabase.customStatement('DROP TABLE share_inbox_items');
|
|
await seedDatabase.customStatement('DROP TABLE pending_share_actions');
|
|
await seedDatabase.customStatement('''
|
|
CREATE TABLE share_inbox_items (
|
|
share_id TEXT NOT NULL PRIMARY KEY,
|
|
sender_user_id TEXT NOT NULL,
|
|
resource_type TEXT NOT NULL CHECK (
|
|
resource_type IN ('program', 'workoutTemplate')
|
|
),
|
|
payload_json TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK (
|
|
status IN ('pending', 'accepted', 'declined', 'revoked')
|
|
),
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
responded_at INTEGER
|
|
)
|
|
''');
|
|
await seedDatabase.customStatement('''
|
|
CREATE TABLE pending_share_actions (
|
|
id TEXT NOT NULL PRIMARY KEY,
|
|
action_type TEXT NOT NULL CHECK (
|
|
action_type IN ('send', 'accept', 'decline', 'revoke')
|
|
),
|
|
share_id TEXT,
|
|
resource_type TEXT CHECK (
|
|
resource_type IS NULL OR
|
|
resource_type IN ('program', 'workoutTemplate')
|
|
),
|
|
payload_json TEXT,
|
|
recipient_emails_json TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
last_attempt_at INTEGER,
|
|
attempt_count INTEGER NOT NULL CHECK (attempt_count >= 0),
|
|
status TEXT NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed'))
|
|
)
|
|
''');
|
|
await seedDatabase.customStatement(
|
|
'INSERT INTO share_inbox_items VALUES '
|
|
'''('share-program-1', 'sender-1', 'program', '{"name":"P"}', '''
|
|
"'pending', 1785225600000, 1785225600000, NULL)",
|
|
);
|
|
await seedDatabase.customStatement(
|
|
'INSERT INTO pending_share_actions VALUES '
|
|
'''('pending-program-1', 'send', NULL, 'program', '{"name":"P"}', '''
|
|
''''["coach@example.com"]', 1785225600000, NULL, 0, 'pending')''',
|
|
);
|
|
await seedDatabase.customStatement('PRAGMA user_version = 22');
|
|
await seedDatabase.close();
|
|
|
|
final migratedDatabase = local.AppDatabase(NativeDatabase(file));
|
|
addTearDown(migratedDatabase.close);
|
|
|
|
final inboxRepository = local.DriftShareInboxRepository(migratedDatabase);
|
|
final pendingRepository = local.DriftPendingShareActionRepository(
|
|
migratedDatabase,
|
|
);
|
|
final now = DateTime.utc(2026, 7, 28, 11);
|
|
|
|
await inboxRepository.upsert(
|
|
ShareInboxItem(
|
|
shareId: 'share-pack-1',
|
|
senderUserId: 'sender-2',
|
|
resourceType: ShareResourceType.pack,
|
|
payloadJson: jsonEncode({
|
|
'kind': 'pack',
|
|
'name': 'Pack reprise',
|
|
'workouts': const [],
|
|
}),
|
|
status: ShareInboxStatus.pending,
|
|
createdAt: now,
|
|
),
|
|
);
|
|
await pendingRepository.add(
|
|
PendingShareAction(
|
|
id: 'pending-pack-1',
|
|
actionType: PendingShareActionType.send,
|
|
resourceType: ShareResourceType.pack,
|
|
payloadJson: jsonEncode({
|
|
'kind': 'pack',
|
|
'name': 'Pack reprise',
|
|
'workouts': const [],
|
|
}),
|
|
recipientEmailsJson: jsonEncode(['coach@example.com']),
|
|
createdAt: now,
|
|
),
|
|
);
|
|
|
|
final version = await migratedDatabase
|
|
.customSelect('PRAGMA user_version')
|
|
.getSingle();
|
|
final inboxItems = await inboxRepository.listAll();
|
|
final pendingActions = await pendingRepository.listPending();
|
|
|
|
expect(version.data['user_version'], 24);
|
|
expect(inboxItems.map((item) => item.shareId), contains('share-program-1'));
|
|
expect(inboxItems.map((item) => item.shareId), contains('share-pack-1'));
|
|
expect(
|
|
pendingActions.map((action) => action.id),
|
|
containsAll(['pending-program-1', 'pending-pack-1']),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'workout template saveAll rolls back every template on failure',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 28, 12);
|
|
final valid = WorkoutTemplate(
|
|
metadata: _metadata('template-valid', now),
|
|
name: 'Séance valide',
|
|
);
|
|
final invalid = WorkoutTemplate(
|
|
metadata: _metadata('template-invalid', now),
|
|
name: 'Séance invalide',
|
|
programs: [
|
|
WorkoutTemplateProgram(
|
|
metadata: _metadata('template-invalid-program-1', now),
|
|
workoutTemplateId: 'template-invalid',
|
|
position: 0,
|
|
programNameSnapshot: 'Programme A',
|
|
defaultRestSecondsSnapshot: 60,
|
|
programSnapshotJson: jsonEncode({'exercises': const []}),
|
|
),
|
|
WorkoutTemplateProgram(
|
|
metadata: _metadata('template-invalid-program-2', now),
|
|
workoutTemplateId: 'template-invalid',
|
|
position: 0,
|
|
programNameSnapshot: 'Programme B',
|
|
defaultRestSecondsSnapshot: 60,
|
|
programSnapshotJson: jsonEncode({'exercises': const []}),
|
|
),
|
|
],
|
|
);
|
|
|
|
await expectLater(
|
|
templateRepository.saveAll([valid, invalid]),
|
|
throwsA(isA<Exception>()),
|
|
);
|
|
|
|
expect(await templateRepository.findById(valid.metadata.id), isNull);
|
|
expect(await templateRepository.findById(invalid.metadata.id), isNull);
|
|
},
|
|
);
|
|
|
|
test('repositories save and load normalized tags', () async {
|
|
final now = DateTime.utc(2026, 7, 22, 10);
|
|
await exerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('exercise-tags', now),
|
|
name: 'Shoot',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
tags: const [' Match ', 'Extérieur'],
|
|
),
|
|
);
|
|
await programRepository.save(
|
|
Program(
|
|
metadata: _metadata('program-tags', now),
|
|
name: 'Program',
|
|
defaultRestSeconds: 45,
|
|
tags: const ['Intense'],
|
|
),
|
|
);
|
|
await templateRepository.save(
|
|
WorkoutTemplate(
|
|
metadata: _metadata('template-tags', now),
|
|
name: 'Template',
|
|
tags: const ['Routine'],
|
|
),
|
|
);
|
|
|
|
final exercise = await exerciseRepository.findById('exercise-tags');
|
|
final program = await programRepository.findById('program-tags');
|
|
final template = await templateRepository.findById('template-tags');
|
|
|
|
expect(exercise!.tags, ['match', 'extérieur']);
|
|
expect(program!.tags, ['intense']);
|
|
expect(template!.tags, ['routine']);
|
|
});
|
|
|
|
test('local sync payload includes tags for taggable resources', () async {
|
|
final now = DateTime.utc(2026, 7, 22, 10, 30);
|
|
await exerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('exercise-sync-tags', now),
|
|
name: 'Shoot',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
tags: const ['match'],
|
|
),
|
|
);
|
|
await programRepository.save(
|
|
Program(
|
|
metadata: _metadata('program-sync-tags', now),
|
|
name: 'Program',
|
|
defaultRestSeconds: 45,
|
|
tags: const ['intense'],
|
|
),
|
|
);
|
|
await templateRepository.save(
|
|
WorkoutTemplate(
|
|
metadata: _metadata('template-sync-tags', now),
|
|
name: 'Template',
|
|
tags: const ['routine'],
|
|
),
|
|
);
|
|
|
|
final changes = await syncChangeRepository.listPendingChanges();
|
|
final payloadsById = {
|
|
for (final change in changes) change.item.clientId: change.item.payload,
|
|
};
|
|
|
|
expect(payloadsById['exercise-sync-tags']!['tags'], ['match']);
|
|
expect(payloadsById['program-sync-tags']!['tags'], ['intense']);
|
|
expect(payloadsById['template-sync-tags']!['tags'], ['routine']);
|
|
});
|
|
|
|
test('local sync payload includes exercise business types', () async {
|
|
final now = DateTime.utc(2026, 7, 30, 13);
|
|
final exercise = Exercise(
|
|
metadata: _metadata('exercise-sync-business-types', now),
|
|
name: 'Drive',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: false,
|
|
);
|
|
await exerciseRepository.save(exercise);
|
|
|
|
final updatedExercise = exercise.copyWith(
|
|
metadata: _metadata(
|
|
'exercise-sync-business-types',
|
|
now.add(const Duration(seconds: 1)),
|
|
1,
|
|
),
|
|
businessTypes: const [
|
|
BusinessExerciseType.dribble,
|
|
BusinessExerciseType.finishing,
|
|
],
|
|
);
|
|
await exerciseRepository.save(updatedExercise);
|
|
await programRepository.save(
|
|
Program(
|
|
metadata: _metadata('program-sync-business-types', now),
|
|
name: 'Program business types',
|
|
defaultRestSeconds: 45,
|
|
exercises: [
|
|
ProgramExercise.snapshotFromExercise(
|
|
metadata: _metadata('program-exercise-sync-business-types', now),
|
|
programId: 'program-sync-business-types',
|
|
exercise: updatedExercise,
|
|
position: 0,
|
|
setsCount: 1,
|
|
enabledMeasures: const {WorkoutMeasure.time},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final changes = await syncChangeRepository.listPendingChanges();
|
|
final payloadsById = {
|
|
for (final change in changes) change.item.clientId: change.item.payload,
|
|
};
|
|
|
|
expect(payloadsById['exercise-sync-business-types']!['businessTypes'], [
|
|
'dribble',
|
|
'finishing',
|
|
]);
|
|
final programExercises =
|
|
payloadsById['program-sync-business-types']!['exercises'] as List;
|
|
expect(programExercises.single['healthServicesExerciseTypeStrategy'], [
|
|
'RUNNING',
|
|
'HIGH_INTENSITY_INTERVAL_TRAINING',
|
|
'WORKOUT',
|
|
]);
|
|
});
|
|
|
|
test('local sync payload includes full workout history aggregate', () async {
|
|
final now = DateTime.utc(2026, 7, 22, 10, 45);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'sync-history-full',
|
|
startedAt: now,
|
|
result: _historySetResult(
|
|
id: 'sync-history-set-result',
|
|
historyId: 'sync-history-full',
|
|
sourceExerciseId: 'exercise-sync-full',
|
|
setIndex: 0,
|
|
startedAt: now,
|
|
actualScore: 12,
|
|
),
|
|
stepResults: [
|
|
_historyStepResult(
|
|
id: 'sync-history-step-result',
|
|
historyId: 'sync-history-full',
|
|
sourceExerciseId: 'exercise-sync-full',
|
|
startedAt: now,
|
|
),
|
|
],
|
|
minHeartRateBpm: 90,
|
|
averageHeartRateBpm: 120,
|
|
maxHeartRateBpm: 150,
|
|
totalDistanceMeters: 42,
|
|
totalCaloriesKcal: 12,
|
|
),
|
|
);
|
|
|
|
final changes = await syncChangeRepository.listPendingChanges();
|
|
final payload = changes
|
|
.singleWhere((change) => change.item.clientId == 'sync-history-full')
|
|
.item
|
|
.payload;
|
|
|
|
expect(payload['minHeartRateBpm'], 90);
|
|
expect(payload['averageHeartRateBpm'], 120);
|
|
expect(payload['maxHeartRateBpm'], 150);
|
|
expect(payload['totalDistanceMeters'], 42);
|
|
expect(payload['totalCaloriesKcal'], 12);
|
|
expect(payload['results'], hasLength(1));
|
|
expect(payload['stepResults'], hasLength(1));
|
|
});
|
|
|
|
test('local sync pull restores exercise images and steps', () async {
|
|
final now = DateTime.utc(2026, 7, 22, 10, 50);
|
|
await mediaAssetRepository.save(
|
|
MediaAsset(
|
|
metadata: _metadata('remote-image', now),
|
|
kind: MediaKind.image,
|
|
localUri: 'file:///remote-image.png',
|
|
),
|
|
);
|
|
|
|
final applied = await syncChangeRepository.applyRemoteItem(
|
|
RemoteSyncedItem(
|
|
resourceType: SyncResourceType.exercise,
|
|
clientId: 'remote-exercise-with-children',
|
|
serverId: 'server-exercise-with-children',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: now,
|
|
serverUpdatedAt: now,
|
|
deletedAt: null,
|
|
payload: {
|
|
'id': 'remote-exercise-with-children',
|
|
'name': 'Remote exercise',
|
|
'imageMediaIds': const ['remote-image'],
|
|
'iconMediaId': 'remote-image',
|
|
'hasTimeMeasure': false,
|
|
'hasRepsMeasure': true,
|
|
'hasScoreMeasure': true,
|
|
'scoreInputMode': 'manual',
|
|
'scoreLabel': 'Paniers',
|
|
'scoreUnit': 'pts',
|
|
'steps': [
|
|
_exerciseStep(
|
|
id: 'remote-step',
|
|
position: 0,
|
|
name: 'Tir main droite',
|
|
type: ExerciseStepType.reps,
|
|
defaultTargetValue: 10,
|
|
hasScore: true,
|
|
scoreLabel: 'Paniers',
|
|
scoreUnit: 'pts',
|
|
linkedToSeriesScore: true,
|
|
).toSnapshotJson(),
|
|
],
|
|
},
|
|
),
|
|
);
|
|
|
|
final exercise = await exerciseRepository.findById(
|
|
'remote-exercise-with-children',
|
|
);
|
|
|
|
expect(applied, isTrue);
|
|
expect(exercise!.imageMediaIds, ['remote-image']);
|
|
expect(exercise.steps, hasLength(1));
|
|
expect(exercise.steps.single.linkedToSeriesScore, isTrue);
|
|
});
|
|
|
|
test('local sync pull restores full workout history aggregate', () async {
|
|
final now = DateTime.utc(2026, 7, 22, 10, 55);
|
|
final payload = <String, Object?>{
|
|
'metadata': {
|
|
'id': 'remote-history-full',
|
|
'createdAt': now.toUtc().toIso8601String(),
|
|
'updatedAt': now.toUtc().toIso8601String(),
|
|
'schemaVersion': 1,
|
|
'syncState': 'synced',
|
|
'localRevision': 0,
|
|
'originDeviceId': 'device-remote',
|
|
},
|
|
'id': 'remote-history-full',
|
|
'nameSnapshot': 'Remote history',
|
|
'startedAt': now.toUtc().toIso8601String(),
|
|
'endedAt': now.add(const Duration(minutes: 5)).toUtc().toIso8601String(),
|
|
'totalActiveMs': 300000,
|
|
'completed': true,
|
|
'historySnapshotJson': '{"name":"remote-history-full"}',
|
|
'minHeartRateBpm': 95,
|
|
'averageHeartRateBpm': 125,
|
|
'maxHeartRateBpm': 155,
|
|
'totalDistanceMeters': 84,
|
|
'totalCaloriesKcal': 24,
|
|
'results': [
|
|
{
|
|
'id': 'remote-history-set-result',
|
|
'workoutHistoryId': 'remote-history-full',
|
|
'programSnapshotId': 'program-snapshot',
|
|
'exerciseSnapshotId': 'exercise-snapshot-remote-exercise',
|
|
'programIndex': 0,
|
|
'exerciseIndex': 0,
|
|
'setIndex': 0,
|
|
'programNameSnapshot': 'Program',
|
|
'exerciseNameSnapshot': 'Exercise',
|
|
'timeEnabledSnapshot': false,
|
|
'repsEnabledSnapshot': false,
|
|
'scoreEnabledSnapshot': true,
|
|
'scoreInputModeSnapshot': 'stopwatch',
|
|
'actualScoreTimeMs': 12000,
|
|
'sourceExerciseIdSnapshot': 'remote-exercise',
|
|
'completedAt': now
|
|
.add(const Duration(minutes: 1))
|
|
.toUtc()
|
|
.toIso8601String(),
|
|
'status': 'completed',
|
|
},
|
|
],
|
|
'stepResults': [
|
|
{
|
|
'id': 'remote-history-step-result',
|
|
'workoutHistoryId': 'remote-history-full',
|
|
'programSnapshotId': 'program-snapshot',
|
|
'exerciseSnapshotId': 'exercise-snapshot-remote-exercise',
|
|
'programIndex': 0,
|
|
'exerciseIndex': 0,
|
|
'setIndex': 0,
|
|
'passageIndex': 0,
|
|
'stepIndex': 0,
|
|
'stepSnapshotId': 'step-snapshot',
|
|
'stepNameSnapshot': 'Step',
|
|
'stepTypeSnapshot': 'reps',
|
|
'targetValueSnapshot': 10,
|
|
'hasScoreSnapshot': false,
|
|
'status': 'completed',
|
|
'startedAt': now.toUtc().toIso8601String(),
|
|
'completedAt': now
|
|
.add(const Duration(seconds: 10))
|
|
.toUtc()
|
|
.toIso8601String(),
|
|
'actualReps': 10,
|
|
'sourceExerciseIdSnapshot': 'remote-exercise',
|
|
},
|
|
],
|
|
};
|
|
|
|
final applied = await syncChangeRepository.applyRemoteItem(
|
|
RemoteSyncedItem(
|
|
resourceType: SyncResourceType.workoutHistory,
|
|
clientId: 'remote-history-full',
|
|
serverId: 'server-history-full',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: now,
|
|
serverUpdatedAt: now,
|
|
deletedAt: null,
|
|
payload: payload,
|
|
),
|
|
);
|
|
|
|
final restored = await historyRepository.findById('remote-history-full');
|
|
|
|
expect(applied, isTrue);
|
|
expect(restored!.minHeartRateBpm, 95);
|
|
expect(restored.averageHeartRateBpm, 125);
|
|
expect(restored.maxHeartRateBpm, 155);
|
|
expect(restored.totalDistanceMeters, 84);
|
|
expect(restored.totalCaloriesKcal, 24);
|
|
expect(restored.results.single.actualScoreTimeMs, 12000);
|
|
expect(restored.stepResults.single.actualReps, 10);
|
|
});
|
|
|
|
test('local sync pull defaults missing tags to empty lists', () async {
|
|
final now = DateTime.utc(2026, 7, 22, 11);
|
|
await syncChangeRepository.applyRemoteItem(
|
|
RemoteSyncedItem(
|
|
resourceType: SyncResourceType.exercise,
|
|
clientId: 'remote-exercise-no-tags',
|
|
serverId: 'server-exercise-no-tags',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: now,
|
|
serverUpdatedAt: now,
|
|
deletedAt: null,
|
|
payload: const {
|
|
'id': 'remote-exercise-no-tags',
|
|
'name': 'Remote exercise',
|
|
'hasTimeMeasure': false,
|
|
'hasRepsMeasure': true,
|
|
'hasScoreMeasure': false,
|
|
},
|
|
),
|
|
);
|
|
await syncChangeRepository.applyRemoteItem(
|
|
RemoteSyncedItem(
|
|
resourceType: SyncResourceType.program,
|
|
clientId: 'remote-program-no-tags',
|
|
serverId: 'server-program-no-tags',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: now,
|
|
serverUpdatedAt: now,
|
|
deletedAt: null,
|
|
payload: const {
|
|
'id': 'remote-program-no-tags',
|
|
'name': 'Remote program',
|
|
'defaultRestSeconds': 30,
|
|
},
|
|
),
|
|
);
|
|
await syncChangeRepository.applyRemoteItem(
|
|
RemoteSyncedItem(
|
|
resourceType: SyncResourceType.workoutTemplate,
|
|
clientId: 'remote-template-no-tags',
|
|
serverId: 'server-template-no-tags',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: now,
|
|
serverUpdatedAt: now,
|
|
deletedAt: null,
|
|
payload: const {
|
|
'id': 'remote-template-no-tags',
|
|
'name': 'Remote template',
|
|
},
|
|
),
|
|
);
|
|
|
|
final exercise = await exerciseRepository.findById(
|
|
'remote-exercise-no-tags',
|
|
);
|
|
final program = await programRepository.findById('remote-program-no-tags');
|
|
final template = await templateRepository.findById(
|
|
'remote-template-no-tags',
|
|
);
|
|
|
|
expect(exercise!.tags, isEmpty);
|
|
expect(program!.tags, isEmpty);
|
|
expect(template!.tags, isEmpty);
|
|
});
|
|
|
|
test(
|
|
'local sync pull keeps program loadable when source exercise is absent',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 23, 11);
|
|
|
|
final applied = await syncChangeRepository.applyRemoteItem(
|
|
RemoteSyncedItem(
|
|
resourceType: SyncResourceType.program,
|
|
clientId: 'remote-program-missing-exercise',
|
|
serverId: 'server-program-missing-exercise',
|
|
schemaVersion: 1,
|
|
clientUpdatedAt: now,
|
|
serverUpdatedAt: now,
|
|
deletedAt: null,
|
|
payload: const {
|
|
'id': 'remote-program-missing-exercise',
|
|
'name': 'Remote program',
|
|
'defaultRestSeconds': 30,
|
|
'exercises': [
|
|
{
|
|
'id': 'remote-program-exercise-missing-source',
|
|
'sourceExerciseId': 'remote-exercise-deleted',
|
|
'position': 0,
|
|
'exerciseNameSnapshot': 'Remote deleted exercise',
|
|
'availableTimeSnapshot': false,
|
|
'availableRepsSnapshot': true,
|
|
'availableScoreSnapshot': false,
|
|
'setsCount': 2,
|
|
'timeEnabled': false,
|
|
'repsEnabled': true,
|
|
'scoreEnabled': false,
|
|
'targetReps': 15,
|
|
},
|
|
],
|
|
},
|
|
),
|
|
);
|
|
|
|
final program = await programRepository.findById(
|
|
'remote-program-missing-exercise',
|
|
);
|
|
|
|
expect(applied, isTrue);
|
|
expect(program, isNotNull);
|
|
expect(program!.exercises, hasLength(1));
|
|
expect(program.exercises.single.sourceExerciseId, isNull);
|
|
expect(
|
|
program.exercises.single.exerciseNameSnapshot,
|
|
'Remote deleted exercise',
|
|
);
|
|
expect(program.exercises.single.targetReps, 15);
|
|
},
|
|
);
|
|
|
|
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(
|
|
'local backup round-trip merges exported data into a fresh database',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 22, 10);
|
|
await exerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('roundtrip-exercise', now),
|
|
name: 'Roundtrip Shoot',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
tags: const ['match'],
|
|
),
|
|
);
|
|
await historyRepository.save(
|
|
WorkoutHistory(
|
|
metadata: _metadata('roundtrip-history', now),
|
|
nameSnapshot: 'Roundtrip Session',
|
|
startedAt: now,
|
|
endedAt: now.add(const Duration(minutes: 10)),
|
|
totalActiveMs: 600000,
|
|
completed: true,
|
|
historySnapshotJson: '{"name":"Roundtrip Session"}',
|
|
results: [
|
|
_historySetResult(
|
|
id: 'roundtrip-result',
|
|
historyId: 'roundtrip-history',
|
|
sourceExerciseId: 'roundtrip-exercise',
|
|
setIndex: 0,
|
|
startedAt: now,
|
|
actualReps: 12,
|
|
),
|
|
],
|
|
stepResults: [
|
|
_historyStepResult(
|
|
id: 'roundtrip-step-result',
|
|
historyId: 'roundtrip-history',
|
|
sourceExerciseId: 'roundtrip-exercise',
|
|
startedAt: now,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final snapshot = await localDataBackupRepository.readExportSnapshot(
|
|
DateTime.utc(2026, 7, 22, 12),
|
|
);
|
|
final bytes = const LocalBackupCodec().encode(snapshot);
|
|
final decoded = const LocalBackupCodec().decode(bytes);
|
|
|
|
final targetDatabase = local.AppDatabase(NativeDatabase.memory());
|
|
final targetExerciseRepository = local.DriftExerciseRepository(
|
|
targetDatabase,
|
|
);
|
|
final targetHistoryRepository = local.DriftWorkoutHistoryRepository(
|
|
targetDatabase,
|
|
);
|
|
final targetBackupRepository = local.DriftLocalDataBackupRepository(
|
|
targetDatabase,
|
|
);
|
|
addTearDown(targetDatabase.close);
|
|
await targetExerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('target-existing', now),
|
|
name: 'Target existing',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 6,
|
|
),
|
|
);
|
|
|
|
final result = await targetBackupRepository.applyImportSnapshot(
|
|
snapshot: decoded,
|
|
mode: LocalBackupImportMode.merge,
|
|
importedAt: DateTime.utc(2026, 7, 22, 14),
|
|
);
|
|
|
|
expect(result.insertedCount, 2);
|
|
final restoredExercise = await targetExerciseRepository.findById(
|
|
'roundtrip-exercise',
|
|
);
|
|
expect(restoredExercise!.name, 'Roundtrip Shoot');
|
|
expect(restoredExercise.tags, ['match']);
|
|
final restoredHistory = await targetHistoryRepository.findById(
|
|
'roundtrip-history',
|
|
);
|
|
expect(restoredHistory!.results, hasLength(1));
|
|
expect(restoredHistory.stepResults, hasLength(1));
|
|
expect(
|
|
await targetExerciseRepository.findById('target-existing'),
|
|
isNotNull,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'local backup round-trip replaceAll restores exported data and purges the rest',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 22, 10);
|
|
await exerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('roundtrip-replace-exercise', now),
|
|
name: 'Kept',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
),
|
|
);
|
|
|
|
final snapshot = await localDataBackupRepository.readExportSnapshot(
|
|
DateTime.utc(2026, 7, 22, 12),
|
|
);
|
|
final bytes = const LocalBackupCodec().encode(snapshot);
|
|
final decoded = const LocalBackupCodec().decode(bytes);
|
|
|
|
final targetDatabase = local.AppDatabase(NativeDatabase.memory());
|
|
final targetExerciseRepository = local.DriftExerciseRepository(
|
|
targetDatabase,
|
|
);
|
|
final targetBackupRepository = local.DriftLocalDataBackupRepository(
|
|
targetDatabase,
|
|
);
|
|
addTearDown(targetDatabase.close);
|
|
await targetExerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('target-to-purge', now),
|
|
name: 'Should disappear',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 6,
|
|
),
|
|
);
|
|
|
|
final result = await targetBackupRepository.applyImportSnapshot(
|
|
snapshot: decoded,
|
|
mode: LocalBackupImportMode.replaceAll,
|
|
importedAt: DateTime.utc(2026, 7, 22, 14),
|
|
);
|
|
|
|
expect(result.deletedByReplaceCount, 1);
|
|
expect(
|
|
await targetExerciseRepository.listActive(),
|
|
isNot(
|
|
contains(
|
|
isA<Exercise>().having(
|
|
(e) => e.metadata.id,
|
|
'id',
|
|
'target-to-purge',
|
|
),
|
|
),
|
|
),
|
|
);
|
|
expect(
|
|
(await targetExerciseRepository.findById(
|
|
'target-to-purge',
|
|
))!.metadata.deletedAt,
|
|
isNotNull,
|
|
);
|
|
expect(
|
|
(await targetExerciseRepository.findById(
|
|
'roundtrip-replace-exercise',
|
|
))!.name,
|
|
'Kept',
|
|
);
|
|
final tombstones =
|
|
await (targetDatabase.select(targetDatabase.changeLogEntries)..where(
|
|
(table) =>
|
|
table.entityId.equals('target-to-purge') &
|
|
table.operation.equals('softDelete'),
|
|
))
|
|
.get();
|
|
expect(tombstones, hasLength(1));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'local backup import rolls back all mutations when applying a resource fails',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 22, 10);
|
|
await exerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('rollback-existing', now),
|
|
name: 'Existing',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
),
|
|
);
|
|
final snapshot = LocalDataExportSnapshot(
|
|
exportedAt: DateTime.utc(2026, 7, 22, 12),
|
|
appSchemaVersion: 19,
|
|
originDeviceId: 'device-backup',
|
|
mediaAssets: const [],
|
|
exercises: [
|
|
_backupExerciseResource(
|
|
id: 'rollback-ok',
|
|
name: 'Should not persist',
|
|
updatedAt: now.add(const Duration(hours: 1)),
|
|
),
|
|
],
|
|
programs: const [],
|
|
workoutTemplates: const [],
|
|
// totalActiveMs has the wrong type, which makes the cast throw while
|
|
// applying this resource, after the exercise above already inserted.
|
|
workoutHistories: [
|
|
LocalBackupResource(
|
|
id: 'rollback-broken-history',
|
|
updatedAt: now.add(const Duration(hours: 1)),
|
|
payload: const {
|
|
'id': 'rollback-broken-history',
|
|
'totalActiveMs': 'not-a-number',
|
|
},
|
|
),
|
|
],
|
|
);
|
|
|
|
await expectLater(
|
|
localDataBackupRepository.applyImportSnapshot(
|
|
snapshot: snapshot,
|
|
mode: LocalBackupImportMode.merge,
|
|
importedAt: DateTime.utc(2026, 7, 22, 14),
|
|
),
|
|
throwsA(anything),
|
|
);
|
|
|
|
expect(await exerciseRepository.findById('rollback-ok'), isNull);
|
|
expect(
|
|
(await exerciseRepository.findById('rollback-existing'))!.name,
|
|
'Existing',
|
|
);
|
|
},
|
|
);
|
|
|
|
test('program duplication persists copied children through Drift', () async {
|
|
final now = DateTime.utc(2026, 7, 22, 11, 15);
|
|
await exerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('exercise-dup-source', now),
|
|
name: 'Shoot',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
),
|
|
);
|
|
await programRepository.replaceExercises(
|
|
Program(
|
|
metadata: _metadata('program-dup-source', now),
|
|
name: 'Programme tirs',
|
|
defaultRestSeconds: 30,
|
|
isExample: true,
|
|
tags: const ['match'],
|
|
exercises: [
|
|
_programExercise(
|
|
'program-exercise-dup-source',
|
|
now,
|
|
programId: 'program-dup-source',
|
|
position: 0,
|
|
),
|
|
],
|
|
),
|
|
now,
|
|
);
|
|
final useCase = ProgramUseCases(
|
|
programRepository: programRepository,
|
|
exerciseRepository: exerciseRepository,
|
|
templateRepository: templateRepository,
|
|
clock: _FakeClock(now.add(const Duration(minutes: 1))),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final copy = await useCase.duplicate('program-dup-source');
|
|
final restored = await programRepository.findById(copy.metadata.id);
|
|
|
|
expect(restored, isNotNull);
|
|
expect(restored!.name, 'Copie de Programme tirs');
|
|
expect(restored.isExample, isFalse);
|
|
expect(restored.tags, ['match']);
|
|
expect(
|
|
restored.exercises.single.metadata.id,
|
|
isNot('program-exercise-dup-source'),
|
|
);
|
|
expect(restored.exercises.single.programId, restored.metadata.id);
|
|
expect(restored.exercises.single.exerciseNameSnapshot, 'Exercise 0');
|
|
});
|
|
|
|
test(
|
|
'workout template duplication persists remapped overrides through Drift',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 22, 11, 30);
|
|
await programRepository.save(
|
|
Program(
|
|
metadata: _metadata('program-template-source', now),
|
|
name: 'Program source',
|
|
defaultRestSeconds: 30,
|
|
),
|
|
);
|
|
await templateRepository.replaceComposition(
|
|
WorkoutTemplate(
|
|
metadata: _metadata('template-dup-source', now),
|
|
name: 'Prépa match',
|
|
lastStartedAt: now,
|
|
isExample: true,
|
|
tags: const ['intense'],
|
|
programs: [
|
|
WorkoutTemplateProgram(
|
|
metadata: _metadata('template-program-dup-source', now),
|
|
workoutTemplateId: 'template-dup-source',
|
|
sourceProgramId: 'program-template-source',
|
|
position: 0,
|
|
programNameSnapshot: 'Program source',
|
|
defaultRestSecondsSnapshot: 30,
|
|
programSnapshotJson: '{"exercises":[]}',
|
|
),
|
|
],
|
|
overrides: [
|
|
WorkoutTemplateExerciseOverride(
|
|
metadata: _metadata('template-override-dup-source', now),
|
|
workoutTemplateProgramId: 'template-program-dup-source',
|
|
snapshotProgramExerciseId: 'snapshot-exercise',
|
|
setsCountOverride: 4,
|
|
),
|
|
],
|
|
),
|
|
now,
|
|
);
|
|
final useCase = WorkoutTemplateUseCases(
|
|
templateRepository: templateRepository,
|
|
programRepository: programRepository,
|
|
clock: _FakeClock(now.add(const Duration(minutes: 1))),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final copy = await useCase.duplicate('template-dup-source');
|
|
final restored = await templateRepository.findById(copy.metadata.id);
|
|
|
|
expect(restored, isNotNull);
|
|
expect(restored!.name, 'Copie de Prépa match');
|
|
expect(restored.lastStartedAt, isNull);
|
|
expect(restored.isExample, isFalse);
|
|
expect(restored.tags, ['intense']);
|
|
expect(
|
|
restored.programs.single.metadata.id,
|
|
isNot('template-program-dup-source'),
|
|
);
|
|
expect(restored.programs.single.workoutTemplateId, restored.metadata.id);
|
|
expect(
|
|
restored.overrides.single.metadata.id,
|
|
isNot('template-override-dup-source'),
|
|
);
|
|
expect(
|
|
restored.overrides.single.workoutTemplateProgramId,
|
|
restored.programs.single.metadata.id,
|
|
);
|
|
expect(
|
|
restored.overrides.single.snapshotProgramExerciseId,
|
|
'snapshot-exercise',
|
|
);
|
|
},
|
|
);
|
|
|
|
test('starter seed populates a fresh database once', () async {
|
|
final seedRepository = local.DriftStarterSeedRepository(database);
|
|
final result = await SeedStarterContentUseCase(
|
|
seedStateRepository: seedRepository,
|
|
contentRepository: seedRepository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)),
|
|
originDeviceId: 'local-device',
|
|
).run();
|
|
|
|
expect(result.status, StarterSeedStatus.inserted);
|
|
expect(await seedRepository.readAppliedStarterSeedVersion(), 1);
|
|
|
|
final exercises = await exerciseRepository.listActive();
|
|
final programs = await programRepository.listActive();
|
|
final templates = await templateRepository.listActive();
|
|
expect(exercises, hasLength(21));
|
|
expect(programs, hasLength(1));
|
|
expect(templates, hasLength(1));
|
|
expect(exercises.every((exercise) => exercise.isExample), isTrue);
|
|
expect(programs.single.isExample, isTrue);
|
|
expect(templates.single.isExample, isTrue);
|
|
expect(exercises.map((exercise) => exercise.category).toSet(), {
|
|
ExerciseCategory.shoot,
|
|
ExerciseCategory.freeThrows,
|
|
ExerciseCategory.dribble,
|
|
ExerciseCategory.finishing,
|
|
ExerciseCategory.conditioning,
|
|
ExerciseCategory.defense,
|
|
ExerciseCategory.mobility,
|
|
});
|
|
|
|
final secondRun = await SeedStarterContentUseCase(
|
|
seedStateRepository: seedRepository,
|
|
contentRepository: seedRepository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 21, 9)),
|
|
originDeviceId: 'local-device',
|
|
).run();
|
|
expect(secondRun.status, StarterSeedStatus.skippedAlreadyApplied);
|
|
expect(await exerciseRepository.listActive(), hasLength(21));
|
|
});
|
|
|
|
test(
|
|
'starter seed marks non-empty database without inserting examples',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 21, 8);
|
|
await exerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('user-exercise-1', now),
|
|
name: 'Exercice utilisateur',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
),
|
|
);
|
|
final seedRepository = local.DriftStarterSeedRepository(database);
|
|
|
|
final result = await SeedStarterContentUseCase(
|
|
seedStateRepository: seedRepository,
|
|
contentRepository: seedRepository,
|
|
clock: _FakeClock(now),
|
|
originDeviceId: 'local-device',
|
|
).run();
|
|
|
|
expect(result.status, StarterSeedStatus.skippedNotEmpty);
|
|
expect(await seedRepository.readAppliedStarterSeedVersion(), 1);
|
|
expect(await exerciseRepository.listActive(), hasLength(1));
|
|
expect(await programRepository.listActive(), isEmpty);
|
|
expect(await templateRepository.listActive(), isEmpty);
|
|
},
|
|
);
|
|
|
|
test('starter seed does not reappear after example deletion', () async {
|
|
final seedRepository = local.DriftStarterSeedRepository(database);
|
|
await SeedStarterContentUseCase(
|
|
seedStateRepository: seedRepository,
|
|
contentRepository: seedRepository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)),
|
|
originDeviceId: 'local-device',
|
|
).run();
|
|
|
|
final exercise = (await exerciseRepository.listActive()).first;
|
|
await exerciseRepository.save(
|
|
exercise.copyWith(
|
|
metadata: exercise.metadata.markDeleted(DateTime.utc(2026, 7, 21, 9)),
|
|
),
|
|
);
|
|
await SeedStarterContentUseCase(
|
|
seedStateRepository: seedRepository,
|
|
contentRepository: seedRepository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 21, 10)),
|
|
originDeviceId: 'local-device',
|
|
).run();
|
|
|
|
expect(await exerciseRepository.listActive(), hasLength(20));
|
|
expect(await exerciseRepository.findById(exercise.metadata.id), isNotNull);
|
|
});
|
|
|
|
test('program remains loadable after source exercise deletion', () async {
|
|
final now = DateTime.utc(2026, 7, 23, 9);
|
|
final exercise = Exercise(
|
|
metadata: _metadata('exercise-deleted-source', now),
|
|
name: 'Tirs en course',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 8,
|
|
);
|
|
await exerciseRepository.save(exercise);
|
|
await programRepository.save(
|
|
Program(
|
|
metadata: _metadata('program-deleted-source', now),
|
|
name: 'Programme source supprimée',
|
|
defaultRestSeconds: 30,
|
|
exercises: [
|
|
ProgramExercise.snapshotFromExercise(
|
|
metadata: _metadata('program-exercise-deleted-source', now),
|
|
programId: 'program-deleted-source',
|
|
exercise: exercise,
|
|
position: 0,
|
|
setsCount: 3,
|
|
enabledMeasures: const {WorkoutMeasure.reps},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
await exerciseRepository.save(
|
|
exercise.copyWith(
|
|
metadata: exercise.metadata.markDeleted(
|
|
now.add(const Duration(minutes: 1)),
|
|
),
|
|
),
|
|
);
|
|
|
|
final programs = await programRepository.listActive();
|
|
|
|
expect(programs, hasLength(1));
|
|
expect(programs.single.name, 'Programme source supprimée');
|
|
expect(
|
|
programs.single.exercises.single.sourceExerciseId,
|
|
exercise.metadata.id,
|
|
);
|
|
expect(
|
|
programs.single.exercises.single.exerciseNameSnapshot,
|
|
'Tirs en course',
|
|
);
|
|
expect(programs.single.exercises.single.targetReps, 8);
|
|
});
|
|
|
|
test(
|
|
'starter program and workout template snapshot exercise steps',
|
|
() async {
|
|
final seedRepository = local.DriftStarterSeedRepository(database);
|
|
await SeedStarterContentUseCase(
|
|
seedStateRepository: seedRepository,
|
|
contentRepository: seedRepository,
|
|
clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)),
|
|
originDeviceId: 'local-device',
|
|
).run();
|
|
|
|
final program = (await programRepository.listActive()).single;
|
|
expect(program.name, 'Fondations basket - 45 min');
|
|
expect(program.exercises, hasLength(8));
|
|
expect(
|
|
program.exercises
|
|
.map((exercise) => exercise.setsCount)
|
|
.reduce((total, count) => total + count),
|
|
22,
|
|
);
|
|
final spotShooting = program.exercises.singleWhere(
|
|
(exercise) =>
|
|
exercise.exerciseNameSnapshot == 'Spot shooting 5 positions',
|
|
);
|
|
expect(spotShooting.exerciseStepsSnapshot, hasLength(5));
|
|
expect(spotShooting.exerciseStepsSnapshot.map((step) => step.name), [
|
|
'Coin droit',
|
|
'Aile droite',
|
|
'Face cercle',
|
|
'Aile gauche',
|
|
'Coin gauche',
|
|
]);
|
|
|
|
final template = (await templateRepository.listActive()).single;
|
|
expect(template.name, 'Séance exemple - Fondations basket');
|
|
expect(template.programs, hasLength(1));
|
|
expect(template.programs.single.sourceProgramId, program.metadata.id);
|
|
final snapshot =
|
|
jsonDecode(template.programs.single.programSnapshotJson)
|
|
as Map<String, Object?>;
|
|
expect(snapshot['programId'], program.metadata.id);
|
|
expect(snapshot['name'], program.name);
|
|
expect(snapshot['exercises'], isA<List<Object?>>());
|
|
expect(snapshot['exercises'] as List<Object?>, hasLength(8));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'step chaining settings round-trip through drift repositories',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 17, 12);
|
|
final exercise = Exercise(
|
|
metadata: _metadata('exercise-chain-1', now),
|
|
name: 'Circuit',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 10,
|
|
autoStartNextTimedStep: false,
|
|
);
|
|
await exerciseRepository.save(exercise);
|
|
|
|
final restoredExercise = await exerciseRepository.findById(
|
|
exercise.metadata.id,
|
|
);
|
|
expect(restoredExercise!.autoStartNextTimedStep, isFalse);
|
|
|
|
final programExercise = _programExercise(
|
|
'program-exercise-chain-1',
|
|
now,
|
|
programId: 'program-chain-1',
|
|
position: 0,
|
|
autoStartNextTimedStepSnapshot: false,
|
|
autoStartNextTimedStepOverride: true,
|
|
);
|
|
await programRepository.save(
|
|
Program(
|
|
metadata: _metadata('program-chain-1', now),
|
|
name: 'Programme',
|
|
defaultRestSeconds: 60,
|
|
exercises: [programExercise],
|
|
),
|
|
);
|
|
|
|
final restoredProgram = await programRepository.findById(
|
|
'program-chain-1',
|
|
);
|
|
expect(
|
|
restoredProgram!.exercises.single.autoStartNextTimedStepSnapshot,
|
|
isFalse,
|
|
);
|
|
expect(
|
|
restoredProgram.exercises.single.autoStartNextTimedStepOverride,
|
|
isTrue,
|
|
);
|
|
|
|
final templateProgram = WorkoutTemplateProgram(
|
|
metadata: _metadata('template-program-chain-1', now),
|
|
workoutTemplateId: 'template-chain-1',
|
|
sourceProgramId: 'program-chain-1',
|
|
position: 0,
|
|
programNameSnapshot: 'Programme',
|
|
defaultRestSecondsSnapshot: 60,
|
|
programSnapshotJson: jsonEncode({
|
|
'exercises': [programExercise.toSnapshotJson()],
|
|
}),
|
|
);
|
|
await templateRepository.save(
|
|
WorkoutTemplate(
|
|
metadata: _metadata('template-chain-1', now),
|
|
name: 'Séance',
|
|
programs: [templateProgram],
|
|
overrides: [
|
|
WorkoutTemplateExerciseOverride(
|
|
metadata: _metadata('override-chain-1', now),
|
|
workoutTemplateProgramId: templateProgram.metadata.id,
|
|
snapshotProgramExerciseId: programExercise.metadata.id,
|
|
),
|
|
WorkoutTemplateExerciseOverride(
|
|
metadata: _metadata('override-chain-2', now),
|
|
workoutTemplateProgramId: templateProgram.metadata.id,
|
|
snapshotProgramExerciseId: 'program-exercise-chain-2',
|
|
autoStartNextTimedStepOverride: false,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final restoredTemplate = await templateRepository.findById(
|
|
'template-chain-1',
|
|
);
|
|
expect(
|
|
restoredTemplate!.overrides
|
|
.singleWhere(
|
|
(override) => override.metadata.id == 'override-chain-1',
|
|
)
|
|
.autoStartNextTimedStepOverride,
|
|
isNull,
|
|
);
|
|
expect(
|
|
restoredTemplate.overrides
|
|
.singleWhere(
|
|
(override) => override.metadata.id == 'override-chain-2',
|
|
)
|
|
.autoStartNextTimedStepOverride,
|
|
isFalse,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('exercise repository round-trips configured steps', () async {
|
|
final now = DateTime.utc(2026, 7, 17, 12);
|
|
final exercise = Exercise(
|
|
metadata: _metadata('exercise-steps-1', now),
|
|
name: 'Burpee complexe',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 1,
|
|
steps: [
|
|
ExerciseStep(
|
|
id: 'step-1',
|
|
position: 0,
|
|
name: 'Planche',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 20,
|
|
),
|
|
ExerciseStep(
|
|
id: 'step-2',
|
|
position: 1,
|
|
name: 'Sauts',
|
|
type: ExerciseStepType.reps,
|
|
defaultTargetValue: 8,
|
|
hasScore: true,
|
|
scoreLabel: 'Amplitude',
|
|
scoreUnit: 'pts',
|
|
defaultTargetScore: 0,
|
|
),
|
|
ExerciseStep(
|
|
id: 'step-3',
|
|
position: 2,
|
|
name: 'Sprint final',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 10,
|
|
hasScore: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
defaultTargetScoreTimeMs: 12000,
|
|
),
|
|
],
|
|
);
|
|
|
|
await exerciseRepository.save(exercise);
|
|
|
|
final restored = await exerciseRepository.findById(exercise.metadata.id);
|
|
|
|
expect(restored, isNotNull);
|
|
expect(restored!.steps, hasLength(3));
|
|
expect(restored.steps.map((step) => step.name), [
|
|
'Planche',
|
|
'Sauts',
|
|
'Sprint final',
|
|
]);
|
|
expect(restored.steps[1].defaultTargetScore, 0);
|
|
expect(restored.steps[2].scoreInputMode, ScoreInputMode.stopwatch);
|
|
expect(restored.steps[2].defaultTargetScoreTimeMs, 12000);
|
|
});
|
|
|
|
group('exercise repository round-trips exercise option combinations', () {
|
|
final now = DateTime.utc(2026, 7, 17, 12);
|
|
final cases = <_ExerciseRoundTripCase>[
|
|
_ExerciseRoundTripCase(
|
|
label: 'reps without score or steps',
|
|
exercise: Exercise(
|
|
metadata: _metadata('exercise-combo-reps', now),
|
|
name: 'Pompes',
|
|
description: 'Simple reps target',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 12,
|
|
),
|
|
),
|
|
_ExerciseRoundTripCase(
|
|
label: 'reps without score and with steps',
|
|
exercise: Exercise(
|
|
metadata: _metadata('exercise-combo-reps-steps', now),
|
|
name: 'Complexe poids du corps',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 1,
|
|
steps: [
|
|
_exerciseStep(
|
|
id: 'combo-reps-step-1',
|
|
position: 0,
|
|
name: 'Pompes',
|
|
type: ExerciseStepType.reps,
|
|
defaultTargetValue: 10,
|
|
),
|
|
_exerciseStep(
|
|
id: 'combo-reps-step-2',
|
|
position: 1,
|
|
name: 'Gainage',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 30,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_ExerciseRoundTripCase(
|
|
label: 'manual score without steps',
|
|
exercise: Exercise(
|
|
metadata: _metadata('exercise-combo-manual-score', now),
|
|
name: 'Charge max',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
defaultTargetScore: 0,
|
|
),
|
|
),
|
|
_ExerciseRoundTripCase(
|
|
label: 'manual score with unscored steps',
|
|
exercise: Exercise(
|
|
metadata: _metadata('exercise-combo-manual-score-steps', now),
|
|
name: 'Technique haltères',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: true,
|
|
scoreLabel: 'Charge',
|
|
scoreUnit: 'kg',
|
|
defaultTargetReps: 8,
|
|
defaultTargetScore: 12.5,
|
|
steps: [
|
|
_exerciseStep(
|
|
id: 'combo-manual-score-step-1',
|
|
position: 0,
|
|
name: 'Installation',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 15,
|
|
),
|
|
_exerciseStep(
|
|
id: 'combo-manual-score-step-2',
|
|
position: 1,
|
|
name: 'Serie',
|
|
type: ExerciseStepType.reps,
|
|
defaultTargetValue: 8,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_ExerciseRoundTripCase(
|
|
label: 'manual exercise score with manual scored steps',
|
|
exercise: Exercise(
|
|
metadata: _metadata('exercise-combo-manual-step-score', now),
|
|
name: 'Circuit precision',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: true,
|
|
scoreLabel: 'Qualite',
|
|
scoreUnit: 'pts',
|
|
defaultTargetTimeSeconds: 45,
|
|
defaultTargetReps: 10,
|
|
defaultTargetScore: 80,
|
|
autoStartNextTimedStep: false,
|
|
steps: [
|
|
_exerciseStep(
|
|
id: 'combo-manual-step-score-1',
|
|
position: 0,
|
|
name: 'Bloc reps',
|
|
type: ExerciseStepType.reps,
|
|
defaultTargetValue: 10,
|
|
hasScore: true,
|
|
scoreLabel: 'Amplitude',
|
|
scoreUnit: 'pts',
|
|
defaultTargetScore: 0,
|
|
),
|
|
_exerciseStep(
|
|
id: 'combo-manual-step-score-2',
|
|
position: 1,
|
|
name: 'Bloc temps',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 35,
|
|
hasScore: true,
|
|
scoreLabel: 'Tenue',
|
|
scoreUnit: 's',
|
|
defaultTargetScore: 35,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_ExerciseRoundTripCase(
|
|
label: 'stopwatch score without steps',
|
|
exercise: Exercise(
|
|
metadata: _metadata('exercise-combo-stopwatch-score', now),
|
|
name: 'Sprint chrono',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: false,
|
|
hasScoreMeasure: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
defaultTargetTimeSeconds: 20,
|
|
defaultTargetScoreTimeMs: 11500,
|
|
),
|
|
),
|
|
_ExerciseRoundTripCase(
|
|
label: 'stopwatch score with stopwatch scored steps',
|
|
exercise: Exercise(
|
|
metadata: _metadata('exercise-combo-stopwatch-score-steps', now),
|
|
name: 'Sprint fractionne',
|
|
imageMediaIds: const ['media-start', 'media-finish'],
|
|
iconMediaId: 'media-start',
|
|
videoMediaId: 'video-demo',
|
|
hasTimeMeasure: true,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
defaultTargetTimeSeconds: 60,
|
|
defaultTargetReps: 4,
|
|
defaultTargetScoreTimeMs: 42000,
|
|
steps: [
|
|
_exerciseStep(
|
|
id: 'combo-stopwatch-step-1',
|
|
position: 0,
|
|
name: 'Acceleration',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 10,
|
|
hasScore: true,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
defaultTargetScoreTimeMs: 9500,
|
|
),
|
|
_exerciseStep(
|
|
id: 'combo-stopwatch-step-2',
|
|
position: 1,
|
|
name: 'Recuperation active',
|
|
type: ExerciseStepType.reps,
|
|
defaultTargetValue: 6,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
];
|
|
|
|
for (final roundTripCase in cases) {
|
|
test(roundTripCase.label, () async {
|
|
await exerciseRepository.save(roundTripCase.exercise);
|
|
|
|
final restored = await exerciseRepository.findById(
|
|
roundTripCase.exercise.metadata.id,
|
|
);
|
|
|
|
expect(restored, isNotNull);
|
|
_expectExerciseEquals(restored!, roundTripCase.exercise);
|
|
});
|
|
}
|
|
});
|
|
|
|
test(
|
|
'program exercise step snapshot is preserved when source exercise changes',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 17, 12);
|
|
final source = Exercise(
|
|
metadata: _metadata('exercise-source-1', now),
|
|
name: 'Complexe original',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
defaultTargetReps: 1,
|
|
steps: [
|
|
ExerciseStep(
|
|
id: 'step-original-1',
|
|
position: 0,
|
|
name: 'Phase originale A',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 30,
|
|
),
|
|
ExerciseStep(
|
|
id: 'step-original-2',
|
|
position: 1,
|
|
name: 'Phase originale B',
|
|
type: ExerciseStepType.reps,
|
|
defaultTargetValue: 12,
|
|
),
|
|
],
|
|
);
|
|
await exerciseRepository.save(source);
|
|
await programRepository.save(
|
|
Program(
|
|
metadata: _metadata('program-steps-1', now),
|
|
name: 'Programme étapes',
|
|
defaultRestSeconds: 60,
|
|
),
|
|
);
|
|
final useCase = ProgramUseCases(
|
|
programRepository: programRepository,
|
|
exerciseRepository: exerciseRepository,
|
|
templateRepository: templateRepository,
|
|
clock: _FakeClock(now),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final snapshot = await useCase.addExercise(
|
|
programId: 'program-steps-1',
|
|
exerciseId: source.metadata.id,
|
|
position: 0,
|
|
setsCount: 1,
|
|
enabledMeasures: const {WorkoutMeasure.reps},
|
|
);
|
|
await exerciseRepository.save(
|
|
source.copyWith(
|
|
metadata: _metadata(
|
|
source.metadata.id,
|
|
now.add(const Duration(minutes: 1)),
|
|
1,
|
|
),
|
|
steps: [
|
|
ExerciseStep(
|
|
id: 'step-updated-1',
|
|
position: 0,
|
|
name: 'Phase modifiée',
|
|
type: ExerciseStepType.time,
|
|
defaultTargetValue: 45,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final restoredProgram = await programRepository.findById(
|
|
'program-steps-1',
|
|
);
|
|
final restoredSnapshot = restoredProgram!.exercises.single;
|
|
final snapshotJson = snapshot.toSnapshotJson();
|
|
|
|
expect(restoredSnapshot.exerciseStepsSnapshot, hasLength(2));
|
|
expect(
|
|
restoredSnapshot.exerciseStepsSnapshot.first.name,
|
|
'Phase originale A',
|
|
);
|
|
expect(
|
|
restoredSnapshot.exerciseStepsSnapshot.last.type,
|
|
ExerciseStepType.reps,
|
|
);
|
|
expect(
|
|
(snapshotJson['exerciseStepsSnapshot'] as List).map(
|
|
(step) => (step as Map)['name'],
|
|
),
|
|
['Phase originale A', 'Phase originale B'],
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'removing a program exercise writes a soft delete change log entry',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 17, 12);
|
|
final first = _programExercise('program-exercise-1', now, position: 0);
|
|
final second = _programExercise('program-exercise-2', now, position: 1);
|
|
await programRepository.save(
|
|
Program(
|
|
metadata: _metadata('program-1', now),
|
|
name: 'Jambes',
|
|
defaultRestSeconds: 60,
|
|
exercises: [first, second],
|
|
),
|
|
);
|
|
|
|
final deletedAt = now.add(const Duration(minutes: 1));
|
|
await programRepository.replaceExercises(
|
|
Program(
|
|
metadata: _metadata('program-1', deletedAt, 1),
|
|
name: 'Jambes',
|
|
defaultRestSeconds: 60,
|
|
exercises: [first],
|
|
),
|
|
deletedAt,
|
|
);
|
|
|
|
final deletedRow = await (database.select(
|
|
database.programExercises,
|
|
)..where((table) => table.id.equals('program-exercise-2'))).getSingle();
|
|
final change =
|
|
await (database.select(database.changeLogEntries)..where(
|
|
(table) =>
|
|
table.entityType.equals('ProgramExercise') &
|
|
table.entityId.equals('program-exercise-2') &
|
|
table.operation.equals('softDelete'),
|
|
))
|
|
.getSingleOrNull();
|
|
|
|
expect(deletedRow.deletedAt?.toUtc(), deletedAt);
|
|
expect(deletedRow.syncState, 'deleted');
|
|
expect(deletedRow.localRevision, 1);
|
|
expect(change, isNotNull);
|
|
expect(change!.localRevision, 1);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'running session elapsed time survives repository reconstruction',
|
|
() async {
|
|
final persistedAt = DateTime.utc(2026, 7, 17, 12);
|
|
final session = ActiveWorkoutSession(
|
|
metadata: _metadata('session-1', persistedAt),
|
|
sourceWorkoutTemplateId: null,
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: persistedAt.subtract(const Duration(seconds: 30)),
|
|
lastPersistedAt: persistedAt,
|
|
elapsedActiveMs: 30000,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
|
|
);
|
|
|
|
await activeRepository.save(session);
|
|
|
|
final afterAppKillRepository = local.DriftActiveSessionRepository(
|
|
database,
|
|
);
|
|
final afterAppKillUseCases = ActiveWorkoutSessionUseCases(
|
|
sessionRepository: afterAppKillRepository,
|
|
templateRepository: templateRepository,
|
|
clock: _FakeClock(persistedAt.add(const Duration(seconds: 20))),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final restored = await afterAppKillRepository.findOpen();
|
|
|
|
expect(restored, isNotNull);
|
|
expect(afterAppKillUseCases.elapsedActiveMilliseconds(restored!), 50000);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'active set result persists duration and stopwatch score separately',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 17, 12);
|
|
await activeRepository.save(
|
|
ActiveWorkoutSession(
|
|
metadata: _metadata('session-chrono', now),
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: now,
|
|
lastPersistedAt: now,
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
|
|
),
|
|
);
|
|
await activeRepository.saveSetResult(
|
|
ActiveSetResult(
|
|
metadata: _metadata('active-result-chrono', now),
|
|
activeWorkoutSessionId: 'session-chrono',
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualTimeMs: 30000,
|
|
actualScoreTimeMs: 12000,
|
|
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
|
|
completedAt: now,
|
|
),
|
|
);
|
|
|
|
final afterAppKillRepository = local.DriftActiveSessionRepository(
|
|
database,
|
|
);
|
|
final restored = await afterAppKillRepository.listSetResults(
|
|
'session-chrono',
|
|
);
|
|
|
|
expect(restored, hasLength(1));
|
|
expect(restored.single.actualTimeMs, 30000);
|
|
expect(restored.single.actualScoreTimeMs, 12000);
|
|
expect(restored.single.scoreInputModeSnapshot, ScoreInputMode.stopwatch);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'active score states are soft deleted with softDelete change log entries',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 17, 12);
|
|
await activeRepository.save(
|
|
ActiveWorkoutSession(
|
|
metadata: _metadata('session-score-delete', now),
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: now,
|
|
lastPersistedAt: now,
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
|
|
),
|
|
);
|
|
await activeRepository.saveScoreStopwatchState(
|
|
ActiveScoreStopwatchState(
|
|
metadata: _metadata('score-stopwatch-delete', now),
|
|
activeWorkoutSessionId: 'session-score-delete',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
status: ActiveScoreStopwatchStatus.stopped,
|
|
startedAt: now,
|
|
accumulatedMs: 12000,
|
|
stoppedAt: now.add(const Duration(seconds: 12)),
|
|
),
|
|
);
|
|
await activeRepository.saveManualScoreState(
|
|
ActiveManualScoreState(
|
|
metadata: _metadata('manual-score-delete', now),
|
|
activeWorkoutSessionId: 'session-score-delete',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
value: 3,
|
|
updatedAt: now,
|
|
),
|
|
);
|
|
|
|
final deletedAt = now.add(const Duration(minutes: 1));
|
|
await activeRepository.deleteScoreStopwatchState(
|
|
sessionId: 'session-score-delete',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
deletedAt: deletedAt,
|
|
);
|
|
await activeRepository.deleteManualScoreState(
|
|
sessionId: 'session-score-delete',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
deletedAt: deletedAt,
|
|
);
|
|
|
|
final stopwatchRow =
|
|
await (database.select(database.activeScoreStopwatchStates)
|
|
..where((table) => table.id.equals('score-stopwatch-delete')))
|
|
.getSingle();
|
|
final manualRow = await (database.select(
|
|
database.activeManualScoreStates,
|
|
)..where((table) => table.id.equals('manual-score-delete'))).getSingle();
|
|
final changes =
|
|
await (database.select(database.changeLogEntries)..where(
|
|
(table) =>
|
|
table.entityId.isIn([
|
|
'score-stopwatch-delete',
|
|
'manual-score-delete',
|
|
]) &
|
|
table.localRevision.equals(1),
|
|
))
|
|
.get();
|
|
|
|
expect(stopwatchRow.deletedAt?.toUtc(), deletedAt);
|
|
expect(stopwatchRow.syncState, 'deleted');
|
|
expect(manualRow.deletedAt?.toUtc(), deletedAt);
|
|
expect(manualRow.syncState, 'deleted');
|
|
expect(changes.map((change) => change.operation), [
|
|
'softDelete',
|
|
'softDelete',
|
|
]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'active score states keep stored identity when upserted by position',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 17, 12);
|
|
await activeRepository.save(
|
|
ActiveWorkoutSession(
|
|
metadata: _metadata('session-score-upsert', now),
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: now,
|
|
lastPersistedAt: now,
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
|
|
),
|
|
);
|
|
await activeRepository.saveScoreStopwatchState(
|
|
ActiveScoreStopwatchState(
|
|
metadata: _metadata('score-stopwatch-original', now),
|
|
activeWorkoutSessionId: 'session-score-upsert',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
status: ActiveScoreStopwatchStatus.running,
|
|
startedAt: now,
|
|
accumulatedMs: 0,
|
|
),
|
|
);
|
|
await activeRepository.saveManualScoreState(
|
|
ActiveManualScoreState(
|
|
metadata: _metadata('manual-score-original', now),
|
|
activeWorkoutSessionId: 'session-score-upsert',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
value: 1,
|
|
updatedAt: now,
|
|
),
|
|
);
|
|
|
|
final updatedAt = now.add(const Duration(minutes: 1));
|
|
await activeRepository.saveScoreStopwatchState(
|
|
ActiveScoreStopwatchState(
|
|
metadata: _metadata('score-stopwatch-new-id', updatedAt, 1),
|
|
activeWorkoutSessionId: 'session-score-upsert',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
status: ActiveScoreStopwatchStatus.paused,
|
|
startedAt: updatedAt,
|
|
accumulatedMs: 30000,
|
|
stoppedAt: updatedAt,
|
|
),
|
|
);
|
|
await activeRepository.saveManualScoreState(
|
|
ActiveManualScoreState(
|
|
metadata: _metadata('manual-score-new-id', updatedAt, 1),
|
|
activeWorkoutSessionId: 'session-score-upsert',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
value: 4,
|
|
updatedAt: updatedAt,
|
|
),
|
|
);
|
|
|
|
final stopwatchRow =
|
|
await (database.select(database.activeScoreStopwatchStates)
|
|
..where((table) => table.id.equals('score-stopwatch-original')))
|
|
.getSingle();
|
|
final manualRow =
|
|
await (database.select(database.activeManualScoreStates)
|
|
..where((table) => table.id.equals('manual-score-original')))
|
|
.getSingle();
|
|
final changes =
|
|
await (database.select(database.changeLogEntries)..where(
|
|
(table) =>
|
|
table.entityId.isIn([
|
|
'score-stopwatch-original',
|
|
'manual-score-original',
|
|
]) &
|
|
table.operation.equals('update'),
|
|
))
|
|
.get();
|
|
|
|
expect(stopwatchRow.createdAt.toUtc(), now);
|
|
expect(stopwatchRow.status, 'paused');
|
|
expect(stopwatchRow.accumulatedMs, 30000);
|
|
expect(manualRow.createdAt.toUtc(), now);
|
|
expect(manualRow.value, 4);
|
|
expect(changes.map((change) => change.localRevision), [1, 1]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'active exercise step progress keeps stored identity when upserted by position',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 17, 12);
|
|
await activeRepository.save(
|
|
ActiveWorkoutSession(
|
|
metadata: _metadata('session-step-progress-upsert', now),
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: now,
|
|
lastPersistedAt: now,
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
|
|
),
|
|
);
|
|
await activeRepository.saveExerciseStepProgressState(
|
|
ActiveExerciseStepProgressState(
|
|
metadata: _metadata('step-progress-original', now),
|
|
activeWorkoutSessionId: 'session-step-progress-upsert',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
currentPassageIndex: 0,
|
|
currentStepIndex: 0,
|
|
currentStepSnapshotId: 'step-snapshot-1',
|
|
status: ActiveExerciseStepProgressStatus.runningTimer,
|
|
startedAt: now,
|
|
accumulatedMs: 0,
|
|
lastTransitionAt: now,
|
|
),
|
|
);
|
|
|
|
final updatedAt = now.add(const Duration(minutes: 1));
|
|
await activeRepository.saveExerciseStepProgressState(
|
|
ActiveExerciseStepProgressState(
|
|
metadata: _metadata('step-progress-new-id', updatedAt, 1),
|
|
activeWorkoutSessionId: 'session-step-progress-upsert',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
currentPassageIndex: 0,
|
|
currentStepIndex: 1,
|
|
currentStepSnapshotId: 'step-snapshot-2',
|
|
status: ActiveExerciseStepProgressStatus.stoppedTimer,
|
|
accumulatedMs: 30000,
|
|
lastTransitionAt: updatedAt,
|
|
),
|
|
);
|
|
|
|
final rows = await database
|
|
.select(database.activeExerciseStepProgressStates)
|
|
.get();
|
|
final row =
|
|
await (database.select(database.activeExerciseStepProgressStates)
|
|
..where((table) => table.id.equals('step-progress-original')))
|
|
.getSingle();
|
|
final changes =
|
|
await (database.select(database.changeLogEntries)..where(
|
|
(table) =>
|
|
table.entityId.equals('step-progress-original') &
|
|
table.operation.equals('update'),
|
|
))
|
|
.get();
|
|
|
|
expect(rows, hasLength(1));
|
|
expect(row.createdAt.toUtc(), now);
|
|
expect(row.currentStepIndex, 1);
|
|
expect(row.currentStepSnapshotId, 'step-snapshot-2');
|
|
expect(row.status, 'stoppedTimer');
|
|
expect(row.accumulatedMs, 30000);
|
|
expect(changes.map((change) => change.localRevision), [1]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'closing a session stores autonomous history rows with set snapshots',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 17, 12);
|
|
final template = WorkoutTemplate(
|
|
metadata: _metadata('template-1', now),
|
|
name: 'Séance jambes',
|
|
);
|
|
await templateRepository.save(template);
|
|
|
|
final session = ActiveWorkoutSession(
|
|
metadata: _metadata('session-1', now),
|
|
sourceWorkoutTemplateId: template.metadata.id,
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: now.subtract(const Duration(minutes: 10)),
|
|
lastPersistedAt: now,
|
|
elapsedActiveMs: 600000,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
|
|
);
|
|
await activeRepository.save(session);
|
|
await activeRepository.saveSetResult(
|
|
ActiveSetResult(
|
|
metadata: _metadata('active-result-1', now),
|
|
activeWorkoutSessionId: session.metadata.id,
|
|
programSnapshotId: 'program-snapshot-1',
|
|
exerciseSnapshotId: 'exercise-snapshot-1',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: 0,
|
|
actualTimeMs: 45000,
|
|
actualReps: 10,
|
|
actualScore: 80,
|
|
scoreLabelSnapshot: 'Charge',
|
|
scoreUnitSnapshot: 'kg',
|
|
completedAt: now,
|
|
),
|
|
);
|
|
|
|
final closeUseCase = CloseWorkoutSessionUseCase(
|
|
sessionRepository: activeRepository,
|
|
historyRepository: historyRepository,
|
|
clock: _FakeClock(now.add(const Duration(seconds: 5))),
|
|
ids: _FakeIds(),
|
|
originDeviceId: 'device-1',
|
|
);
|
|
|
|
final history = await closeUseCase.close(
|
|
sessionId: session.metadata.id,
|
|
nameSnapshot: 'Séance jambes',
|
|
completed: true,
|
|
);
|
|
await templateRepository.save(
|
|
WorkoutTemplate(
|
|
metadata: template.metadata.markDeleted(now),
|
|
name: template.name,
|
|
),
|
|
);
|
|
|
|
final restored = await historyRepository.findById(history.metadata.id);
|
|
|
|
expect(restored, isNotNull);
|
|
expect(restored!.nameSnapshot, 'Séance jambes');
|
|
expect(restored.results, hasLength(1));
|
|
expect(restored.results.single.programNameSnapshot, 'Programme jambes');
|
|
expect(restored.results.single.exerciseNameSnapshot, 'Squat');
|
|
expect(restored.results.single.actualTimeMs, 45000);
|
|
expect(restored.results.single.actualReps, 10);
|
|
expect(restored.results.single.actualScore, 80);
|
|
expect(restored.results.single.scoreUnitSnapshot, 'kg');
|
|
expect(restored.results.single.sourceExerciseIdSnapshot, 'exercise-1');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'performance reference ignores skipped and null values for latest set',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 22, 10);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'history-old',
|
|
startedAt: now.subtract(const Duration(days: 2)),
|
|
result: _historySetResult(
|
|
id: 'result-old',
|
|
historyId: 'history-old',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 0,
|
|
startedAt: now.subtract(const Duration(days: 2)),
|
|
actualReps: 8,
|
|
),
|
|
),
|
|
);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'history-new',
|
|
startedAt: now,
|
|
results: [
|
|
_historySetResult(
|
|
id: 'result-skipped',
|
|
historyId: 'history-new',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 0,
|
|
startedAt: now,
|
|
status: SetResultStatus.skipped,
|
|
),
|
|
_historySetResult(
|
|
id: 'result-null',
|
|
historyId: 'history-new',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 1,
|
|
startedAt: now,
|
|
),
|
|
_historySetResult(
|
|
id: 'result-value',
|
|
historyId: 'history-new',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 2,
|
|
startedAt: now,
|
|
actualReps: 11,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final latest = await performanceReferenceRepository
|
|
.findLatestSetPerformance(
|
|
exerciseId: 'exercise-1',
|
|
activeMeasures: const ActivePerformanceMeasures(
|
|
timeEnabled: false,
|
|
repsEnabled: true,
|
|
scoreEnabled: false,
|
|
),
|
|
currentSetIndex: 0,
|
|
);
|
|
|
|
expect(latest, isNotNull);
|
|
expect(latest!.workoutHistoryId, 'history-new');
|
|
expect(latest.setIndex, 2);
|
|
expect(latest.actualReps, 11);
|
|
},
|
|
);
|
|
|
|
test('performance reference uses same set when available', () async {
|
|
final now = DateTime.utc(2026, 7, 22, 11);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'history-sets',
|
|
startedAt: now,
|
|
results: [
|
|
_historySetResult(
|
|
id: 'result-set-0',
|
|
historyId: 'history-sets',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 0,
|
|
startedAt: now,
|
|
actualReps: 7,
|
|
),
|
|
_historySetResult(
|
|
id: 'result-set-1',
|
|
historyId: 'history-sets',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 1,
|
|
startedAt: now,
|
|
actualReps: 9,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final latest = await performanceReferenceRepository
|
|
.findLatestSetPerformance(
|
|
exerciseId: 'exercise-1',
|
|
activeMeasures: const ActivePerformanceMeasures(
|
|
timeEnabled: false,
|
|
repsEnabled: true,
|
|
scoreEnabled: false,
|
|
),
|
|
currentSetIndex: 0,
|
|
);
|
|
|
|
expect(latest, isNotNull);
|
|
expect(latest!.setIndex, 0);
|
|
expect(latest.actualReps, 7);
|
|
});
|
|
|
|
test('performance reference finds records by metric rules', () async {
|
|
final now = DateTime.utc(2026, 7, 22, 12);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'history-records',
|
|
startedAt: now,
|
|
results: [
|
|
_historySetResult(
|
|
id: 'result-reps-low',
|
|
historyId: 'history-records',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 0,
|
|
startedAt: now,
|
|
actualTimeMs: 30000,
|
|
actualReps: 6,
|
|
actualScore: 15,
|
|
),
|
|
_historySetResult(
|
|
id: 'result-reps-high',
|
|
historyId: 'history-records',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 1,
|
|
startedAt: now,
|
|
actualTimeMs: 45000,
|
|
actualReps: 12,
|
|
actualScore: 20,
|
|
),
|
|
_historySetResult(
|
|
id: 'result-stopwatch-slow',
|
|
historyId: 'history-records',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 2,
|
|
startedAt: now,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
actualScoreTimeMs: 11000,
|
|
),
|
|
_historySetResult(
|
|
id: 'result-stopwatch-fast',
|
|
historyId: 'history-records',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 3,
|
|
startedAt: now,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
actualScoreTimeMs: 9000,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final reps = await performanceReferenceRepository.findBestMetricPerformance(
|
|
exerciseId: 'exercise-1',
|
|
metric: PerformanceMetric.reps,
|
|
scoreInputMode: ScoreInputMode.manual,
|
|
);
|
|
final time = await performanceReferenceRepository.findBestMetricPerformance(
|
|
exerciseId: 'exercise-1',
|
|
metric: PerformanceMetric.time,
|
|
scoreInputMode: ScoreInputMode.manual,
|
|
);
|
|
final manualScore = await performanceReferenceRepository
|
|
.findBestMetricPerformance(
|
|
exerciseId: 'exercise-1',
|
|
metric: PerformanceMetric.score,
|
|
scoreInputMode: ScoreInputMode.manual,
|
|
);
|
|
final stopwatchScore = await performanceReferenceRepository
|
|
.findBestMetricPerformance(
|
|
exerciseId: 'exercise-1',
|
|
metric: PerformanceMetric.score,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
);
|
|
|
|
expect(reps!.actualReps, 12);
|
|
expect(time!.actualTimeMs, 45000);
|
|
expect(manualScore!.actualScore, 20);
|
|
expect(stopwatchScore!.actualScoreTimeMs, 9000);
|
|
});
|
|
|
|
test('performance reference matches archived source exercise id', () async {
|
|
final now = DateTime.utc(2026, 7, 22, 13);
|
|
await exerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('exercise-archived', now),
|
|
name: 'Archived drill',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
archivedAt: now,
|
|
),
|
|
);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'history-archived',
|
|
startedAt: now,
|
|
result: _historySetResult(
|
|
id: 'result-archived',
|
|
historyId: 'history-archived',
|
|
sourceExerciseId: 'exercise-archived',
|
|
setIndex: 0,
|
|
startedAt: now,
|
|
actualReps: 13,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
await performanceReferenceRepository.hasAnyCompletedHistoryForExercise(
|
|
'exercise-archived',
|
|
),
|
|
isTrue,
|
|
);
|
|
final latest = await performanceReferenceRepository
|
|
.findLatestSetPerformance(
|
|
exerciseId: 'exercise-archived',
|
|
activeMeasures: const ActivePerformanceMeasures(
|
|
timeEnabled: false,
|
|
repsEnabled: true,
|
|
scoreEnabled: false,
|
|
),
|
|
currentSetIndex: 0,
|
|
);
|
|
expect(latest!.actualReps, 13);
|
|
});
|
|
|
|
test(
|
|
'progression global stats count active weeks and ignore inactive rows',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 22, 13);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'progression-week-1',
|
|
startedAt: now.subtract(const Duration(days: 1)),
|
|
totalActiveMs: 120000,
|
|
result: _historySetResult(
|
|
id: 'progression-result-1',
|
|
historyId: 'progression-week-1',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 0,
|
|
startedAt: now.subtract(const Duration(days: 1)),
|
|
actualReps: 10,
|
|
),
|
|
),
|
|
);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'progression-week-2',
|
|
startedAt: now.subtract(const Duration(days: 8)),
|
|
totalActiveMs: 180000,
|
|
result: _historySetResult(
|
|
id: 'progression-result-2',
|
|
historyId: 'progression-week-2',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 0,
|
|
startedAt: now.subtract(const Duration(days: 8)),
|
|
actualReps: 8,
|
|
),
|
|
),
|
|
);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'progression-incomplete',
|
|
startedAt: now,
|
|
completed: false,
|
|
result: _historySetResult(
|
|
id: 'progression-result-incomplete',
|
|
historyId: 'progression-incomplete',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 0,
|
|
startedAt: now,
|
|
actualReps: 99,
|
|
),
|
|
),
|
|
);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'progression-deleted',
|
|
startedAt: now.subtract(const Duration(days: 3)),
|
|
totalActiveMs: 900000,
|
|
result: _historySetResult(
|
|
id: 'progression-result-deleted',
|
|
historyId: 'progression-deleted',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 0,
|
|
startedAt: now.subtract(const Duration(days: 3)),
|
|
actualReps: 20,
|
|
),
|
|
),
|
|
);
|
|
await historyRepository.delete('progression-deleted', now);
|
|
|
|
final stats = await progressionStatsRepository.readGlobalStats(
|
|
ProgressionDateRange(
|
|
startedAt: now.subtract(const Duration(days: 28)),
|
|
endedAt: now,
|
|
),
|
|
);
|
|
|
|
expect(stats.completedSessionCount, 2);
|
|
expect(stats.totalActiveMs, 300000);
|
|
expect(stats.activeWeekStarts, hasLength(2));
|
|
expect(stats.hasAnyCompletedHistory, isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'progression lists exercises, archive state and measure options',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 22, 14);
|
|
await exerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('exercise-active', now),
|
|
name: 'Active drill',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
),
|
|
);
|
|
await exerciseRepository.save(
|
|
Exercise(
|
|
metadata: _metadata('exercise-archived', now),
|
|
name: 'Archived drill',
|
|
hasTimeMeasure: false,
|
|
hasRepsMeasure: true,
|
|
hasScoreMeasure: false,
|
|
archivedAt: now,
|
|
),
|
|
);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'progression-options',
|
|
startedAt: now,
|
|
results: [
|
|
_historySetResult(
|
|
id: 'progression-active-score',
|
|
historyId: 'progression-options',
|
|
sourceExerciseId: 'exercise-active',
|
|
setIndex: 0,
|
|
startedAt: now,
|
|
actualScore: 12,
|
|
),
|
|
_historySetResult(
|
|
id: 'progression-active-chrono',
|
|
historyId: 'progression-options',
|
|
sourceExerciseId: 'exercise-active',
|
|
setIndex: 1,
|
|
startedAt: now,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
actualScoreTimeMs: 42000,
|
|
),
|
|
_historySetResult(
|
|
id: 'progression-archived-reps',
|
|
historyId: 'progression-options',
|
|
sourceExerciseId: 'exercise-archived',
|
|
setIndex: 2,
|
|
startedAt: now,
|
|
actualReps: 15,
|
|
),
|
|
_historySetResult(
|
|
id: 'progression-skipped',
|
|
historyId: 'progression-options',
|
|
sourceExerciseId: 'exercise-skipped',
|
|
setIndex: 3,
|
|
startedAt: now,
|
|
status: SetResultStatus.skipped,
|
|
),
|
|
_historySetResult(
|
|
id: 'progression-absent-value',
|
|
historyId: 'progression-options',
|
|
sourceExerciseId: 'exercise-absent',
|
|
setIndex: 4,
|
|
startedAt: now,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final range = ProgressionDateRange(
|
|
startedAt: now.subtract(const Duration(days: 1)),
|
|
endedAt: now,
|
|
);
|
|
final options = await progressionStatsRepository.listExerciseOptions(
|
|
range,
|
|
);
|
|
final activeMeasures = await progressionStatsRepository
|
|
.listMeasureOptions(range: range, exerciseKey: 'exercise-active');
|
|
|
|
expect(options.map((option) => option.exerciseKey), [
|
|
'exercise-active',
|
|
'exercise-archived',
|
|
]);
|
|
expect(options.first.isArchived, isFalse);
|
|
expect(options.last.isArchived, isTrue);
|
|
expect(activeMeasures.map((option) => option.measure), [
|
|
ProgressionMeasure.manualScore,
|
|
ProgressionMeasure.stopwatchScore,
|
|
]);
|
|
expect(activeMeasures.first.scoreLabel, 'Score');
|
|
expect(activeMeasures.first.scoreUnit, 'pts');
|
|
expect(activeMeasures.last.lowerIsBetter, isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'progression series reports completed exercise rows without graphable value',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 22, 14, 30);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'progression-no-graphable',
|
|
startedAt: now,
|
|
result: _historySetResult(
|
|
id: 'progression-no-graphable-result',
|
|
historyId: 'progression-no-graphable',
|
|
sourceExerciseId: 'exercise-no-graphable',
|
|
setIndex: 0,
|
|
startedAt: now,
|
|
),
|
|
),
|
|
);
|
|
|
|
final series = await progressionStatsRepository.readExerciseSeries(
|
|
range: ProgressionDateRange(
|
|
startedAt: now.subtract(const Duration(days: 1)),
|
|
endedAt: now,
|
|
),
|
|
exerciseKey: 'exercise-no-graphable',
|
|
measure: ProgressionMeasure.reps,
|
|
);
|
|
|
|
expect(series.points, isEmpty);
|
|
expect(series.hasAnyAllTimeData, isFalse);
|
|
expect(series.hasAnyCompletedExerciseResult, isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'progression exercise series aggregate each measure by session',
|
|
() async {
|
|
final now = DateTime.utc(2026, 7, 22, 15);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'progression-series-old',
|
|
startedAt: now.subtract(const Duration(days: 2)),
|
|
results: [
|
|
_historySetResult(
|
|
id: 'progression-old-score-low',
|
|
historyId: 'progression-series-old',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 0,
|
|
startedAt: now.subtract(const Duration(days: 2)),
|
|
actualScore: 7,
|
|
),
|
|
_historySetResult(
|
|
id: 'progression-old-score-high',
|
|
historyId: 'progression-series-old',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 1,
|
|
startedAt: now.subtract(const Duration(days: 2)),
|
|
actualScore: 9,
|
|
actualReps: 4,
|
|
actualTimeMs: 10000,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'progression-series-new',
|
|
startedAt: now,
|
|
results: [
|
|
_historySetResult(
|
|
id: 'progression-new-score',
|
|
historyId: 'progression-series-new',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 0,
|
|
startedAt: now,
|
|
actualScore: 11,
|
|
actualReps: 6,
|
|
actualTimeMs: 12000,
|
|
),
|
|
_historySetResult(
|
|
id: 'progression-new-chrono-slow',
|
|
historyId: 'progression-series-new',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 1,
|
|
startedAt: now,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
actualScoreTimeMs: 45000,
|
|
),
|
|
_historySetResult(
|
|
id: 'progression-new-chrono-fast',
|
|
historyId: 'progression-series-new',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 2,
|
|
startedAt: now,
|
|
scoreInputMode: ScoreInputMode.stopwatch,
|
|
actualScoreTimeMs: 42000,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final range = ProgressionDateRange(
|
|
startedAt: now.subtract(const Duration(days: 7)),
|
|
endedAt: now,
|
|
);
|
|
|
|
final score = await progressionStatsRepository.readExerciseSeries(
|
|
range: range,
|
|
exerciseKey: 'exercise-1',
|
|
measure: ProgressionMeasure.manualScore,
|
|
);
|
|
final chrono = await progressionStatsRepository.readExerciseSeries(
|
|
range: range,
|
|
exerciseKey: 'exercise-1',
|
|
measure: ProgressionMeasure.stopwatchScore,
|
|
);
|
|
final reps = await progressionStatsRepository.readExerciseSeries(
|
|
range: range,
|
|
exerciseKey: 'exercise-1',
|
|
measure: ProgressionMeasure.reps,
|
|
);
|
|
final time = await progressionStatsRepository.readExerciseSeries(
|
|
range: range,
|
|
exerciseKey: 'exercise-1',
|
|
measure: ProgressionMeasure.time,
|
|
);
|
|
|
|
expect(score.points.map((point) => point.rawValue), [9.0, 11.0]);
|
|
expect(chrono.points.single.rawValue, 42000);
|
|
expect(reps.points.map((point) => point.rawValue), [4, 6]);
|
|
expect(time.points.map((point) => point.rawValue), [10000, 12000]);
|
|
expect(score.points.first.workoutHistoryId, 'progression-series-old');
|
|
},
|
|
);
|
|
|
|
test('progression reports all-time data outside selected period', () async {
|
|
final now = DateTime.utc(2026, 7, 22, 16);
|
|
await historyRepository.save(
|
|
_history(
|
|
id: 'progression-all-time',
|
|
startedAt: now.subtract(const Duration(days: 60)),
|
|
result: _historySetResult(
|
|
id: 'progression-all-time-score',
|
|
historyId: 'progression-all-time',
|
|
sourceExerciseId: 'exercise-1',
|
|
setIndex: 0,
|
|
startedAt: now.subtract(const Duration(days: 60)),
|
|
actualScore: 10,
|
|
),
|
|
),
|
|
);
|
|
|
|
final series = await progressionStatsRepository.readExerciseSeries(
|
|
range: ProgressionDateRange(
|
|
startedAt: now.subtract(const Duration(days: 7)),
|
|
endedAt: now,
|
|
),
|
|
exerciseKey: 'exercise-1',
|
|
measure: ProgressionMeasure.manualScore,
|
|
);
|
|
|
|
expect(series.points, isEmpty);
|
|
expect(series.hasAnyAllTimeData, isTrue);
|
|
});
|
|
|
|
test(
|
|
'performance reference use case prioritizes record score metric',
|
|
() async {
|
|
final repository = _FakePerformanceReferenceRepository();
|
|
final useCase = ExercisePerformanceReferenceUseCase(
|
|
repository: repository,
|
|
);
|
|
|
|
final reference = await useCase.getExercisePerformanceReference(
|
|
exerciseId: 'exercise-1',
|
|
activeMeasures: const ActivePerformanceMeasures(
|
|
timeEnabled: true,
|
|
repsEnabled: true,
|
|
scoreEnabled: true,
|
|
),
|
|
currentSetIndex: 0,
|
|
);
|
|
|
|
expect(reference.hasAnyHistoryForExercise, isTrue);
|
|
expect(repository.requestedMetric, PerformanceMetric.score);
|
|
},
|
|
);
|
|
}
|
|
|
|
String _resolvedSnapshot() {
|
|
return jsonEncode({
|
|
'name': 'Séance jambes',
|
|
'programs': [
|
|
{
|
|
'id': 'program-snapshot-1',
|
|
'programNameSnapshot': 'Programme jambes',
|
|
'programSnapshotJson': jsonEncode({
|
|
'exercises': [
|
|
{
|
|
'id': 'exercise-snapshot-1',
|
|
'sourceExerciseId': 'exercise-1',
|
|
'exerciseNameSnapshot': 'Squat',
|
|
'setsCount': 1,
|
|
'timeEnabled': true,
|
|
'repsEnabled': true,
|
|
'scoreEnabled': true,
|
|
'targetTimeSeconds': 45,
|
|
'targetReps': 10,
|
|
'targetScore': 80,
|
|
'scoreLabelSnapshot': 'Charge',
|
|
'scoreUnitSnapshot': 'kg',
|
|
'restSecondsOverride': 0,
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
],
|
|
'overrides': const [],
|
|
});
|
|
}
|
|
|
|
EntityMetadata _metadata(String id, DateTime now, [int localRevision = 0]) {
|
|
return EntityMetadata(
|
|
id: id,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
originDeviceId: 'device-1',
|
|
localRevision: localRevision,
|
|
);
|
|
}
|
|
|
|
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,
|
|
WorkoutHistorySetResult? result,
|
|
List<WorkoutHistorySetResult>? results,
|
|
List<WorkoutHistoryStepResult> stepResults = const [],
|
|
bool completed = true,
|
|
int totalActiveMs = 300000,
|
|
int? minHeartRateBpm,
|
|
double? averageHeartRateBpm,
|
|
int? maxHeartRateBpm,
|
|
double? totalDistanceMeters,
|
|
double? totalCaloriesKcal,
|
|
}) {
|
|
return WorkoutHistory(
|
|
metadata: _metadata(id, startedAt),
|
|
nameSnapshot: id,
|
|
startedAt: startedAt,
|
|
endedAt: startedAt.add(const Duration(minutes: 5)),
|
|
totalActiveMs: totalActiveMs,
|
|
completed: completed,
|
|
historySnapshotJson: '{"name":"$id"}',
|
|
results: results ?? [result!],
|
|
stepResults: stepResults,
|
|
minHeartRateBpm: minHeartRateBpm,
|
|
averageHeartRateBpm: averageHeartRateBpm,
|
|
maxHeartRateBpm: maxHeartRateBpm,
|
|
totalDistanceMeters: totalDistanceMeters,
|
|
totalCaloriesKcal: totalCaloriesKcal,
|
|
);
|
|
}
|
|
|
|
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,
|
|
required String sourceExerciseId,
|
|
required int setIndex,
|
|
required DateTime startedAt,
|
|
int? actualTimeMs,
|
|
int? actualReps,
|
|
double? actualScore,
|
|
int? actualScoreTimeMs,
|
|
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
|
SetResultStatus status = SetResultStatus.completed,
|
|
}) {
|
|
final scoreEnabled = actualScore != null || actualScoreTimeMs != null;
|
|
return WorkoutHistorySetResult(
|
|
metadata: _metadata(id, startedAt),
|
|
workoutHistoryId: historyId,
|
|
programSnapshotId: 'program-snapshot',
|
|
exerciseSnapshotId: 'exercise-snapshot-$sourceExerciseId',
|
|
programIndex: 0,
|
|
exerciseIndex: 0,
|
|
setIndex: setIndex,
|
|
programNameSnapshot: 'Program',
|
|
exerciseNameSnapshot: 'Exercise',
|
|
timeEnabledSnapshot: actualTimeMs != null,
|
|
repsEnabledSnapshot:
|
|
actualReps != null || (actualTimeMs == null && !scoreEnabled),
|
|
scoreEnabledSnapshot: scoreEnabled,
|
|
actualTimeMs: actualTimeMs,
|
|
actualReps: actualReps,
|
|
actualScore: actualScore,
|
|
actualScoreTimeMs: actualScoreTimeMs,
|
|
scoreInputModeSnapshot: scoreInputMode,
|
|
scoreLabelSnapshot: scoreEnabled && scoreInputMode == ScoreInputMode.manual
|
|
? 'Score'
|
|
: null,
|
|
scoreUnitSnapshot: scoreEnabled && scoreInputMode == ScoreInputMode.manual
|
|
? 'pts'
|
|
: null,
|
|
sourceExerciseIdSnapshot: sourceExerciseId,
|
|
completedAt: status == SetResultStatus.completed
|
|
? startedAt.add(const Duration(minutes: 1))
|
|
: null,
|
|
status: status,
|
|
);
|
|
}
|
|
|
|
ProgramExercise _programExercise(
|
|
String id,
|
|
DateTime now, {
|
|
String programId = 'program-1',
|
|
required int position,
|
|
bool autoStartNextTimedStepSnapshot = true,
|
|
bool? autoStartNextTimedStepOverride,
|
|
}) {
|
|
return ProgramExercise(
|
|
metadata: _metadata(id, now),
|
|
programId: programId,
|
|
position: position,
|
|
exerciseNameSnapshot: 'Exercise $position',
|
|
autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot,
|
|
autoStartNextTimedStepOverride: autoStartNextTimedStepOverride,
|
|
availableTimeSnapshot: false,
|
|
availableRepsSnapshot: true,
|
|
availableScoreSnapshot: false,
|
|
setsCount: 1,
|
|
timeEnabled: false,
|
|
repsEnabled: true,
|
|
scoreEnabled: false,
|
|
);
|
|
}
|
|
|
|
ExerciseStep _exerciseStep({
|
|
required String id,
|
|
required int position,
|
|
required String name,
|
|
required ExerciseStepType type,
|
|
required int defaultTargetValue,
|
|
bool hasScore = false,
|
|
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
|
String? scoreLabel,
|
|
String? scoreUnit,
|
|
double? defaultTargetScore,
|
|
int? defaultTargetScoreTimeMs,
|
|
bool linkedToSeriesScore = false,
|
|
}) {
|
|
return ExerciseStep(
|
|
id: id,
|
|
position: position,
|
|
name: name,
|
|
type: type,
|
|
defaultTargetValue: defaultTargetValue,
|
|
hasScore: hasScore,
|
|
scoreInputMode: scoreInputMode,
|
|
scoreLabel: scoreLabel,
|
|
scoreUnit: scoreUnit,
|
|
defaultTargetScore: defaultTargetScore,
|
|
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
|
linkedToSeriesScore: linkedToSeriesScore,
|
|
);
|
|
}
|
|
|
|
void _expectExerciseEquals(Exercise actual, Exercise expected) {
|
|
_expectMetadataEquals(actual.metadata, expected.metadata);
|
|
expect(actual.name, expected.name);
|
|
expect(actual.description, expected.description);
|
|
expect(actual.imageMediaIds, expected.imageMediaIds);
|
|
expect(actual.iconMediaId, expected.iconMediaId);
|
|
expect(actual.videoMediaId, expected.videoMediaId);
|
|
expect(actual.hasTimeMeasure, expected.hasTimeMeasure);
|
|
expect(actual.hasRepsMeasure, expected.hasRepsMeasure);
|
|
expect(actual.hasScoreMeasure, expected.hasScoreMeasure);
|
|
expect(actual.scoreInputMode, expected.scoreInputMode);
|
|
expect(actual.scoreLabel, expected.scoreLabel);
|
|
expect(actual.scoreUnit, expected.scoreUnit);
|
|
expect(actual.defaultTargetTimeSeconds, expected.defaultTargetTimeSeconds);
|
|
expect(actual.defaultTargetReps, expected.defaultTargetReps);
|
|
expect(actual.defaultTargetScore, expected.defaultTargetScore);
|
|
expect(actual.defaultTargetScoreTimeMs, expected.defaultTargetScoreTimeMs);
|
|
expect(actual.autoStartNextTimedStep, expected.autoStartNextTimedStep);
|
|
expect(actual.archivedAt?.toUtc(), expected.archivedAt?.toUtc());
|
|
expect(actual.steps, hasLength(expected.steps.length));
|
|
for (var index = 0; index < expected.steps.length; index++) {
|
|
_expectExerciseStepEquals(actual.steps[index], expected.steps[index]);
|
|
}
|
|
}
|
|
|
|
void _expectExerciseStepEquals(ExerciseStep actual, ExerciseStep expected) {
|
|
expect(actual.id, expected.id);
|
|
expect(actual.position, expected.position);
|
|
expect(actual.name, expected.name);
|
|
expect(actual.type, expected.type);
|
|
expect(actual.defaultTargetValue, expected.defaultTargetValue);
|
|
expect(actual.hasScore, expected.hasScore);
|
|
expect(actual.scoreInputMode, expected.scoreInputMode);
|
|
expect(actual.scoreLabel, expected.scoreLabel);
|
|
expect(actual.scoreUnit, expected.scoreUnit);
|
|
expect(actual.defaultTargetScore, expected.defaultTargetScore);
|
|
expect(actual.defaultTargetScoreTimeMs, expected.defaultTargetScoreTimeMs);
|
|
}
|
|
|
|
void _expectMetadataEquals(EntityMetadata actual, EntityMetadata expected) {
|
|
expect(actual.id, expected.id);
|
|
expect(actual.createdAt.toUtc(), expected.createdAt.toUtc());
|
|
expect(actual.updatedAt.toUtc(), expected.updatedAt.toUtc());
|
|
expect(actual.deletedAt?.toUtc(), expected.deletedAt?.toUtc());
|
|
expect(actual.schemaVersion, expected.schemaVersion);
|
|
expect(actual.syncState, expected.syncState);
|
|
expect(actual.localRevision, expected.localRevision);
|
|
expect(actual.originDeviceId, expected.originDeviceId);
|
|
expect(actual.futureOwnerProfileId, expected.futureOwnerProfileId);
|
|
expect(actual.lastSyncedAt?.toUtc(), expected.lastSyncedAt?.toUtc());
|
|
expect(actual.remoteRevision, expected.remoteRevision);
|
|
}
|
|
|
|
final class _ExerciseRoundTripCase {
|
|
const _ExerciseRoundTripCase({required this.label, required this.exercise});
|
|
|
|
final String label;
|
|
final Exercise exercise;
|
|
}
|
|
|
|
final class _FakeClock implements Clock {
|
|
const _FakeClock(this.value);
|
|
|
|
final DateTime value;
|
|
|
|
@override
|
|
DateTime now() => value;
|
|
}
|
|
|
|
final class _FakeIds implements IdGenerator {
|
|
var _next = 0;
|
|
|
|
@override
|
|
String newId() {
|
|
_next += 1;
|
|
return 'id-$_next';
|
|
}
|
|
}
|
|
|
|
final class _FakePerformanceReferenceRepository
|
|
implements ExercisePerformanceReferenceRepository {
|
|
PerformanceMetric? requestedMetric;
|
|
|
|
@override
|
|
Future<bool> hasAnyCompletedHistoryForExercise(String exerciseId) async {
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Future<WorkoutHistorySetPerformance?> findLatestSetPerformance({
|
|
required String exerciseId,
|
|
required ActivePerformanceMeasures activeMeasures,
|
|
required int currentSetIndex,
|
|
}) async {
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<WorkoutHistoryMetricPerformance?> findBestMetricPerformance({
|
|
required String exerciseId,
|
|
required PerformanceMetric metric,
|
|
required ScoreInputMode scoreInputMode,
|
|
}) async {
|
|
requestedMetric = metric;
|
|
return WorkoutHistoryMetricPerformance(
|
|
workoutHistoryId: 'history-1',
|
|
startedAt: DateTime.utc(2026, 7, 22),
|
|
setIndex: 0,
|
|
exerciseNameSnapshot: 'Exercise',
|
|
metric: metric,
|
|
scoreInputModeSnapshot: scoreInputMode,
|
|
actualScore: 10,
|
|
);
|
|
}
|
|
}
|