mirror of
https://github.com/lichess-org/mobile.git
synced 2026-05-26 13:50:52 +00:00
Use a resetId to make clock server update more robust
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user