// Author: fruit.com.cn import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/services/interaction_controller.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; import 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart'; import 'package:fruitshorts/pages/player/widgets/player_tap_gesture_region.dart'; import '../support/fake_catalog.dart'; import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; void main() { testWidgets('double tap within 320ms never pauses', (tester) async { for (final delay in [120, 250, 300]) { final repository = FakeInteractionRepository(); final haptics = FakeHaptics(); final key = GlobalKey<_HarnessState>(); final clock = _TestClock(); await _pumpHarness(tester, repository, haptics, key, clock); final center = tester.getCenter( find.byKey(const Key('home-feed-gesture')), ); await tester.tapAt(center); clock.advance(Duration(milliseconds: delay)); await tester.pump(Duration(milliseconds: delay)); await tester.tapAt(center); await tester.pump(); clock.advance(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); expect( key.currentState!.pauseToggles, 0, reason: 'double tap with $delay ms gap paused playback', ); expect(repository.likeTargets, hasLength(1)); expect(haptics.lightImpacts, 1); expect( find.descendant( of: find.byKey(const Key('rail-like')), matching: find.byIcon(Icons.favorite_rounded), ), findsOneWidget, ); await tester.pumpAndSettle(); } }); testWidgets('taps 500ms apart toggle playback twice', (tester) async { final key = GlobalKey<_HarnessState>(); final clock = _TestClock(); await _pumpHarness( tester, FakeInteractionRepository(), FakeHaptics(), key, clock, ); await tester.tapAt( tester.getCenter(find.byKey(const Key('home-feed-gesture'))), ); clock.advance(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); expect(key.currentState!.pauseToggles, 1); await tester.tapAt( tester.getCenter(find.byKey(const Key('home-feed-gesture'))), ); clock.advance(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); expect(key.currentState!.pauseToggles, 2); }); testWidgets('liked episode keeps heart feedback without liking again', ( tester, ) async { final repository = FakeInteractionRepository(); final haptics = FakeHaptics(); final key = GlobalKey<_HarnessState>(); final clock = _TestClock(); await _pumpHarness(tester, repository, haptics, key, clock); final center = tester.getCenter(find.byKey(const Key('home-feed-gesture'))); await tester.tapAt(center); clock.advance(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100)); await tester.tapAt(center); await tester.pump(); expect(repository.likeTargets, hasLength(1)); await tester.pump(const Duration(milliseconds: 2300)); clock.advance(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500)); await tester.tapAt(center); clock.advance(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100)); await tester.tapAt(center); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(repository.likeTargets, hasLength(1)); expect(haptics.lightImpacts, 2); expect(key.currentState!.pauseToggles, 0); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); }); testWidgets('parent rebuild 30 times keeps the heart burst alive', ( tester, ) async { final key = GlobalKey<_HarnessState>(); final clock = _TestClock(); await _pumpHarness( tester, FakeInteractionRepository(), FakeHaptics(), key, clock, ); final center = tester.getCenter(find.byKey(const Key('home-feed-gesture'))); await tester.tapAt(center); clock.advance(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100)); await tester.tapAt(center); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); final gestureFinder = find.byType(PlayerTapGestureRegion); final burstFinder = find.byType(PlayerDoubleTapHeartBurst); final gestureState = tester.state(gestureFinder); final burstState = tester.state(burstFinder); var elapsedMs = 100; for (var index = 0; index < 30; index++) { key.currentState!.rebuild(); await tester.pump(const Duration(milliseconds: 16)); elapsedMs += 16; } expect(tester.state(gestureFinder), same(gestureState)); expect(tester.state(burstFinder), same(burstState)); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); while (burstFinder.evaluate().isNotEmpty && elapsedMs < 2000) { await tester.pump(const Duration(milliseconds: 16)); elapsedMs += 16; } expect(burstFinder, findsNothing); expect(elapsedMs, greaterThanOrEqualTo(1500)); }); testWidgets('heart burst is cleaned up when gesture region is disposed', ( tester, ) async { final key = GlobalKey<_HarnessState>(); final clock = _TestClock(); await _pumpHarness( tester, FakeInteractionRepository(), FakeHaptics(), key, clock, ); const tapPosition = Offset(120, 220); await tester.tapAt(tapPosition); clock.advance(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100)); await tester.tapAt(tapPosition); await tester.pump(); await tester.pump(const Duration(milliseconds: 1)); expect(find.byType(PlayerDoubleTapHeartBurst), findsOneWidget); expect( (tester.getCenter(find.byKey(const Key('double-tap-heart-0'))) - tapPosition) .distance, lessThanOrEqualTo(1), ); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); }); testWidgets('multiple heart bursts run independently', (tester) async { final key = GlobalKey<_HarnessState>(); final clock = _TestClock(); await _pumpHarness( tester, FakeInteractionRepository(), FakeHaptics(), key, clock, ); await tester.tapAt(const Offset(100, 180)); clock.advance(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100)); await tester.tapAt(const Offset(100, 180)); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); clock.advance(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400)); await tester.tapAt(const Offset(260, 360)); clock.advance(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100)); await tester.tapAt(const Offset(260, 360)); await tester.pump(); await tester.pump(const Duration(milliseconds: 1)); expect(find.byType(PlayerDoubleTapHeartBurst), findsNWidgets(2)); expect(find.byKey(const ValueKey('heart-overlay-0')), findsOneWidget); expect(find.byKey(const ValueKey('heart-overlay-1')), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); }); } Future _pumpHarness( WidgetTester tester, FakeInteractionRepository repository, FakeHaptics haptics, GlobalKey<_HarnessState> key, _TestClock clock, ) async { await tester.pumpWidget( MaterialApp( theme: AppTheme.dark(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: _Harness( key: key, repository: repository, haptics: haptics, clock: clock, ), ), ); await tester.pumpAndSettle(); } class _Harness extends StatefulWidget { const _Harness({ super.key, required this.repository, required this.haptics, required this.clock, }); final FakeInteractionRepository repository; final FakeHaptics haptics; final _TestClock clock; @override State<_Harness> createState() => _HarnessState(); } class _HarnessState extends State<_Harness> { late final InteractionController interactions = InteractionController( widget.repository, ); int pauseToggles = 0; void rebuild() => setState(() {}); Future _like({bool likeOnly = false}) async { await widget.haptics.lightImpact(); final episodeId = demoDramas.first.episodes.first.id; if (interactions.isEpisodeBusy(episodeId) || (likeOnly && interactions.isEpisodeLiked(episodeId))) { return; } await interactions.toggleEpisodeLike(episodeId); } @override void dispose() { interactions.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: Colors.black, body: ListenableBuilder( listenable: interactions, builder: (context, _) { final episode = demoDramas.first.episodes.first; return HomeFeedOverlay( l10n: l10n, drama: demoDramas.first, episode: episode, controller: null, locked: false, liked: interactions.isEpisodeLiked(episode.id), likeCount: interactions.episodeLikeCount(episode.id), saved: false, visible: true, boosting: false, onLike: () => _like(), onDoubleTapLike: (_) => _like(likeOnly: true), now: widget.clock.now, onSave: () {}, onComments: () {}, onShare: () {}, onDetail: () {}, onOpenFullPlayer: () {}, onTogglePlay: () => setState(() => pauseToggles++), onBoostStart: () {}, onBoostEnd: () {}, onMore: () {}, ); }, ), ); } } class _TestClock { DateTime _now = DateTime(2026, 10, 22, 12); DateTime now() => _now; void advance(Duration duration) { _now = _now.add(duration); } }