// 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/content_category.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.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/theme/app_colors.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/recommend/recommend_types.dart'; import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.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_category.dart'; import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; import '../support/widget_player.dart'; const _categories = [ ContentCategory(id: 'costume', name: '古装'), ContentCategory(id: 'community', name: '社群'), ContentCategory(id: 'uplifting', name: '爽剧'), ContentCategory(id: 'sweet', name: '甜宠'), ContentCategory(id: 'face-slap', name: '打脸虐渣'), ContentCategory(id: 'angst', name: '虐恋'), ]; void main() { setUp(() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); }); testWidgets('should_render_inner_top_tabs_in_order', (tester) async { final repository = _PagedCatalogRepository(); await _pumpApp(tester, repository); final tabKeys = [ const Key('recommend-tab-recommend'), const Key('recommend-tab-playlist'), const Key('recommend-tab-all'), ]; final centers = tabKeys .map((key) => tester.getCenter(find.byKey(key)).dx) .toList(); expect(centers, orderedEquals(centers.toList()..sort())); expect(find.byKey(const Key('recommend-section-hits')), findsNothing); expect(find.text('Featured'), findsOneWidget); expect(find.text('Categories'), findsNothing); expect(find.text('Charts'), findsNothing); expect(find.byKey(const Key('mine-mini-player')), findsNothing); expect(find.byType(FloatingActionButton), findsNothing); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); final selectedUnderline = tester.widget( find.byKey(const Key('recommend-tab-recommend-underline')), ); expect( (selectedUnderline.decoration! as BoxDecoration).color, AppColors.brandRed, ); final idleUnderline = tester.widget( find.byKey(const Key('recommend-tab-playlist-underline')), ); expect( (idleUnderline.decoration! as BoxDecoration).color, AppColors.transparent, ); }); testWidgets('should_render_only_categories_returned_by_api', (tester) async { final repository = _PagedCatalogRepository(); await _pumpApp( tester, repository, categories: const [ContentCategory(id: 'romance', name: 'Romance')], ); 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-romance')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-costume')), findsNothing); expect(find.byKey(const Key('recommend-tab-community')), findsNothing); }); testWidgets('should_switch_home_sections_without_pushing_pages', ( tester, ) async { final repository = _PagedCatalogRepository(); await _pumpApp(tester, repository); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); expect(find.byKey(const Key('recommend-section-hits')), findsNothing); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); _drainLayoutExceptions(tester); expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); expect( Navigator.of( tester.element(find.byKey(const Key('recommend-page'))), ).canPop(), isFalse, ); await _openAllDramasTab(tester); expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('filter-page')), findsNothing); }); testWidgets('should_keep_scroll_position_when_switching_sections', ( tester, ) async { final repository = _PagedCatalogRepository(); await _pumpApp(tester, repository); await _openAllDramasTab(tester); await tester.drag( find.byKey(const Key('all-dramas-scroll')), const Offset(0, -320), ); await tester.pumpAndSettle(); final scrollable = find .descendant( of: find.byKey(const Key('all-dramas-scroll')), matching: find.byType(Scrollable), ) .first; final before = tester.state(scrollable).position.pixels; expect(before, greaterThan(0)); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); _drainLayoutExceptions(tester); await _openAllDramasTab(tester); final afterScrollable = find .descendant( of: find.byKey(const Key('all-dramas-scroll')), matching: find.byType(Scrollable), ) .first; expect( tester.state(afterScrollable).position.pixels, closeTo(before, 0.1), ); }); testWidgets('should_swipe_between_inner_sections', (tester) async { final repository = _PagedCatalogRepository(); await _pumpApp(tester, repository); final pages = find.byKey(const Key('recommend-tab-page-view')); final topNavBefore = tester.getRect( find.byKey(const Key('recommend-top-nav')), ); await tester.drag(pages, const Offset(-320, 0)); await tester.pumpAndSettle(); _drainLayoutExceptions(tester); expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-player')), findsNothing); final selected = tester.widget( find.descendant( of: find.byKey(const Key('recommend-tab-playlist')), matching: find.text('Playlists'), ), ); expect(selected.style?.fontWeight, FontWeight.w400); expect( tester.getRect(find.byKey(const Key('recommend-top-nav'))), topNavBefore, ); await tester.drag(pages, const Offset(-320, 0)); await tester.pumpAndSettle(); _drainLayoutExceptions(tester); expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); await tester.drag(pages, const Offset(640, 0)); await tester.pumpAndSettle(); _drainLayoutExceptions(tester); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); }); testWidgets('should_follow_partial_swipe_before_selected_page_changes', ( tester, ) async { await _pumpApp(tester, _PagedCatalogRepository()); final pages = find.byKey(const Key('recommend-tab-page-view')); final gesture = await tester.startGesture(tester.getCenter(pages)); await gesture.moveBy(const Offset(-30, 0)); await tester.pump(); await gesture.moveBy(const Offset(-80, 0)); await tester.pump(); final nav = tester.widget(find.byType(RecommendTopNav)); expect(nav.pagePosition, inExclusiveRange(0.0, 0.5)); final underline = tester.widget( find.byKey(const Key('recommend-tab-playlist-underline')), ); final color = (underline.decoration! as BoxDecoration).color!; expect(color.a, inExclusiveRange(0.0, 0.5)); await gesture.up(); await tester.pumpAndSettle(); _drainLayoutExceptions(tester); }); testWidgets( 'should_keep_all_dramas_selected_during_direct_cross_page_click', (tester) async { await _pumpApp(tester, _PagedCatalogRepository()); 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.pump(); await tester.pump(const Duration(milliseconds: 70)); final nav = tester.widget(find.byType(RecommendTopNav)); expect(nav.pagePosition, inExclusiveRange(0.5, 1.5)); expect(nav.selected, RecommendInnerTab.allDramas); await tester.pumpAndSettle(); _drainLayoutExceptions(tester); expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); }, ); testWidgets('should_load_next_page_without_expanding_page_size', ( tester, ) async { final repository = _PagedCatalogRepository(); await _pumpApp(tester, repository); await _openAllDramasTab(tester); expect(find.byKey(const Key('home-feed-card-page1-0')), findsOneWidget); for (var attempt = 0; attempt < 3; attempt++) { await tester.drag( find.byKey(const Key('all-dramas-scroll')), const Offset(0, -800), ); await tester.pumpAndSettle(); _drainLayoutExceptions(tester); } expect(repository.cursors, contains('page1')); expect(repository.requestedSizes.every((size) => size <= 20), isTrue); final gridScrollable = find .descendant( of: find.byKey(const Key('all-dramas-scroll')), matching: find.byType(Scrollable), ) .first; final secondPageCard = find.byKey(const Key('home-feed-card-page2-0')); final position = tester.state(gridScrollable).position; await tester.pump(); position.jumpTo(position.maxScrollExtent); await tester.pump(); position.jumpTo(position.maxScrollExtent); await tester.pump(); _drainLayoutExceptions(tester); expect(secondPageCard, findsOneWidget); }); testWidgets('should_open_player_when_grid_card_is_tapped', (tester) async { final repository = _PagedCatalogRepository(); await _pumpApp(tester, repository); await _openAllDramasTab(tester); await tester.tap(find.byKey(const Key('home-feed-card-page1-0'))); await tester.pumpAndSettle(); final player = tester.widget(find.byType(PlayerPage).last); expect(player.homeMode, isFalse); }); } /// 打开首页内层「全部短剧」页;顺带消费测试字体(Ahem)造成的卡片溢出。 Future _openAllDramasTab(WidgetTester tester) async { 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(); _drainLayoutExceptions(tester); } /// 剧单/网格卡片高度按真机字体设计,测试字体行高更大时会溢出几个像素。 void _drainLayoutExceptions(WidgetTester tester) { while (tester.takeException() != null) {} } Future _pumpApp( WidgetTester tester, _PagedCatalogRepository repository, { List categories = _categories, }) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: repository, categoryRepository: FakeCategoryRepository( catalog: repository, categories: categories, ), watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository( cards: repository.allDramas, ), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), haptics: FakeHaptics(), ), ); await tester.pumpAndSettle(); } class _PagedCatalogRepository implements CatalogRepository { _PagedCatalogRepository() : pageOne = List.generate(30, (index) => _drama('page1-$index')), pageTwo = List.generate(2, (index) => _drama('page2-$index')); final List pageOne; final List pageTwo; final List cursors = []; final List requestedSizes = []; List get allDramas => [...pageOne, ...pageTwo]; @override Future fetchHomeFeed() async => _feed(pageOne); @override Future fetchDiscoverFeed() async => _feed(pageOne); @override Future fetchFeed({ List types = const [], String? categoryId, String? cursor, int size = 10, }) async { requestedSizes.add(size); if (types.isNotEmpty) { return const FeedPage(dramas: []); } cursors.add(cursor); if (cursor == 'page1') { return FeedPage(dramas: pageTwo, hasMore: false); } return FeedPage(dramas: pageOne, nextCursor: 'page1', hasMore: true); } @override Future fetchDrama(String dramaId) async { return allDramas.firstWhere((drama) => drama.id == dramaId); } HomeFeed _feed(List dramas) { return HomeFeed( sections: [FeedSection(code: 'hot', title: 'Hot', dramas: dramas)], ); } } Drama _drama(String id) { final index = int.tryParse(id.split('-').last) ?? 0; return Drama( id: id, title: 'Grid Drama $id', synopsis: 'synopsis', posterUrl: '', genre: 'Romance', category: index.isEven ? '古装' : 'Urban', viewCount: (10 - index) * 10000, createdAt: DateTime(2026, 10, 1).add(Duration(days: index)), detailLoaded: true, episodes: [ Episode( id: '$id-1', number: 1, title: 'Episode 1', isFree: true, videoUrl: 'https://example.test/$id.mp4', ), ], ); }