// 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/category_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/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/pages/discover/discover_view.dart'; import 'package:fruitshorts/pages/discover/widgets/discover_category_tab.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_interaction_repositories.dart'; import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { setUp(() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); }); testWidgets('should_show_search_category_language_chips_and_drama_grid', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsNothing); for (final tool in ['filter', 'ranking', 'new', 'reserve']) { expect(find.byKey(Key('theater-toolbar-$tool')), findsOneWidget); } expect( find.byKey(const Key('drama-heat-city-lights-demo')), findsOneWidget, ); expect( find.byKey(const Key('drama-tag-chip-city-lights-demo-0')), findsOneWidget, ); expect(find.byKey(const Key('drama-tag-city-lights-demo')), findsNothing); expect(tester.takeException(), isNull); }); testWidgets('should_open_filter_page_from_theater_toolbar', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); await tester.tap(find.byKey(const Key('theater-toolbar-filter'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('filter-page')), findsOneWidget); expect(find.byKey(const Key('filter-groups')), findsOneWidget); await tester.tap(find.byKey(const Key('filter-back'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('filter-page')), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); }); testWidgets('should_filter_grid_when_category_chip_is_tapped', ( tester, ) async { final catalog = _CategoryTheaterCatalog(); await _pumpTheater( tester, catalogRepository: catalog, categoryRepository: _GridCategoryRepository(), ); await tester.tap(find.byKey(const Key('theater-category-7'))); await tester.pumpAndSettle(); expect( catalog.requestedCategoryIds.where((categoryId) => categoryId == '7'), hasLength(1), ); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, ); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsNothing, ); }); testWidgets('should_keep_discover_header_fixed_when_content_scrolls', ( tester, ) async { await _pumpTheater( tester, catalogRepository: _SinglePageTheaterCatalog( dramas: _tallDiscoverFeed.dramas, ), categoryRepository: _GridCategoryRepository( discoverFeed: _tallDiscoverFeed, ), ); final headerBefore = tester.getTopLeft( find.byKey(const Key('discover-search')), ); final scrollable = find.descendant( of: find.byKey(const Key('discover-refresh')), matching: find.byType(Scrollable), ); await tester.drag(_verticalFeed, const Offset(0, -140)); await tester.pumpAndSettle(); expect( tester.getTopLeft(find.byKey(const Key('discover-search'))), headerBefore, ); expect( tester.state(scrollable).position.pixels, greaterThan(0), ); }); testWidgets('should_show_empty_and_retry_states', (tester) async { await _pumpTheater( tester, catalogRepository: _SinglePageTheaterCatalog(dramas: const []), categoryRepository: _GridCategoryRepository( discoverFeed: const HomeFeed(), ), ); expect(find.byKey(const Key('discover-empty')), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); await _pumpTheater( tester, categoryRepository: _FailingCategoryRepository(), ); expect(find.byKey(const Key('home-categories-error')), findsOneWidget); expect(find.text('Retry'), findsOneWidget); expect(find.byKey(const Key('catalog-debug-endpoint')), findsOneWidget); expect(find.byKey(const Key('catalog-debug-error')), findsOneWidget); expect( find.byKey(const Key('catalog-debug-endpoint-edit')), findsOneWidget, ); expect(find.textContaining('discover feed unavailable'), findsOneWidget); }); testWidgets('should_show_retry_when_theater_feed_fails', (tester) async { await _pumpTheater( tester, catalogRepository: _FailingTheaterCatalog(), categoryRepository: _GridCategoryRepository(), ); expect(find.byKey(const Key('home-categories-error')), findsOneWidget); expect(find.text('Retry'), findsOneWidget); expect(find.textContaining('theater feed unavailable'), findsWidgets); }); testWidgets('should_show_search_results_when_text_changes', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); await tester.pump(const Duration(milliseconds: 400)); await tester.pumpAndSettle(); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, ); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsNothing, ); }); testWidgets('should_refresh_feed_when_pulled_down', (tester) async { final counter = _CountingCategoryRepository( _GridCategoryRepository(discoverFeed: _tallDiscoverFeed), ); final theaterCounter = _CountingTheaterCatalog( dramas: _tallDiscoverFeed.dramas, ); await _pumpTheater( tester, catalogRepository: theaterCounter, categoryRepository: counter, ); final beforeCategories = counter.categoryLoads + counter.feedLoads; final beforeTheater = theaterCounter.feedCalls; await tester.drag(_verticalFeed, const Offset(0, 320)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); await tester.pumpAndSettle(); expect( counter.categoryLoads + counter.feedLoads, greaterThan(beforeCategories), ); expect(theaterCounter.feedCalls, beforeTheater + 1); expect(find.byType(RefreshProgressIndicator), findsNothing); }); testWidgets('should_style_search_shell_and_focus_border', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); BoxDecoration shell() => tester .widget( find.byKey(const Key('discover-search-shell')), ) .decoration! as BoxDecoration; expect(shell().borderRadius, BorderRadius.circular(12)); expect(shell().color, AppColors.card); expect((shell().border! as Border).top.color, Colors.transparent); expect( tester.getSize(find.byKey(const Key('discover-search-shell'))).height, 44, ); await tester.tap(find.byKey(const Key('discover-search'))); await tester.pump(const Duration(milliseconds: 200)); expect(shell().color, AppColors.card); expect((shell().border! as Border).top.color, AppColors.blue); }); testWidgets('should_pin_theater_filters_when_scrolling', (tester) async { await _pumpTheater( tester, catalogRepository: _SinglePageTheaterCatalog( dramas: _tallDiscoverFeed.dramas, ), categoryRepository: _GridCategoryRepository( discoverFeed: _tallDiscoverFeed, ), ); BoxDecoration header() => tester .widget( find.byKey(const Key('theater-sticky-header')), ) .decoration! as BoxDecoration; final before = tester.getRect( find.byKey(const Key('theater-sticky-header')), ); expect(header().color, AppColors.ink); await tester.drag(_verticalFeed, const Offset(0, -220)); await tester.pumpAndSettle(); final after = tester.getRect( find.byKey(const Key('theater-sticky-header')), ); expect(after.top, closeTo(before.top, 0.5)); expect(header().color, AppColors.ink); }); testWidgets('should_paint_theater_surface_with_ink', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); final surface = tester.widget( find.byKey(const Key('theater-page-surface')), ); expect(surface.color, AppColors.ink); }); testWidgets('should_keep_category_row_stable_across_scrolls', (tester) async { await _pumpTheater( tester, catalogRepository: _SinglePageTheaterCatalog( dramas: _tallDiscoverFeed.dramas, ), categoryRepository: _GridCategoryRepository( discoverFeed: _tallDiscoverFeed, ), ); double categoryTop() => tester.getRect(find.byKey(const Key('theater-category-chips'))).top; final initial = categoryTop(); await tester.drag(_verticalFeed, const Offset(0, -120)); await tester.pumpAndSettle(); final once = categoryTop(); await tester.drag(_verticalFeed, const Offset(0, -120)); await tester.pumpAndSettle(); final twice = categoryTop(); await tester.drag(_verticalFeed, const Offset(0, 400)); await tester.pumpAndSettle(); final backToTop = categoryTop(); expect(once, closeTo(initial, 0.5)); expect(twice, closeTo(initial, 0.5)); expect(backToTop, closeTo(initial, 0.5)); }); testWidgets('should_keep_small_gap_between_category_and_toolbar', ( tester, ) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); final categoryBottom = tester .getRect(find.byKey(const Key('theater-category-chips'))) .bottom; final toolbarTop = tester .getRect(find.byKey(const Key('theater-toolbar-filter'))) .top; expect(toolbarTop - categoryBottom, inInclusiveRange(0, 6)); }); testWidgets('should_show_category_underline_for_selection', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); CategoryUnderlinePainter underline(String id) => tester .widget( find.byKey(Key('theater-category-underline-$id')), ) .painter! as CategoryUnderlinePainter; expect( tester .widget( find.byKey(const Key('theater-category-underline-all')), ) .size, const Size(20, 3), ); expect(underline('all').color, AppColors.blue); expect(underline('7').color, Colors.transparent); await tester.tap(find.byKey(const Key('theater-category-7'))); await tester.pumpAndSettle(); expect(underline('all').color, Colors.transparent); expect(underline('7').color, AppColors.blue); }); testWidgets('should_scroll_offscreen_category_into_view_when_tapped', ( tester, ) async { final categories = [ for (var index = 0; index < 12; index++) ContentCategory(id: '$index', name: 'Long category $index'), ]; await _pumpTheater( tester, categoryRepository: _GridCategoryRepository(categories: categories), ); final target = find.byKey(const Key('theater-category-11')); expect(tester.getRect(target).right, greaterThan(390)); tester.widget(target).onTap!(); await tester.pump(); await tester.pumpAndSettle(); final rect = tester.getRect(target); expect(rect.left, greaterThanOrEqualTo(0)); expect(rect.right, lessThanOrEqualTo(390)); }); testWidgets('should_use_text_pull_to_refresh', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); final refresh = tester.widget( find.byKey(const Key('discover-refresh')), ); expect(refresh.edgeOffset, 52); expect( tester.widget(_verticalFeed).physics, AppPullToRefresh.physics, ); await tester.drag(_verticalFeed, const Offset(0, 240)); await tester.pump(); expect(find.byKey(const Key('pull-refresh-indicator')), findsOneWidget); expect(find.text('Refreshing…'), findsNothing); await tester.pumpAndSettle(); }); } final Finder _verticalFeed = find.byWidgetPredicate( (widget) => widget is CustomScrollView && widget.scrollDirection == Axis.vertical, ); Future _pumpTheater( WidgetTester tester, { required CategoryRepository categoryRepository, FakeCatalogRepository? catalogRepository, }) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final catalog = catalogRepository ?? FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, searchRepository: catalog, categoryRepository: categoryRepository, watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), ), ); await tester.pumpAndSettle(); Navigator.of(tester.element(find.byKey(const Key('home-tabs')))) .push( MaterialPageRoute(builder: (_) => const DiscoverPage()), ); await tester.pumpAndSettle(); } class _GridCategoryRepository implements CategoryRepository { _GridCategoryRepository({ HomeFeed? discoverFeed, this.categories = const [ContentCategory(id: '7', name: 'Romance')], }) : discoverFeed = discoverFeed ?? _defaultDiscoverFeed; final HomeFeed discoverFeed; final List categories; @override Future> fetchCategories() async => categories; @override Future fetchDiscoverFeed() async => discoverFeed; } class _FailingCategoryRepository implements CategoryRepository { @override Future> fetchCategories() async => const [ ContentCategory(id: '7', name: 'Romance'), ]; @override Future fetchDiscoverFeed() async { throw StateError('discover feed unavailable'); } } final _defaultDiscoverFeed = HomeFeed( sections: [ FeedSection(code: 'hot', title: 'Hot', dramas: demoDramas), FeedSection( code: 'category-7', title: 'Romance', dramas: [ Drama( id: 'city-lights-demo', title: 'City Lights & Velvet Nights', synopsis: 'City Lights & Velvet Nights synopsis', posterUrl: '/files/city-lights-demo.png', genre: 'Urban Romance', detailLoaded: true, ), ], ), ], ); final _tallDiscoverFeed = HomeFeed( sections: [ FeedSection( code: 'hot', title: 'Hot', dramas: [ for (var index = 0; index < 12; index++) Drama( id: 'tall-$index', title: 'Tall Drama $index', synopsis: 'Tall Drama $index synopsis', posterUrl: '/files/tall-$index.png', genre: 'Urban Romance', detailLoaded: true, ), ], ), ], ); class _CountingCategoryRepository implements CategoryRepository { _CountingCategoryRepository(this.delegate); final CategoryRepository delegate; int categoryLoads = 0; int feedLoads = 0; @override Future> fetchCategories() { categoryLoads++; return delegate.fetchCategories(); } @override Future fetchDiscoverFeed() { feedLoads++; return delegate.fetchDiscoverFeed(); } } class _TheaterOnlyCatalog extends FakeCatalogRepository { _TheaterOnlyCatalog({super.dramas}); final FakeCatalogRepository _homeDelegate = FakeCatalogRepository(); @override Future fetchHomeFeed() => _homeDelegate.fetchHomeFeed(); @override Future fetchDiscoverFeed() => _homeDelegate.fetchDiscoverFeed(); @override Future fetchFeed({ List types = const [], String? categoryId, String? cursor, int size = 10, }) { return _homeDelegate.fetchFeed( types: types, categoryId: categoryId, cursor: cursor, size: size, ); } } class _SinglePageTheaterCatalog extends _TheaterOnlyCatalog { _SinglePageTheaterCatalog({required List dramas}) : super(dramas: dramas); @override Future fetchFeed({ List types = const [], String? categoryId, String? cursor, int size = 10, }) async { if (size != 20) { return super.fetchFeed( types: types, categoryId: categoryId, cursor: cursor, size: size, ); } return FeedPage(dramas: dramas.take(size).toList()); } } class _CategoryTheaterCatalog extends _TheaterOnlyCatalog { final List requestedCategoryIds = []; @override Future fetchFeed({ List types = const [], String? categoryId, String? cursor, int size = 10, }) async { if (size != 20) { return super.fetchFeed( types: types, categoryId: categoryId, cursor: cursor, size: size, ); } requestedCategoryIds.add(categoryId); final rows = categoryId == '7' ? dramas.where((drama) => drama.id == 'city-lights-demo').toList() : dramas; return FeedPage(dramas: rows.take(size).toList()); } } class _CountingTheaterCatalog extends _TheaterOnlyCatalog { _CountingTheaterCatalog({required List dramas}) : super(dramas: dramas); int feedCalls = 0; @override Future fetchFeed({ List types = const [], String? categoryId, String? cursor, int size = 10, }) { if (size != 20) { return super.fetchFeed( types: types, categoryId: categoryId, cursor: cursor, size: size, ); } feedCalls++; return super.fetchFeed( types: types, categoryId: categoryId, cursor: cursor, size: size, ); } } class _FailingTheaterCatalog extends _TheaterOnlyCatalog { @override Future fetchFeed({ List types = const [], String? categoryId, String? cursor, int size = 10, }) { if (size == 20) { return Future.error(StateError('theater feed unavailable')); } return super.fetchFeed( types: types, categoryId: categoryId, cursor: cursor, size: size, ); } }