import 'dart:convert'; 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('routes startCurrentExercise to active execution timers', () async { final env = _env( session: _session(timeEnabled: true), projection: _projection( primaryAction: WatchPrimaryAction.startCurrentExercise, ), ); final ack = await env.dispatch(WatchCommandType.startCurrentExercise); expect(ack, WatchCommandAck.accepted); expect(env.repository.setTimerStates, hasLength(1)); expect(env.projections.emitCount, 1); }); test('routes pauseSession and resumeSession to session use cases', () async { final pauseEnv = _env( session: _session(timeEnabled: true), projection: _projection( phase: WatchSessionPhase.running, primaryAction: WatchPrimaryAction.pauseSession, ), ); expect( await pauseEnv.dispatch(WatchCommandType.pauseSession), WatchCommandAck.accepted, ); expect(pauseEnv.repository.session?.status, ActiveWorkoutStatus.paused); final resumeEnv = _env( session: _session(status: ActiveWorkoutStatus.paused, pausedAt: _now), projection: _projection( phase: WatchSessionPhase.paused, primaryAction: WatchPrimaryAction.resumeSession, ), ); expect( await resumeEnv.dispatch(WatchCommandType.resumeSession), WatchCommandAck.accepted, ); expect(resumeEnv.repository.session?.status, ActiveWorkoutStatus.running); }); test('routes startPreparedTimedStep to step timer start', () async { final session = _session( steps: [ _step(), _step(id: 'step-2', position: 1), ], ); final env = _env( session: session, projection: _projection( phase: WatchSessionPhase.nextTimerReady, primaryAction: WatchPrimaryAction.startPreparedTimedStep, ), ); env.repository.stepProgressStates['step-state'] = _stepState( sessionId: session.metadata.id, stepId: 'step-2', stepIndex: 1, ); final ack = await env.dispatch(WatchCommandType.startPreparedTimedStep); expect(ack, WatchCommandAck.accepted); expect( env.repository.stepProgressStates['step-state']?.status, ActiveExerciseStepProgressStatus.runningTimer, ); }); test('routes skipCurrentStep to step use case', () async { final session = _session(steps: [_step()]); final env = _env( session: session, projection: _projection( secondaryActions: [WatchSecondaryAction.skipCurrentStep], ), ); env.repository.stepProgressStates['step-state'] = _stepState( sessionId: session.metadata.id, ); final ack = await env.dispatch(WatchCommandType.skipCurrentStep); expect(ack, WatchCommandAck.accepted); expect(env.repository.stepResults, hasLength(1)); }); test('routes skipCurrentPassage to step use case', () async { final session = _session( targetReps: 2, steps: [ _step(), _step(id: 'step-2', position: 1), ], ); final env = _env( session: session, projection: _projection( secondaryActions: [WatchSecondaryAction.skipCurrentPassage], ), ); env.repository.stepProgressStates['step-state'] = _stepState( sessionId: session.metadata.id, ); final ack = await env.dispatch(WatchCommandType.skipCurrentPassage); expect(ack, WatchCommandAck.accepted); expect(env.repository.stepResults, hasLength(2)); }); test( 'routes finishCurrentSet and advances to next set without rest', () async { final session = _session(timeEnabled: true, setsCount: 2); final env = _env( session: session, projection: _projection( secondaryActions: [WatchSecondaryAction.finishCurrentSet], ), ); env.repository.setTimerStates['set'] = _setTimer( sessionId: session.metadata.id, ); final ack = await env.dispatch(WatchCommandType.finishCurrentSet); expect(ack, WatchCommandAck.accepted); expect(env.repository.results, hasLength(1)); expect(env.repository.session?.currentSetIndex, 1); }, ); test('routes finishCurrentSet and starts rest before next set', () async { final session = _session(timeEnabled: true, setsCount: 2, restSeconds: 60); final env = _env( session: session, projection: _projection( secondaryActions: [WatchSecondaryAction.finishCurrentSet], ), ); env.repository.setTimerStates['set'] = _setTimer( sessionId: session.metadata.id, ); final ack = await env.dispatch(WatchCommandType.finishCurrentSet); expect(ack, WatchCommandAck.accepted); expect(env.repository.restStates.values.single.plannedRestSeconds, 60); expect(env.repository.session?.currentSetIndex, 0); }); test('routes skipCurrentSet and advances once on retry duplicate', () async { final session = _session(timeEnabled: true, setsCount: 3); final env = _env( session: session, projection: _projection( secondaryActions: [WatchSecondaryAction.skipCurrentSet], ), ); env.repository.setTimerStates['set'] = _setTimer( sessionId: session.metadata.id, ); final command = _command(WatchCommandType.skipCurrentSet); final firstAck = await env.handler.dispatch(command); final retryAck = await env.handler.dispatch(command); expect(firstAck, WatchCommandAck.accepted); expect(retryAck, WatchCommandAck.acceptedNoOp); expect(env.repository.results, hasLength(1)); expect(env.repository.session?.currentSetIndex, 1); }); test('routes skipCurrentRest to rest skip and next position', () async { final session = _session(setsCount: 2); final env = _env( session: session, projection: _projection( phase: WatchSessionPhase.restRunning, primaryAction: WatchPrimaryAction.pauseSession, secondaryActions: [WatchSecondaryAction.skipCurrentRest], ), ); env.repository.restStates['rest'] = ActiveRestState( metadata: _metadata('rest'), activeWorkoutSessionId: session.metadata.id, afterProgramIndex: 0, afterExerciseIndex: 0, afterSetIndex: 0, plannedRestSeconds: 60, adjustedRestSeconds: 60, startedAt: _now, ); final ack = await env.dispatch(WatchCommandType.skipCurrentRest); expect(ack, WatchCommandAck.accepted); expect(env.repository.restStates['rest']?.skippedAt, isNotNull); expect(env.repository.session?.currentSetIndex, 1); }); test( 'rejects stale revision, non applicable, missing and mismatch', () async { final stale = _env( session: _session(), projection: _projection(revision: 2), ); expect( await stale.dispatch(WatchCommandType.startCurrentExercise), WatchCommandAck.rejectedStaleRevision, ); final nonApplicable = _env( session: _session(), projection: _projection(), ); expect( await nonApplicable.dispatch(WatchCommandType.pauseSession), WatchCommandAck.rejectedNotApplicable, ); final missing = _env( projection: _projection( phase: WatchSessionPhase.noActiveSession, deviceSessionId: '', primaryAction: WatchPrimaryAction.none, ), ); expect( await missing.dispatch(WatchCommandType.startCurrentExercise), WatchCommandAck.rejectedNoActiveSession, ); final mismatch = _env( session: _session(), projection: _projection(deviceSessionId: 'other-session'), ); expect( await mismatch.dispatch(WatchCommandType.startCurrentExercise), WatchCommandAck.rejectedSessionMismatch, ); }, ); } final _now = DateTime.utc(2026, 7, 25, 12); _Harness _env({ ActiveWorkoutSession? session, required WatchSessionProjection projection, }) { final repository = _FakeActiveSessionRepository()..session = session; final clock = _FakeClock(_now); final ids = _FakeIds(); final activeUseCases = ActiveWorkoutSessionUseCases( sessionRepository: repository, templateRepository: _FakeWorkoutTemplateRepository(), clock: clock, ids: ids, originDeviceId: 'device-1', ); final stepUseCases = ActiveExerciseStepUseCases( sessionRepository: repository, clock: clock, ids: ids, originDeviceId: 'device-1', ); final projections = _FakeProjectionSource(projection); return _Harness( repository: repository, projections: projections, handler: WatchCompanionCommandHandler( sessionRepository: repository, activeSessionUseCases: activeUseCases, stepUseCases: stepUseCases, projectionSource: projections, ), ); } final class _Harness { const _Harness({ required this.repository, required this.projections, required this.handler, }); final _FakeActiveSessionRepository repository; final _FakeProjectionSource projections; final WatchCompanionCommandHandler handler; Future dispatch(WatchCommandType type) { return handler.dispatch(_command(type)); } } WatchCommandEnvelope _command( WatchCommandType type, { String commandId = 'command-1', }) { return WatchCommandEnvelope( commandId: commandId, type: type, sessionId: 'session-1', expectedRevision: 1, sentAtEpochMs: _now.millisecondsSinceEpoch, ); } WatchSessionProjection _projection({ WatchSessionPhase phase = WatchSessionPhase.ready, String deviceSessionId = 'session-1', int revision = 1, WatchPrimaryAction primaryAction = WatchPrimaryAction.startCurrentExercise, List secondaryActions = const [ WatchSecondaryAction.finishCurrentSet, WatchSecondaryAction.skipCurrentSet, ], }) { return WatchSessionProjection( deviceSessionId: deviceSessionId, revision: revision, projectedAtEpochMs: _now.millisecondsSinceEpoch, phase: phase, phoneReachable: true, seriesIndex: 1, seriesTotal: 2, exerciseName: 'Squat', primaryAction: primaryAction, secondaryActions: secondaryActions, ); } ActiveWorkoutSession _session({ ActiveWorkoutStatus status = ActiveWorkoutStatus.running, DateTime? pausedAt, int currentSetIndex = 0, int setsCount = 2, bool timeEnabled = false, int? targetReps = 10, int restSeconds = 0, List steps = const [], }) { final exerciseSnapshot = { 'id': 'exercise-snapshot-1', 'exerciseNameSnapshot': 'Squat', 'setsCount': setsCount, 'timeEnabled': timeEnabled, 'repsEnabled': true, 'scoreEnabled': false, 'targetReps': targetReps, 'scoreInputModeSnapshot': ScoreInputMode.manual.name, 'restSecondsOverride': restSeconds, 'exerciseStepsSnapshot': steps .map((step) => step.toSnapshotJson()) .toList(), 'autoStartNextTimedStepSnapshot': false, }; return ActiveWorkoutSession( metadata: _metadata('session-1'), status: status, startedAt: _now, pausedAt: pausedAt, lastPersistedAt: _now, elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: currentSetIndex, resolvedTemplateSnapshotJson: jsonEncode({ 'programs': [ { 'id': 'program-snapshot-1', 'programNameSnapshot': 'Programme', 'programSnapshotJson': jsonEncode({ 'exercises': [exerciseSnapshot], }), }, ], }), ); } ExerciseStep _step({String id = 'step-1', int position = 0}) { return ExerciseStep( id: id, position: position, name: 'Step ${position + 1}', type: ExerciseStepType.time, defaultTargetValue: 1, ); } ActiveExerciseStepProgressState _stepState({ required String sessionId, String stepId = 'step-1', int stepIndex = 0, }) { return ActiveExerciseStepProgressState( metadata: _metadata('step-state'), activeWorkoutSessionId: sessionId, programIndex: 0, exerciseIndex: 0, setIndex: 0, currentPassageIndex: 0, currentStepIndex: stepIndex, currentStepSnapshotId: stepId, status: ActiveExerciseStepProgressStatus.stoppedTimer, accumulatedMs: 0, lastTransitionAt: _now, ); } ActiveSetTimerState _setTimer({required String sessionId}) { return ActiveSetTimerState( metadata: _metadata('set'), activeWorkoutSessionId: sessionId, programIndex: 0, exerciseIndex: 0, setIndex: 0, status: ActiveSetTimerStatus.running, startedAt: _now, accumulatedMs: 0, ); } EntityMetadata _metadata(String id) { return EntityMetadata( id: id, createdAt: _now, updatedAt: _now, originDeviceId: 'device-1', ); } final class _FakeProjectionSource implements WatchProjectionSource { _FakeProjectionSource(this.projection); WatchSessionProjection projection; var emitCount = 0; @override Stream get projections => const Stream.empty(); @override Future currentProjection() async => projection; @override Future emitCurrentProjection() async { emitCount += 1; projection = WatchSessionProjection( deviceSessionId: projection.deviceSessionId, revision: projection.revision + 1, projectedAtEpochMs: projection.projectedAtEpochMs, phase: projection.phase, phoneReachable: projection.phoneReachable, seriesIndex: projection.seriesIndex, seriesTotal: projection.seriesTotal, exerciseName: projection.exerciseName, primaryAction: projection.primaryAction, secondaryActions: projection.secondaryActions, ); return projection; } } 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 _FakeWorkoutTemplateRepository implements WorkoutTemplateRepository { @override Future findById(String id) async => null; @override Future> listActive() async => const []; @override Future replaceComposition( WorkoutTemplate template, DateTime deletedAt, ) async {} @override Future save(WorkoutTemplate template) async {} @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @override Future saveProgram(WorkoutTemplateProgram program) async {} } final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final results = []; final restStates = {}; final setTimerStates = {}; final scoreStopwatchStates = {}; final stepProgressStates = {}; final stepResults = []; @override Future deleteScoreStopwatchState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, required DateTime deletedAt, }) async { scoreStopwatchStates.clear(); } @override Future findById(String id) async { return session?.metadata.id == id ? session : null; } @override Future findOpen() async => session; @override Future findExerciseStepProgressState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { return stepProgressStates.values.where((state) { return state.activeWorkoutSessionId == sessionId && state.programIndex == programIndex && state.exerciseIndex == exerciseIndex && state.setIndex == setIndex; }).firstOrNull; } @override Future findRestStateById(String id) async { return restStates[id]; } @override Future findScoreStopwatchState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { return scoreStopwatchStates.values.where((state) { return state.activeWorkoutSessionId == sessionId && state.programIndex == programIndex && state.exerciseIndex == exerciseIndex && state.setIndex == setIndex; }).firstOrNull; } @override Future findSetTimerState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { return setTimerStates.values.where((state) { return state.activeWorkoutSessionId == sessionId && state.programIndex == programIndex && state.exerciseIndex == exerciseIndex && state.setIndex == setIndex; }).firstOrNull; } @override Future> listExerciseStepProgressStates( String sessionId, ) async { return stepProgressStates.values .where((state) => state.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listExerciseStepResults( String sessionId, ) async { return stepResults .where((result) => result.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listRestStates(String sessionId) async { return restStates.values .where((state) => state.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listScoreStopwatchStates( String sessionId, ) async { return scoreStopwatchStates.values .where((state) => state.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listSetResults(String sessionId) async { return results .where((result) => result.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listSetTimerStates(String sessionId) async { return setTimerStates.values .where((state) => state.activeWorkoutSessionId == sessionId) .toList(); } @override Future save(ActiveWorkoutSession session) async { this.session = session; } @override Future saveExerciseStepProgressState( ActiveExerciseStepProgressState state, ) async { stepProgressStates[state.metadata.id] = state; } @override Future saveExerciseStepResult(ActiveExerciseStepResult result) async { stepResults.add(result); } @override Future saveRestState(ActiveRestState restState) async { restStates[restState.metadata.id] = restState; } @override Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async { scoreStopwatchStates[state.metadata.id] = state; } @override Future saveSetResult(ActiveSetResult result) async { results.add(result); } @override Future saveSetTimerState(ActiveSetTimerState state) async { setTimerStates[state.metadata.id] = state; } }