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/models/watch_progress.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 '../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_all_dramas_in_two_column_masonry_without_continue_watching', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.text('Continue Watching'), findsNothing); expect(find.text('Recommended'), findsOneWidget); for (final key in const [ Key('discover-search-button'), Key('discover-profile'), ]) { expect(tester.getSize(find.byKey(key)), const Size(36, 36)); expect( tester .widget( find.descendant( of: find.byKey(key), matching: find.byType(Icon), ), ) .size, 18, ); } expect(find.text('For You'), findsNothing); final masonry = tester.widget( find.byKey(const Key('home-all-dramas-grid')), ); expect(masonry.children, hasLength(3)); expect((masonry.children[1] as SizedBox).width, 10); expect( tester .widget(find.byKey(const Key('home-all-dramas-section'))) .padding, const EdgeInsets.symmetric(horizontal: 14), ); expect(find.byType(GridView), findsNothing); expect(find.byType(CustomScrollView), findsOneWidget); final signalCard = find.byKey( const Key('home-drama-card-the-signal-demo'), ); final cityCard = find.byKey( const Key('home-drama-card-city-lights-demo'), ); final vengeanceCard = find.byKey( const Key('home-drama-card-vengeance-demo'), ); expect(tester.getTopLeft(signalCard).dy, tester.getTopLeft(cityCard).dy); expect( tester.getTopLeft(vengeanceCard).dx, tester.getTopLeft(signalCard).dx, ); expect( tester.getTopLeft(vengeanceCard).dy - tester.getBottomLeft(signalCard).dy, closeTo(10, 1), ); final cover = find.byKey(const Key('home-drama-cover-city-lights-demo')); expect(tester.getSize(cover).height, greaterThan(120)); expect( tester .widget( find.descendant(of: cover, matching: find.byType(Image)), ) .fit, BoxFit.fitWidth, ); expect( find.descendant( of: cover, matching: find.text('City Lights & Velvet Nights'), ), findsNothing, ); expect( find.byKey(const Key('home-drama-title-city-lights-demo')), findsOneWidget, ); expect( tester .getTopLeft( find.byKey(const Key('home-drama-title-city-lights-demo')), ) .dy, greaterThan(tester.getBottomLeft(cover).dy), ); expect( tester .widget( find.byKey(const Key('home-drama-title-city-lights-demo')), ) .maxLines, 1, ); expect( tester .widget( find.byKey(const Key('home-drama-genre-city-lights-demo')), ) .maxLines, 1, ); expect(tester.takeException(), isNull); }, ); testWidgets('should_update_grid_when_category_is_selected', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsWidgets); expect(find.text('City Lights & Velvet Nights'), findsWidgets); await tester.tap(find.text('Romance')); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsNothing); expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsNothing, ); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, ); }); testWidgets('should_keep_discover_header_fixed_when_content_scrolls', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await _pumpApp( tester, categoryRepository: _GridCategoryRepository( discoverFeed: _tallDiscoverFeed, ), ); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); final headerBefore = tester.getTopLeft( find.byKey(const Key('discover-search-button')), ); final contentBefore = tester.getTopLeft( find.byKey(const Key('home-category-chips')), ); await tester.drag(find.byType(CustomScrollView), const Offset(0, -40)); await tester.pumpAndSettle(); expect( tester.getTopLeft(find.byKey(const Key('discover-search-button'))), headerBefore, ); expect( tester.getTopLeft(find.byKey(const Key('home-category-chips'))).dy, lessThan(contentBefore.dy), ); }); testWidgets('should_keep_empty_messages_when_feed_has_no_dramas', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await _pumpApp( tester, categoryRepository: _GridCategoryRepository( discoverFeed: const HomeFeed(), ), ); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.text('No matching dramas.'), findsOneWidget); expect(find.byKey(const Key('empty-state-content')), findsOneWidget); await tester.tap(find.text('Romance')); await tester.pumpAndSettle(); expect(find.text('No dramas in this category yet.'), findsOneWidget); }); testWidgets('should_show_retry_when_category_feed_fails', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await _pumpApp(tester, categoryRepository: _FailingCategoryRepository()); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.text('Categories could not be loaded.'), findsOneWidget); expect(find.text('Retry'), findsOneWidget); expect(find.byKey(const Key('empty-state-icon')), findsOneWidget); final icon = tester.getCenter(find.byKey(const Key('empty-state-icon'))); final feed = tester.getRect(find.byType(CustomScrollView)); expect(icon.dx, closeTo(feed.center.dx, 1)); expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing); }); testWidgets('should_fit_two_column_grid_on_compact_screen', (tester) async { tester.view.physicalSize = const Size(320, 568); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); await tester.scrollUntilVisible( find.byKey(const Key('home-drama-card-city-lights-demo')), 180, scrollable: find.byType(Scrollable).last, ); await tester.pumpAndSettle(); expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect(tester.takeException(), isNull); }); testWidgets('should_filter_trending_grid_when_search_text_changes', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsOneWidget, ); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, ); await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); await tester.pumpAndSettle(); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsNothing, ); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, ); await tester.enterText(find.byKey(const Key('discover-search')), ''); await tester.pumpAndSettle(); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsOneWidget, ); }); testWidgets('should_unfocus_search_without_losing_input_or_filter', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('discover-search'))); await tester.pump(); expect( tester .widget(find.byKey(const Key('discover-search'))) .focusNode! .hasFocus, isTrue, ); await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); 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, ); await tester.tapAt(const Offset(70, 23)); await tester.pump(); final field = tester.widget( find.byKey(const Key('discover-search')), ); expect(field.focusNode!.hasFocus, isFalse); expect(field.controller!.text, 'velvet'); 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 { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final counter = _CountingCategoryRepository( _GridCategoryRepository(discoverFeed: _tallDiscoverFeed), ); await _pumpApp(tester, categoryRepository: counter); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); final before = counter.categoryLoads + counter.feedLoads; await tester.drag(find.byType(CustomScrollView), const Offset(0, 320)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); await tester.pumpAndSettle(); expect(counter.categoryLoads + counter.feedLoads, greaterThan(before)); expect(find.byType(RefreshProgressIndicator), findsNothing); }); testWidgets('should_show_continue_watching_only_when_a_record_exists', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await _pumpApp( tester, categoryRepository: _GridCategoryRepository(), history: InMemoryWatchHistoryRepository( seed: const [ WatchProgress( dramaId: 'city-lights-demo', episodeId: 'city-lights-demo-1', position: Duration(seconds: 42), duration: Duration(minutes: 2), ), ], ), ); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('discover-continue-watching')), findsOneWidget); expect(find.byKey(const Key('discover-continue-play')), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget); expect(find.byKey(const Key('discover-quick-charts')), findsOneWidget); expect(find.byKey(const Key('discover-quick-new')), findsOneWidget); expect(find.byKey(const Key('discover-quick-watchlist')), findsOneWidget); }); } Future _pumpApp( WidgetTester tester, { required CategoryRepository categoryRepository, WatchHistoryRepository? history, }) async { final catalog = FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: categoryRepository, watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), ), ); await tester.pumpAndSettle(); } class _GridCategoryRepository implements CategoryRepository { _GridCategoryRepository({HomeFeed? discoverFeed}) : discoverFeed = discoverFeed ?? _defaultDiscoverFeed; final HomeFeed discoverFeed; @override Future> fetchCategories() async => const [ ContentCategory(id: '7', name: 'Romance'), ]; @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(); } }