feat(watch): Android Wear Data Layer adapter + foreground service (#91-D)
This commit is contained in:
@ -0,0 +1,319 @@
|
||||
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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.delayed(Duration.zero);
|
||||
|
||||
expect(ingress.started, ['first']);
|
||||
ingress.completeNext();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(ingress.started, ['first', 'second']);
|
||||
ingress.completeNext();
|
||||
await Future<void>.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<WatchSessionProjection>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<WatchSessionProjection> get projections => _controller.stream;
|
||||
|
||||
void emit(WatchSessionProjection projection) {
|
||||
current = projection;
|
||||
_controller.add(projection);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WatchSessionProjection> currentProjection() async => current;
|
||||
|
||||
@override
|
||||
Future<WatchSessionProjection> 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 = <WatchCommandEnvelope>[];
|
||||
|
||||
@override
|
||||
Future<WatchCommandAck> dispatch(WatchCommandEnvelope command) async {
|
||||
commands.add(command);
|
||||
return WatchCommandAck.accepted;
|
||||
}
|
||||
}
|
||||
|
||||
final class _BlockingCommandIngress implements WatchCommandIngress {
|
||||
final started = <String>[];
|
||||
final _pending = <Completer<WatchCommandAck>>[];
|
||||
|
||||
@override
|
||||
Future<WatchCommandAck> dispatch(WatchCommandEnvelope command) {
|
||||
started.add(command.commandId);
|
||||
final completer = Completer<WatchCommandAck>();
|
||||
_pending.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void completeNext() {
|
||||
_pending.removeAt(0).complete(WatchCommandAck.accepted);
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
|
||||
final published = <WatchSessionProjection>[];
|
||||
final acks = <_SentAck>[];
|
||||
final _commands = StreamController<WatchCommandEnvelope>.broadcast();
|
||||
final _connections = StreamController<WatchBridgeConnectionEvent>.broadcast();
|
||||
var capabilityRefreshCount = 0;
|
||||
var foregroundStartCount = 0;
|
||||
var foregroundStopCount = 0;
|
||||
|
||||
@override
|
||||
Stream<WatchCommandEnvelope> get commands => _commands.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
|
||||
_connections.stream;
|
||||
|
||||
void emitCommand(WatchCommandEnvelope command) {
|
||||
_commands.add(command);
|
||||
}
|
||||
|
||||
void emitConnection(WatchBridgeConnectionEvent event) {
|
||||
_connections.add(event);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> publishProjection(WatchSessionProjection projection) async {
|
||||
published.add(projection);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestCapabilityRefresh() async {
|
||||
capabilityRefreshCount += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> sendCommandAck(
|
||||
WatchCommandEnvelope command,
|
||||
WatchCommandAck ack, {
|
||||
int? revisionAtAck,
|
||||
}) async {
|
||||
acks.add(_SentAck(command, ack, revisionAtAck));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startForegroundService() async {
|
||||
foregroundStartCount += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopForegroundService() async {
|
||||
foregroundStopCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
final class _SentAck {
|
||||
const _SentAck(this.command, this.ack, this.revisionAtAck);
|
||||
|
||||
final WatchCommandEnvelope command;
|
||||
final WatchCommandAck ack;
|
||||
final int? revisionAtAck;
|
||||
}
|
||||
Reference in New Issue
Block a user