Files
GameTime/watch_app/test/presentation/watch_session_screen_test.dart
Blomios 7130635177 fix(watch): stabilise stats live, foreground et resync apres perte de connexion (#179-#189)
Reduit le cout radio des samples live et la cadence des projections telephone -> montre (#179-#183).
Restaure les statistiques live FC/distance/calories et le maintien foreground/ongoing activity (#184-#185).
Fiabilise le demarrage de seance et l'orchestration des permissions montre (#187).
Renforce la resynchronisation des statistiques live et du score apres perte puis retour de connexion (#188-#189).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 11:32:30 +02:00

1539 lines
47 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime_watch/application/watch_session_view_model.dart';
import 'package:gametime_watch/infrastructure/watch_bridge/native_watch_bridge_client.dart';
import 'package:gametime_watch/presentation/watch_session_screen.dart';
import 'package:gametime_watch/presentation/watch_theme.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'renders an active session on a compact round-sized viewport without overflow',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Squat jump'), findsOneWidget);
expect(find.text('02:14'), findsOneWidget);
expect(find.text('Chrono étape'), findsOneWidget);
expect(find.byTooltip('Pause'), findsOneWidget);
expect(find.text('Séance active'), findsNothing);
expect(find.text('Série 00:45'), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets(
'transitions from no-session to manual score controls without rendering a black screen',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_noSessionStartProjection());
await tester.pump();
expect(find.text('Aucune séance en cours'), findsOneWidget);
client.emitProjection(_manualScoreProjection());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Lancers francs'), findsOneWidget);
expect(find.text('Routine de tir'), findsOneWidget);
expect(find.text('Cible : 8'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
expect(find.byTooltip('Ajouter'), findsOneWidget);
expect(find.byTooltip('Retirer'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets('shows reps target on step manual score content', (tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_manualScoreProjectionWithRepsTarget());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Répétitions : 10 · Cible : 8'), findsOneWidget);
expect(find.text('SCORE'), findsOneWidget);
expect(find.byTooltip('Ajouter'), findsOneWidget);
expect(find.byTooltip('Valider létape'), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('hides set timer even when it is projected as dominant', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_setTimerDominantProjection());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Chrono étape'), findsOneWidget);
expect(find.text('02:14'), findsOneWidget);
expect(find.text('Série'), findsNothing);
expect(find.text('00:45'), findsNothing);
expect(find.byTooltip('Pause'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('does not offer a start action from the no-session screen', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_noSessionStartProjection());
await tester.pump();
expect(find.text('Aucune séance en cours'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing);
expect(client.sentCommands, isEmpty);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('starts a stopped exercise timer from the timer button', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_readyProjection());
await tester.pump();
expect(find.text('Routine de tir'), findsOneWidget);
await tester.tap(find.byTooltip('Démarrer'));
await tester.pump();
expect(
client.sentCommands.single.type,
WatchCommandType.startCurrentExercise,
);
expect(
find.byKey(const ValueKey('timer-toggle-pending-dot')),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('keeps the no-session start action disabled without phone', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false));
client.emitProjection(_noSessionStartProjection(phoneReachable: false));
await tester.pump();
expect(find.text('Téléphone indisponible'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('shows compact timer controls with a manual score step', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_manualScoreProjectionWithTimer());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('3'), findsOneWidget);
expect(find.text('Routine de tir'), findsOneWidget);
expect(find.text('Chrono étape'), findsOneWidget);
expect(find.text('02:14'), findsOneWidget);
expect(find.byTooltip('Pause'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets(
'shows live heart rate on main page and full telemetry on stats',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
expect(find.byTooltip('Stats'), findsNothing);
client.emitSensorSample(
WatchSensorSample(
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
27,
10,
).millisecondsSinceEpoch,
heartRateBpm: 142,
distanceMeters: 840,
caloriesKcal: 186,
),
);
await tester.pump();
expect(find.text('FC 142 bpm'), findsNothing);
expect(find.byIcon(Icons.favorite), findsOneWidget);
expect(find.text('142 bpm'), findsOneWidget);
expect(find.text('840 m'), findsNothing);
expect(find.text('186 kcal'), findsNothing);
expect(find.byTooltip('Stats'), findsOneWidget);
await tester.drag(
find.byKey(const ValueKey('watch-session-page')),
const Offset(-220, 0),
);
await tester.pumpAndSettle();
await tester.drag(
find.byKey(const ValueKey('watch-actions-page')),
const Offset(-220, 0),
);
await tester.pumpAndSettle();
expect(find.text('Stats'), findsOneWidget);
expect(find.text('FC'), findsOneWidget);
expect(find.text('142 bpm'), findsOneWidget);
expect(find.text('840 m'), findsOneWidget);
expect(find.text('186 kcal'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets(
'sends pause command from the icon button when a timer dominates',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
await tester.tap(find.byTooltip('Pause'));
await tester.pump();
expect(client.sentCommands.single.type, WatchCommandType.pauseSession);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets('shows pending feedback inside the pause button', (tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
await tester.tap(find.byTooltip('Pause'));
await tester.pump();
expect(
find.byKey(const ValueKey('timer-toggle-pending-dot')),
findsOneWidget,
);
expect(client.sentCommands.single.type, WatchCommandType.pauseSession);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets(
'returns to session and shows a discreet notice when a set command is rejected',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Terminer la série'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(
client.sentCommands.single.type,
WatchCommandType.finishCurrentSet,
);
expect(find.text('Squat jump'), findsOneWidget);
client.emitAck(
WatchCommandAckEvent(
commandId: client.sentCommands.single.commandId,
status: WatchCommandAck.rejectedNotApplicable,
sessionId: 'session-1',
),
);
await tester.pump();
await tester.pump();
expect(find.text('Série non modifiée'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 1900));
expect(find.text('Série non modifiée'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets(
'keeps rest skip secondary action open until the session projection updates',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_secondaryRestActionProjection());
await tester.pump();
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
_expectActionsPageVisible(tester);
await tester.tap(find.text('Passer le repos'));
await tester.pump();
expect(client.sentCommands.single.type, WatchCommandType.skipCurrentRest);
_expectActionsPageVisible(tester);
client.emitProjection(_runningProjection());
await tester.pump();
await tester.pumpAndSettle();
_expectSessionPageVisible(tester);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets(
'keeps rest skip secondary action open and shows feedback when rejected',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_secondaryRestActionProjection());
await tester.pump();
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Passer le repos'));
await tester.pump();
client.emitAck(
WatchCommandAckEvent(
commandId: client.sentCommands.single.commandId,
status: WatchCommandAck.rejectedNotApplicable,
sessionId: 'session-1',
),
);
await tester.pump();
await tester.pump();
_expectActionsPageVisible(tester);
expect(find.text('Commande non appliquée'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets(
'renders rest without overlapping series progress and next name',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_restProjection());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('REPOS'), findsOneWidget);
expect(find.text('Après série 2/4'), findsOneWidget);
expect(find.text('Ensuite'), findsOneWidget);
expect(find.text('Gainage latéral'), findsOneWidget);
expect(find.text('Série 2/4'), findsNothing);
expect(find.textContaining('Ensuite :'), findsNothing);
expect(find.text('00:30'), findsOneWidget);
expect(find.byTooltip('Pause'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets('hides timer controls when no active timer is projected', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_manualScoreProjection());
await tester.pump();
expect(find.text('Chrono étape'), findsNothing);
expect(find.text('02:14'), findsNothing);
expect(find.byTooltip('Pause'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('shows reps target and direct completion on a reps-only step', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_repsStepProjection());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Répétitions'), findsOneWidget);
expect(find.text('12'), findsOneWidget);
expect(find.text('SÉRIE'), findsNothing);
expect(find.text('Prêt pour la série suivante'), findsNothing);
expect(find.byTooltip('Valider létape'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.byTooltip('Valider létape'));
await tester.pump();
expect(
client.sentCommands.single.type,
WatchCommandType.completeCurrentStep,
);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets(
'keeps optimistic score visible after timeout until explicit rejection',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(
nativeClient: client,
waitingThreshold: const Duration(milliseconds: 50),
commandTimeout: const Duration(milliseconds: 120),
);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_manualScoreProjection());
await tester.pump();
await tester.tap(find.byTooltip('Ajouter'));
await tester.pump();
final commandId = client.sentCommands.single.commandId;
expect(find.text('4'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 150));
expect(find.text('4'), findsOneWidget);
expect(find.byKey(const ValueKey('score-pending-dot')), findsOneWidget);
expect(client.resyncRequests, greaterThanOrEqualTo(2));
client.emitProjection(_manualScoreProjection());
await tester.pump();
expect(find.text('4'), findsOneWidget);
client.emitAck(
WatchCommandAckEvent(
commandId: commandId,
status: WatchCommandAck.rejectedNotApplicable,
sessionId: 'session-1',
),
);
await tester.pump();
expect(find.text('3'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets('blocks score commands until reconnect projection resyncs', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_manualScoreProjection());
await tester.pump();
client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false));
await tester.pump();
client.emitConnection(
const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true),
);
await tester.pump();
await viewModel.incrementScore();
await tester.pump();
expect(client.sentCommands, isEmpty);
expect(client.resyncRequests, greaterThanOrEqualTo(2));
client.emitProjection(_manualScoreProjection());
await tester.pump();
await viewModel.incrementScore();
await tester.pump();
expect(client.sentCommands.single.type, WatchCommandType.incrementScore);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('blocks session actions until reconnect projection resyncs', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false));
await tester.pump();
client.emitConnection(
const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true),
);
await tester.pump();
await viewModel.sendPrimaryAction();
await tester.pump();
expect(client.sentCommands, isEmpty);
expect(client.resyncRequests, greaterThanOrEqualTo(2));
client.emitProjection(_runningProjection());
await tester.pump();
await viewModel.sendPrimaryAction();
await tester.pump();
expect(client.sentCommands.single.type, WatchCommandType.pauseSession);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('blocks score commands while projection is stale', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(
nativeClient: client,
staleProjectionThreshold: const Duration(milliseconds: 50),
connectionLostThreshold: const Duration(milliseconds: 150),
);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(
_manualScoreProjection(expiresIn: const Duration(seconds: 3)),
);
await tester.pump(const Duration(milliseconds: 60));
await viewModel.incrementScore();
await tester.pump();
expect(viewModel.value.staleProjection, isTrue);
expect(client.sentCommands, isEmpty);
client.emitProjection(_manualScoreProjection());
await tester.pump();
await viewModel.incrementScore();
await tester.pump();
expect(client.sentCommands.single.type, WatchCommandType.incrementScore);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets(
'uses a strong pulse sequence when a countdown timer reaches zero',
(tester) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') {
hapticCalls.add(call);
}
return null;
});
addTearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_countdownProjection(accumulatedMs: 29000));
await tester.pump();
expect(hapticCalls, isEmpty);
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(hapticCalls, hasLength(3));
expect(
hapticCalls.map((call) => call.arguments),
everyElement('HapticFeedbackType.heavyImpact'),
);
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
await tester.pump();
expect(hapticCalls, hasLength(3));
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets(
'keeps ticking a visible running countdown until completion without a new projection',
(tester) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') {
hapticCalls.add(call);
}
return null;
});
addTearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
var nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(
viewModel: viewModel,
nowEpochMs: () => nowMs,
),
),
);
client.emitProjection(
_liveCountdownProjection(remainingMs: 1200, nowMs: nowMs),
);
await tester.pump();
expect(find.text('00:01'), findsOneWidget);
expect(hapticCalls, isEmpty);
nowMs += 1300;
await tester.pump(const Duration(milliseconds: 1300));
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('00:00'), findsOneWidget);
expect(hapticCalls, hasLength(3));
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets('does not tick a visible paused countdown', (tester) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') {
hapticCalls.add(call);
}
return null;
});
addTearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_pausedCountdownProjection(remainingMs: 1200));
await tester.pump();
expect(find.text('00:01'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2300));
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('00:01'), findsOneWidget);
expect(hapticCalls, isEmpty);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('expires an orphaned active projection after its TTL', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(
_expiringProjection(expiresIn: const Duration(seconds: 1)),
);
await tester.pump();
expect(find.text('Squat jump'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
expect(viewModel.value.projection.phase, WatchSessionPhase.noActiveSession);
expect(viewModel.value.connectionLost, isTrue);
expect(client.invalidatedProjectionCount, 1);
expect(find.text('Téléphone indisponible'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('marks projection freshness only when thresholds are reached', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(
nativeClient: client,
staleProjectionThreshold: const Duration(milliseconds: 100),
connectionLostThreshold: const Duration(milliseconds: 220),
);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(
_expiringProjection(expiresIn: const Duration(seconds: 3)),
);
await tester.pump();
expect(viewModel.value.staleProjection, isFalse);
expect(viewModel.value.connectionLost, isFalse);
await tester.pump(const Duration(milliseconds: 90));
expect(viewModel.value.staleProjection, isFalse);
expect(viewModel.value.connectionLost, isFalse);
await tester.pump(const Duration(milliseconds: 20));
expect(viewModel.value.staleProjection, isTrue);
expect(viewModel.value.connectionLost, isFalse);
expect(find.text('Dernier état reçu'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 130));
expect(viewModel.value.staleProjection, isTrue);
expect(viewModel.value.connectionLost, isTrue);
expect(find.text('Connexion au téléphone perdue'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('reconnects and refreshes freshness state immediately', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(
nativeClient: client,
staleProjectionThreshold: const Duration(milliseconds: 100),
connectionLostThreshold: const Duration(milliseconds: 220),
);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(
_expiringProjection(expiresIn: const Duration(seconds: 3)),
);
await tester.pump(const Duration(milliseconds: 240));
expect(viewModel.value.connectionLost, isTrue);
client.emitConnection(const WatchBridgeConnectionEvent(isReachable: true));
await tester.pump();
expect(viewModel.value.connectionLost, isFalse);
expect(viewModel.value.staleProjection, isTrue);
expect(client.resyncRequests, greaterThanOrEqualTo(2));
client.emitProjection(
_expiringProjection(expiresIn: const Duration(seconds: 3)),
);
await tester.pump();
expect(viewModel.value.staleProjection, isFalse);
expect(viewModel.value.connectionLost, isFalse);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
}
final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
final _projectionController =
StreamController<WatchSessionProjection>.broadcast();
final _sensorSampleController =
StreamController<WatchSensorSample>.broadcast();
final _ackController = StreamController<WatchCommandAckEvent>.broadcast();
final _alertController = StreamController<WatchAlertEnvelope>.broadcast();
final _connectionController =
StreamController<WatchBridgeConnectionEvent>.broadcast();
var resyncRequests = 0;
var capabilityRefreshRequests = 0;
var invalidatedProjectionCount = 0;
final sentCommands = <WatchCommandEnvelope>[];
@override
Stream<WatchSessionProjection> get projections =>
_projectionController.stream;
@override
Stream<WatchSensorSample> get sensorSamples => _sensorSampleController.stream;
@override
Stream<WatchCommandAckEvent> get acks => _ackController.stream;
@override
Stream<WatchAlertEnvelope> get alerts => _alertController.stream;
@override
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
_connectionController.stream;
void emitProjection(WatchSessionProjection projection) {
_projectionController.add(projection);
}
void emitSensorSample(WatchSensorSample sample) {
_sensorSampleController.add(sample);
}
void emitAck(WatchCommandAckEvent ack) {
_ackController.add(ack);
}
void emitAlert(WatchAlertEnvelope alert) {
_alertController.add(alert);
}
void emitConnection(WatchBridgeConnectionEvent event) {
_connectionController.add(event);
}
@override
Future<void> requestCapabilityRefresh() async {
capabilityRefreshRequests += 1;
}
@override
Future<void> invalidateActiveProjection() async {
invalidatedProjectionCount += 1;
}
@override
Future<void> requestResync() async {
resyncRequests += 1;
}
@override
Future<void> sendCommand(WatchCommandEnvelope command) async {
sentCommands.add(command);
}
}
WatchSessionProjection _runningProjection() {
final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch;
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 1,
projectedAtEpochMs: projectedAt,
expiresAtEpochMs: projectedAt + const Duration(seconds: 12).inMilliseconds,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: 'Squat jump',
statusLabel: 'Séance active',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: _runningStepTimer(),
secondaryTimers: const [
WatchTimerProjection(
kind: WatchTimerKind.setTimer,
label: 'Série',
displayMode: WatchTimerDisplayMode.elapsed,
runState: WatchTimerRunState.running,
referenceEpochMs: 0,
accumulatedMs: 45000,
),
],
secondaryActions: const [WatchSecondaryAction.finishCurrentSet],
);
}
WatchSessionProjection _expiringProjection({required Duration expiresIn}) {
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 99,
projectedAtEpochMs: nowMs,
expiresAtEpochMs: nowMs + expiresIn.inMilliseconds,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Squat jump',
statusLabel: 'Chrono étape',
primaryAction: WatchPrimaryAction.pauseSession,
);
}
WatchSessionProjection _noSessionStartProjection({bool phoneReachable = true}) {
return WatchSessionProjection(
deviceSessionId: '',
revision: 0,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.noActiveSession,
phoneReachable: phoneReachable,
seriesIndex: 0,
seriesTotal: 0,
exerciseName: '',
statusLabel: 'Aucune séance',
primaryAction: WatchPrimaryAction.none,
);
}
WatchSessionProjection _readyProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 1,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.ready,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Lancers francs',
stepName: 'Routine de tir',
statusLabel: 'Prêt à démarrer',
primaryAction: WatchPrimaryAction.startCurrentExercise,
dominantTimer: const WatchTimerProjection(
kind: WatchTimerKind.step,
label: 'Chrono étape',
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.stopped,
referenceEpochMs: 0,
accumulatedMs: 0,
targetMs: 30000,
),
);
}
WatchSessionProjection _repsStepProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 7,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Pompes',
stepIndex: 1,
stepTotal: 2,
stepName: 'Pompes strictes',
stepType: WatchStepType.reps,
stepTargetValue: 12,
primaryAction: WatchPrimaryAction.pauseSession,
secondaryActions: const [WatchSecondaryAction.skipCurrentStep],
);
}
WatchSessionProjection _secondaryRestActionProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 4,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: 'Squat jump',
statusLabel: 'Séance active',
primaryAction: WatchPrimaryAction.pauseSession,
secondaryActions: const [WatchSecondaryAction.skipCurrentRest],
);
}
WatchSessionProjection _setTimerDominantProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 6,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: 'Squat jump',
statusLabel: 'Séance active',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: const WatchTimerProjection(
kind: WatchTimerKind.setTimer,
label: 'Série',
displayMode: WatchTimerDisplayMode.elapsed,
runState: WatchTimerRunState.running,
referenceEpochMs: 0,
accumulatedMs: 45000,
),
secondaryTimers: [_runningStepTimer()],
secondaryActions: const [WatchSecondaryAction.finishCurrentSet],
);
}
void _expectActionsPageVisible(WidgetTester tester) {
expect(
tester.getTopLeft(find.byKey(const ValueKey('watch-actions-page'))).dx,
lessThan(96),
);
}
void _expectSessionPageVisible(WidgetTester tester) {
expect(
tester.getTopLeft(find.byKey(const ValueKey('watch-session-page'))).dx,
lessThan(96),
);
}
WatchSessionProjection _manualScoreProjection({
Duration expiresIn = const Duration(seconds: 12),
}) {
final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch;
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 2,
projectedAtEpochMs: projectedAt,
expiresAtEpochMs: projectedAt + expiresIn.inMilliseconds,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Lancers francs',
stepName: 'Routine de tir',
statusLabel: 'Score manuel',
primaryAction: WatchPrimaryAction.pauseSession,
hasManualScore: true,
currentManualScoreValue: 3,
canDecrementScore: true,
manualScoreTargetValue: 8,
manualScoreTargetLabel: 'Cible',
manualScoreScope: WatchManualScoreScope.series,
);
}
WatchSessionProjection _manualScoreProjectionWithTimer() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 3,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Lancers francs',
stepName: 'Routine de tir',
statusLabel: 'Score manuel',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: _runningStepTimer(),
hasManualScore: true,
currentManualScoreValue: 3,
canDecrementScore: true,
manualScoreTargetValue: 8,
manualScoreTargetLabel: 'Cible',
manualScoreScope: WatchManualScoreScope.step,
);
}
WatchSessionProjection _manualScoreProjectionWithRepsTarget() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 4,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Pompes tempo',
stepName: 'Score libre',
statusLabel: 'Score manuel',
primaryAction: WatchPrimaryAction.pauseSession,
hasManualScore: true,
currentManualScoreValue: 3,
canDecrementScore: true,
manualScoreTargetValue: 8,
manualScoreTargetLabel: 'Cible',
manualScoreRepsTargetValue: 10,
manualScoreScope: WatchManualScoreScope.step,
);
}
WatchSessionProjection _restProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 5,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.restRunning,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: 'Squat jump',
nextExerciseName: 'Gainage latéral',
statusLabel: 'Repos',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: WatchTimerProjection(
kind: WatchTimerKind.rest,
label: 'Repos',
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.running,
referenceEpochMs: DateTime.now()
.toUtc()
.add(const Duration(minutes: 1))
.millisecondsSinceEpoch,
accumulatedMs: 30000,
startedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
targetMs: 60000,
),
);
}
WatchSessionProjection _countdownProjection({required int accumulatedMs}) {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: accumulatedMs,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Gainage',
statusLabel: 'Chrono étape',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: WatchTimerProjection(
kind: WatchTimerKind.step,
label: 'Chrono étape',
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.running,
referenceEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
accumulatedMs: accumulatedMs,
startedAtEpochMs: null,
targetMs: 30000,
),
);
}
WatchSessionProjection _liveCountdownProjection({
required int remainingMs,
required int nowMs,
}) {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: remainingMs,
projectedAtEpochMs: nowMs,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Gainage',
statusLabel: 'Chrono étape',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: WatchTimerProjection(
kind: WatchTimerKind.step,
label: 'Chrono étape',
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.running,
referenceEpochMs: nowMs,
accumulatedMs: 30000 - remainingMs,
startedAtEpochMs: nowMs,
targetMs: 30000,
),
);
}
WatchSessionProjection _pausedCountdownProjection({required int remainingMs}) {
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: remainingMs,
projectedAtEpochMs: nowMs,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Gainage',
statusLabel: 'Chrono étape',
primaryAction: WatchPrimaryAction.resumeSession,
dominantTimer: WatchTimerProjection(
kind: WatchTimerKind.step,
label: 'Chrono étape',
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.paused,
referenceEpochMs: nowMs,
accumulatedMs: 30000 - remainingMs,
startedAtEpochMs: null,
targetMs: 30000,
),
);
}
WatchTimerProjection _runningStepTimer() {
return WatchTimerProjection(
kind: WatchTimerKind.step,
label: 'Chrono étape',
displayMode: WatchTimerDisplayMode.elapsed,
runState: WatchTimerRunState.running,
referenceEpochMs: DateTime.now()
.toUtc()
.add(const Duration(minutes: 1))
.millisecondsSinceEpoch,
accumulatedMs: 134000,
startedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
targetMs: 180000,
);
}