Cote montre pour #180 (vibration timerFinished plus longue et plus forte) et #179 (contrat/collecte des donnees stats vers le telephone). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
742 lines
22 KiB
Dart
742 lines
22 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
|
|
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.single.id, 'sample-1');
|
|
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>[];
|
|
|
|
@override
|
|
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
|
|
if (samples.any((existing) => existing.id == sample.id)) {
|
|
return false;
|
|
}
|
|
samples.add(sample);
|
|
return true;
|
|
}
|
|
|
|
@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;
|
|
}
|