diff --git a/app/lib/core/ui/live_planet.dart b/app/lib/core/ui/live_planet.dart index ce5024e..a480f61 100644 --- a/app/lib/core/ui/live_planet.dart +++ b/app/lib/core/ui/live_planet.dart @@ -18,8 +18,12 @@ class LivePlanet25D extends StatelessWidget { this.glow = AppColors.neonViolet, this.animate = true, this.lowEffects = false, + this.spinBoost = false, }); final VoidCallback? onOpenHistory; + + /// [Planet25D.spinBoost]. + final bool spinBoost; final String fandomId; final PlanetWatch watch; final String image; @@ -54,6 +58,7 @@ class LivePlanet25D extends StatelessWidget { heroEventIds: state?.heroEventIds, animate: animate, lowEffects: lowEffects, + spinBoost: spinBoost, ), ); } diff --git a/app/lib/core/ui/planet_25d.dart b/app/lib/core/ui/planet_25d.dart index d5575e7..ae31594 100644 --- a/app/lib/core/ui/planet_25d.dart +++ b/app/lib/core/ui/planet_25d.dart @@ -145,8 +145,13 @@ class Planet25D extends StatefulWidget { this.onOpenHistory, this.lowEffects = false, this.animate = true, + this.spinBoost = false, }); + /// GIVE 10회 이상 연타 중 — Figma `2033:15818` 「자전속도 1.5 배」. 본체는 자전하지 않으므로(C-185) + /// 구름 · 궤도 · 링 먼지 · 펄스가 도는 주기를 1.5배 빠르게 한다. + final bool spinBoost; + /// 다음 Stage 진화 5% 전 — 표면 아우라([PlanetVisualState.evolutionImminent]). final bool evolutionImminent; @@ -218,9 +223,12 @@ class _Planet25DState extends State // 실패의 감소 폭은 원문에 수치가 없어 이클립스 진행 중과 같은 값을 쓴다(해석 확인 항목). ? eclipseLight : 1; + Duration get _cycle => + Duration(milliseconds: (_period * 1000 / (widget.spinBoost ? 1.5 : 1)).round()); + late final AnimationController _c = AnimationController( vsync: this, - duration: Duration(seconds: _period), + duration: _cycle, ); bool get _motionEnabled => @@ -245,8 +253,9 @@ class _Planet25DState extends State super.didUpdateWidget(old); if (old.isHibernating != widget.isHibernating || old.isEclipsed != widget.isEclipsed || - old.isHeroTime != widget.isHeroTime) { - _c.duration = Duration(seconds: _period); + old.isHeroTime != widget.isHeroTime || + old.spinBoost != widget.spinBoost) { + _c.duration = _cycle; if (_motionEnabled) _c.repeat(); } _syncMotion(); diff --git a/app/lib/features/galaxy/galaxy_screen.dart b/app/lib/features/galaxy/galaxy_screen.dart index d953788..c71efab 100644 --- a/app/lib/features/galaxy/galaxy_screen.dart +++ b/app/lib/features/galaxy/galaxy_screen.dart @@ -297,7 +297,8 @@ class _GalaxyScreenState extends State .clamp(0, 1 << 30) : _selectedIndex, balance: _zap.balance, - pending: _zap.pending, + // 보내는 중인 묶음까지 — 전송 중에도 연타가 쌓인다. + pending: _zap.queued, sending: _zap.sending, touchSerial: _zap.touchSerial, zapResult: _zap.result, @@ -671,9 +672,7 @@ class _Map extends StatelessWidget { myPop: (balance ?? data.myPop) - pending * data.zapPop, // **이클립스와 무관하다.** ZAP 은 행성 성장 기여이며(B-23), 방어는 // 「흔들어 방어」 화면에서 POP 없이 참여한다. - blockedReason: sending - ? context.l10n.zapApplying - : retryZap != null + blockedReason: retryZap != null ? context.l10n.zapNeedsCheck : data.mine == null ? context.l10n.zapNoPlanet diff --git a/app/lib/features/galaxy/zap_controller.dart b/app/lib/features/galaxy/zap_controller.dart index 21230df..29cafce 100644 --- a/app/lib/features/galaxy/zap_controller.dart +++ b/app/lib/features/galaxy/zap_controller.dart @@ -20,6 +20,16 @@ class ZapController extends ChangeNotifier { ({String fandomId, int count, String key})? _batch; bool get needsRetry => _batch != null && !sending; + /// 아직 확정되지 않은 연타 — 보내는 중인 묶음 + 다음 묶음. 표시 잔액은 이만큼 미리 뺀다. + int get queued => pending + (sending ? _batch?.count ?? 0 : 0); + + /// 이어서 누른 횟수 — Figma `2024:20758` 「0.5초간 [+1] 표시(연속 가능)」: [+1] 이 떠 있는 + /// [streakWindow] 안에 다시 누르면 이어지고, 넘기면 1 부터 다시 센다. + int streak = 0; + Timer? _streakTimer; + String? _pendingFandom; + static const streakWindow = Duration(milliseconds: 500); + /// 지금 결과가 ZAP 이 아니라 퀘스트 보상 확정의 행성 반응인가([react]). bool questReaction = false; @@ -41,14 +51,25 @@ class ZapController extends ChangeNotifier { notifyListeners(); } + /// 보내는 중에도 받는다 — 다음 묶음으로 쌓였다가 앞 묶음이 확정되면 보낸다. 실패한 묶음이 + /// 있으면([needsRetry]) 같은 키로 다시 확인하기 전까지 받지 않는다. void tap(String fandomId) { - if (_disposed || sending || _batch != null || fandomId.isEmpty) return; + if (_disposed || needsRetry || fandomId.isEmpty) return; + // 쌓인 연타는 한 행성 몫이다. 다른 행성 탭은 쌓인 묶음이 나간 뒤에 받는다. + if (pending > 0 && fandomId != _pendingFandom) return; + _pendingFandom = fandomId; questReaction = false; if (pending == 0) fxHaptic(FxIntensity.small); + streak = _streakTimer?.isActive == true ? streak + 1 : 1; + _streakTimer?.cancel(); + _streakTimer = Timer(streakWindow, () {}); pending++; touchSerial++; notifyListeners(); - _burst ??= Timer(const Duration(milliseconds: 450), () => _flush(fandomId)); + _burst ??= Timer(const Duration(milliseconds: 450), () { + _burst = null; + _flush(_pendingFandom!); + }); } void retry() { @@ -56,6 +77,7 @@ class ZapController extends ChangeNotifier { } Future _flush(String fandomId) async { + // 보내는 중이면 확정 뒤(finally)에 이어 보낸다. if (_disposed || sending) return; final batch = _batch ?? @@ -84,6 +106,10 @@ class ZapController extends ChangeNotifier { _batch = null; } catch (error) { if (_disposed) return; + // 실패하면 뒤에 쌓인 연타는 보내지 않는다 — 실패 묶음을 같은 키로 다시 확인하는 것이 먼저다. + pending = 0; + _burst?.cancel(); + _burst = null; onFailure(switch (error) { BackendException(code: 'E-POP-01') => appL10n.zapFailNoPop, BackendException(code: 'E-POP-02') => appL10n.zapFailLedger, @@ -94,6 +120,7 @@ class ZapController extends ChangeNotifier { if (!_disposed) { sending = false; notifyListeners(); + if (_batch == null && pending > 0 && _burst == null) _flush(_pendingFandom!); } } } @@ -102,6 +129,7 @@ class ZapController extends ChangeNotifier { void dispose() { _disposed = true; _burst?.cancel(); + _streakTimer?.cancel(); super.dispose(); } } diff --git a/app/lib/features/home/widgets/home_hero.dart b/app/lib/features/home/widgets/home_hero.dart index 0e624d2..4992af3 100644 --- a/app/lib/features/home/widgets/home_hero.dart +++ b/app/lib/features/home/widgets/home_hero.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import '../../../core/ui/planet_fx_scope.dart'; import '../../galaxy/galaxy_data.dart'; import '../../galaxy/zap_controller.dart'; @@ -225,9 +226,14 @@ class _HomeHeroState extends State { clipBehavior: Clip.none, children: [ Positioned.fill( - child: _TapShake( + child: _PlanetReaction( onTap: widget.onTapPlanet, - child: LivePlanet25D( + zap: hasZap ? widget.zap : null, + planetSize: math.min( + planet, + box.maxHeight, + ), + builder: (spinBoost) => LivePlanet25D( onOpenHistory: widget.onOpenHistory, key: _planetKey, @@ -243,6 +249,7 @@ class _HomeHeroState extends State { box.maxHeight, ), glow: AppColors.neonViolet, + spinBoost: spinBoost, ), ), ), @@ -755,9 +762,10 @@ class _Nebula extends StatelessWidget { label: context.l10n.heroNebula, // Figma `2013:15377` 「성운 터치시 흔들림」 — 조건 전에도 흔들림 · 가벼운 햅틱으로 반응하고, // 탄생 조건을 채웠으면 흔들린 뒤 행성 생성으로 간다. - child: _TapShake( + child: _PlanetReaction( onTap: ready && onBirth != null ? onBirth : () {}, - child: Image.asset( + planetSize: 212, + builder: (_) => Image.asset( 'assets/figma/nebula-home.png', width: 212, height: 212, @@ -1086,16 +1094,13 @@ class HomeZap extends StatelessWidget { showLabel: false, onItem: onItem, zapPop: data.zapPop, - myPop: (zap.balance ?? data.myPop) - zap.pending * data.zapPop, + myPop: (zap.balance ?? data.myPop) - zap.queued * data.zapPop, // Figma `516:88102` Zap_버프(아이템 배수 · 지속시간)는 별첨 B B-22 「배수 기능 제거」 · // 10/3 통합회신(C-124) 「사용 시 Drop Pool 발생 후 HERO TIME 발동」이 대체한다 — // 같은 팬덤의 아이템 사용으로 HERO TIME 이 열리면 ZAP 이 Drop POP 참여로 바뀐다. isHeroTime: hero, - blockedReason: zap.sending - ? context.l10n.zapApplying - : zap.needsRetry - ? context.l10n.zapNeedsCheck - : null, + // 보내는 중에도 누를 수 있다 — 연타는 다음 묶음으로 쌓인다(Figma `2033:15818` 10회 이상 연타). + blockedReason: zap.needsRetry ? context.l10n.zapNeedsCheck : null, onZap: () => hero ? onHeroParticipate!() : zap.tap(fandomId), ), ], @@ -1104,54 +1109,159 @@ class HomeZap extends StatelessWidget { ); } -/// 행성 터치 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · UX·FX v1.5 §3 Light Haptic. -/// 흔들림이 끝나면 [onTap](커뮤니티 이동)을 부른다. [onTap] 이 없으면 아무 반응도 하지 않는다. -class _TapShake extends StatefulWidget { - const _TapShake({required this.child, this.onTap}); +/// 행성 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · `2033:15818` 행성 리액션 1. +/// +/// - 행성 터치: Light Haptic · 흔들림 1번이 끝나면 [onTap](커뮤니티 이동). +/// - GIVE: 누를 때마다 흔들림 1번 · 행성 우하단 [+1] 0.5초(연속 가능). 이어서 10회 이상이면 +/// 흔들림 x2 · 자전 1.5배([Planet25D.spinBoost]) · [+10]. 숫자는 누른 횟수이고 기여 결과는 +/// 서버 확정 뒤 [ZapFeedback] 이 따로 띄운다(v1.5 §1.2). +class _PlanetReaction extends StatefulWidget { + const _PlanetReaction({ + required this.builder, + required this.planetSize, + this.onTap, + this.zap, + }); - final Widget child; + final Widget Function(bool spinBoost) builder; + final double planetSize; final VoidCallback? onTap; + final ZapController? zap; + + /// 「10 회 이상 터치시」. + static const boostStreak = 10; + + /// 흔들림 한 번 길이. + static const shakeOnce = Duration(milliseconds: 320); @override - State<_TapShake> createState() => _TapShakeState(); + State<_PlanetReaction> createState() => _PlanetReactionState(); } -class _TapShakeState extends State<_TapShake> +class _PlanetReactionState extends State<_PlanetReaction> with SingleTickerProviderStateMixin { late final _shake = AnimationController( vsync: this, - duration: const Duration(milliseconds: 320), + duration: _PlanetReaction.shakeOnce, ); + int _shakes = 1; + int? _seenSerial; + int _streak = 0; + Timer? _countTimer; + + @override + void initState() { + super.initState(); + _seenSerial = widget.zap?.touchSerial; + widget.zap?.addListener(_onZap); + } + + @override + void didUpdateWidget(_PlanetReaction old) { + super.didUpdateWidget(old); + if (old.zap != widget.zap) { + old.zap?.removeListener(_onZap); + _seenSerial = widget.zap?.touchSerial; + widget.zap?.addListener(_onZap); + } + } @override void dispose() { + widget.zap?.removeListener(_onZap); + _countTimer?.cancel(); _shake.dispose(); super.dispose(); } + void _onZap() { + final zap = widget.zap!; + if (zap.touchSerial == _seenSerial) return; + _seenSerial = zap.touchSerial; + if (zap.questReaction) return; + setState(() => _streak = zap.streak); + _countTimer?.cancel(); + _countTimer = Timer(ZapController.streakWindow, () { + if (mounted) setState(() => _streak = 0); + }); + _play(_streak >= _PlanetReaction.boostStreak ? 2 : 1); + } + + TickerFuture? _play(int shakes) { + // 동작 줄이기면 흔들지 않는다 — 숫자 표시와 이동은 그대로. + if (MediaQuery.disableAnimationsOf(context)) return null; + _shakes = shakes; + _shake.duration = _PlanetReaction.shakeOnce * shakes; + return _shake.forward(from: 0); + } + + bool _opening = false; + + /// 흔들림 한 번을 보여 준 뒤 이동한다. 그사이 GIVE 가 흔들림을 다시 시작해도 이동은 그대로 간다. Future _tap() async { final onTap = widget.onTap; - if (onTap == null || _shake.isAnimating) return; + if (onTap == null || _opening) return; + _opening = true; fxHaptic(FxIntensity.micro); - if (!MediaQuery.disableAnimationsOf(context)) await _shake.forward(from: 0); + if (_play(1) != null) await Future.delayed(_PlanetReaction.shakeOnce); + _opening = false; if (mounted) onTap(); } @override - Widget build(BuildContext context) => GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: widget.onTap == null ? null : _tap, - child: AnimatedBuilder( - animation: _shake, - // 한 번 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠. - builder: (context, child) => Transform.translate( - offset: Offset( - math.sin(_shake.value * math.pi * 4) * 6 * (1 - _shake.value), - 0, - ), - child: child, + Widget build(BuildContext context) { + final boost = _streak >= _PlanetReaction.boostStreak; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: widget.onTap == null ? null : _tap, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + AnimatedBuilder( + animation: _shake, + // 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠. 한 번에 [_shakes] 왕복. + builder: (context, child) => Transform.translate( + key: const ValueKey('planet-give-shake'), + offset: Offset( + math.sin(_shake.value * math.pi * 4 * _shakes) * + 6 * + (1 - _shake.value), + 0, + ), + child: child, + ), + child: widget.builder(boost), + ), + if (_streak > 0) + // 행성 우측 하단 — 행성 그림 상자([planetSize]) 안 오른쪽 아래. + Positioned.fill( + child: IgnorePointer( + child: Center( + child: SizedBox.square( + dimension: widget.planetSize, + child: Align( + alignment: const Alignment(0.85, 0.85), + child: Text( + boost ? '+10' : '+1', + key: const ValueKey('planet-give-count'), + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.onCosmic, + shadows: [ + Shadow(color: AppColors.neonViolet, blurRadius: 8), + ], + ), + ), + ), + ), + ), + ), + ), + ], ), - child: widget.child, - ), - ); + ); + } } diff --git a/app/test/galaxy_zap_fx_test.dart b/app/test/galaxy_zap_fx_test.dart index b57cabb..f255514 100644 --- a/app/test/galaxy_zap_fx_test.dart +++ b/app/test/galaxy_zap_fx_test.dart @@ -200,7 +200,7 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('전송 중 성공을 보여주지 않고 전송 중 연타로 중복 차감하지 않는다', (tester) async { + testWidgets('전송 중 성공을 보여주지 않고, 전송 중 연타는 앞 묶음 확정 뒤 새 키의 다음 묶음으로 한 번만 보낸다', (tester) async { final repository = _DelayedGalaxy(); await _show(tester, repository); await tester.tap(find.byType(ZapButton)); @@ -215,8 +215,16 @@ void main() { const ZapResult(txId: 'confirmed', balanceAfter: 12530), ); await tester.pump(); + expect(repository.counts, [1, 1]); + expect(repository.keys[1], isNot(repository.keys[0])); + expect(find.textContaining('기여 완료'), findsNothing); + repository.replies.last.complete( + const ZapResult(txId: 'confirmed-2', balanceAfter: 12520), + ); + await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.textContaining('기여 완료'), findsOneWidget); + expect(repository.replies, hasLength(2)); }); testWidgets('응답 유실 재확인은 같은 멱등키를 쓰고 실패에는 성공 FX가 없다', (tester) async { diff --git a/app/test/planet_give_reaction_test.dart b/app/test/planet_give_reaction_test.dart new file mode 100644 index 0000000..2d6158a --- /dev/null +++ b/app/test/planet_give_reaction_test.dart @@ -0,0 +1,185 @@ +import 'dart:async'; + +import 'package:fancake/core/theme/app_theme.dart'; +import 'package:fancake/core/ui/planet_25d.dart'; +import 'package:fancake/features/galaxy/galaxy_data.dart'; +import 'package:fancake/features/galaxy/widgets/zap_button.dart'; +import 'package:fancake/features/galaxy/zap_controller.dart'; +import 'package:fancake/features/home/home_data.dart'; +import 'package:fancake/features/home/home_screen.dart'; +import 'package:fancake/features/planet/planet_data.dart'; +import 'package:fancake/features/planet/planet_detail_screen.dart'; +import 'package:fancake/features/quest/quest_data.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'eclipse_stubs.dart'; + +/// 서버 응답을 시험이 직접 풀어 주는 기여 저장소. +class _GatedGalaxy extends SampleGalaxyRepository { + _GatedGalaxy(); + final counts = []; + final fandoms = []; + final gates = >[]; + + @override + Future zap({ + required String fandomId, + required int count, + required String clientSeq, + }) { + counts.add(count); + fandoms.add(fandomId); + gates.add(Completer()); + return gates.last.future; + } +} + +/// 바로 확정하는 기여 저장소. +class _OkGalaxy extends SampleGalaxyRepository { + const _OkGalaxy(); + + @override + Future zap({ + required String fandomId, + required int count, + required String clientSeq, + }) async => ZapResult(txId: clientSeq, balanceAfter: 100000); +} + +/// Figma `2033:15818` 행성 리액션 1 · `2024:20758` 행성 2단계 — GIVE 1회 흔들림 1번 · [+1] 0.5초(연속 가능), +/// 10회 이상 흔들림 x2 · 자전 1.5배 · [+10]. +void main() { + group('ZapController 연타', () { + testWidgets('전송 중에 누른 GIVE 도 버리지 않고 다음 묶음으로 보낸다', (tester) async { + final repo = _GatedGalaxy(); + final zap = ZapController(repo, onFailure: (_) {}); + for (var i = 0; i < 3; i++) { + zap.tap('f1'); + } + await tester.pump(const Duration(milliseconds: 450)); + expect(repo.counts, [3]); + expect(zap.sending, isTrue); + + zap.tap('f1'); + zap.tap('f1'); + expect(zap.pending, 2); + expect(zap.queued, 5); + + await tester.pump(const Duration(milliseconds: 450)); + expect(repo.counts, [3], reason: '앞 묶음이 확정되기 전에는 다음 묶음을 보내지 않는다'); + + repo.gates.first.complete(const ZapResult(txId: 'a', balanceAfter: 10)); + await tester.pump(); + expect(repo.counts, [3, 2]); + repo.gates.last.complete(const ZapResult(txId: 'b', balanceAfter: 8)); + await tester.pump(); + expect(zap.queued, 0); + zap.dispose(); + }); + + testWidgets('보내는 중에 다른 행성으로 누른 연타가 앞 행성으로 가지 않는다', (tester) async { + final repo = _GatedGalaxy(); + final zap = ZapController(repo, onFailure: (_) {}); + zap.tap('a'); + await tester.pump(const Duration(milliseconds: 450)); + zap.tap('b'); + zap.tap('a'); + expect(zap.pending, 1, reason: '쌓인 b 묶음에 a 를 섞지 않는다'); + await tester.pump(const Duration(milliseconds: 450)); + repo.gates.first.complete(const ZapResult(txId: 'a1', balanceAfter: 10)); + await tester.pump(); + expect(repo.fandoms, ['a', 'b']); + repo.gates.last.complete(const ZapResult(txId: 'b1', balanceAfter: 9)); + await tester.pump(); + zap.dispose(); + }); + + testWidgets('연속 횟수는 [+1] 이 떠 있는 0.5초 안에 다시 누를 때만 이어진다', (tester) async { + final zap = ZapController(_GatedGalaxy(), onFailure: (_) {}); + zap.tap('f1'); + await tester.pump(const Duration(milliseconds: 300)); + zap.tap('f1'); + expect(zap.streak, 2); + await tester.pump(const Duration(milliseconds: 600)); + zap.tap('f1'); + expect(zap.streak, 1); + zap.dispose(); + }); + }); + + group('HOME 행성 GIVE 반응', () { + Future pumpHome(WidgetTester tester) async { + tester.view.physicalSize = const Size(1125, 2436); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: HomeScreen( + repository: const SampleHomeRepository(), + questRepository: const SampleQuestRepository(), + eclipse: const NoEclipseRepository(), + galaxyRepository: const _OkGalaxy(), + planetRepository: samplePlanetRepository, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + } + + double shake(WidgetTester tester) => tester + .widget(find.byKey(const ValueKey('planet-give-shake'))) + .transform + .getTranslation() + .x; + + testWidgets('GIVE 1회: 행성이 흔들리고 행성 우하단에 [+1] 이 0.5초 뜬다', (tester) async { + await pumpHome(tester); + await tester.tap(find.byType(ZapButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + expect(shake(tester), isNot(0)); + final label = find.byKey(const ValueKey('planet-give-count')); + expect(tester.widget(label).data, '+1'); + final planet = tester.getRect(find.byType(Planet25D).first); + final at = tester.getCenter(label); + expect(at.dx, greaterThan(planet.center.dx)); + expect(at.dy, greaterThan(planet.center.dy)); + await tester.pump(const Duration(milliseconds: 450)); + expect(label, findsNothing); + await tester.pump(const Duration(seconds: 3)); + }); + + testWidgets('행성 터치 직후 GIVE 가 흔들림을 다시 시작해도 커뮤니티 이동은 간다', (tester) async { + await pumpHome(tester); + await tester.tap(find.byType(Planet25D).first, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byType(ZapButton)); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(seconds: 1)); + expect(find.byType(PlanetDetailScreen), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + }); + + testWidgets('GIVE 10회 이상: [+10] · 자전 1.5배(Planet25D.spinBoost)', (tester) async { + await pumpHome(tester); + for (var i = 0; i < 10; i++) { + await tester.tap(find.byType(ZapButton)); + await tester.pump(const Duration(milliseconds: 120)); + } + expect(find.text('+10'), findsOneWidget); + expect( + tester.widget(find.byType(Planet25D).first).spinBoost, + isTrue, + ); + await tester.pump(const Duration(milliseconds: 600)); + expect( + tester.widget(find.byType(Planet25D).first).spinBoost, + isFalse, + ); + await tester.pump(const Duration(seconds: 3)); + }); + }); +}