// 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_colors.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_heart_overlay_scope.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); final likeImage = tester.widget( find.descendant( of: find.byKey(const Key('rail-like')), matching: find.byType(Image), ), ); // 首页 feed 的互动 rail 点赞选中色统一成品牌红。 expect(likeImage.color, AppColors.brandRed); } }); 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 _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); 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); for (var index = 0; index < 30; index++) { key.currentState!.rebuild(); clock.advance(const Duration(milliseconds: 16)); await tester.pump(const Duration(milliseconds: 16)); } expect(tester.state(gestureFinder), same(gestureState)); expect(tester.state(burstFinder), same(burstState)); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); await _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); expect(burstFinder, findsNothing); }); testWidgets('heart burst starts at tapped location', (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), ); }); testWidgets('heart burst survives gesture region disposal', (tester) async { final logs = []; final originalDebugPrint = debugPrint; debugPrint = (message, {wrapWidth}) { if (message != null) logs.add(message); }; var elapsedMs = 0; try { final clock = _TestClock(); final key = GlobalKey<_RemovableGestureHarnessState>(); await tester.pumpWidget( MaterialApp( home: _RemovableGestureHarness(key: key, clock: 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 _pumpTestClock(tester, clock, const Duration(milliseconds: 100)); final burstFinder = find.byType(PlayerDoubleTapHeartBurst); expect(burstFinder, findsOneWidget); key.currentState!.removeGesture(); await tester.pump(); expect(find.byKey(const Key('removable-tap-gesture')), findsNothing); expect(burstFinder, findsOneWidget); elapsedMs = 100; await _pumpTestClock(tester, clock, const Duration(milliseconds: 1600)); elapsedMs += 1600; expect(burstFinder, findsNothing); } finally { debugPrint = originalDebugPrint; } expect(elapsedMs, greaterThanOrEqualTo(1500)); expect( logs.any( (line) => line.contains('phase=heart-burst-complete') && line.contains('configuredMs=1590'), ), isTrue, ); expect( logs.any( (line) => line.contains('phase=heart-burst-dispose') && line.contains('completed=1'), ), isTrue, ); }); 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 _RemovableGestureHarness extends StatefulWidget { const _RemovableGestureHarness({super.key, required this.clock}); final _TestClock clock; @override State<_RemovableGestureHarness> createState() => _RemovableGestureHarnessState(); } class _RemovableGestureHarnessState extends State<_RemovableGestureHarness> { final PlayerHeartOverlayController _heartController = PlayerHeartOverlayController(); bool _showGesture = true; void removeGesture() => setState(() => _showGesture = false); @override void dispose() { _heartController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return PlayerHeartOverlayScope( controller: _heartController, child: Scaffold( body: Stack( fit: StackFit.expand, children: [ if (_showGesture) Positioned.fill( child: PlayerTapGestureRegion( key: const Key('removable-tap-gesture'), gestureKey: const Key('removable-gesture'), enabled: true, doubleTapEnabled: true, now: widget.clock.now, onSingleTap: () {}, onDoubleTapLike: (_) {}, onLongPressStart: (_, _) {}, onInteractionEnd: () {}, ), ), ], ), ), ); } } class _TestClock { DateTime _now = DateTime(2026, 10, 22, 12); DateTime now() => _now; void advance(Duration duration) { _now = _now.add(duration); } } Future _pumpTestClock( WidgetTester tester, _TestClock clock, Duration duration, ) async { const frameMs = 16; var remainingMs = duration.inMilliseconds; while (remainingMs > 0) { final stepMs = remainingMs >= frameMs ? frameMs : remainingMs; clock.advance(Duration(milliseconds: stepMs)); await tester.pump(const Duration(milliseconds: frameMs)); remainingMs -= stepMs; } }