333 lines
11 KiB
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,
|
|
);
|
|
}
|