Use a resetId to make clock server update more robust

This commit is contained in:
Vincent Velociter
2026-05-06 15:30:33 +02:00
parent 3c3e4d8294
commit 9edb79ec36
8 changed files with 217 additions and 25 deletions
+9 -2
View File
@@ -360,8 +360,15 @@ sealed class GameMeta with _$GameMeta {
@Freezed(fromJson: true, toJson: true)
sealed class CorrespondenceClockData with _$CorrespondenceClockData {
const CorrespondenceClockData._();
const factory CorrespondenceClockData({required Duration white, required Duration black}) =
_CorrespondenceClockData;
const factory CorrespondenceClockData({
required Duration white,
required Duration black,
// Opaque token that the CorrespondenceClock widget uses to detect a new
// server-authoritative reading and reset its displayed time. Excluded from
// JSON so that serialized games always deserialize to resetId == 0, which
// is fine: the offline screen drives resets via lastModified instead.
@JsonKey(includeFromJson: false, includeToJson: false) @Default(0) int resetId,
}) = _CorrespondenceClockData;
factory CorrespondenceClockData.fromJson(Map<String, dynamic> json) =>
_$CorrespondenceClockDataFromJson(json);
+1
View File
@@ -731,6 +731,7 @@ class GameController extends AsyncNotifier<GameState> {
newState = newState.copyWith.game.correspondenceClock!(
white: data.clock!.white,
black: data.clock!.black,
resetId: DateTime.now().millisecondsSinceEpoch,
);
}
}
+1
View File
@@ -326,6 +326,7 @@ CorrespondenceClockData _correspondenceClockDataFromPick(RequiredPick pick) {
return CorrespondenceClockData(
white: pick('white').asDurationFromSecondsOrThrow(),
black: pick('black').asDurationFromSecondsOrThrow(),
resetId: DateTime.now().millisecondsSinceEpoch,
);
}
@@ -156,6 +156,9 @@ class _BodyState extends ConsumerState<_Body> {
? CorrespondenceClock(
duration: game.estimatedTimeLeft(Side.black, widget.lastModified)!,
active: activeClockSide == Side.black,
// lastModified changes on every move write, so it serves as a
// reliable signal that the server sent a new authoritative clock reading.
resetId: widget.lastModified.millisecondsSinceEpoch,
)
: null,
);
@@ -173,6 +176,9 @@ class _BodyState extends ConsumerState<_Body> {
? CorrespondenceClock(
duration: game.estimatedTimeLeft(Side.white, widget.lastModified)!,
active: activeClockSide == Side.white,
// lastModified changes on every move write, so it serves as a
// reliable signal that the server sent a new authoritative clock reading.
resetId: widget.lastModified.millisecondsSinceEpoch,
)
: null,
);
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
import 'package:lichess_mobile/src/constants.dart';
import 'package:lichess_mobile/src/styles/styles.dart';
@@ -14,10 +15,20 @@ class CorrespondenceClock extends StatefulWidget {
/// If [active] is `true`, the clock starts counting down.
final bool active;
/// Opaque token that identifies the server-authoritative clock reading.
/// [timeLeft] is reset to [duration] whenever this value changes.
final int resetId;
/// Callback when the clock reaches zero.
final VoidCallback? onFlag;
const CorrespondenceClock({required this.duration, required this.active, this.onFlag, super.key});
const CorrespondenceClock({
required this.duration,
required this.active,
required this.resetId,
this.onFlag,
super.key,
});
@override
State<CorrespondenceClock> createState() => _CorrespondenceClockState();
@@ -29,7 +40,7 @@ class _CorrespondenceClockState extends State<CorrespondenceClock> {
Timer? _timer;
Duration timeLeft = Duration.zero;
final _stopwatch = Stopwatch();
final _stopwatch = clock.stopwatch();
void startClock() {
_timer?.cancel();
@@ -65,7 +76,7 @@ class _CorrespondenceClockState extends State<CorrespondenceClock> {
@override
void didUpdateWidget(CorrespondenceClock oldClock) {
super.didUpdateWidget(oldClock);
if (widget.duration != oldClock.duration || (widget.active != oldClock.active)) {
if (widget.resetId != oldClock.resetId) {
timeLeft = widget.duration;
}
if (widget.active) {
+6 -2
View File
@@ -167,10 +167,12 @@ class GameBody extends ConsumerWidget {
},
),
)
: gameState.game.correspondenceClock != null
: gameState.game.correspondenceClock != null &&
gameState.game.lastPosition.fullmoves > 1
? CorrespondenceClock(
duration: gameState.game.correspondenceClock!.black,
active: gameState.activeClockSide == Side.black,
resetId: gameState.game.correspondenceClock!.resetId,
onFlag: () => ref.read(ctrlProvider.notifier).onFlag(),
)
: null,
@@ -216,10 +218,12 @@ class GameBody extends ConsumerWidget {
},
),
)
: gameState.game.correspondenceClock != null
: gameState.game.correspondenceClock != null &&
gameState.game.lastPosition.fullmoves > 1
? CorrespondenceClock(
duration: gameState.game.correspondenceClock!.white,
active: gameState.activeClockSide == Side.white,
resetId: gameState.game.correspondenceClock!.resetId,
onFlag: () => ref.read(ctrlProvider.notifier).onFlag(),
)
: null,
@@ -3,11 +3,21 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:lichess_mobile/l10n/l10n.dart';
import 'package:lichess_mobile/src/view/game/correspondence_clock_widget.dart';
Widget _buildClock({required Duration duration, required bool active, VoidCallback? onFlag}) {
Widget _buildClock({
required Duration duration,
required bool active,
required int resetId,
VoidCallback? onFlag,
}) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Scaffold(
body: CorrespondenceClock(duration: duration, active: active, onFlag: onFlag),
body: CorrespondenceClock(
duration: duration,
active: active,
resetId: resetId,
onFlag: onFlag,
),
),
);
}
@@ -15,13 +25,17 @@ Widget _buildClock({required Duration duration, required bool active, VoidCallba
void main() {
group('CorrespondenceClock', () {
testWidgets('displays the initial time correctly', (tester) async {
await tester.pumpWidget(_buildClock(duration: const Duration(minutes: 5), active: false));
await tester.pumpWidget(
_buildClock(duration: const Duration(minutes: 5), active: false, resetId: 0),
);
await tester.pump();
expect(find.text('00:05:00', findRichText: true), findsOneWidget);
});
testWidgets('does not tick when inactive', (tester) async {
await tester.pumpWidget(_buildClock(duration: const Duration(minutes: 5), active: false));
await tester.pumpWidget(
_buildClock(duration: const Duration(minutes: 5), active: false, resetId: 0),
);
await tester.pump();
expect(find.text('00:05:00', findRichText: true), findsOneWidget);
@@ -29,45 +43,83 @@ void main() {
expect(find.text('00:05:00', findRichText: true), findsOneWidget);
});
testWidgets('resets timeLeft to new duration when active changes from true to false', (
testWidgets('resets timeLeft when resetId changes (active true -> false, new duration)', (
tester,
) async {
await tester.pumpWidget(_buildClock(duration: const Duration(minutes: 5), active: true));
await tester.pumpWidget(
_buildClock(duration: const Duration(minutes: 5), active: true, resetId: 1),
);
await tester.pump();
expect(find.text('00:05:00', findRichText: true), findsOneWidget);
// Simulate server sending corrected time when it becomes our turn:
// active changes (true -> false) and a new duration is provided.
await tester.pumpWidget(_buildClock(duration: const Duration(minutes: 3), active: false));
// Server confirms move: active and duration both change, resetId bumped.
await tester.pumpWidget(
_buildClock(duration: const Duration(minutes: 3), active: false, resetId: 2),
);
await tester.pump();
// timeLeft must be reset to the new server duration, not the locally ticked value.
expect(find.text('00:03:00', findRichText: true), findsOneWidget);
});
testWidgets('resets timeLeft to new duration when active changes from false to true', (
testWidgets('resets timeLeft when resetId changes (active false -> true, new duration)', (
tester,
) async {
await tester.pumpWidget(_buildClock(duration: const Duration(minutes: 3), active: false));
await tester.pumpWidget(
_buildClock(duration: const Duration(minutes: 3), active: false, resetId: 1),
);
await tester.pump();
expect(find.text('00:03:00', findRichText: true), findsOneWidget);
// Simulate becoming our turn: active changes (false -> true) with updated duration.
await tester.pumpWidget(_buildClock(duration: const Duration(minutes: 5), active: true));
// Opponent moved: becomes our turn with updated duration, resetId bumped.
await tester.pumpWidget(
_buildClock(duration: const Duration(minutes: 5), active: true, resetId: 2),
);
await tester.pump();
expect(find.text('00:05:00', findRichText: true), findsOneWidget);
});
testWidgets('resets timeLeft when duration changes without active changing', (tester) async {
await tester.pumpWidget(_buildClock(duration: const Duration(minutes: 5), active: false));
// Key scenario: the player whose duration didn't change (their bank is untouched)
// but who becomes active because the opponent just moved. The resetId bumps because
// a move was made, so timeLeft must sync to the server duration even though the
// duration value itself didn't change.
testWidgets('resets timeLeft when resetId changes even if duration is unchanged', (
tester,
) async {
await tester.pumpWidget(
_buildClock(duration: const Duration(minutes: 5), active: false, resetId: 1),
);
await tester.pump();
expect(find.text('00:05:00', findRichText: true), findsOneWidget);
await tester.pumpWidget(_buildClock(duration: const Duration(minutes: 7), active: false));
// Opponent made a move: it's now our turn, our time bank is unchanged (5 min),
// but a new move was made so resetId is bumped.
await tester.pumpWidget(
_buildClock(duration: const Duration(minutes: 5), active: true, resetId: 2),
);
await tester.pump();
expect(find.text('00:07:00', findRichText: true), findsOneWidget);
expect(find.text('00:05:00', findRichText: true), findsOneWidget);
});
testWidgets('does not reset timeLeft on widget rebuild when resetId is unchanged', (
tester,
) async {
await tester.pumpWidget(
_buildClock(duration: const Duration(minutes: 5), active: true, resetId: 1),
);
await tester.pump();
// Let the clock tick for 3 seconds.
await tester.pump(const Duration(seconds: 3));
expect(find.text('00:04:57', findRichText: true), findsOneWidget);
// Widget rebuilds (e.g. unrelated state change) with same resetId — must not reset.
await tester.pumpWidget(
_buildClock(duration: const Duration(minutes: 5), active: true, resetId: 1),
);
await tester.pump();
expect(find.text('00:04:57', findRichText: true), findsOneWidget);
});
});
}
+110
View File
@@ -33,6 +33,7 @@ import 'package:lichess_mobile/src/network/http.dart';
import 'package:lichess_mobile/src/network/socket.dart';
import 'package:lichess_mobile/src/styles/lichess_icons.dart';
import 'package:lichess_mobile/src/view/chat/chat_screen.dart';
import 'package:lichess_mobile/src/view/game/correspondence_clock_widget.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
@@ -991,6 +992,115 @@ void main() {
});
});
group('Correspondence Clock', () {
// Retrieves the CorrespondenceClock widget that contains the given displayed time.
CorrespondenceClock correspondenceClockWithTime(WidgetTester tester, String time) {
return tester.widget<CorrespondenceClock>(
find.ancestor(
of: find.text(time, findRichText: true),
matching: find.byType(CorrespondenceClock),
),
);
}
testWidgets('shows correspondence clocks, not regular clocks', (tester) async {
await createTestGame(
tester,
clock: null,
pgn: 'e4 e5',
correspondenceClock: (
white: const Duration(hours: 20, minutes: 5),
black: const Duration(days: 1),
daysPerTurn: 1,
),
);
expect(find.byType(Clock), findsNothing);
expect(find.byType(CorrespondenceClock), findsNWidgets(2));
expect(find.text('One day', findRichText: true), findsOneWidget);
expect(find.text('20:05', findRichText: true), findsOneWidget);
});
testWidgets("active clock is white's when it is white's turn", (tester) async {
// pgn 'e4 e5': fullmoves = 2, white to move → white active
await createTestGame(
tester,
clock: null,
pgn: 'e4 e5',
correspondenceClock: (
white: const Duration(hours: 20, minutes: 5),
black: const Duration(days: 1),
daysPerTurn: 1,
),
);
expect(correspondenceClockWithTime(tester, '20:05').active, isTrue);
expect(correspondenceClockWithTime(tester, 'One day').active, isFalse);
await tester.pump(const Duration(minutes: 2));
expect(find.text('20:05', findRichText: true), findsNothing);
expect(find.text('20:03', findRichText: true), findsOneWidget);
expect(find.text('One day', findRichText: true), findsOneWidget);
});
testWidgets("active clock is black's when it is black's turn", (tester) async {
// pgn 'e4 e5 Nf3': black to move → black active
await createTestGame(
tester,
clock: null,
pgn: 'e4 e5 Nf3',
correspondenceClock: (
white: const Duration(days: 1),
black: const Duration(hours: 19, minutes: 59),
daysPerTurn: 1,
),
);
expect(correspondenceClockWithTime(tester, '19:59').active, isTrue);
expect(correspondenceClockWithTime(tester, 'One day').active, isFalse);
await tester.pump(const Duration(minutes: 5));
expect(find.text('19:59', findRichText: true), findsNothing);
expect(find.text('19:54', findRichText: true), findsOneWidget);
expect(find.text('One day', findRichText: true), findsOneWidget);
});
testWidgets('clock values and active side update after opponent move', (tester) async {
await createTestGame(
tester,
clock: null,
pgn: 'e4 e5 Nf3', // black to move → black active
correspondenceClock: (
white: const Duration(days: 1),
black: const Duration(hours: 10, minutes: 3),
daysPerTurn: 1,
),
);
expect(correspondenceClockWithTime(tester, '10:03').active, isTrue);
await tester.pump(const Duration(minutes: 3));
expect(find.text('10:00', findRichText: true), findsOneWidget);
// server sends black's move with updated clock values
sendServerSocketMessages(testGameSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 4, "uci": "g8f6", "san": "Nf6", "clock": {"white": 86400, "black": 86400}}}',
]);
await tester.pump(const Duration(milliseconds: 10));
// both clocks reset to server's authoritative values
expect(find.text('One day', findRichText: true), findsNWidgets(2));
await tester.pump(const Duration(seconds: 2));
// white is now active
expect(correspondenceClockWithTime(tester, '23:59').active, isTrue);
expect(correspondenceClockWithTime(tester, 'One day').active, isFalse);
});
});
group('Opening analysis', () {
testWidgets('is not possible for an unfinished real time game', (WidgetTester tester) async {
await createTestGame(