diff --git a/lib/ui/widgets/effects_overlay.dart b/lib/ui/widgets/effects_overlay.dart index b187bf9..9a3752e 100644 --- a/lib/ui/widgets/effects_overlay.dart +++ b/lib/ui/widgets/effects_overlay.dart @@ -60,11 +60,22 @@ class EffectsOverlayState extends State _ticker = createTicker(_tick); } + @visibleForTesting + Ticker get ticker => _ticker; + + @visibleForTesting + Duration get now => _now; + void _tick(Duration elapsed) { setState(() { _now = 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; + } }); } diff --git a/test/ui/effects_overlay_test.dart b/test/ui/effects_overlay_test.dart new file mode 100644 index 0000000..3813a3d --- /dev/null +++ b/test/ui/effects_overlay_test.dart @@ -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(); + 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', + ); + }); +}