import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; import 'package:gametime/infrastructure/infrastructure.dart'; 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(); native.published.clear(); source.emit(_projection(revision: 1)); source.emit(_projection(revision: 2)); await Future.delayed(Duration.zero); expect(native.published.map((projection) => projection.revision), [1, 2]); await adapter.stop(); }); test('heartbeats while a timer is running', () async { final native = _FakeWatchBridgeNativeChannel(); final source = _FakeProjectionSource(_runningProjection(revision: 1)); final adapter = _adapter( native: native, source: source, heartbeatInterval: const Duration(milliseconds: 10), ); await adapter.start(); native.published.clear(); source.emitCount = 0; await adapter.publish(_runningProjection(revision: 1)); await Future.delayed(const Duration(milliseconds: 35)); expect(source.emitCount, greaterThanOrEqualTo(1)); expect(native.published.length, greaterThanOrEqualTo(2)); 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(); 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(); }); } WatchWearDataLayerAdapter _adapter({ required _FakeWatchBridgeNativeChannel native, WatchCommandIngress? ingress, required _FakeProjectionSource source, Duration heartbeatInterval = const Duration(seconds: 5), }) { return WatchWearDataLayerAdapter( nativeChannel: native, commandIngress: ingress ?? _FakeCommandIngress(), projectionSource: source, heartbeatInterval: 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); final class _FakeProjectionSource implements WatchProjectionSource { _FakeProjectionSource(this.current); WatchSessionProjection current; 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; current = WatchSessionProjection( deviceSessionId: current.deviceSessionId, revision: current.revision + 1, projectedAtEpochMs: current.projectedAtEpochMs, phase: current.phase, phoneReachable: current.phoneReachable, seriesIndex: current.seriesIndex, seriesTotal: current.seriesTotal, exerciseName: current.exerciseName, dominantTimer: current.dominantTimer, secondaryTimers: current.secondaryTimers, primaryAction: current.primaryAction, secondaryActions: current.secondaryActions, ); _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 acks = <_SentAck>[]; final _commands = StreamController.broadcast(); final _connections = StreamController.broadcast(); var capabilityRefreshCount = 0; var foregroundStartCount = 0; var foregroundStopCount = 0; @override Stream get commands => _commands.stream; @override Stream get connectionEvents => _connections.stream; void emitCommand(WatchCommandEnvelope command) { _commands.add(command); } void emitConnection(WatchBridgeConnectionEvent event) { _connections.add(event); } @override Future publishProjection(WatchSessionProjection projection) async { published.add(projection); } @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 _SentAck { const _SentAck(this.command, this.ack, this.revisionAtAck); final WatchCommandEnvelope command; final WatchCommandAck ack; final int? revisionAtAck; }