1177 lines
36 KiB
Dart
1177 lines
36 KiB
Dart
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(
|
||
'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('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();
|
||
});
|
||
}
|
||
|
||
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() {
|
||
return WatchSessionProjection(
|
||
deviceSessionId: 'session-1',
|
||
revision: 2,
|
||
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,
|
||
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,
|
||
),
|
||
);
|
||
}
|
||
|
||
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,
|
||
);
|
||
}
|