Ajuste les ports et le bootstrap applicatif, ainsi que le mapping Drift des repositories, pour préparer une future synchronisation cloud (invariants documentés dans docs/sync-invariants.md). Corrige au passage un bug de fuseau horaire UTC dans le mapping Drift. flutter analyze propre, 35/35 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
319 lines
9.6 KiB
Dart
319 lines
9.6 KiB
Dart
import 'dart:convert';
|
|
|
|
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.DriftExerciseRepository exerciseRepository;
|
|
late local.DriftProgramRepository programRepository;
|
|
late local.DriftActiveSessionRepository activeRepository;
|
|
late local.DriftWorkoutTemplateRepository templateRepository;
|
|
late local.DriftWorkoutHistoryRepository historyRepository;
|
|
|
|
setUp(() {
|
|
database = local.AppDatabase(NativeDatabase.memory());
|
|
exerciseRepository = local.DriftExerciseRepository(database);
|
|
programRepository = local.DriftProgramRepository(database);
|
|
activeRepository = local.DriftActiveSessionRepository(database);
|
|
templateRepository = local.DriftWorkoutTemplateRepository(database);
|
|
historyRepository = local.DriftWorkoutHistoryRepository(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,
|
|
);
|
|
|
|
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(
|
|
'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(
|
|
'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');
|
|
},
|
|
);
|
|
}
|
|
|
|
String _resolvedSnapshot() {
|
|
return jsonEncode({
|
|
'name': 'Séance jambes',
|
|
'programs': [
|
|
{
|
|
'id': 'program-snapshot-1',
|
|
'programNameSnapshot': 'Programme jambes',
|
|
'programSnapshotJson': jsonEncode({
|
|
'exercises': [
|
|
{
|
|
'id': 'exercise-snapshot-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,
|
|
);
|
|
}
|
|
|
|
ProgramExercise _programExercise(
|
|
String id,
|
|
DateTime now, {
|
|
required int position,
|
|
}) {
|
|
return ProgramExercise(
|
|
metadata: _metadata(id, now),
|
|
programId: 'program-1',
|
|
position: position,
|
|
exerciseNameSnapshot: 'Exercise $position',
|
|
availableTimeSnapshot: false,
|
|
availableRepsSnapshot: true,
|
|
availableScoreSnapshot: false,
|
|
setsCount: 1,
|
|
timeEnabled: false,
|
|
repsEnabled: true,
|
|
scoreEnabled: false,
|
|
);
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|