merge(develop): clôture lot #91 Wear OS - fréquence cardiaque, notifications, finitions montre

Intègre le commit de clôture du lot #91 resté non fusionné. Conflits
attendus sur entities.dart, workout_execution_screen.dart et
watch_companion_projection_test.dart : arbitrage Architect - garder la
base feature/ticket91-wear-os-watch-sync sur ces 3 fichiers puis réinjecter
uniquement les invariants chronos utiles (Durée / Temps réalisé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	lib/domain/entities.dart
#	lib/presentation/workout_execution_screen.dart
#	test/application/watch_companion_projection_test.dart
This commit is contained in:
2026-07-28 11:35:22 +02:00
80 changed files with 12296 additions and 891 deletions

View File

@ -0,0 +1,126 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
test('builds active timer notification content', () {
final content = buildSessionNotificationContent(
_projection(
exerciseName: 'Squats bulgares',
dominantTimer: _timer(label: 'Chrono étape', accumulatedMs: 134000),
),
);
expect(content.title, 'Squats bulgares');
expect(content.primaryLine, '02:14');
expect(content.secondaryLine, 'Série 2/4');
});
test('builds manual score notification content', () {
final content = buildSessionNotificationContent(
_projection(
exerciseName: 'Lancers francs',
hasManualScore: true,
currentManualScoreValue: 8,
),
);
expect(content.title, 'Lancers francs');
expect(content.primaryLine, 'Série 2/4 · 8');
});
test('builds rest notification content with next exercise', () {
final now = DateTime.utc(2026, 7, 26, 10);
final content = buildSessionNotificationContent(
_projection(
phase: WatchSessionPhase.restRunning,
exerciseName: 'Squats',
nextExerciseName: 'Fentes',
dominantTimer: _timer(
kind: WatchTimerKind.rest,
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.running,
accumulatedMs: 10000,
targetMs: 60000,
startedAtEpochMs: now.millisecondsSinceEpoch,
),
),
now: now.add(const Duration(seconds: 5)),
);
expect(content.title, 'Repos');
expect(content.primaryLine, '00:45 restant · Ensuite : Fentes');
});
test('prefixes paused content without ticking', () {
final content = buildSessionNotificationContent(
_projection(
phase: WatchSessionPhase.paused,
dominantTimer: _timer(
runState: WatchTimerRunState.paused,
accumulatedMs: 42000,
),
),
now: DateTime.utc(2026, 7, 26, 10),
);
expect(content.primaryLine, 'En pause · 00:42');
});
test('falls back to current step name without timer or score', () {
final content = buildSessionNotificationContent(
_projection(stepName: 'Gainage'),
);
expect(content.primaryLine, 'Gainage');
});
}
WatchSessionProjection _projection({
WatchSessionPhase phase = WatchSessionPhase.running,
String exerciseName = 'Pompes',
String? stepName,
WatchTimerProjection? dominantTimer,
bool hasManualScore = false,
double? currentManualScoreValue,
String? nextExerciseName,
}) {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 1,
projectedAtEpochMs: DateTime.utc(2026, 7, 26, 10).millisecondsSinceEpoch,
phase: phase,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: exerciseName,
stepName: stepName,
dominantTimer: dominantTimer,
primaryAction: WatchPrimaryAction.pauseSession,
nextExerciseName: nextExerciseName,
hasManualScore: hasManualScore,
currentManualScoreValue: currentManualScoreValue,
canDecrementScore: (currentManualScoreValue ?? 0) > 0,
);
}
WatchTimerProjection _timer({
WatchTimerKind kind = WatchTimerKind.step,
String label = 'Chrono',
WatchTimerDisplayMode displayMode = WatchTimerDisplayMode.elapsed,
WatchTimerRunState runState = WatchTimerRunState.stopped,
int accumulatedMs = 0,
int? startedAtEpochMs,
int? targetMs,
}) {
return WatchTimerProjection(
kind: kind,
label: label,
displayMode: displayMode,
runState: runState,
referenceEpochMs: DateTime.utc(2026, 7, 26, 10).millisecondsSinceEpoch,
accumulatedMs: accumulatedMs,
startedAtEpochMs: startedAtEpochMs,
targetMs: targetMs,
);
}

