feat(watch): shared watch bridge contract package (#91-A)
This commit is contained in:
@ -0,0 +1,217 @@
|
||||
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('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',
|
||||
passageIndex: 1,
|
||||
passageTotal: 3,
|
||||
stepIndex: 2,
|
||||
stepTotal: 4,
|
||||
stepName: 'Descente',
|
||||
dominantTimer: _stepTimer(),
|
||||
secondaryTimers: [_scoreStopwatchTimer(), _setTimer()],
|
||||
primaryAction: primaryAction,
|
||||
secondaryActions: WatchSecondaryAction.values,
|
||||
nextExerciseName: 'Fentes sautees',
|
||||
statusLabel: 'Chrono etape',
|
||||
);
|
||||
|
||||
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.dominantTimer, isNull);
|
||||
expect(projection.secondaryTimers, isEmpty);
|
||||
expect(projection.primaryAction, WatchPrimaryAction.resumeSession);
|
||||
expect(projection.secondaryActions, [
|
||||
WatchSecondaryAction.skipCurrentRest,
|
||||
]);
|
||||
expect(projection.nextExerciseName, isNull);
|
||||
expect(projection.statusLabel, 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);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user