import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; import 'package:gametime/domain/domain.dart'; import 'package:gametime/infrastructure/infrastructure.dart' hide WorkoutHistory, WorkoutHistorySetResult, WorkoutHistoryStepResult, WorkoutTelemetryAggregate, WorkoutTelemetrySample; import 'package:watch_bridge_contract/watch_bridge_contract.dart'; void main() { test('publishes every projection revision from the source stream', () async { final native = _FakeWatchBridgeNativeChannel(); final source = _FakeProjectionSource(_projection(revision: 0)); final adapter = _adapter(native: native, source: source); await adapter.start(); await Future.delayed(Duration.zero); native.published.clear(); source.emit(_projection(revision: 2)); source.emit(_projection(revision: 3)); await Future.delayed(Duration.zero); expect(native.published.map((projection) => projection.revision), [2, 3]); await adapter.stop(); }); test('republishes volatile heartbeats as non urgent keepalives', () async { final native = _FakeWatchBridgeNativeChannel(); final source = _FakeProjectionSource( _runningProjection(revision: 1), incrementsRevisionOnEmit: false, ); final adapter = _adapter( native: native, source: source, heartbeatInterval: const Duration(milliseconds: 10), ); await adapter.start(); await Future.delayed(Duration.zero); native.published.clear(); native.urgentFlags.clear(); source.emitCount = 0; await Future.delayed(const Duration(milliseconds: 35)); expect(source.emitCount, greaterThanOrEqualTo(1)); expect(native.published.length, greaterThanOrEqualTo(1)); expect(native.urgentFlags, everyElement(false)); await adapter.stop(); }); test('marks only projection revision changes as urgent', () async { final native = _FakeWatchBridgeNativeChannel(); final source = _FakeProjectionSource(_runningProjection(revision: 0)); final adapter = _adapter(native: native, source: source); await adapter.start(); await Future.delayed(Duration.zero); native.published.clear(); native.urgentFlags.clear(); await adapter.publish(_runningProjection(revision: 2)); await adapter.publish(_runningProjection(revision: 2)); expect(native.published.map((projection) => projection.revision), [2]); expect(native.urgentFlags, [true]); await adapter.stop(); }); test( 'forces an urgent projection resync even when state is unchanged', () async { final native = _FakeWatchBridgeNativeChannel(); final source = _FakeProjectionSource( _runningProjection(revision: 1), incrementsRevisionOnEmit: false, ); final adapter = _adapter(native: native, source: source); await adapter.start(); await Future.delayed(Duration.zero); native.published.clear(); native.urgentFlags.clear(); source.emitCount = 0; native.emitConnection( const WatchBridgeConnectionEvent( isReachable: true, requestsResync: true, ), ); await Future.delayed(Duration.zero); expect(source.emitCount, 1); expect(native.published.map((projection) => projection.revision), [1]); expect(native.urgentFlags, [true]); await adapter.stop(); }, ); test('dispatches watch command and sends ack back to native layer', () async { final native = _FakeWatchBridgeNativeChannel(); final ingress = _FakeCommandIngress(); final source = _FakeProjectionSource(_projection(revision: 0)); final adapter = _adapter(native: native, ingress: ingress, source: source); await adapter.start(); native.emitCommand(_command(WatchCommandType.pauseSession)); await Future.delayed(Duration.zero); expect(ingress.commands.single.type, WatchCommandType.pauseSession); expect(native.acks.single.ack, WatchCommandAck.accepted); expect(native.acks.single.revisionAtAck, 1); await adapter.stop(); }); test('deduplicates retry before dispatching to ingress again', () async { final native = _FakeWatchBridgeNativeChannel(); final ingress = _FakeCommandIngress(); final source = _FakeProjectionSource(_projection(revision: 0)); final adapter = _adapter(native: native, ingress: ingress, source: source); await adapter.start(); final command = _command(WatchCommandType.skipCurrentSet); native.emitCommand(command); native.emitCommand(command); await Future.delayed(Duration.zero); expect(ingress.commands, hasLength(1)); expect(native.acks.map((ack) => ack.ack), [ WatchCommandAck.accepted, WatchCommandAck.acceptedNoOp, ]); await adapter.stop(); }); test('emits a full resync when a watch node reconnects', () async { final native = _FakeWatchBridgeNativeChannel(); final source = _FakeProjectionSource(_projection(revision: 3)); final adapter = _adapter(native: native, source: source); await adapter.start(); await Future.delayed(Duration.zero); native.published.clear(); source.emitCount = 0; native.emitConnection( const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true), ); await Future.delayed(Duration.zero); expect(source.emitCount, 1); expect(native.published.single.revision, 5); await adapter.stop(); }); test('processes commands sequentially in receive order', () async { final native = _FakeWatchBridgeNativeChannel(); final ingress = _BlockingCommandIngress(); final source = _FakeProjectionSource(_projection(revision: 0)); final adapter = _adapter(native: native, ingress: ingress, source: source); await adapter.start(); native.emitCommand(_command(WatchCommandType.skipCurrentStep, id: 'first')); native.emitCommand(_command(WatchCommandType.skipCurrentSet, id: 'second')); await Future.delayed(Duration.zero); expect(ingress.started, ['first']); ingress.completeNext(); await Future.delayed(Duration.zero); expect(ingress.started, ['first', 'second']); ingress.completeNext(); await Future.delayed(Duration.zero); expect(native.acks.map((ack) => ack.command.commandId), [ 'first', 'second', ]); await adapter.stop(); }); test('patches workout history when a sensor summary arrives', () async { final native = _FakeWatchBridgeNativeChannel(); final source = _FakeProjectionSource(_projection(revision: 0)); final historyRepository = _FakeWorkoutHistoryRepository() ..histories.add( WorkoutHistory( metadata: _metadata('history-1'), sourceActiveWorkoutSessionId: 'session-1', nameSnapshot: 'Seance', startedAt: _now.subtract(const Duration(hours: 1)), endedAt: _now, totalActiveMs: 3600000, completed: true, historySnapshotJson: '{"programs":[]}', ), ); final adapter = _adapter( native: native, source: source, historyUseCases: WorkoutHistoryUseCases( repository: historyRepository, clock: _FakeClock(_now), ), ); await adapter.start(); native.emitSensorSummary( const WatchSensorSummary( sessionId: 'session-1', sampleCount: 8, averageHeartRateBpm: 121.5, maxHeartRateBpm: 168, ), ); await Future.delayed(Duration.zero); expect(historyRepository.histories.single.averageHeartRateBpm, 121.5); expect(historyRepository.histories.single.maxHeartRateBpm, 168); await adapter.stop(); }); test('records live telemetry samples in active sensor state', () async { final native = _FakeWatchBridgeNativeChannel(); final source = _FakeProjectionSource(_runningProjection(revision: 1)); final sensorUseCases = ActiveWorkoutSensorUseCases(clock: _FakeClock(_now)); final adapter = _adapter( native: native, source: source, sensorUseCases: sensorUseCases, ); await adapter.start(); native.emitSensorSample( WatchSensorSample( sampleId: 'sample-1', sessionId: 'session-1', recordedAtEpochMs: _now.millisecondsSinceEpoch, heartRateBpm: 120, distanceMeters: 500, ), ); native.emitSensorSample( WatchSensorSample( sampleId: 'sample-2', sessionId: 'session-1', recordedAtEpochMs: _now .add(const Duration(minutes: 30)) .millisecondsSinceEpoch, heartRateBpm: 150, distanceMeters: 900, caloriesKcal: 120, ), ); native.emitSensorSample( WatchSensorSample( sampleId: 'sample-2', sessionId: 'session-1', recordedAtEpochMs: _now .add(const Duration(minutes: 31)) .millisecondsSinceEpoch, heartRateBpm: 170, distanceMeters: 100, caloriesKcal: 10, ), ); await Future.delayed(Duration.zero); final state = sensorUseCases.current('session-1'); expect(state?.latestHeartRateBpm, 150); expect(state?.sampleCount, 2); expect(state?.averageHeartRateBpm, 135); expect(state?.maxHeartRateBpm, 150); expect(state?.latestDistanceMeters, 900); expect(state?.latestCaloriesKcal, 120); expect(state?.estimatedCaloriesKcal, 187.5); await adapter.stop(); await sensorUseCases.dispose(); }); test('persists telemetry samples from the native stream', () async { final native = _FakeWatchBridgeNativeChannel(); final source = _FakeProjectionSource(_runningProjection(revision: 1)); final telemetryRepository = _FakeWorkoutTelemetryRepository(); final adapter = _adapter( native: native, source: source, telemetryUseCases: WorkoutTelemetryUseCases( repository: telemetryRepository, clock: _FakeClock(_now), ids: _FakeIds(), ), ); await adapter.start(); native.emitSensorSample( WatchSensorSample( sampleId: 'sample-1', sessionId: 'session-1', recordedAtEpochMs: _now.millisecondsSinceEpoch, programIndex: 0, exerciseIndex: 0, setIndex: 0, stepIndex: 0, heartRateBpm: 120, distanceMeters: 500, caloriesKcal: 42, ), ); await Future.delayed(Duration.zero); expect( telemetryRepository.samples.single.id, 'telemetry:session-1:1784980800000', ); final aggregate = telemetryRepository.aggregates.singleWhere( (aggregate) => aggregate.scope == WorkoutTelemetryAggregateScope.session, ); expect(aggregate.sampleCount, 1); expect(aggregate.minHeartRateBpm, 120); expect(aggregate.totalDistanceMeters, 500); expect(aggregate.totalCaloriesKcal, 42); await adapter.stop(); }); } WatchWearDataLayerAdapter _adapter({ required _FakeWatchBridgeNativeChannel native, WatchCommandIngress? ingress, required _FakeProjectionSource source, WorkoutHistoryUseCases? historyUseCases, ActiveWorkoutSensorUseCases? sensorUseCases, WorkoutTelemetryUseCases? telemetryUseCases, Duration heartbeatInterval = const Duration(seconds: 5), }) { return WatchWearDataLayerAdapter( nativeChannel: native, commandIngress: ingress ?? _FakeCommandIngress(), projectionSource: source, workoutHistoryUseCases: historyUseCases, activeWorkoutSensorUseCases: sensorUseCases, workoutTelemetryUseCases: telemetryUseCases, projectionRefreshInterval: heartbeatInterval, ); } WatchCommandEnvelope _command( WatchCommandType type, { String id = 'command-1', }) { return WatchCommandEnvelope( commandId: id, type: type, sessionId: 'session-1', expectedRevision: 1, sentAtEpochMs: _now.millisecondsSinceEpoch, ); } WatchSessionProjection _projection({required int revision}) { return WatchSessionProjection( deviceSessionId: 'session-1', revision: revision, projectedAtEpochMs: _now.millisecondsSinceEpoch, phase: WatchSessionPhase.ready, phoneReachable: true, seriesIndex: 1, seriesTotal: 2, exerciseName: 'Squat', primaryAction: WatchPrimaryAction.startCurrentExercise, ); } WatchSessionProjection _runningProjection({required int revision}) { return WatchSessionProjection( deviceSessionId: 'session-1', revision: revision, projectedAtEpochMs: _now.millisecondsSinceEpoch, phase: WatchSessionPhase.running, phoneReachable: true, seriesIndex: 1, seriesTotal: 2, exerciseName: 'Squat', primaryAction: WatchPrimaryAction.pauseSession, dominantTimer: WatchTimerProjection( kind: WatchTimerKind.step, label: 'Chrono étape', displayMode: WatchTimerDisplayMode.countdown, runState: WatchTimerRunState.running, referenceEpochMs: _now.millisecondsSinceEpoch, accumulatedMs: 0, startedAtEpochMs: _now.millisecondsSinceEpoch, targetMs: 30000, ), ); } final _now = DateTime.utc(2026, 7, 25, 12); EntityMetadata _metadata(String id) { return EntityMetadata( id: id, createdAt: _now, updatedAt: _now, originDeviceId: 'device-1', ); } 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 _FakeProjectionSource implements WatchProjectionSource { _FakeProjectionSource(this.current, {this.incrementsRevisionOnEmit = true}); WatchSessionProjection current; final bool incrementsRevisionOnEmit; var emitCount = 0; final _controller = StreamController.broadcast(); @override Stream get projections => _controller.stream; void emit(WatchSessionProjection projection) { current = projection; _controller.add(projection); } @override Future currentProjection() async => current; @override Future emitCurrentProjection() async { emitCount += 1; final nextRevision = incrementsRevisionOnEmit ? current.revision + 1 : current.revision; current = WatchSessionProjection( deviceSessionId: current.deviceSessionId, revision: nextRevision, projectedAtEpochMs: current.projectedAtEpochMs + 1000, expiresAtEpochMs: current.expiresAtEpochMs == 0 ? 0 : current.expiresAtEpochMs + 1000, phase: current.phase, phoneReachable: current.phoneReachable, seriesIndex: current.seriesIndex, seriesTotal: current.seriesTotal, exerciseName: current.exerciseName, programIndex: current.programIndex, exerciseIndex: current.exerciseIndex, setIndex: current.setIndex, passageIndex: current.passageIndex, passageTotal: current.passageTotal, stepIndex: current.stepIndex, stepTotal: current.stepTotal, stepName: current.stepName, stepType: current.stepType, stepTargetValue: current.stepTargetValue, dominantTimer: current.dominantTimer, secondaryTimers: current.secondaryTimers, primaryAction: current.primaryAction, secondaryActions: current.secondaryActions, nextExerciseName: current.nextExerciseName, statusLabel: current.statusLabel, hasManualScore: current.hasManualScore, currentManualScoreValue: current.currentManualScoreValue, canDecrementScore: current.canDecrementScore, manualScoreTargetValue: current.manualScoreTargetValue, manualScoreTargetLabel: current.manualScoreTargetLabel, manualScoreRepsTargetValue: current.manualScoreRepsTargetValue, manualScoreScope: current.manualScoreScope, ); _controller.add(current); return current; } } final class _FakeCommandIngress implements WatchCommandIngress { final commands = []; @override Future dispatch(WatchCommandEnvelope command) async { commands.add(command); return WatchCommandAck.accepted; } } final class _BlockingCommandIngress implements WatchCommandIngress { final started = []; final _pending = >[]; @override Future dispatch(WatchCommandEnvelope command) { started.add(command.commandId); final completer = Completer(); _pending.add(completer); return completer.future; } void completeNext() { _pending.removeAt(0).complete(WatchCommandAck.accepted); } } final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel { final published = []; final alerts = []; final acks = <_SentAck>[]; final _commands = StreamController.broadcast(); final _sensorSummaries = StreamController.broadcast(); final _sensorSamples = StreamController.broadcast(); final _connections = StreamController.broadcast(); var capabilityRefreshCount = 0; var foregroundStartCount = 0; var foregroundStopCount = 0; @override Stream get commands => _commands.stream; @override Stream get sensorSummaries => _sensorSummaries.stream; @override Stream get sensorSamples => _sensorSamples.stream; @override Stream get connectionEvents => _connections.stream; void emitCommand(WatchCommandEnvelope command) { _commands.add(command); } void emitSensorSummary(WatchSensorSummary summary) { _sensorSummaries.add(summary); } void emitSensorSample(WatchSensorSample sample) { _sensorSamples.add(sample); } void emitConnection(WatchBridgeConnectionEvent event) { _connections.add(event); } final urgentFlags = []; @override Future publishProjection( WatchSessionProjection projection, { bool urgent = true, }) async { published.add(projection); urgentFlags.add(urgent); } @override Future publishAlert(WatchAlertEnvelope alert) async { alerts.add(alert); } @override Future requestCapabilityRefresh() async { capabilityRefreshCount += 1; } @override Future sendCommandAck( WatchCommandEnvelope command, WatchCommandAck ack, { int? revisionAtAck, }) async { acks.add(_SentAck(command, ack, revisionAtAck)); } @override Future startForegroundService() async { foregroundStartCount += 1; } @override Future stopForegroundService() async { foregroundStopCount += 1; } } final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { final histories = []; @override Future findById(String id) async { return histories.where((history) => history.metadata.id == id).firstOrNull; } @override Future> listActive() async => histories; @override Future save(WorkoutHistory history) async {} @override Future patchHeartRateSummary({ required String historyId, int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, double? totalDistanceMeters, double? totalCaloriesKcal, required DateTime patchedAt, }) async { final index = histories.indexWhere( (history) => history.metadata.id == historyId && 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 saveSetResult(WorkoutHistorySetResult result) async {} @override Future saveStepResult(WorkoutHistoryStepResult result) async {} @override Future delete(String id, DateTime deletedAt) async {} } final class _FakeWorkoutTelemetryRepository implements WorkoutTelemetryRepository { final samples = []; final aggregates = []; @override Future saveSample(WorkoutTelemetrySample sample) async { if (samples.any((existing) => existing.id == sample.id)) { return false; } samples.add(sample); return true; } @override Future> listSamples(String sessionId) async { return samples .where((sample) => sample.sessionId == sessionId) .toList(growable: false); } @override Future> listSamplesForScope({ required String sessionId, required WorkoutTelemetryAggregateScope scope, int? programIndex, int? exerciseIndex, int? setIndex, int? passageIndex, int? stepIndex, }) async { return samples .where((sample) => sample.sessionId == sessionId) .toList(growable: false); } @override Future replaceAggregatesForSession({ required String sessionId, required List aggregates, }) async { this.aggregates.removeWhere( (aggregate) => aggregate.sessionId == sessionId, ); this.aggregates.addAll(aggregates); } @override Future> listAggregates( String sessionId, ) async { return aggregates .where((aggregate) => aggregate.sessionId == sessionId) .toList(growable: false); } @override Future findAggregate({ required String sessionId, required WorkoutTelemetryAggregateScope scope, int? programIndex, int? exerciseIndex, int? setIndex, int? passageIndex, int? stepIndex, }) async { return aggregates .where( (aggregate) => aggregate.sessionId == sessionId && aggregate.scope == scope && aggregate.programIndex == programIndex && aggregate.exerciseIndex == exerciseIndex && aggregate.setIndex == setIndex && aggregate.passageIndex == passageIndex && aggregate.stepIndex == stepIndex, ) .firstOrNull; } } final class _SentAck { const _SentAck(this.command, this.ack, this.revisionAtAck); final WatchCommandEnvelope command; final WatchCommandAck ack; final int? revisionAtAck; }