// Author: fruit.com.cn import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/models/watch_progress.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/auth_controller.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.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 '../support/fake_catalog.dart'; import '../support/fake_category.dart'; import '../support/fake_interaction_repositories.dart'; import '../support/widget_player.dart'; void main() { testWidgets('should_defer_watch_history_until_following_tab_is_opened', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); final catalog = FakeCatalogRepository(); final history = _IdentityWatchHistoryRepository( device: [_historyProgress('the-signal-demo', 'Device history')], account: const [], ); await tester.pumpWidget(_app(catalog: catalog, history: history)); await tester.pumpAndSettle(); // 首页默认停在推荐 tab:追剧页在启动时还没被构建,不该提前拉观看历史。 expect(history.recentCalls, 0); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); expect(history.recentCalls, greaterThan(0)); }); testWidgets('should_render_following_tabs_grid_and_edit_actions', ( tester, ) async { _signIn(); final catalog = FakeCatalogRepository(); await tester.pumpWidget( _app( catalog: catalog, favorites: InMemoryFavoriteRepository( cards: catalog.dramas.take(1).toList(), saved: {catalog.dramas.first.id}, ), ), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('following-page')), findsOneWidget); expect(find.byKey(const Key('following-tab-favorites')), findsOneWidget); expect(find.byKey(const Key('following-tab-history')), findsOneWidget); expect(find.byKey(const Key('following-tab-reserve')), findsNothing); expect(find.byKey(const Key('following-favorites-grid')), findsOneWidget); expect(find.byKey(const Key('following-add-card')), findsOneWidget); expect(find.byType(CustomScrollView), findsWidgets); final tab = tester.widget( find.descendant( of: find.byKey(const Key('following-tab-favorites')), matching: find.byType(AnimatedDefaultTextStyle), ), ); expect(tab.style.fontSize, AppDimens.followingSegmentFont); expect(tab.style.fontWeight, FontWeight.w400); expect(tab.style.color, AppColors.textPrimary); // 新版头部右上角是「Manage」文字按钮,不再是编辑图标。 final manage = tester.widget( find.descendant( of: find.byKey(const Key('following-edit')), matching: find.byType(Text), ), ); expect(manage.style?.fontSize, AppDimens.followingManageFont); expect(manage.style?.color, AppColors.textPrimary); final grid = tester.widget( find.descendant( of: find.byKey(const Key('following-favorites-grid')), matching: find.byType(SliverGrid), ), ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect(delegate.crossAxisCount, 3); expect(delegate.crossAxisSpacing, AppDimens.followingGridGap); expect(delegate.mainAxisSpacing, AppDimens.followingGridRowGap); expect(delegate.mainAxisExtent, greaterThan(0)); final card = find.byKey(const Key('following-card-the-signal-demo')); final coverFinder = find.byKey( const Key('following-cover-the-signal-demo'), ); final cover = tester.widget(coverFinder); expect(cover.aspectRatio, AppDimens.cardCoverAspectRatio); final coverClip = tester.widget( find.descendant( of: find.byKey(const Key('following-cover-the-signal-demo')), matching: find.byType(ClipRRect), ), ); expect( coverClip.borderRadius, BorderRadius.circular(AppDimens.coverRadius), ); final titleFinder = find.descendant( of: card, matching: find.text('The Signal'), ); final title = tester.widget(titleFinder); expect(title.style?.fontSize, AppDimens.followingCardTitleFontNew); expect(title.style?.fontWeight, FontWeight.w400); expect(title.style?.height, AppDimens.cardTitleLineHeight); final views = tester.widget( find.descendant( of: find.byKey(const Key('following-views-the-signal-demo')), matching: find.byType(Text), ), ); expect(views.style?.fontSize, AppDimens.cardPlayCountFont); expect(views.style?.fontWeight, FontWeight.w400); expect( find.descendant( of: coverFinder, matching: find.byKey(const Key('following-views-the-signal-demo')), ), findsOneWidget, ); expect(views.style?.color, AppColors.textPrimary); expect( find.descendant( of: find.byKey(const Key('following-views-the-signal-demo')), matching: find.byIcon(Icons.local_fire_department_rounded), ), findsOneWidget, ); expect( tester.getTopLeft(titleFinder).dx, tester.getTopLeft(coverFinder).dx, ); expect( tester.getTopLeft(card).dx, lessThan( tester.getTopLeft(find.byKey(const Key('following-add-card'))).dx, ), ); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); expect( find.byKey(const Key('following-select-the-signal-demo')), findsOneWidget, ); expect(find.byKey(const Key('following-cancel-follow')), findsOneWidget); await tester.tap(find.byKey(const Key('following-card-the-signal-demo'))); await tester.pumpAndSettle(); // 新版取消追剧不再弹二次确认框,点「取消追剧」即直接移除。 await tester.tap(find.byKey(const Key('following-cancel-follow'))); await tester.pumpAndSettle(); expect( find.byKey(const Key('following-select-the-signal-demo')), findsNothing, ); expect(find.byKey(const Key('following-edit-bar')), findsNothing); expect( find.byKey(const Key('following-card-the-signal-demo')), findsNothing, ); await tester.tap(find.byKey(const Key('following-tab-history'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('following-history-empty')), findsOneWidget); _drainOverflows(tester); }); testWidgets('should_unfollow_every_selected_drama_when_edit_confirms', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); final catalog = FakeCatalogRepository(); final dramas = catalog.dramas.take(2).toList(); final favorites = InMemoryFavoriteRepository( cards: dramas, saved: dramas.map((drama) => drama.id).toSet(), ); await tester.pumpWidget(_app(catalog: catalog, favorites: favorites)); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('following-select-all'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('following-cancel-follow'))); await tester.pumpAndSettle(); expect( find.byKey(const Key('following-card-the-signal-demo')), findsNothing, ); expect( find.byKey(const Key('following-card-city-lights-demo')), findsNothing, ); expect(find.byKey(const Key('following-edit-bar')), findsNothing); expect(favorites.removeManyCalls, 1); _drainOverflows(tester); }); testWidgets('should_show_device_favorites_and_history_when_signed_out', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); final catalog = FakeCatalogRepository(); await tester.pumpWidget( _app( catalog: catalog, favorites: InMemoryFavoriteRepository( cards: catalog.dramas.take(1).toList(), saved: {catalog.dramas.first.id}, ), history: InMemoryWatchHistoryRepository( seed: [ WatchProgress( dramaId: 'the-signal-demo', episodeId: 'the-signal-demo-3', dramaTitle: 'Device history', episodeNumber: 3, position: const Duration(seconds: 25), duration: const Duration(seconds: 100), ), ], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('auth-gate-panel')), findsNothing); expect(find.byKey(const Key('following-favorites-grid')), findsOneWidget); expect( find.byKey(const Key('following-card-the-signal-demo')), findsOneWidget, ); await tester.tap(find.byKey(const Key('following-tab-history'))); await tester.pumpAndSettle(); expect( find.byKey(const Key('following-history-card-the-signal-demo')), findsOneWidget, ); expect(find.text('Device history'), findsOneWidget); _drainOverflows(tester); }); testWidgets('should_reload_identity_data_after_login_and_logout', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); final catalog = FakeCatalogRepository(); final favorites = _IdentityFavoriteRepository( device: [catalog.dramas[0]], account: [catalog.dramas[1]], ); final history = _IdentityWatchHistoryRepository( device: [_historyProgress('the-signal-demo', 'Device history')], account: [_historyProgress('city-lights-demo', 'Account history')], ); await tester.pumpWidget( _app(catalog: catalog, favorites: favorites, history: history), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); expect( find.byKey(const Key('following-card-the-signal-demo')), findsOneWidget, ); final initialFavoriteCalls = favorites.pageCalls; final initialHistoryCalls = history.recentCalls; final auth = Provider.of( tester.element(find.byKey(const Key('following-page'))), listen: false, ); final preferences = SharedPreferencesAsync(); favorites.scope = 'account'; history.scope = 'account'; await _setSignedIn(preferences); await auth.load(); await tester.pumpAndSettle(); expect(auth.isSignedIn, isTrue); expect(favorites.pageCalls, greaterThan(initialFavoriteCalls)); expect(history.recentCalls, greaterThan(initialHistoryCalls)); expect( find.byKey(const Key('following-card-the-signal-demo')), findsNothing, ); expect( find.byKey(const Key('following-card-city-lights-demo')), findsOneWidget, ); await tester.tap(find.byKey(const Key('following-tab-history'))); await tester.pumpAndSettle(); expect(find.text('Account history'), findsOneWidget); expect(find.text('Device history'), findsNothing); favorites.scope = 'device'; history.scope = 'device'; await _clearSignedIn(preferences); await auth.load(); await tester.pumpAndSettle(); expect(auth.isSignedIn, isFalse); expect(find.text('Device history'), findsOneWidget); expect(find.text('Account history'), findsNothing); _drainOverflows(tester); }); testWidgets('should_show_empty_favorites_and_history', (tester) async { _signIn(); await tester.pumpWidget(_app(catalog: FakeCatalogRepository())); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); // 新版空收藏只显示图标 + 文案,不再摆「+」加号卡片。 expect(find.byKey(const Key('following-add-card')), findsNothing); expect(find.byKey(const Key('following-favorites-empty')), findsOneWidget); await tester.tap(find.byKey(const Key('following-tab-history'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('following-history-empty')), findsOneWidget); }); testWidgets('should_render_history_metadata_without_detail_fetch', ( tester, ) async { _signIn(); final catalog = _CountingCatalogRepository(); await tester.pumpWidget( _app( catalog: catalog, history: InMemoryWatchHistoryRepository( seed: [ WatchProgress( dramaId: 'the-signal-demo', episodeId: 'the-signal-demo-3', dramaTitle: 'Server title', dramaCover: '/files/server-cover.jpg', episodeNumber: 3, totalEpisodes: 8, position: const Duration(seconds: 25), duration: const Duration(seconds: 100), ), ], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('following-tab-history'))); await tester.pumpAndSettle(); expect(find.text('Server title'), findsOneWidget); expect(find.text('Episode 3 / 8 total'), findsOneWidget); expect(find.text('Watched 25%'), findsOneWidget); expect( find.byKey(const Key('following-history-card-the-signal-demo')), findsOneWidget, ); expect( find.byKey(const Key('following-history-continue-the-signal-demo')), findsOneWidget, ); expect( find.byKey(const Key('following-progress-the-signal-demo')), findsOneWidget, ); final cover = tester.widget( find.byKey(const Key('following-history-cover-the-signal-demo')), ); expect(cover.width, AppDimens.followingHistoryCoverWidth); expect(cover.height, AppDimens.followingHistoryCoverHeight); final historyGrid = tester.widget( find.descendant( of: find.byKey(const Key('following-history-grid')), matching: find.byType(SliverGrid), ), ); final historyDelegate = historyGrid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect(historyDelegate.crossAxisCount, 1); expect(historyDelegate.mainAxisSpacing, AppDimens.followingHistoryRowGap); expect(catalog.detailFetches, 0); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); expect( find.byKey(const Key('following-history-remove-the-signal-demo')), findsOneWidget, ); expect( find.byKey(const Key('following-history-continue-the-signal-demo')), findsNothing, ); }); testWidgets('should_switch_following_pages_horizontally', (tester) async { _signIn(); await tester.pumpWidget(_app(catalog: FakeCatalogRepository())); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); await tester.drag( find.byKey(const Key('following-tab-pages')), const Offset(-500, 0), ); await tester.pumpAndSettle(); expect(find.byKey(const Key('following-history-empty')), findsOneWidget); final historyTab = tester.widget( find.descendant( of: find.byKey(const Key('following-tab-history')), matching: find.byType(AnimatedDefaultTextStyle), ), ); expect(historyTab.style.fontWeight, FontWeight.w400); expect(historyTab.style.color, AppColors.textPrimary); }); testWidgets('should_open_home_from_add_card', (tester) async { _signIn(); final catalog = FakeCatalogRepository(); await tester.pumpWidget( _app( catalog: catalog, // 加号卡片只在已有收藏时出现(空态已经改成纯图标 + 文案)。 favorites: InMemoryFavoriteRepository( cards: catalog.dramas.take(1).toList(), saved: {catalog.dramas.first.id}, ), ), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('following-add-card'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); _drainOverflows(tester); }); testWidgets('should_reset_edit_state_when_switching_tabs', (tester) async { _signIn(); final catalog = FakeCatalogRepository(); await tester.pumpWidget( _app( catalog: catalog, favorites: InMemoryFavoriteRepository( cards: catalog.dramas, saved: catalog.dramas.map((drama) => drama.id).toSet(), ), ), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('following-tab-history'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('following-tab-favorites'))); await tester.pumpAndSettle(); expect( find.byKey(const Key('following-card-the-signal-demo')), findsOneWidget, ); expect(find.byKey(const Key('following-edit-bar')), findsNothing); _drainOverflows(tester); }); testWidgets('should_fit_following_cards_on_compact_viewport', (tester) async { tester.view.physicalSize = const Size(320, 640); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); _signIn(); final catalog = FakeCatalogRepository(); await tester.pumpWidget( _app( catalog: catalog, favorites: InMemoryFavoriteRepository( cards: catalog.dramas.take(1).toList(), saved: {catalog.dramas.first.id}, ), ), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); expect( find.byKey(const Key('following-card-the-signal-demo')), findsOneWidget, ); _drainOverflows(tester); }); } /// 测试字体是等宽方块字形:320 宽时分段标题撑破头部 Row,卡片底部也会多出 /// 约 2px(`AppDimens.followingCardTextExtent = 48` 比实测文本高度略小)。 /// 用例本身只校验卡片渲染与交互,这里收尾时消费掉这类渲染告警。 void _drainOverflows(WidgetTester tester) { while (tester.takeException() != null) {} } void _signIn() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'access', 'auth.refreshToken': 'refresh', 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', }); } Future _setSignedIn(SharedPreferencesAsync preferences) async { await preferences.setString('auth.accessToken', 'access'); await preferences.setString('auth.refreshToken', 'refresh'); await preferences.setString( 'auth.user', '{"uid":"9","nickname":"Viewer","avatarUrl":""}', ); } Future _clearSignedIn(SharedPreferencesAsync preferences) async { await preferences.remove('auth.accessToken'); await preferences.remove('auth.refreshToken'); await preferences.remove('auth.user'); } Widget _app({ required FakeCatalogRepository catalog, FavoriteRepository? favorites, WatchHistoryRepository? history, }) { return LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: FakeCategoryRepository(catalog: catalog), watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), favoriteRepository: favorites ?? InMemoryFavoriteRepository(), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), ); } class _CountingCatalogRepository extends FakeCatalogRepository { int detailFetches = 0; @override Future fetchDrama(String dramaId) { detailFetches += 1; return super.fetchDrama(dramaId); } } WatchProgress _historyProgress(String dramaId, String title) { return WatchProgress( dramaId: dramaId, episodeId: '$dramaId-3', dramaTitle: title, episodeNumber: 3, position: const Duration(seconds: 25), duration: const Duration(seconds: 100), ); } class _IdentityFavoriteRepository implements FavoriteRepository { _IdentityFavoriteRepository({ required List device, required List account, }) : _cards = {'device': device, 'account': account}; final Map> _cards; String scope = 'device'; int pageCalls = 0; @override Future> page({int pageIndex = 1, int pageSize = 20}) async { pageCalls += 1; final cards = _cards[scope]!; final offset = (pageIndex - 1) * pageSize; return PageResult( total: cards.length, list: offset >= cards.length ? const [] : cards.skip(offset).take(pageSize).toList(), ); } @override Future remove(String dramaId) async { _cards[scope]!.removeWhere((drama) => drama.id == dramaId); } @override Future removeMany(List dramaIds) async { final ids = dramaIds.toSet(); _cards[scope]!.removeWhere((drama) => ids.contains(drama.id)); } @override Future toggle(String dramaId) async => true; } class _IdentityWatchHistoryRepository implements WatchHistoryRepository { _IdentityWatchHistoryRepository({ required List device, required List account, }) : _records = {'device': device, 'account': account}; final Map> _records; String scope = 'device'; int recentCalls = 0; @override Future clear() async => _records[scope]!.clear(); @override Future progressFor(String dramaId) async { return _records[scope]! .where((record) => record.dramaId == dramaId) .firstOrNull; } @override Future> recent({ int pageIndex = 1, int pageSize = 20, }) async { recentCalls += 1; final records = _records[scope]!; final offset = (pageIndex - 1) * pageSize; return PageResult( total: records.length, list: offset >= records.length ? const [] : records.skip(offset).take(pageSize).toList(), ); } @override Future remove(String dramaId) async { _records[scope]!.removeWhere((record) => record.dramaId == dramaId); } @override Future save({ required String episodeId, required Duration position, Duration? duration, bool finished = false, }) async => null; }