fix: re-anchor effects clock when ticker drains (stale-clock freeze)

After Ticker.stop(), elapsed resets to zero on the next start(). _now was
left frozen at the old elapsed, so effects added after a drain captured a
stale start time and their progress() clamped to 0 forever — ticker never
stopped, second batch broken. Fix: reset _now = Duration.zero on drain.
Adds @visibleForTesting getters and a regression test that catches the
stale value directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 22:16:51 +09:00
parent 8c3c2ae9a9
commit f1b8052f77
2 changed files with 78 additions and 1 deletions
+12 -1
View File
@@ -60,11 +60,22 @@ class EffectsOverlayState extends State<EffectsOverlay>
_ticker = createTicker(_tick); _ticker = createTicker(_tick);
} }
@visibleForTesting
Ticker get ticker => _ticker;
@visibleForTesting
Duration get now => _now;
void _tick(Duration elapsed) { void _tick(Duration elapsed) {
setState(() { setState(() {
_now = elapsed; _now = elapsed;
_fx.removeWhere((e) => e.done(elapsed)); _fx.removeWhere((e) => e.done(elapsed));
if (_fx.isEmpty) _ticker.stop(); if (_fx.isEmpty) {
_ticker.stop();
// Ticker elapsed restarts from zero after stop(); re-anchor so
// effects added later don't inherit a stale clock.
_now = Duration.zero;
}
}); });
} }
+66
View File
@@ -0,0 +1,66 @@
import 'package:block_seasons/game/engine/game_engine.dart';
import 'package:block_seasons/ui/widgets/effects_overlay.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
PlacementResult _clearResult() => const PlacementResult(
events: [],
pointsGained: 250,
linesCleared: 1,
gemsCleared: 0,
clearedRows: [3],
clearedCols: [],
comboStreak: 2,
);
void main() {
testWidgets('second batch after drain completes within its own duration',
(tester) async {
final key = GlobalKey<EffectsOverlayState>();
await tester.pumpWidget(MaterialApp(
home:
Stack(children: [Positioned.fill(child: EffectsOverlay(key: key))]),
));
const board = Rect.fromLTWH(0, 0, 320, 320);
// ── First batch ──────────────────────────────────────────────────────────
key.currentState!.onPlacement(_clearResult(), boardRect: board);
await tester.pumpAndSettle();
// Ticker must be stopped after the first drain.
expect(
key.currentState!.ticker.isActive,
isFalse,
reason: 'ticker should be idle after first batch drains',
);
// _now must be Duration.zero after drain (regression for stale-clock bug).
// Without the fix, _now stays frozen at the elapsed value from the last
// tick of the first batch (~1000 ms).
expect(
key.currentState!.now,
Duration.zero,
reason:
'_now must be reset to zero when the list drains so the next '
'batch starts from a clean clock',
);
// ── Second batch ─────────────────────────────────────────────────────────
// With the stale-clock bug the effects get start: ~1000ms, but the
// restarted ticker delivers elapsed from 0, so progress stays 0 forever
// and they never drain. We verify the second batch completes via
// pumpAndSettle (which would time out if the ticker ran forever).
key.currentState!.onPlacement(_clearResult(), boardRect: board);
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(
tester.hasRunningAnimations,
isFalse,
reason:
'second batch must finish; stale clock would keep effects frozen '
'and the ticker running indefinitely',
);
});
}