Files
GameTime/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart

333 lines
11 KiB
Dart

import 'dart:convert';
import 'package:test/test.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
group('WatchCommandEnvelope', () {
test('round-trips every command type through JSON', () {
for (final type in WatchCommandType.values) {
final command = WatchCommandEnvelope(
commandId: 'command-${type.name}',
type: type,
sessionId: 'session-1',
expectedRevision: 12,
sentAtEpochMs: 1710000000000,
);
final decoded = WatchCommandEnvelope.fromJson(
jsonDecode(jsonEncode(command.toJson())) as Map<String, Object?>,
);
expect(decoded, command);
}
});
test('ignores unknown fields and falls back for missing fields', () {
final command = WatchCommandEnvelope.fromJson({
'type': 'pauseSession',
'unknown': 'ignored',
});
expect(command.schemaVersion, watchBridgeSchemaVersion);
expect(command.commandId, '');
expect(command.type, WatchCommandType.pauseSession);
expect(command.sessionId, '');
expect(command.expectedRevision, 0);
expect(command.sentAtEpochMs, 0);
});
});
group('WatchCommandAck', () {
test('round-trips every ack enum value by stable JSON name', () {
for (final ack in WatchCommandAck.values) {
final encoded = jsonEncode(ack.name);
final decodedName = jsonDecode(encoded) as String;
final decoded = WatchCommandAck.values.singleWhere(
(value) => value.name == decodedName,
);
expect(decoded, ack);
}
});
});
group('WatchSensorSummary', () {
test('round-trips heart rate summary through JSON', () {
const summary = WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 12,
minHeartRateBpm: 91,
averageHeartRateBpm: 128.5,
maxHeartRateBpm: 174,
totalDistanceMeters: 842.4,
totalCaloriesKcal: 186.2,
);
final decoded = WatchSensorSummary.fromJson(
jsonDecode(jsonEncode(summary.toJson())) as Map<String, Object?>,
);
expect(decoded, summary);
});
test('falls back safely for absent fields', () {
final summary = WatchSensorSummary.fromJson({});
expect(summary.schemaVersion, watchBridgeSchemaVersion);
expect(summary.sessionId, '');
expect(summary.sampleCount, 0);
expect(summary.minHeartRateBpm, isNull);
expect(summary.averageHeartRateBpm, isNull);
expect(summary.maxHeartRateBpm, isNull);
expect(summary.totalDistanceMeters, isNull);
expect(summary.totalCaloriesKcal, isNull);
});
});
group('WatchSensorSample', () {
test('round-trips live telemetry sample through JSON', () {
const sample = WatchSensorSample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: 1710000000300,
programIndex: 0,
exerciseIndex: 1,
setIndex: 2,
passageIndex: 3,
stepIndex: 4,
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
stepSnapshotId: 'step-snapshot-1',
heartRateBpm: 142,
distanceMeters: 840.5,
caloriesKcal: 184.2,
);
final decoded = WatchSensorSample.fromJson(
jsonDecode(jsonEncode(sample.toJson())) as Map<String, Object?>,
);
expect(decoded, sample);
});
test('falls back safely for absent fields', () {
final sample = WatchSensorSample.fromJson({});
expect(sample.schemaVersion, watchBridgeSchemaVersion);
expect(sample.sampleId, isNull);
expect(sample.sessionId, '');
expect(sample.recordedAtEpochMs, 0);
expect(sample.capturedAtEpochMs, 0);
expect(sample.programIndex, isNull);
expect(sample.exerciseIndex, isNull);
expect(sample.setIndex, isNull);
expect(sample.passageIndex, isNull);
expect(sample.stepIndex, isNull);
expect(sample.heartRateBpm, isNull);
expect(sample.distanceMeters, isNull);
expect(sample.caloriesKcal, isNull);
});
test('accepts legacy recordedAtEpochMs as captured timestamp', () {
final sample = WatchSensorSample.fromJson({
'sessionId': 'session-1',
'recordedAtEpochMs': 1710000000300,
});
expect(sample.capturedAtEpochMs, 1710000000300);
expect(sample.recordedAtEpochMs, 1710000000300);
});
});
group('WatchSessionProjection', () {
test('round-trips every phase, primary action, and secondary action', () {
for (final phase in WatchSessionPhase.values) {
for (final primaryAction in WatchPrimaryAction.values) {
final projection = WatchSessionProjection(
deviceSessionId: 'session-${phase.name}-${primaryAction.name}',
revision: 4,
projectedAtEpochMs: 1710000000100,
phase: phase,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 5,
exerciseName: 'Pompes tempo',
programIndex: 0,
exerciseIndex: 1,
setIndex: 1,
passageIndex: 1,
passageTotal: 3,
stepIndex: 2,
stepTotal: 4,
stepName: 'Descente',
stepType: WatchStepType.reps,
stepTargetValue: 12,
dominantTimer: _stepTimer(),
secondaryTimers: [_scoreStopwatchTimer(), _setTimer()],
primaryAction: primaryAction,
secondaryActions: WatchSecondaryAction.values,
nextExerciseName: 'Fentes sautees',
statusLabel: 'Chrono etape',
hasManualScore: true,
currentManualScoreValue: 7.5,
canDecrementScore: true,
manualScoreTargetValue: 10,
manualScoreTargetLabel: 'Cible',
manualScoreScope: WatchManualScoreScope.step,
);
final decoded = WatchSessionProjection.fromJson(
jsonDecode(jsonEncode(projection.toJson())) as Map<String, Object?>,
);
expect(decoded, projection);
}
}
});
test('ignores unknown fields and accepts absent optional fields', () {
final projection = WatchSessionProjection.fromJson({
'schemaVersion': 1,
'deviceSessionId': 'session-1',
'revision': 9,
'projectedAtEpochMs': 1710000000200,
'phase': 'restPaused',
'phoneReachable': true,
'seriesIndex': 3,
'seriesTotal': 5,
'exerciseName': 'Burpees',
'primaryAction': 'resumeSession',
'secondaryActions': ['skipCurrentRest', 'futureAction'],
'extra': {'ignored': true},
});
expect(projection.phase, WatchSessionPhase.restPaused);
expect(projection.passageIndex, isNull);
expect(projection.stepIndex, isNull);
expect(projection.stepType, isNull);
expect(projection.stepTargetValue, isNull);
expect(projection.dominantTimer, isNull);
expect(projection.secondaryTimers, isEmpty);
expect(projection.primaryAction, WatchPrimaryAction.resumeSession);
expect(projection.secondaryActions, [
WatchSecondaryAction.skipCurrentRest,
]);
expect(projection.nextExerciseName, isNull);
expect(projection.statusLabel, isNull);
expect(projection.hasManualScore, isFalse);
expect(projection.currentManualScoreValue, isNull);
expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull);
expect(projection.manualScoreTargetLabel, isNull);
expect(projection.manualScoreScope, isNull);
});
test('falls back to neutral values for absent required fields', () {
final projection = WatchSessionProjection.fromJson({});
expect(projection.schemaVersion, watchBridgeSchemaVersion);
expect(projection.deviceSessionId, '');
expect(projection.revision, 0);
expect(projection.projectedAtEpochMs, 0);
expect(projection.phase, WatchSessionPhase.noActiveSession);
expect(projection.phoneReachable, false);
expect(projection.seriesIndex, 0);
expect(projection.seriesTotal, 0);
expect(projection.exerciseName, '');
expect(projection.primaryAction, WatchPrimaryAction.none);
expect(projection.secondaryActions, isEmpty);
expect(projection.stepType, isNull);
expect(projection.stepTargetValue, isNull);
expect(projection.hasManualScore, isFalse);
expect(projection.currentManualScoreValue, isNull);
expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull);
expect(projection.manualScoreTargetLabel, isNull);
expect(projection.manualScoreScope, isNull);
});
});
group('WatchTimerProjection', () {
test('round-trips every timer enum combination through JSON', () {
for (final kind in WatchTimerKind.values) {
for (final displayMode in WatchTimerDisplayMode.values) {
for (final runState in WatchTimerRunState.values) {
final timer = WatchTimerProjection(
kind: kind,
label: 'timer-${kind.name}',
displayMode: displayMode,
runState: runState,
referenceEpochMs: 1710000000300,
accumulatedMs: 42000,
startedAtEpochMs: 1710000000000,
targetMs: 90000,
);
final decoded = WatchTimerProjection.fromJson(
jsonDecode(jsonEncode(timer.toJson())) as Map<String, Object?>,
);
expect(decoded, timer);
}
}
}
});
test('ignores unknown fields and falls back for missing fields', () {
final timer = WatchTimerProjection.fromJson({
'kind': 'rest',
'displayMode': 'countdown',
'runState': 'paused',
'unknown': 'ignored',
});
expect(timer.kind, WatchTimerKind.rest);
expect(timer.label, '');
expect(timer.displayMode, WatchTimerDisplayMode.countdown);
expect(timer.runState, WatchTimerRunState.paused);
expect(timer.referenceEpochMs, 0);
expect(timer.accumulatedMs, 0);
expect(timer.startedAtEpochMs, isNull);
expect(timer.targetMs, isNull);
});
});
}
WatchTimerProjection _stepTimer() {
return const WatchTimerProjection(
kind: WatchTimerKind.step,
label: 'Chrono etape',
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.running,
referenceEpochMs: 1710000000000,
accumulatedMs: 18000,
startedAtEpochMs: 1710000000000,
targetMs: 20000,
);
}
WatchTimerProjection _scoreStopwatchTimer() {
return const WatchTimerProjection(
kind: WatchTimerKind.scoreStopwatch,
label: 'Score chrono',
displayMode: WatchTimerDisplayMode.elapsed,
runState: WatchTimerRunState.running,
referenceEpochMs: 1710000000000,
accumulatedMs: 51000,
startedAtEpochMs: 1710000000000,
);
}
WatchTimerProjection _setTimer() {
return const WatchTimerProjection(
kind: WatchTimerKind.setTimer,
label: 'Temps de serie',
displayMode: WatchTimerDisplayMode.elapsed,
runState: WatchTimerRunState.running,
referenceEpochMs: 1710000000000,
accumulatedMs: 102000,
startedAtEpochMs: 1710000000000,
);
}