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, ); 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, ); 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, ); 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, expiresAtEpochMs: 1710000012100, 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', manualScoreRepsTargetValue: 12, manualScoreScope: WatchManualScoreScope.step, ); final decoded = WatchSessionProjection.fromJson( jsonDecode(jsonEncode(projection.toJson())) as Map, ); 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.manualScoreRepsTargetValue, 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.expiresAtEpochMs, 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.manualScoreRepsTargetValue, 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, ); 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, ); }