Files
ReelShort/test/player/double_tap_like_test.dart
T

455 lines
14 KiB
Dart

// 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<Image>(
find.descendant(
of: find.byKey(const Key('rail-like')),
matching: find.byType(Image),
),
);
expect(likeImage.color, AppColors.doubleTapHeartRed);
}
});
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 = <String>[];
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<void> _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<void> _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<void> _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;
}
}