Files
GameTime/watch_app/test/presentation/watch_session_screen_test.dart
Blomios 2e40694de4 fix(watch): corrige applicationId et nettoie la config de build montre
Répare la régression de packaging introduite par le reset/remerge de
develop : l'APK montre était packagée avec com.gametime.app au lieu de
com.gametime.watch (namespace correct mais applicationId hérité du
téléphone), ce qui cassait la détection du bridge. Aligne applicationId
sur com.gametime.watch, nettoie la config de signing partagée sur les
deux modules android/app et watch_app/android/app, et ajuste
use_cases + watch_session_screen en conséquence.

Validé : APK montre release package = com.gametime.watch, même SHA-256
de certificat sur les deux APKs (70cc69e8...45c7815), tests use_cases et
watch_session_screen OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:18:48 +02:00

691 lines
21 KiB
Dart

import 'dart:async';
import 'package:flutter/material.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.byTooltip('Pause'), findsOneWidget);
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('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 02:14'), findsOneWidget);
expect(find.byTooltip('Pause'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('shows telemetry only on stats page when available', (
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('142 bpm'), 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();
},
);
}
final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
final _projectionController =
StreamController<WatchSessionProjection>.broadcast();
final _sensorSampleController =
StreamController<WatchSensorSample>.broadcast();
final _ackController = StreamController<WatchCommandAckEvent>.broadcast();
final _connectionController =
StreamController<WatchBridgeConnectionEvent>.broadcast();
var resyncRequests = 0;
var capabilityRefreshRequests = 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<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 emitConnection(WatchBridgeConnectionEvent event) {
_connectionController.add(event);
}
@override
Future<void> requestCapabilityRefresh() async {
capabilityRefreshRequests += 1;
}
@override
Future<void> requestResync() async {
resyncRequests += 1;
}
@override
Future<void> sendCommand(WatchCommandEnvelope command) async {
sentCommands.add(command);
}
}
WatchSessionProjection _runningProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 1,
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: _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 _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 _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],
);
}
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,
);
}
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,
);
}