// Author: fruit.com.cn import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.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 'package:fruitshorts/core/data/models/auth_session.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/download_license.dart'; import 'package:fruitshorts/core/data/models/download_policy.dart'; import 'package:fruitshorts/core/data/models/wallet.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; import 'package:fruitshorts/core/data/repositories/download_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/interaction_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/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; import 'package:fruitshorts/core/data/services/download_controller.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; import 'package:fruitshorts/core/data/services/interaction_controller.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/wallet_controller.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import '../support/fake_catalog.dart'; import '../support/fake_haptics.dart'; import '../support/fake_http_adapter.dart'; import '../support/fake_interaction_repositories.dart'; import '../support/widget_player.dart'; void main() { setUp(() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); }); testWidgets('should_render_new_detail_layout_and_existing_menu', ( tester, ) async { await _pumpDetail(tester); final overlay = tester.widget>( find.byKey(const Key('detail-system-overlay')), ); expect(overlay.value.systemNavigationBarColor, AppColors.ink); expect(overlay.value.systemNavigationBarContrastEnforced, isFalse); expect(find.byKey(const Key('detail-hero')), findsOneWidget); expect( tester.getSize(find.byKey(const Key('detail-poster'))), const Size(96, 128), ); expect( tester.getSize(find.byKey(const Key('detail-back'))), const Size(40, 40), ); expect( tester .widget( find.descendant( of: find.byKey(const Key('detail-back')), matching: find.byType(Material), ), ) .color, const Color(0x59000000), ); expect( tester .widget(find.byKey(const Key('detail-title'))) .style ?.fontSize, 20, ); expect(find.byKey(const Key('detail-episode-count')), findsOneWidget); expect(tester.getSize(find.byKey(const Key('detail-favorite'))).height, 34); final stats = tester.widget( find.byKey(const Key('detail-stats')), ); final statsDecoration = stats.decoration! as BoxDecoration; expect(statsDecoration.color, AppColors.card); expect(statsDecoration.borderRadius, BorderRadius.circular(14)); expect(find.byKey(const Key('detail-synopsis-section')), findsOneWidget); expect(find.byKey(const Key('detail-tags')), findsOneWidget); expect(find.byKey(const Key('detail-episodes-section')), findsOneWidget); expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 46); final bottomBar = tester.widget( find.byKey(const Key('detail-bottom-bar')), ); expect((bottomBar.decoration! as BoxDecoration).color, AppColors.ink); expect(find.byKey(const Key('detail-share')), findsOneWidget); expect(find.byKey(const Key('detail-more')), findsOneWidget); for (final key in const [Key('detail-share'), Key('detail-more')]) { expect(tester.getSize(find.byKey(key)), const Size(40, 40)); expect( tester .widget( find.descendant( of: find.byKey(key), matching: find.byType(Material), ), ) .color, const Color(0x59000000), ); } await tester.tap(find.byKey(const Key('detail-more'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('detail-menu-favorite')), findsOneWidget); expect(find.byKey(const Key('detail-download')), findsOneWidget); expect(find.byKey(const Key('detail-menu-share')), findsOneWidget); expect(find.byKey(const Key('detail-report')), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('should_switch_favorite_button_style_when_tapped', ( tester, ) async { await _pumpDetail(tester); BoxDecoration decoration() => tester .widget(find.byKey(const Key('detail-favorite'))) .decoration! as BoxDecoration; expect(decoration().color, AppColors.card); expect((decoration().border! as Border).top.color, AppColors.blue); await tester.tap(find.byKey(const Key('detail-favorite'))); await tester.pumpAndSettle(); expect(decoration().color, AppColors.blue); expect( find.descendant( of: find.byKey(const Key('detail-favorite')), matching: find.byIcon(Icons.star_rounded), ), findsOneWidget, ); }); testWidgets('should_expand_synopsis_when_it_exceeds_three_lines', ( tester, ) async { await _pumpDetail(tester, drama: _detailDrama(longSynopsis: true)); Text synopsis() => tester.widget(find.byKey(const Key('detail-synopsis'))); expect(synopsis().maxLines, 3); final toggle = find.byKey(const Key('detail-synopsis-toggle')); expect(toggle, findsOneWidget); await tester.ensureVisible(toggle); await tester.tap(toggle); await tester.pumpAndSettle(); expect(synopsis().maxLines, isNull); expect( tester.widget(find.byKey(const Key('detail-synopsis'))).data, contains('final secret'), ); }); testWidgets('should_render_stats_and_honest_empty_values', (tester) async { await _pumpDetail(tester); expect(find.text('480.0万 heat'), findsOneWidget); expect(find.text('3200 Favorite'), findsOneWidget); expect(find.text('1.2万 Like'), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); await _pumpDetail(tester, drama: _detailDrama(withCounts: false)); expect( tester .widget(find.byKey(const Key('detail-stat-favorite-value'))) .data, '8 episodes', ); expect( tester.widget(find.byKey(const Key('detail-stat-like-value'))).data, '—', ); }); testWidgets('should_render_six_column_grid_and_open_original_index', ( tester, ) async { await _pumpDetail(tester, drama: _detailDrama(count: 80, descending: true)); await tester.ensureVisible( find.byKey(const Key('detail-episodes-section')), ); await tester.pumpAndSettle(); final grid = tester.widget( find.byKey(const Key('detail-episode-chips')), ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect(delegate.crossAxisCount, 6); expect(delegate.mainAxisSpacing, 8); expect(delegate.crossAxisSpacing, 8); expect(delegate.mainAxisExtent, 44); final chip1 = find.byKey(const Key('detail-episode-chip-1')); final chip2 = find.byKey(const Key('detail-episode-chip-2')); final chip6 = find.byKey(const Key('detail-episode-chip-6')); final chip7 = find.byKey(const Key('detail-episode-chip-7')); expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy); expect( tester.getTopLeft(chip7).dy, greaterThan(tester.getTopLeft(chip1).dy), ); expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx)); expect(tester.getSize(chip1).height, 44); expect(find.byKey(const Key('detail-episode-lock-2')), findsOneWidget); expect(find.byKey(const Key('detail-episode-lock-1')), findsNothing); await tester.tap(chip1); await tester.pumpAndSettle(); final player = tester.widget(find.byType(PlayerPage)); expect(player.initialIndex, 79); }); testWidgets('should_fit_compact_viewport_and_keep_cta_visible', ( tester, ) async { await _pumpDetail( tester, drama: _detailDrama(count: 80), size: const Size(320, 568), ); expect(find.byKey(const Key('detail-cta')), findsOneWidget); await tester.ensureVisible( find.byKey(const Key('detail-episodes-section')), ); await tester.pumpAndSettle(); expect(find.byKey(const Key('detail-cta')), findsOneWidget); expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); expect(tester.takeException(), isNull); }); } Future _pumpDetail( WidgetTester tester, { Drama? drama, Size size = const Size(390, 844), }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final target = drama ?? _detailDrama(); final catalog = FakeCatalogRepository(dramas: [target]); final interactions = FakeInteractionRepository(); final auth = await _signedInAuth(); final favorites = FavoriteController( InMemoryFavoriteRepository(cards: [target]), ); await favorites.load(); await tester.pumpWidget( MultiProvider( providers: [ Provider.value( value: InMemoryWatchHistoryRepository(), ), Provider.value(value: FakePlaybackRepository()), Provider.value(value: createWidgetPlayer), Provider(create: (_) => PlaybackCoordinator()), Provider.value(value: interactions), Provider.value(value: FakeCommentRepository()), Provider.value(value: FakeHaptics()), ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider.value(value: favorites), ChangeNotifierProvider( create: (_) => InteractionController(interactions), ), ChangeNotifierProvider( create: (_) => WalletController(_FakeCommerceRepository()), ), ChangeNotifierProvider( create: (_) => DownloadController(_FakeDownloadRepository()), ), ChangeNotifierProvider( create: (_) => CatalogController(catalog)..load(), ), ], child: MaterialApp( theme: AppTheme.light(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: DramaDetailPage(drama: target), ), ), ); await tester.pumpAndSettle(); } Future _signedInAuth() async { final preferences = SharedPreferencesAsync(); final store = AuthSessionStore(preferences: preferences); await store.save( const AuthSession( accessToken: 'access', refreshToken: 'refresh', user: AuthUser(uid: '9', nickname: 'Viewer'), ), ); final auth = AuthController( AuthApi( ApiClient( baseUrl: 'http://127.0.0.1:1', adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), ), ), store, ); await auth.load(); return auth; } class _FakeCommerceRepository implements CommerceRepository { @override Future fetchWallet() async => const WalletSnapshot( wallet: CoinWallet( coinBalance: 100, totalRechargedCoin: 0, totalConsumedCoin: 0, totalRefundedCoin: 0, ), ); @override Future unlock(String episodeId) async => const UnlockResult( unlocked: true, accessType: 'UNLOCKED', coinCost: 0, coinBalance: 100, ); } class _FakeDownloadRepository implements DownloadRepository { @override Future fetchPolicy() async => const DownloadPolicy( maxPerDevice: 20, licenseDays: 30, wifiOnly: false, maxQuality: 'AUTO', maxStorageBytes: 0, used: 0, remaining: 20, expirePolicyText: '', ); @override Future> list() async => const []; @override Future grant(String episodeId, {String? quality}) async => throw UnimplementedError(); @override Future updateSettings({bool? wifiOnly, String? maxQuality}) async {} @override Future refreshUrl(String episodeId) async => throw UnimplementedError(); @override Future reportProgress( String episodeId, { String? deviceId, String? status, int? progressPercent, int? downloadedBytes, int? totalBytes, String? errorMsg, }) async => throw UnimplementedError(); @override Future remove(String episodeId) async {} @override Future clear() async => 0; } Drama _detailDrama({ int count = 8, bool descending = false, bool withCounts = true, bool longSynopsis = false, }) { final numbers = List.generate( count, (index) => descending ? count - index : index + 1, ); final synopsis = longSynopsis ? 'She agreed to marry a mysterious stranger to save her family, ' 'until she discovered his secret identity and the city he controls. ' 'Every choice pulls her deeper into a dangerous game, and the ' 'final secret changes everything she thought she knew about him.' : 'She agreed to marry a mysterious stranger to save her family, ' 'until she discovered his secret identity and the city he controls.'; return Drama( id: 'detail-k38', title: 'Detail K38', synopsis: synopsis, posterUrl: '/files/detail-k38.png', genre: 'Urban Romance', category: 'Romance', language: 'id', totalEpisodes: count, viewCount: 4800000, favoriteCount: withCounts ? 3200 : null, likeCount: withCounts ? 12400 : null, detailLoaded: true, episodes: [ for (final number in numbers) Episode( id: 'detail-k38-$number', number: number, title: 'Episode $number', isFree: number == 1, durationSec: 135, locked: number > 1, videoUrl: number == 1 ? '/files/detail-k38-1.mp4' : '', ), ], ); }