feat(watch): route watch commands to existing session use cases (#91-C)

This commit is contained in:
2026-07-25 17:53:11 +02:00
parent d1c6076899
commit 6c177de6f8
3 changed files with 1104 additions and 17 deletions

View File

@ -0,0 +1,686 @@
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<WatchCommandAck> 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<WatchSecondaryAction> 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<ExerciseStep> 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<WatchSessionProjection> get projections => const Stream.empty();
@override
Future<WatchSessionProjection> currentProjection() async => projection;
@override
Future<WatchSessionProjection> 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<WorkoutTemplate?> findById(String id) async => null;
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session;
final results = <ActiveSetResult>[];
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
scoreStopwatchStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveExerciseStepProgressState?> 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<ActiveRestState?> findRestStateById(String id) async {
return restStates[id];
}
@override
Future<ActiveScoreStopwatchState?> 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<ActiveSetTimerState?> 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<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return stepProgressStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return stepResults
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return scoreStopwatchStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {
stepProgressStates[state.metadata.id] = state;
}
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
stepResults.add(result);
}
@override
Future<void> saveRestState(ActiveRestState restState) async {
restStates[restState.metadata.id] = restState;
}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
setTimerStates[state.metadata.id] = state;
}
}