Files
GameTime/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart
Blomios fe3608cc4a feat(server): implemente synchronisation et serveur avec fixtures (#187)
- Implémente la couche de synchronisation avec le serveur
- Ajoute les fixtures versionnées pour les tests
- Met à jour Drift database et repositories pour le support sync
- Améliore les tests de synchronisation
- Corrige et améliore le watch companion pour la collecte de métriques

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 23:07:31 +02:00

783 lines
23 KiB
Dart

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
ActiveWorkoutTelemetryWindowState,
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<void>.delayed(Duration.zero);
native.published.clear();
source.emit(_projection(revision: 2));
source.emit(_projection(revision: 3));
await Future<void>.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<void>.delayed(Duration.zero);
native.published.clear();
native.urgentFlags.clear();
source.emitCount = 0;
await Future<void>.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<void>.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<void>.delayed(Duration.zero);
native.published.clear();
native.urgentFlags.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.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<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();
await Future<void>.delayed(Duration.zero);
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();
});
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<void>.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<void>.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<void>.delayed(Duration.zero);
expect(telemetryRepository.samples, isEmpty);
native.emitSensorSample(
WatchSensorSample(
sampleId: 'sample-2',
sessionId: 'session-1',
recordedAtEpochMs: _now
.add(const Duration(seconds: 20))
.millisecondsSinceEpoch,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
heartRateBpm: 130,
distanceMeters: 520,
caloriesKcal: 45,
),
);
await Future<void>.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<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;
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 = <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 alerts = <WatchAlertEnvelope>[];
final acks = <_SentAck>[];
final _commands = StreamController<WatchCommandEnvelope>.broadcast();
final _sensorSummaries = StreamController<WatchSensorSummary>.broadcast();
final _sensorSamples = StreamController<WatchSensorSample>.broadcast();
final _connections = StreamController<WatchBridgeConnectionEvent>.broadcast();
var capabilityRefreshCount = 0;
var foregroundStartCount = 0;
var foregroundStopCount = 0;
@override
Stream<WatchCommandEnvelope> get commands => _commands.stream;
@override
Stream<WatchSensorSummary> get sensorSummaries => _sensorSummaries.stream;
@override
Stream<WatchSensorSample> get sensorSamples => _sensorSamples.stream;
@override
Stream<WatchBridgeConnectionEvent> 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 = <bool>[];
@override
Future<void> publishProjection(
WatchSessionProjection projection, {
bool urgent = true,
}) async {
published.add(projection);
urgentFlags.add(urgent);
}
@override
Future<void> publishAlert(WatchAlertEnvelope alert) async {
alerts.add(alert);
}
@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 _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
final histories = <WorkoutHistory>[];
@override
Future<WorkoutHistory?> findById(String id) async {
return histories.where((history) => history.metadata.id == id).firstOrNull;
}
@override
Future<List<WorkoutHistory>> listActive() async => histories;
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> 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<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
@override
Future<void> delete(String id, DateTime deletedAt) async {}
}
final class _FakeWorkoutTelemetryRepository
implements WorkoutTelemetryRepository {
final samples = <WorkoutTelemetrySample>[];
final aggregates = <WorkoutTelemetryAggregate>[];
final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};
@override
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
if (samples.any((existing) => existing.id == sample.id)) {
return false;
}
samples.add(sample);
return true;
}
@override
Future<ActiveWorkoutTelemetryWindowState?> findWindowState(
String sessionId,
) async {
return windowStates[sessionId];
}
@override
Future<void> saveWindowState(ActiveWorkoutTelemetryWindowState state) async {
windowStates[state.sessionId] = state;
}
@override
Future<void> deleteWindowState(String sessionId) async {
windowStates.remove(sessionId);
}
@override
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
return samples
.where((sample) => sample.sessionId == sessionId)
.toList(growable: false);
}
@override
Future<List<WorkoutTelemetrySample>> 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<void> replaceAggregatesForSession({
required String sessionId,
required List<WorkoutTelemetryAggregate> aggregates,
}) async {
this.aggregates.removeWhere(
(aggregate) => aggregate.sessionId == sessionId,
);
this.aggregates.addAll(aggregates);
}
@override
Future<List<WorkoutTelemetryAggregate>> listAggregates(
String sessionId,
) async {
return aggregates
.where((aggregate) => aggregate.sessionId == sessionId)
.toList(growable: false);
}
@override
Future<WorkoutTelemetryAggregate?> 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;
}