View File

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
test('Exercise requires at least one active measure', () {
@ -374,6 +375,40 @@ void main() {
);
});
test('linked step score requires manual series score', () async {
final linkedStep = _step(
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'pts',
linkedToSeriesScore: true,
);
final useCase = _exerciseUseCase(_FakeExerciseRepository());
await expectLater(
useCase.create(
name: 'Tirs',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [linkedStep],
),
throwsA(isA<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Tirs chrono',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetReps: 1,
steps: [linkedStep],
),
throwsA(isA<DomainException>()),
);
});
test('Exercise image gallery rejects a sixth image', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
@ -649,6 +684,26 @@ void main() {
expect(program.exercises.single.autoStartNextTimedStepOverride, isFalse);
});
test('program saveConfigured rejects empty exercise list', () async {
final useCase = ProgramUseCases(
programRepository: _FakeProgramRepository(),
exerciseRepository: _FakeExerciseRepository(),
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.saveConfigured(
name: 'Programme',
defaultRestSeconds: 60,
exercises: const [],
),
throwsA(isA<DomainException>()),
);
});
test('workout template override preserves step chaining override', () async {
final templateRepository = _FakeWorkoutTemplateRepository();
final useCase = WorkoutTemplateUseCases(
@ -692,6 +747,54 @@ void main() {
expect(template.overrides.single.autoStartNextTimedStepOverride, isFalse);
});
test('workout template saveConfigured rejects empty program list', () async {
final useCase = WorkoutTemplateUseCases(
templateRepository: _FakeWorkoutTemplateRepository(),
programRepository: _FakeProgramRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.saveConfigured(
name: 'Séance',
programs: const [],
overrides: const [],
),
throwsA(isA<DomainException>()),
);
});
test(
'workout template saveConfigured rejects program snapshot without sets',
() async {
final useCase = WorkoutTemplateUseCases(
templateRepository: _FakeWorkoutTemplateRepository(),
programRepository: _FakeProgramRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.saveConfigured(
name: 'Séance',
programs: [
WorkoutTemplateProgramConfig(
clientKey: 'program-1',
programNameSnapshot: 'Programme',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({'exercises': const []}),
),
],
overrides: const [],
),
throwsA(isA<DomainException>()),
);
},
);
test('delete exercise keeps existing program snapshots unchanged', () async {
final exerciseRepository = _FakeExerciseRepository()
..exercise = Exercise(
@ -943,6 +1046,152 @@ void main() {
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000);
});
test(
'startFromTemplate rejects template without playable exercise',
() async {
final templateRepository = _FakeWorkoutTemplateRepository()
..templates.add(
WorkoutTemplate(
metadata: _metadata('template-1'),
name: 'Séance vide',
),
);
final repository = _FakeActiveSessionRepository();
final useCase = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: templateRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.startFromTemplate('template-1'),
throwsA(isA<DomainException>()),
);
expect(repository.session, isNull);
},
);
test('startFromTemplate stamps the source template start time', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository()
..templates.add(_playableTemplate(id: 'template-1', name: 'A'));
final repository = _FakeActiveSessionRepository();
final useCase = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final session = await useCase.startFromTemplate('template-1');
expect(session.sourceWorkoutTemplateId, 'template-1');
expect(repository.session, session);
expect(templateRepository.templates.single.lastStartedAt, clock.now());
});
test(
'startFromLastTemplate starts most recently used playable template',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository()
..templates.addAll([
_playableTemplate(
id: 'older',
name: 'B',
lastStartedAt: DateTime.utc(2026, 7, 10, 12),
),
_playableTemplate(
id: 'latest',
name: 'A',
lastStartedAt: DateTime.utc(2026, 7, 12, 12),
),
WorkoutTemplate(metadata: _metadata('empty'), name: 'Empty'),
]);
final repository = _FakeActiveSessionRepository();
final useCase = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final session = await useCase.startFromLastTemplate();
expect(session.sourceWorkoutTemplateId, 'latest');
expect(repository.session, session);
},
);
test('resume abandons invalid session snapshot before throwing', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
status: ActiveWorkoutStatus.paused,
startedAt: clock.now(),
pausedAt: clock.now(),
lastPersistedAt: clock.now(),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
await expectLater(
useCase.resume(session.metadata.id),
throwsA(isA<DomainException>()),
);
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
expect(repository.session?.endedAt, clock.now());
});
test('findOpen silently abandons invalid legacy session', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
status: ActiveWorkoutStatus.running,
startedAt: clock.now(),
lastPersistedAt: clock.now(),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
final open = await useCase.findOpen();
expect(open, isNull);
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
expect(repository.session?.endedAt, clock.now());
});
test('findOpen returns playable open session unchanged', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
final open = await useCase.findOpen();
expect(open, session);
expect(repository.session?.status, ActiveWorkoutStatus.running);
expect(repository.session?.endedAt, isNull);
});
test('adjustRestSeconds persists adjusted rest duration', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository()
@ -1509,6 +1758,78 @@ void main() {
},
);
test('linked step score controls current series manual score', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
scoreEnabled: true,
exerciseSteps: [
_step(
id: 'step-1',
position: 0,
name: 'Tirs main droite',
type: ExerciseStepType.reps,
defaultTargetValue: 10,
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'pts',
linkedToSeriesScore: true,
),
],
);
final repository = _FakeActiveSessionRepository()..session = session;
final activeUseCase = _activeUseCase(repository, clock);
final stepUseCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
activeSessionUseCases: activeUseCase,
);
await stepUseCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await stepUseCase.incrementCurrentStepScore(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await stepUseCase.incrementCurrentStepScore(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await stepUseCase.decrementCurrentStepScore(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(repository.manualScoreStates.values.single.value, 1);
final result = await stepUseCase.completeCurrentStep(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 3,
);
expect(result.actualScore, 3);
expect(repository.manualScoreStates.values.single.value, 4);
});
test(
'step chaining resolution uses template override before program override',
() async {
@ -2346,6 +2667,365 @@ void main() {
);
});
test(
'recordCurrentSetResult uses live manual score unless overridden',
() async {
final repository = _FakeActiveSessionRepository()
..session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
scoreEnabled: true,
);
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
final useCase = _activeUseCase(repository, clock);
await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final fromLiveState = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
expect(fromLiveState.actualScore, 2);
expect(repository.manualScoreStates, isEmpty);
await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final overridden = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 5,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
expect(overridden.actualScore, 5);
},
);
test('recordCurrentSetResult upserts by logical set key', () async {
final repository = _FakeActiveSessionRepository()
..session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
scoreEnabled: true,
);
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
final useCase = _activeUseCase(repository, clock);
final first = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 2,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
clock.value = DateTime.utc(2026, 7, 25, 12, 1);
final second = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 5,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
expect(second.metadata.id, first.metadata.id);
expect(second.metadata.localRevision, first.metadata.localRevision + 1);
expect(repository.results, hasLength(1));
expect(repository.results.single.actualScore, 5);
});
test(
'setManualScore pushes phone corrections into live manual score state',
() async {
final repository = _FakeActiveSessionRepository()
..session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
scoreEnabled: true,
);
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
final useCase = _activeUseCase(repository, clock);
final corrected = await useCase.setManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
value: 7.5,
);
expect(corrected.state.value, 7.5);
expect(corrected.changed, isTrue);
final incremented = await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(incremented.state.value, 8.5);
final clamped = await useCase.setManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
value: -4,
);
expect(clamped.state.value, 0);
final decremented = await useCase.decrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(decremented.state.value, 0);
expect(decremented.changed, isFalse);
},
);
test('WorkoutHistoryUseCases applies heart rate summary once', () async {
final repository = _FakeWorkoutHistoryRepository()
..histories.add(
WorkoutHistory(
metadata: _metadata('history-1'),
sourceActiveWorkoutSessionId: 'session-1',
nameSnapshot: 'Seance',
startedAt: DateTime.utc(2026, 7, 25, 11),
endedAt: DateTime.utc(2026, 7, 25, 12),
totalActiveMs: 3600000,
completed: true,
historySnapshotJson: '{"programs":[]}',
),
);
final useCase = WorkoutHistoryUseCases(
repository: repository,
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)),
);
await useCase.updateHeartRateSummary(
const WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 12,
averageHeartRateBpm: 126.5,
maxHeartRateBpm: 171,
),
);
await useCase.updateHeartRateSummary(
const WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 14,
averageHeartRateBpm: 130,
maxHeartRateBpm: 180,
),
);
expect(repository.histories.single.averageHeartRateBpm, 126.5);
expect(repository.histories.single.maxHeartRateBpm, 171);
});
test(
'ActiveWorkoutSensorUseCases tracks live heart rate and calories',
() async {
final useCase = ActiveWorkoutSensorUseCases(
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
);
final updates = <ActiveWorkoutSensorState>[];
final subscription = useCase.updates.listen(updates.add);
expect(
useCase.recordHeartRateSample(
const WatchSensorSample(
sessionId: '',
recordedAtEpochMs: 0,
heartRateBpm: 120,
),
),
isNull,
);
useCase.recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
).millisecondsSinceEpoch,
heartRateBpm: 120,
),
);
final state = useCase.recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
30,
).millisecondsSinceEpoch,
heartRateBpm: 150,
),
);
await Future<void>.delayed(Duration.zero);
expect(state?.latestHeartRateBpm, 150);
expect(state?.sampleCount, 2);
expect(state?.averageHeartRateBpm, 135);
expect(state?.maxHeartRateBpm, 150);
expect(state?.estimatedCaloriesKcal, 187.5);
expect(updates, hasLength(2));
useCase.clear('session-1');
expect(useCase.current('session-1'), isNull);
await subscription.cancel();
await useCase.dispose();
},
);
test(
'ActiveWorkoutSensorUseCases tracks telemetry and ignores duplicates',
() async {
final useCase = ActiveWorkoutSensorUseCases(
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
);
final first = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
).millisecondsSinceEpoch,
heartRateBpm: 120,
distanceMeters: 500,
caloriesKcal: 42,
),
);
final duplicate = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
1,
).millisecondsSinceEpoch,
heartRateBpm: 160,
distanceMeters: 300,
caloriesKcal: 10,
),
);
final distanceOnly = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-2',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
2,
).millisecondsSinceEpoch,
distanceMeters: 620,
caloriesKcal: 48,
),
);
expect(first?.latestHeartRateBpm, 120);
expect(duplicate, isNull);
expect(distanceOnly?.sampleCount, 2);
expect(distanceOnly?.latestHeartRateBpm, 120);
expect(distanceOnly?.minHeartRateBpm, 120);
expect(distanceOnly?.averageHeartRateBpm, 120);
expect(distanceOnly?.maxHeartRateBpm, 120);
expect(distanceOnly?.latestDistanceMeters, 620);
expect(distanceOnly?.latestCaloriesKcal, 48);
await useCase.dispose();
},
);
test(
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
() async {
final repository = _FakeWorkoutHistoryRepository()
..histories.add(
WorkoutHistory(
metadata: _metadata('history-1'),
sourceActiveWorkoutSessionId: 'session-1',
nameSnapshot: 'Seance',
startedAt: DateTime.utc(2026, 7, 25, 11),
endedAt: DateTime.utc(2026, 7, 25, 12),
totalActiveMs: 3600000,
completed: true,
historySnapshotJson: '{"programs":[]}',
),
);
final useCase = WorkoutHistoryUseCases(
repository: repository,
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)),
);
await useCase.updateHeartRateSummary(
const WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 2,
averageHeartRateBpm: 126.5,
maxHeartRateBpm: 171,
),
);
expect(repository.histories.single.averageHeartRateBpm, isNull);
expect(repository.histories.single.maxHeartRateBpm, isNull);
},
);
test('Tags are normalized and validated on taggable entities', () {
final exercise = Exercise(
metadata: _metadata('exercise-tags'),
@ -3135,6 +3815,7 @@ ExerciseStep _step({
String? scoreUnit,
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
bool linkedToSeriesScore = false,
}) {
return ExerciseStep(
id: id,
@ -3148,6 +3829,7 @@ ExerciseStep _step({
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
linkedToSeriesScore: linkedToSeriesScore,
);
}
@ -3809,6 +4491,43 @@ WorkoutTemplateProgram _templateProgram({
);
}
WorkoutTemplate _playableTemplate({
required String id,
required String name,
DateTime? lastStartedAt,
}) {
return WorkoutTemplate(
metadata: _metadata(id),
name: name,
lastStartedAt: lastStartedAt,
programs: [
WorkoutTemplateProgram(
metadata: _metadata('$id-program'),
workoutTemplateId: id,
sourceProgramId: null,
position: 0,
programNameSnapshot: 'Programme',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({
'exercises': [
{
'id': '$id-exercise',
'exerciseNameSnapshot': 'Squat',
'setsCount': 2,
'timeEnabled': false,
'repsEnabled': true,
'scoreEnabled': false,
'targetReps': 10,
'exerciseStepsSnapshot': const [],
'autoStartNextTimedStepSnapshot': false,
},
],
}),
),
],
);
}
final class _FakeExerciseRepository implements ExerciseRepository {
Exercise? exercise;
final saved = <Exercise>[];
@ -4059,6 +4778,72 @@ final class _FakeProgramRepository implements ProgramRepository {
}
}
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
final histories = <WorkoutHistory>[];
@override
Future<WorkoutHistory?> findById(String id) async {
return histories.where((history) => history.metadata.id == id).firstOrNull;
}
@override
Future<List<WorkoutHistory>> listActive() async {
return histories
.where((history) => history.metadata.deletedAt == null)
.toList();
}
@override
Future<void> save(WorkoutHistory history) async {
histories.removeWhere(
(existing) => existing.metadata.id == history.metadata.id,
);
histories.add(history);
}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {
final index = histories.indexWhere(
(history) =>
history.metadata.id == historyId &&
history.metadata.deletedAt == null &&
history.averageHeartRateBpm == null &&
history.maxHeartRateBpm == null,
);
if (index == -1) {
return;
}
final history = histories[index];
histories[index] = history.copyWith(
metadata: history.metadata.touch(patchedAt),
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
);
}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
@override
Future<void> delete(String id, DateTime deletedAt) async {
final index = histories.indexWhere((history) => history.metadata.id == id);
if (index == -1) {
return;
}
histories[index] = histories[index].copyWith(
metadata: histories[index].metadata.markDeleted(deletedAt),
);
}
}
ActiveWorkoutSessionUseCases _activeUseCase(
_FakeActiveSessionRepository repository,
_FakeClock clock,
@ -4078,6 +4863,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final manualScoreStates = <String, ActiveManualScoreState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@ -4111,6 +4897,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.firstOrNull;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return manualScoreStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null,
)
.firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -4169,6 +4974,19 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.metadata.deletedAt == null,
)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -4225,6 +5043,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
manualScoreStates[state.metadata.id] = state;
}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
@ -4266,6 +5089,26 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.remove(state.metadata.id);
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
final state = await findManualScoreState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state == null) {
return;
}
manualScoreStates.remove(state.metadata.id);
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.removeWhere(

View File

@ -213,6 +213,189 @@ void main() {
expect(env.repository.session?.currentSetIndex, 1);
});
test(
'accepts skipCurrentRest after a refresh that keeps the same revision',
() async {
final session = _session(setsCount: 2);
final repository = _FakeActiveSessionRepository()..session = session;
repository.restStates['rest'] = ActiveRestState(
metadata: _metadata('rest'),
activeWorkoutSessionId: session.metadata.id,
afterProgramIndex: 0,
afterExerciseIndex: 0,
afterSetIndex: 0,
plannedRestSeconds: 60,
adjustedRestSeconds: 60,
startedAt: _now,
);
final clock = _FakeClock(_now);
final ids = _FakeIds();
final activeUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: clock,
ids: ids,
originDeviceId: 'device-1',
);
final projectionUseCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: clock,
ids: ids,
originDeviceId: 'device-1',
);
final handler = WatchCompanionCommandHandler(
sessionRepository: repository,
activeSessionUseCases: activeUseCases,
stepUseCases: ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: ids,
originDeviceId: 'device-1',
activeSessionUseCases: activeUseCases,
),
projectionSource: projectionUseCases,
);
final firstProjection = await projectionUseCases.emitCurrentProjection();
final refreshedProjection = await projectionUseCases
.emitCurrentProjection();
final ack = await handler.dispatch(
_command(
WatchCommandType.skipCurrentRest,
expectedRevision: firstProjection.revision,
),
);
expect(refreshedProjection.revision, firstProjection.revision);
expect(ack, WatchCommandAck.accepted);
expect(repository.restStates['rest']?.skippedAt, isNotNull);
expect(repository.session?.currentSetIndex, 1);
await projectionUseCases.dispose();
},
);
test(
'routes incrementScore and decrementScore to manual score use cases',
() async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(hasManualScore: true),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 1);
expect(
await env.dispatch(
WatchCommandType.decrementScore,
commandId: 'command-2',
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 0);
},
);
test('routes score commands to independent current step score', () async {
final session = _session(
scoreEnabled: true,
steps: [
_step(
hasScore: true,
scoreLabel: 'Réussites',
scoreUnit: 'pts',
defaultTargetScore: 5,
),
],
);
final env = _env(
session: session,
projection: _projection(
hasManualScore: true,
manualScoreScope: WatchManualScoreScope.step,
),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates, isEmpty);
expect(env.repository.stepResults.single.actualScore, 1);
expect(
await env.dispatch(
WatchCommandType.decrementScore,
commandId: 'command-2',
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates, isEmpty);
expect(env.repository.stepResults.last.actualScore, 0);
});
test('decrementScore at zero is accepted no-op', () async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(hasManualScore: true),
);
final ack = await env.dispatch(WatchCommandType.decrementScore);
expect(ack, WatchCommandAck.acceptedNoOp);
expect(env.repository.manualScoreStates.values.single.value, 0);
expect(env.projections.emitCount, 0);
});
test('rejects score commands outside manual score mode', () async {
final env = _env(
session: _session(
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
),
projection: _projection(hasManualScore: false),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.rejectedNotApplicable,
);
expect(env.repository.manualScoreStates, isEmpty);
});
test(
'accepts repeated score commands with stale expected revisions',
() async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(revision: 3, hasManualScore: true),
);
expect(
await env.dispatch(
WatchCommandType.incrementScore,
commandId: 'command-1',
expectedRevision: 1,
),
WatchCommandAck.accepted,
);
expect(
await env.dispatch(
WatchCommandType.incrementScore,
commandId: 'command-2',
expectedRevision: 1,
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 2);
},
);
test(
'rejects stale revision, non applicable, missing and mismatch',
() async {
@ -256,6 +439,22 @@ void main() {
);
},
);
test('rejects commands when no session is active', () async {
final env = _env(
projection: _projection(
phase: WatchSessionPhase.noActiveSession,
deviceSessionId: '',
primaryAction: WatchPrimaryAction.none,
),
);
expect(
await env.dispatch(WatchCommandType.startCurrentExercise),
WatchCommandAck.rejectedNoActiveSession,
);
expect(env.repository.session, isNull);
});
}
final _now = DateTime.utc(2026, 7, 25, 12);
@ -263,13 +462,14 @@ final _now = DateTime.utc(2026, 7, 25, 12);
_Harness _env({
ActiveWorkoutSession? session,
required WatchSessionProjection projection,
_FakeWorkoutTemplateRepository? templateRepository,
}) {
final repository = _FakeActiveSessionRepository()..session = session;
final clock = _FakeClock(_now);
final ids = _FakeIds();
final activeUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: _FakeWorkoutTemplateRepository(),
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
clock: clock,
ids: ids,
originDeviceId: 'device-1',
@ -304,20 +504,27 @@ final class _Harness {
final _FakeProjectionSource projections;
final WatchCompanionCommandHandler handler;
Future<WatchCommandAck> dispatch(WatchCommandType type) {
return handler.dispatch(_command(type));
Future<WatchCommandAck> dispatch(
WatchCommandType type, {
String commandId = 'command-1',
int expectedRevision = 1,
}) {
return handler.dispatch(
_command(type, commandId: commandId, expectedRevision: expectedRevision),
);
}
}
WatchCommandEnvelope _command(
WatchCommandType type, {
String commandId = 'command-1',
int expectedRevision = 1,
}) {
return WatchCommandEnvelope(
commandId: commandId,
type: type,
sessionId: 'session-1',
expectedRevision: 1,
expectedRevision: expectedRevision,
sentAtEpochMs: _now.millisecondsSinceEpoch,
);
}
@ -331,6 +538,10 @@ WatchSessionProjection _projection({
WatchSecondaryAction.finishCurrentSet,
WatchSecondaryAction.skipCurrentSet,
],
bool hasManualScore = false,
double? currentManualScoreValue,
bool canDecrementScore = false,
WatchManualScoreScope? manualScoreScope,
}) {
return WatchSessionProjection(
deviceSessionId: deviceSessionId,
@ -343,6 +554,12 @@ WatchSessionProjection _projection({
exerciseName: 'Squat',
primaryAction: primaryAction,
secondaryActions: secondaryActions,
hasManualScore: hasManualScore,
currentManualScoreValue: currentManualScoreValue,
canDecrementScore: canDecrementScore,
manualScoreScope:
manualScoreScope ??
(hasManualScore ? WatchManualScoreScope.series : null),
);
}
@ -354,6 +571,8 @@ ActiveWorkoutSession _session({
bool timeEnabled = false,
int? targetReps = 10,
int restSeconds = 0,
bool scoreEnabled = false,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
List<ExerciseStep> steps = const [],
}) {
final exerciseSnapshot = {
@ -362,9 +581,9 @@ ActiveWorkoutSession _session({
'setsCount': setsCount,
'timeEnabled': timeEnabled,
'repsEnabled': true,
'scoreEnabled': false,
'scoreEnabled': scoreEnabled,
'targetReps': targetReps,
'scoreInputModeSnapshot': ScoreInputMode.manual.name,
'scoreInputModeSnapshot': scoreInputMode.name,
'restSecondsOverride': restSeconds,
'exerciseStepsSnapshot': steps
.map((step) => step.toSnapshotJson())
@ -395,13 +614,24 @@ ActiveWorkoutSession _session({
);
}
ExerciseStep _step({String id = 'step-1', int position = 0}) {
ExerciseStep _step({
String id = 'step-1',
int position = 0,
bool hasScore = false,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
}) {
return ExerciseStep(
id: id,
position: position,
name: 'Step ${position + 1}',
type: ExerciseStepType.time,
defaultTargetValue: 1,
hasScore: hasScore,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
);
}
@ -473,6 +703,10 @@ final class _FakeProjectionSource implements WatchProjectionSource {
exerciseName: projection.exerciseName,
primaryAction: projection.primaryAction,
secondaryActions: projection.secondaryActions,
hasManualScore: projection.hasManualScore,
currentManualScoreValue: projection.currentManualScoreValue,
canDecrementScore: projection.canDecrementScore,
manualScoreScope: projection.manualScoreScope,
);
return projection;
}
@ -499,11 +733,24 @@ final class _FakeIds implements IdGenerator {
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
final templates = <WorkoutTemplate>[];
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
Future<WorkoutTemplate?> findById(String id) async {
for (final template in templates) {
if (template.metadata.id == id && template.metadata.deletedAt == null) {
return template;
}
}
return null;
}
@override
Future<List<WorkoutTemplate>> listActive() async {
return templates
.where((template) => template.metadata.deletedAt == null)
.toList();
}
@override
Future<void> replaceComposition(
@ -512,7 +759,10 @@ final class _FakeWorkoutTemplateRepository
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
Future<void> save(WorkoutTemplate template) async {
templates.removeWhere((saved) => saved.metadata.id == template.metadata.id);
templates.add(template);
}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@ -527,6 +777,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final manualScoreStates = <String, ActiveManualScoreState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@ -541,6 +792,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.clear();
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
@ -584,6 +846,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}).firstOrNull;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return manualScoreStates.values.where((state) {
return state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex;
}).firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -633,6 +910,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -661,6 +947,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
stepResults.removeWhere((item) => item.metadata.id == result.metadata.id);
stepResults.add(result);
}
@ -674,6 +961,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
manualScoreStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);

View File

@ -69,10 +69,10 @@ void main() {
expect(projection.phase, WatchSessionPhase.running);
expect(projection.dominantTimer?.kind, WatchTimerKind.step);
expect(projection.dominantTimer?.accumulatedMs, 0);
expect(projection.dominantTimer?.accumulatedMs, 5000);
expect(
projection.dominantTimer?.startedAtEpochMs,
now.subtract(const Duration(seconds: 5)).millisecondsSinceEpoch,
now.millisecondsSinceEpoch,
);
expect(projection.secondaryTimers.map((timer) => timer.kind), [
WatchTimerKind.setTimer,
@ -110,9 +110,111 @@ void main() {
expect(projection.phase, WatchSessionPhase.running);
expect(projection.dominantTimer?.kind, WatchTimerKind.setTimer);
expect(projection.secondaryTimers, isEmpty);
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
expect(
projection.secondaryActions,
contains(WatchSecondaryAction.finishCurrentSet),
);
},
);
test('projects manual score state for the current set', () async {
final session = _session(scoreEnabled: true, targetScore: 8);
final repository = _FakeActiveSessionRepository()
..session = session
..manualScoreStates['manual-score'] = ActiveManualScoreState(
metadata: _metadata('manual-score'),
activeWorkoutSessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
value: 3,
updatedAt: DateTime.utc(2026, 7, 25, 12),
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isTrue);
expect(projection.currentManualScoreValue, 3);
expect(projection.canDecrementScore, isTrue);
expect(projection.manualScoreTargetValue, 8);
expect(projection.manualScoreTargetLabel, 'Cible');
expect(projection.manualScoreScope, WatchManualScoreScope.series);
});
test(
'projects independent current step manual score before series score',
() async {
final session = _session(
scoreEnabled: true,
targetScore: 8,
steps: [
_step(
hasScore: true,
scoreLabel: 'Réussites',
scoreUnit: 'pts',
defaultTargetScore: 5,
),
],
);
final repository = _FakeActiveSessionRepository()
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
status: ActiveExerciseStepProgressStatus.waitingManual,
)
..stepResults.add(
ActiveExerciseStepResult(
metadata: _metadata('step-score'),
activeWorkoutSessionId: session.metadata.id,
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
passageIndex: 0,
stepIndex: 0,
stepSnapshotId: 'step-1',
stepNameSnapshot: 'Step 1',
stepTypeSnapshot: ExerciseStepType.time,
targetValueSnapshot: 1,
hasScoreSnapshot: true,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Réussites',
scoreUnitSnapshot: 'pts',
targetScoreSnapshot: 5,
status: SetResultStatus.completed,
actualScore: 2,
),
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isTrue);
expect(projection.currentManualScoreValue, 2);
expect(projection.manualScoreTargetValue, 5);
expect(projection.manualScoreScope, WatchManualScoreScope.step);
},
);
test('does not project manual score controls for stopwatch score', () async {
final repository = _FakeActiveSessionRepository()
..session = _session(
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isFalse);
expect(projection.currentManualScoreValue, isNull);
expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull);
});
test('projects paused after a running session is paused', () async {
final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(
@ -307,14 +409,22 @@ void main() {
);
test(
'emits projections through stream and publisher with incremented revision',
'keeps revision stable when refresh only changes volatile timing fields',
() async {
final now = DateTime.utc(2026, 7, 25, 12);
final clock = _clock(now);
final session = _session(steps: [_step(defaultTargetValue: 30)]);
final repository = _FakeActiveSessionRepository()
..session = _session(steps: [_step()]);
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now,
);
final publisher = _FakeWatchProjectionPublisher();
final useCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: _clock(),
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
publisher: publisher,
@ -323,21 +433,54 @@ void main() {
final subscription = useCases.projections.listen(emitted.add);
final first = await useCases.emitCurrentProjection();
clock.value = now.add(const Duration(seconds: 2));
final second = await useCases.emitCurrentProjection();
await Future<void>.delayed(Duration.zero);
expect(first.revision, 1);
expect(second.revision, 2);
expect(emitted.map((projection) => projection.revision), [1, 2]);
expect(second.revision, 1);
expect(second.projectedAtEpochMs, greaterThan(first.projectedAtEpochMs));
expect(
second.dominantTimer?.accumulatedMs,
greaterThan(first.dominantTimer?.accumulatedMs ?? 0),
);
expect(emitted.map((projection) => projection.revision), [1, 1]);
expect(publisher.published.map((projection) => projection.revision), [
1,
2,
1,
]);
await subscription.cancel();
await useCases.dispose();
},
);
test('increments revision when session command state changes', () async {
final repository = _FakeActiveSessionRepository()
..session = _session(setsCount: 2);
final publisher = _FakeWatchProjectionPublisher();
final useCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: _clock(),
ids: _FakeIds(),
originDeviceId: 'device-1',
publisher: publisher,
);
final first = await useCases.emitCurrentProjection();
repository.session = repository.session!.copyWith(currentSetIndex: 1);
final second = await useCases.emitCurrentProjection();
expect(first.revision, 1);
expect(second.revision, 2);
expect(second.seriesIndex, 2);
expect(publisher.published.map((projection) => projection.revision), [
1,
2,
]);
await useCases.dispose();
});
}
WatchSessionProjectionProjector _projector(
@ -366,6 +509,7 @@ ActiveWorkoutSession _session({
bool repsEnabled = true,
bool scoreEnabled = false,
int? targetTimeSeconds,
double? targetScore,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
bool? autoStartNextTimedStepSnapshot = true,
List<ExerciseStep> steps = const [],
@ -380,6 +524,7 @@ ActiveWorkoutSession _session({
'scoreEnabled': scoreEnabled,
'targetTimeSeconds': targetTimeSeconds,
'targetReps': repsEnabled ? setsCount : null,
'targetScore': targetScore,
'scoreInputModeSnapshot': scoreInputMode.name,
'exerciseStepsSnapshot': steps
.map((step) => step.toSnapshotJson())
@ -428,6 +573,10 @@ ExerciseStep _step({
String id = 'step-1',
int position = 0,
int defaultTargetValue = 1,
bool hasScore = false,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
}) {
return ExerciseStep(
id: id,
@ -435,6 +584,10 @@ ExerciseStep _step({
name: 'Step ${position + 1}',
type: ExerciseStepType.time,
defaultTargetValue: defaultTargetValue,
hasScore: hasScore,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
);
}
@ -539,6 +692,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final manualScoreStates = <String, ActiveManualScoreState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@ -553,6 +707,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.clear();
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
@ -596,6 +761,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}).firstOrNull;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return manualScoreStates.values.where((state) {
return state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex;
}).firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -645,6 +825,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -686,6 +875,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
manualScoreStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);