fix(watch): finalise correctif sync workoutHistory/exercise et distance live montre (#157)

This commit is contained in:
2026-07-29 11:22:17 +02:00
parent 6f913e4e8d
commit 30c6259748
28 changed files with 1658 additions and 248 deletions

View File

@ -87,6 +87,36 @@ void main() {
},
);
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 {
@ -683,22 +713,63 @@ void main() {
},
);
testWidgets('vibrates once when a countdown timer reaches zero', (
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 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);
@ -714,20 +785,18 @@ void main() {
),
);
client.emitProjection(_countdownProjection(accumulatedMs: 29000));
client.emitProjection(
_expiringProjection(expiresIn: const Duration(seconds: 1)),
);
await tester.pump();
expect(hapticCalls, isEmpty);
expect(find.text('Squat jump'), findsOneWidget);
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 2));
expect(hapticCalls, hasLength(1));
expect(hapticCalls.single.arguments, 'HapticFeedbackType.heavyImpact');
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
await tester.pump();
expect(hapticCalls, hasLength(1));
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();
@ -745,6 +814,7 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
var resyncRequests = 0;
var capabilityRefreshRequests = 0;
var invalidatedProjectionCount = 0;
final sentCommands = <WatchCommandEnvelope>[];
@override
@ -782,6 +852,11 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
capabilityRefreshRequests += 1;
}
@override
Future<void> invalidateActiveProjection() async {
invalidatedProjectionCount += 1;
}
@override
Future<void> requestResync() async {
resyncRequests += 1;
@ -794,10 +869,12 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
}
WatchSessionProjection _runningProjection() {
final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch;
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 1,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
projectedAtEpochMs: projectedAt,
expiresAtEpochMs: projectedAt + const Duration(seconds: 12).inMilliseconds,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 2,
@ -820,6 +897,23 @@ WatchSessionProjection _runningProjection() {
);
}
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: '',
@ -980,6 +1074,29 @@ WatchSessionProjection _manualScoreProjectionWithTimer() {
);
}
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',