// Author: fruit.com.cn import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import 'package:video_player/video_player.dart'; void main() { test('should_keep_seek_target_inside_playable_duration_when_dragging', () { const duration = Duration(seconds: 12); expect(seekPositionForFraction(0, duration), Duration.zero); expect( seekPositionForFraction(0.5, duration), greaterThan(const Duration(seconds: 5)), ); expect(seekPositionForFraction(1, duration), lessThan(duration)); }); test('should_clamp_preview_card_inside_track_safe_margins', () { expect( scrubPreviewLeft(trackWidth: 300, fraction: 0), scrubPreviewSafeMargin, ); expect( scrubPreviewLeft(trackWidth: 300, fraction: 1), 300 - scrubPreviewWidth - scrubPreviewSafeMargin, ); expect(scrubPreviewLeft(trackWidth: 100, fraction: 0.5), 0); }); testWidgets('should_show_inactive_track_when_video_is_not_initialized', ( tester, ) async { final video = ValueNotifier( const VideoPlayerValue.uninitialized(), ); addTearDown(video.dispose); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SeekProgressBar( valueListenable: video, semanticsLabel: 'Seek video', ), ), ), ); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); }); testWidgets('should_show_inactive_track_and_disable_seek_when_locked', ( tester, ) async { final video = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 12), position: Duration(seconds: 3), isInitialized: true, ), ); addTearDown(video.dispose); var seeks = 0; await tester.pumpWidget( MaterialApp( home: Scaffold( body: SeekProgressBar( valueListenable: video, semanticsLabel: 'Seek video', locked: true, onSeek: (_) async => seeks++, ), ), ), ); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-loading-highlight')), findsNothing); expect(find.byType(Slider), findsNothing); final inactive = tester.widget( find.byKey(const Key('progress-inactive')), ); expect(inactive.color, AppColors.border); await tester.drag( find.byKey(const Key('progress-inactive')), const Offset(120, 0), ); await tester.pumpAndSettle(); expect(seeks, 0); }); testWidgets('should_debounce_buffering_before_showing_loading_track', ( tester, ) async { final video = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 12), position: Duration(seconds: 3), isInitialized: true, isBuffering: true, ), ); addTearDown(video.dispose); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SeekProgressBar( valueListenable: video, semanticsLabel: 'Seek video', onSeek: (_) async {}, ), ), ), ); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byType(Slider), findsOneWidget); expect(find.textContaining('00:12'), findsNothing); await tester.pump(const Duration(milliseconds: 200)); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byType(Slider), findsOneWidget); await tester.pump(const Duration(milliseconds: 200)); expect(find.byKey(const Key('progress-loading')), findsOneWidget); expect(find.byType(Slider), findsNothing); video.value = const VideoPlayerValue( duration: Duration(seconds: 12), position: Duration(seconds: 3), isInitialized: true, ); await tester.pump(); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byType(Slider), findsOneWidget); }); testWidgets('should_keep_slider_while_scrubbing_during_buffering', ( tester, ) async { final video = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 12), position: Duration(seconds: 3), isInitialized: true, ), ); addTearDown(video.dispose); await tester.pumpWidget( MaterialApp( home: Scaffold( body: Center( child: SizedBox( width: 300, child: SeekProgressBar( valueListenable: video, semanticsLabel: 'Seek video', onSeek: (_) async {}, ), ), ), ), ), ); final gesture = await tester.startGesture( tester.getCenter(find.byType(Slider)), ); await gesture.moveBy(const Offset(60, 0)); await tester.pump(); video.value = const VideoPlayerValue( duration: Duration(seconds: 12), position: Duration(seconds: 5), isInitialized: true, isBuffering: true, ); await tester.pump(const Duration(milliseconds: 400)); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byType(Slider), findsOneWidget); video.value = const VideoPlayerValue( duration: Duration(seconds: 12), position: Duration(seconds: 5), isInitialized: true, ); await tester.pump(); await gesture.up(); await tester.pumpAndSettle(); }); testWidgets( 'should_preview_while_dragging_and_seek_on_release_when_slider_is_horizontal', (tester) async { final video = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 12), position: Duration(seconds: 3), isInitialized: true, ), ); addTearDown(video.dispose); var started = 0; Duration? target; await tester.pumpWidget( MaterialApp( home: Scaffold( body: Center( child: SizedBox( width: 300, child: SeekProgressBar( valueListenable: video, semanticsLabel: 'Seek video', onScrubStart: () => started++, onSeek: (position) async { target = position; }, ), ), ), ), ), ); final gesture = await tester.startGesture( tester.getCenter(find.byType(Slider)), ); await gesture.moveBy(const Offset(120, 0)); await tester.pump(); expect(started, 1); expect(target, isNull); expect(find.textContaining('/ 00:12'), findsOneWidget); await gesture.up(); await tester.pumpAndSettle(); expect(target, isNotNull); expect(target!, greaterThan(const Duration(seconds: 3))); expect(target!, lessThan(const Duration(seconds: 12))); expect(find.textContaining('/ 00:12'), findsNothing); }, ); testWidgets('should_show_frame_card_with_cover_then_hide_on_release', ( tester, ) async { final video = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 12), position: Duration(seconds: 3), isInitialized: true, ), ); final service = _thumbnailService(); addTearDown(video.dispose); addTearDown(service.dispose); await tester.pumpWidget( MaterialApp( home: Scaffold( body: Center( child: SizedBox( width: 300, child: SeekProgressBar( valueListenable: video, semanticsLabel: 'Seek video', episodeId: 'episode-1', posterUrl: '', thumbnailService: service, onSeek: (_) async {}, ), ), ), ), ), ); final gesture = await tester.startGesture( tester.getCenter(find.byType(Slider)), ); await gesture.moveBy(const Offset(80, 0)); await tester.pump(); expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); expect(find.textContaining('/ 00:12'), findsOneWidget); expect( tester.getSize(find.byKey(const Key('scrub-preview-card'))).width, 120, ); await gesture.up(); await tester.pump(); expect(find.byKey(const Key('scrub-preview-card')), findsNothing); }); testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', ( tester, ) async { final video = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 12), position: Duration(seconds: 3), isInitialized: true, ), ); addTearDown(video.dispose); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SeekProgressBar( valueListenable: video, semanticsLabel: 'Seek video', onSeek: (_) async {}, ), ), ), ); expect(find.textContaining('00:12'), findsNothing); final gesture = await tester.startGesture( tester.getCenter(find.byType(Slider)), ); await gesture.moveBy(const Offset(40, 0)); await tester.pump(); expect(find.textContaining('/ 00:12'), findsOneWidget); await gesture.up(); await tester.pumpAndSettle(); expect(find.textContaining('00:12'), findsNothing); }); } PlaybackThumbnailService _thumbnailService() { return PlaybackThumbnailService( frameLoader: _NullThumbnailLoader(), sourceResolver: (_) => const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'), prefetchDelay: const Duration(hours: 1), ); } class _NullThumbnailLoader implements PlaybackThumbnailFrameLoader { @override Future load(PlaybackThumbnailFrameRequest request) async => null; @override void dispose() {} }