// Author: fruit.com.cn import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/content_category.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/home/home_feed_channel.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:video_player/video_player.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { testWidgets('should_render_three_column_explorer_grid_on_home', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final catalog = FakeCatalogRepository( banners: const [ FeedBanner( id: 'banner-1', imageUrl: '/files/banner.jpg', linkType: 'DRAMA', linkValue: 'the-signal-demo', ), ], ); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: FakeCategoryRepository( catalog: catalog, categories: const [ ContentCategory(id: 'costume', name: '古装'), ContentCategory(id: 'community', name: '社群'), ], ), watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), haptics: FakeHaptics(), ), ); await tester.pumpAndSettle(); // 首页即沉浸式推荐流:顶部悬浮「推荐 / 片单 / 全部短剧」+ 搜索与礼物入口。 expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); final searchButton = tester.widget( find.byKey(const Key('recommend-search')), ); expect(searchButton.iconSize, 26); expect( tester.getSize(find.byKey(const Key('recommend-search'))), const Size.square(AppDimens.tapTarget), ); expect( tester.getSize(find.byKey(const Key('recommend-gift'))), const Size.square(AppDimens.tapTarget), ); expect( tester.getCenter(find.byKey(const Key('recommend-search'))).dy, closeTo( tester.getCenter(find.byKey(const Key('recommend-gift'))).dy, 0.1, ), ); // 「全部短剧」仍是三列浏览网格。 await tester.ensureVisible(find.byKey(const Key('recommend-tab-all'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('recommend-tab-all'))); await tester.pumpAndSettle(); // 切换到「全部短剧」时 PageView 会临时布局「片单」页;剧单卡片高度贴着 // 228pt 上限,测试字体(Ahem)行高偏大会溢出约 2.7pt,真机字体不触发。 while (tester.takeException() != null) {} expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); final grid = tester.widget( find.byKey(const Key('all-dramas-grid')), ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect(delegate.crossAxisCount, 3); expect(delegate.crossAxisSpacing, AppDimens.allDramasGridGap); expect(delegate.mainAxisSpacing, AppDimens.allDramasGridRowGap); for (var row = 0; row < 3; row++) { expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); } // 底部「推荐」tab 的频道导航来自分类接口。 await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-channel-nav')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-costume')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-community')), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('should_show_immersive_player_and_open_playlist_in_recommend', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final catalog = FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: FakeCategoryRepository(catalog: catalog), watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), haptics: FakeHaptics(), ), ); await tester.pumpAndSettle(); // 首页沉浸式播放器与顶部内层导航。 expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); expect(find.byType(PlayerPage), findsOneWidget); expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); expect(find.byKey(const Key('recommend-section-hits')), findsNothing); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); // 剧单卡片高度贴着 228pt 上限,测试字体(Ahem)行高比真机字体大, // 会溢出约 2.7pt;真机字体不触发,这里消费该渲染字体差异。 while (tester.takeException() != null) {} expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); await tester.tap( find.byKey(const Key('recommend-card-hits-the-signal-demo')), ); await tester.pumpAndSettle(); while (tester.takeException() != null) {} expect( tester.widget(find.byType(PlayerPage).last).homeMode, isFalse, ); }); testWidgets('should_render_new_feed_structure_with_formatted_counts', ( tester, ) async { const drama = Drama( id: 'feed-drama', title: 'Feed Drama', synopsis: 'A long synopsis follows the heroine through a dangerous city, ' 'a hidden identity, and a final choice that changes everyone around ' 'her forever.', posterUrl: '', genre: 'Romance', category: 'Urban', types: [ {'en': 'Romance'}, {'en': 'Urban'}, {'en': 'Revenge'}, ], totalEpisodes: 8, freeEpisodes: 8, viewCount: 419000, likeCount: 1006, favoriteCount: 32000, commentCount: 12, ); var shares = 0; await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Builder( builder: (context) => Scaffold( body: HomeFeedOverlay( l10n: AppLocalizations.of(context)!, drama: drama, episode: const Episode( id: 'feed-episode', number: 2, title: 'Episode 2', isFree: true, ), controller: null, locked: false, liked: true, saved: false, visible: true, boosting: false, onTogglePlay: () {}, onBoostStart: () {}, onBoostEnd: () {}, likeCount: 1006, favoriteCount: 32000, commentCount: 12, onLike: () {}, onSave: () {}, onComments: () {}, onShare: () => shares++, onDetail: () {}, onOpenFullPlayer: () {}, onEpisodes: () {}, onMore: () {}, ), ), ), ), ); await tester.pumpAndSettle(); final seriesTitle = tester.widget( find.byKey(const Key('home-feed-series-title')), ); expect(seriesTitle.data, 'Feed Drama'); expect(find.text('Episode 2'), findsNothing); expect(seriesTitle.style?.fontSize, 18); expect(seriesTitle.style?.fontWeight, FontWeight.w400); expect(seriesTitle.style?.color, AppColors.textPrimary); final seriesChevron = tester.widget( find.descendant( of: find.byKey(const Key('home-feed-series-name')), matching: find.byIcon(Icons.chevron_right_rounded), ), ); expect(seriesChevron.size, 18); expect(seriesChevron.color, AppColors.textPrimary); expect( tester .getTopLeft( find.descendant( of: find.byKey(const Key('home-feed-series-name')), matching: find.byIcon(Icons.chevron_right_rounded), ), ) .dx - tester .getTopRight(find.byKey(const Key('home-feed-series-title'))) .dx, 2, ); expect(find.byKey(const Key('home-feed-info-panel')), findsOneWidget); expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byKey(const Key('home-feed-tags')), findsOneWidget); expect(find.byKey(const Key('home-feed-tag-Romance')), findsOneWidget); expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing); expect(find.byKey(const Key('home-feed-tag-Urban')), findsOneWidget); expect(find.byKey(const Key('home-feed-tag-Revenge')), findsNothing); final collapsedSynopsis = tester.widget( find.byKey(const Key('home-feed-synopsis')), ); expect(collapsedSynopsis.maxLines, 2); expect(collapsedSynopsis.overflow, TextOverflow.ellipsis); expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-synopsis-more'))); await tester.pump(); expect( find.byKey(const Key('home-feed-synopsis-expanded')), findsOneWidget, ); expect( tester .widget(find.byKey(const Key('home-feed-synopsis-expanded'))) .textSpan! .toPlainText(), contains('dangerous city'), ); expect(find.text('Episode 2 / 8'), findsOneWidget); expect( tester.widget(find.byKey(const Key('rail-like-count'))).data, '1006', ); expect( tester.widget(find.byKey(const Key('rail-save-count'))).data, '3.2万', ); final likeImage = tester.widget( find.descendant( of: find.byKey(const Key('rail-like')), matching: find.byType(Image), ), ); final saveImage = tester.widget( find.descendant( of: find.byKey(const Key('rail-save')), matching: find.byType(Image), ), ); expect( (likeImage.image as AssetImage).assetName, 'assets/icon/rail/rail_like.png', ); expect(likeImage.color, AppColors.brandRed); expect( (saveImage.image as AssetImage).assetName, 'assets/icon/rail/rail_save.png', ); expect(saveImage.color, AppColors.onPrimary); expect(find.byKey(const Key('rail-comments')), findsOneWidget); expect(find.byKey(const Key('rail-share')), findsOneWidget); expect(find.byKey(const Key('rail-episodes')), findsNothing); expect( tester.widget(find.byKey(const Key('rail-comments-count'))).data, '12', ); expect( (tester .widget( find.descendant( of: find.byKey(const Key('rail-comments')), matching: find.byType(Image), ), ) .image as AssetImage) .assetName, 'assets/icon/rail/rail_comment.png', ); final saveRect = tester.getRect(find.byKey(const Key('rail-save'))); final likeRect = tester.getRect(find.byKey(const Key('rail-like'))); final commentsRect = tester.getRect(find.byKey(const Key('rail-comments'))); final shareRect = tester.getRect(find.byKey(const Key('rail-share'))); // 互动栏顺序:收藏 → 评论 → 喜欢 → 分享,紧凑间距 6。 expect(commentsRect.top - saveRect.bottom, closeTo(6, 0.1)); expect(likeRect.top - commentsRect.bottom, closeTo(6, 0.1)); expect(shareRect.top - likeRect.bottom, closeTo(6, 0.1)); expect( tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right, closeTo( tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 6, 0.1, ), ); expect(find.byKey(const Key('progress-loading')), findsNothing); expect( find.byKey(const Key('home-feed-progress-inactive')), findsOneWidget, ); expect(find.byKey(const Key('home-feed-progress-played')), findsNothing); await tester.tap(find.byKey(const Key('rail-share'))); expect(shares, 1); }); testWidgets('should_show_static_track_without_seek_when_feed_is_locked', ( tester, ) async { var scrubStarts = 0; var seeks = 0; await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Builder( builder: (context) => Scaffold( body: HomeFeedOverlay( l10n: AppLocalizations.of(context)!, drama: const Drama( id: 'locked-feed', title: 'Locked Feed', synopsis: 'synopsis', posterUrl: '', ), episode: const Episode( id: 'locked-feed-1', number: 1, title: 'Locked', isFree: false, locked: true, ), controller: null, locked: true, liked: false, saved: false, visible: true, boosting: false, onTogglePlay: () {}, onBoostStart: () {}, onBoostEnd: () {}, onLike: () {}, onSave: () {}, onComments: () {}, onShare: () {}, onDetail: () {}, onOpenFullPlayer: () {}, onMore: () {}, onScrubStart: () => scrubStarts++, onSeek: (_) => seeks++, ), ), ), ), ); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-loading-highlight')), findsNothing); expect( find.byKey(const Key('home-feed-progress-inactive')), findsOneWidget, ); expect( tester .getSize(find.byKey(const Key('home-feed-progress-inactive'))) .height, 2, ); await tester.drag( find.byKey(const Key('home-feed-progress')), const Offset(120, 0), ); await tester.pumpAndSettle(); expect(scrubStarts, 0); expect(seeks, 0); }); testWidgets('should_show_static_track_when_feed_video_is_uninitialized', ( tester, ) async { final controller = ValueNotifier( const VideoPlayerValue.uninitialized(), ); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Builder( builder: (context) => Scaffold( body: HomeFeedOverlay( l10n: AppLocalizations.of(context)!, drama: const Drama( id: 'loading-feed', title: 'Loading Feed', synopsis: 'synopsis', posterUrl: '', ), episode: const Episode( id: 'loading-feed-1', number: 1, title: 'Loading', isFree: true, ), controller: controller, locked: false, liked: false, saved: false, visible: true, boosting: false, onTogglePlay: () {}, onBoostStart: () {}, onBoostEnd: () {}, onLike: () {}, onSave: () {}, onComments: () {}, onShare: () {}, onDetail: () {}, onOpenFullPlayer: () {}, onMore: () {}, onSeek: (_) {}, ), ), ), ), ); expect(find.byKey(const Key('progress-loading')), findsNothing); expect( find.byKey(const Key('home-feed-progress-inactive')), findsOneWidget, ); }); testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async { var opened = 0; Duration? seeked; final scrubVisibility = []; final thumbnailService = _nullThumbnailService(); addTearDown(thumbnailService.dispose); final controller = ValueNotifier( VideoPlayerValue( duration: const Duration(seconds: 100), position: const Duration(seconds: 10), isInitialized: true, ), ); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Builder( builder: (context) => Scaffold( body: HomeFeedOverlay( l10n: AppLocalizations.of(context)!, drama: const Drama( id: 'action-drama', title: 'Action Drama', synopsis: 'synopsis', posterUrl: '', totalEpisodes: 8, viewCount: 1200, ), episode: const Episode( id: 'action-episode', number: 1, title: 'Episode 1', isFree: true, ), controller: controller, locked: false, liked: false, saved: false, visible: true, boosting: false, onTogglePlay: () {}, onBoostStart: () {}, onBoostEnd: () {}, onLike: () {}, onSave: () {}, onComments: () {}, onShare: () {}, onDetail: () {}, onOpenFullPlayer: () => opened++, onMore: () {}, onScrubStart: () {}, onScrubVisibilityChanged: scrubVisibility.add, onSeek: (value) => seeked = value, thumbnailService: thumbnailService, ), ), ), ), ); await tester.pumpAndSettle(); final bar = tester.getRect(find.byKey(const Key('home-feed-full-series'))); expect(bar.height, 38); final overlayRect = tester.getRect( find.byKey(const Key('home-feed-gesture')), ); // 观看条与左侧信息等宽,进度条仍横跨整个页面,避开右侧互动栏。 expect(bar.left, closeTo(overlayRect.left + 20, 0.1)); final infoRect = tester.getRect( find.byKey(const Key('home-feed-info-panel')), ); expect(bar.width, closeTo(infoRect.width, 0.1)); expect(bar.right, closeTo(infoRect.right, 0.1)); expect( bar.left, closeTo( tester.getRect(find.byKey(const Key('home-feed-progress'))).left, 0.1, ), ); expect( bar.width, lessThan( tester.getRect(find.byKey(const Key('home-feed-progress'))).width, ), ); // 进度线贴着浮层底部(线心距底 4pt),且位于观看条下方。 final trackCenter = tester.getCenter( find.byKey(const Key('home-feed-progress-track')), ); expect(overlayRect.bottom - trackCenter.dy, closeTo(4, 0.1)); expect(trackCenter.dy, greaterThan(bar.bottom)); expect( tester .widget(find.byKey(const Key('home-feed-full-series-bg'))) .color, Colors.transparent, ); expect( tester.getRect(find.text('Watch all')).center.dy, closeTo(bar.center.dy, 2), ); expect( tester.getSize(find.byKey(const Key('home-feed-progress'))).height, greaterThanOrEqualTo(16), ); expect( tester .widget(find.byKey(const Key('home-feed-progress'))) .dragStartBehavior, DragStartBehavior.down, ); final idleTrackCenter = tester .getRect(find.byKey(const Key('home-feed-progress-track'))) .center .dy; final gesture = await tester.startGesture( tester.getCenter(find.byKey(const Key('home-feed-progress'))), ); await gesture.moveBy(const Offset(120, 0)); await tester.pump(); expect(scrubVisibility.last, isTrue); expect(find.byKey(const Key('home-feed-series-title')), findsOneWidget); expect(find.byKey(const Key('rail-save')), findsOneWidget); expect( tester .widget( find.byKey(const Key('home-feed-content-opacity')), ) .opacity, 0, ); expect( tester .widget( find.byKey(const Key('home-feed-content-ignore')), ) .ignoring, isTrue, ); expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-time')), findsOneWidget); final previewRect = tester.getRect( find.byKey(const Key('scrub-preview-card')), ); expect(previewRect.left, greaterThanOrEqualTo(12)); expect( previewRect.right, lessThanOrEqualTo( tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 12, ), ); expect( tester .getRect(find.byKey(const Key('home-feed-progress-track'))) .center .dy - previewRect.bottom, closeTo(16, 0.1), ); final previewContainer = tester.widget( find.byKey(const Key('scrub-preview-card')), ); final previewDecoration = previewContainer.decoration! as BoxDecoration; expect(previewDecoration.color, AppColors.card); expect(previewDecoration.borderRadius, BorderRadius.circular(8)); expect( (previewDecoration.border! as Border).top.color, AppColors.onPrimary, ); await gesture.moveBy(const Offset(-600, 0)); await tester.pump(); final trackRect = tester.getRect( find.byKey(const Key('home-feed-progress-track')), ); expect( tester.getRect(find.byKey(const Key('scrub-preview-card'))).left, closeTo(trackRect.left + 12, 0.1), ); await gesture.moveBy(const Offset(1200, 0)); await tester.pump(); expect( tester.getRect(find.byKey(const Key('scrub-preview-card'))).right, closeTo(trackRect.right - 12, 0.1), ); await tester.pump(const Duration(milliseconds: 130)); expect( tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, 6, ); expect( tester .getRect(find.byKey(const Key('home-feed-progress-track'))) .center .dy, closeTo(idleTrackCenter, 0.1), ); await gesture.up(); await tester.pumpAndSettle(); expect(scrubVisibility.last, isFalse); expect(find.byKey(const Key('home-feed-series-title')), findsOneWidget); expect( tester .widget( find.byKey(const Key('home-feed-content-opacity')), ) .opacity, 1, ); expect(seeked, isNotNull); expect(seeked!.inSeconds, greaterThan(0)); expect( tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, 2, ); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pump(); expect(opened, 1); }); testWidgets('should_debounce_feed_loading_and_keep_track_while_scrubbing', ( tester, ) async { final controller = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 100), position: Duration(seconds: 10), isInitialized: true, ), ); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Builder( builder: (context) => Scaffold( body: HomeFeedOverlay( l10n: AppLocalizations.of(context)!, drama: const Drama( id: 'debounce-drama', title: 'Debounce Drama', synopsis: 'synopsis', posterUrl: '', totalEpisodes: 8, ), episode: const Episode( id: 'debounce-episode', number: 1, title: 'Episode 1', isFree: true, ), controller: controller, locked: false, liked: false, saved: false, visible: true, boosting: false, onTogglePlay: () {}, onBoostStart: () {}, onBoostEnd: () {}, onLike: () {}, onSave: () {}, onComments: () {}, onShare: () {}, onDetail: () {}, onOpenFullPlayer: () {}, onMore: () {}, onSeek: (_) {}, ), ), ), ), ); await tester.pumpAndSettle(); controller.value = const VideoPlayerValue( duration: Duration(seconds: 100), position: Duration(seconds: 10), isInitialized: true, isBuffering: true, ); await tester.pump(const Duration(milliseconds: 200)); expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget); expect(find.byKey(const Key('progress-loading')), findsNothing); await tester.pump(const Duration(milliseconds: 200)); expect(find.byKey(const Key('home-feed-progress-played')), findsNothing); expect(find.byKey(const Key('progress-loading')), findsOneWidget); controller.value = const VideoPlayerValue( duration: Duration(seconds: 100), position: Duration(seconds: 10), isInitialized: true, ); await tester.pump(); expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget); final gesture = await tester.startGesture( tester.getCenter(find.byKey(const Key('home-feed-progress'))), ); await gesture.moveBy(const Offset(60, 0)); await tester.pump(); controller.value = const VideoPlayerValue( duration: Duration(seconds: 100), position: Duration(seconds: 10), isInitialized: true, isBuffering: true, ); await tester.pump(const Duration(milliseconds: 400)); expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget); expect(find.byKey(const Key('progress-loading')), findsNothing); controller.value = const VideoPlayerValue( duration: Duration(seconds: 100), position: Duration(seconds: 10), isInitialized: true, ); await tester.pump(); await gesture.up(); await tester.pumpAndSettle(); controller.value = const VideoPlayerValue( duration: Duration(seconds: 100), position: Duration(seconds: 40), isInitialized: true, isBuffering: true, ); await tester.pump(const Duration(milliseconds: 400)); expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget); expect(find.byKey(const Key('progress-loading')), findsNothing); }); testWidgets('should_show_refresh_spinner_until_refresh_finishes', ( tester, ) async { final completer = Completer(); var calls = 0; await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Builder( builder: (context) => Scaffold( body: HomeFeedHeader( l10n: AppLocalizations.of(context)!, channel: HomeFeedChannel.recommend, onChannelSelected: (_) {}, onRefresh: () { calls++; return completer.future; }, onSearch: () {}, onMore: () {}, ), ), ), ), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('home-feed-refresh'))); await tester.pump(); expect(find.byKey(const Key('home-feed-refreshing')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-refresh'))); await tester.pump(); expect(calls, 1); completer.complete(true); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-feed-refreshing')), findsNothing); expect(find.byIcon(Icons.refresh_rounded), findsOneWidget); }); testWidgets('should_show_scrub_preview_card_from_home_progress', ( tester, ) async { final controller = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 100), position: Duration(seconds: 10), isInitialized: true, ), ); final service = _nullThumbnailService(); addTearDown(controller.dispose); addTearDown(service.dispose); await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Builder( builder: (context) => Scaffold( body: HomeFeedOverlay( l10n: AppLocalizations.of(context)!, drama: const Drama( id: 'preview-drama', title: 'Preview Drama', synopsis: 'synopsis', posterUrl: '', totalEpisodes: 8, ), episode: const Episode( id: 'preview-episode', number: 1, title: 'Episode 1', isFree: true, ), controller: controller, locked: false, liked: false, saved: false, visible: true, boosting: false, thumbnailService: service, onTogglePlay: () {}, onBoostStart: () {}, onBoostEnd: () {}, onLike: () {}, onSave: () {}, onComments: () {}, onShare: () {}, onDetail: () {}, onOpenFullPlayer: () {}, onMore: () {}, onScrubStart: () {}, onSeek: (_) {}, ), ), ), ), ); await tester.pumpAndSettle(); final gesture = await tester.startGesture( tester.getCenter(find.byKey(const Key('home-feed-progress'))), ); await gesture.moveBy(const Offset(60, 0)); await tester.pump(); expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-background')), findsOneWidget); expect(find.textContaining('/ 01:40'), findsOneWidget); await gesture.up(); await tester.pump(); expect(find.byKey(const Key('scrub-preview-card')), findsNothing); }); testWidgets('should_seek_from_series_bar_when_overlay_is_cleared', ( tester, ) async { Duration? seeked; final controller = ValueNotifier( VideoPlayerValue( duration: const Duration(seconds: 100), position: const Duration(seconds: 10), isInitialized: true, ), ); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Builder( builder: (context) => Scaffold( body: HomeFeedOverlay( l10n: AppLocalizations.of(context)!, drama: const Drama( id: 'clear-drama', title: 'Clear Drama', synopsis: 'synopsis', posterUrl: '', totalEpisodes: 8, ), episode: const Episode( id: 'clear-episode', number: 1, title: 'Episode 1', isFree: true, ), controller: controller, locked: false, liked: false, saved: false, visible: false, boosting: false, onTogglePlay: () {}, onBoostStart: () {}, onBoostEnd: () {}, onLike: () {}, onSave: () {}, onComments: () {}, onShare: () {}, onDetail: () {}, onOpenFullPlayer: () {}, onMore: () {}, onScrubStart: () {}, onSeek: (value) => seeked = value, ), ), ), ), ); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); final gesture = await tester.startGesture( tester.getCenter(find.byKey(const Key('home-feed-progress'))), ); await gesture.moveBy(const Offset(80, 0)); await gesture.up(); await tester.pumpAndSettle(); expect(seeked, isNotNull); expect(seeked!.inSeconds, greaterThan(0)); }); } PlaybackThumbnailService _nullThumbnailService() { 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() {} }