// 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/wallet.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/commerce_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/data/services/auth_controller.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; import 'package:fruitshorts/pages/mine/about_page.dart'; import 'package:fruitshorts/pages/mine/mine_types.dart'; import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart'; import 'package:fruitshorts/pages/store/vip_page.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/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { testWidgets('should_render_visitor_user_vip_and_eight_grid_entries', ( tester, ) async { await _pumpApp(tester, signedIn: false); await _openMine(tester); expect(find.byKey(const Key('mine-user-row')), findsOneWidget); expect(find.byKey(const Key('mine-avatar')), findsOneWidget); expect(find.text('Visitor'), findsOneWidget); expect(find.text('UID: --'), findsOneWidget); expect(find.byKey(const Key('mine-uid-copy')), findsOneWidget); final uidRowSize = tester.getSize(find.byKey(const Key('mine-uid'))); expect(uidRowSize.height, closeTo(AppDimens.mineUidRowHeight, 0.1)); final nicknameRect = tester.getRect(find.byKey(const Key('mine-nickname'))); final uidRect = tester.getRect(find.byKey(const Key('mine-uid'))); expect(uidRect.top - nicknameRect.bottom, lessThanOrEqualTo(6)); final nickname = tester.widget( find.byKey(const Key('mine-nickname')), ); expect(nickname.style?.fontSize, AppDimens.mineUserName); expect(nickname.style?.fontWeight, FontWeight.w700); expect(nickname.style?.height, AppDimens.mineUserNameLineHeight); final uid = tester.widget(find.text('UID: --')); expect(uid.style?.fontSize, AppDimens.mineUid); expect(uid.style?.color, AppColors.textTertiary); final copyIcon = tester.widget( find.descendant( of: find.byKey(const Key('mine-uid-copy')), matching: find.byType(Icon), ), ); expect(copyIcon.size, AppDimens.mineCopyIcon); expect(copyIcon.color, AppColors.textTertiary); expect(find.byKey(const Key('mine-login')), findsOneWidget); expect(find.byKey(const Key('mine-edit')), findsNothing); expect(find.byKey(const Key('mine-vip')), findsOneWidget); expect(find.byKey(const Key('mine-vip-open')), findsOneWidget); expect(find.byKey(const Key('mine-continue-watching')), findsNothing); expect(find.byKey(const Key('mine-stats')), findsOneWidget); expect(_statValue(tester, const Key('mine-stat-following')), '3'); expect(_statValue(tester, const Key('mine-stat-watch-records')), '0'); expect(_statValue(tester, const Key('mine-stat-coins')), '—'); expect(find.byKey(const Key('mine-menu')), findsOneWidget); expect(find.byKey(const Key('mine-more-services')), findsOneWidget); expect(find.byKey(const Key('mine-mini-player')), findsNothing); final surface = tester.widget( find.byKey(const Key('mine-page-surface')), ); final surfaceDecoration = surface.decoration as BoxDecoration; final background = surfaceDecoration.gradient! as LinearGradient; expect(background.begin, Alignment.topCenter); expect(background.end, Alignment.bottomCenter); expect(background.colors, const [ AppColors.mineGradientTop, AppColors.mineGradientMiddle, AppColors.mineGradientBottom, ]); expect(background.stops, const [0, 0.4, 0.75]); expect( find.descendant( of: find.byKey(const Key('mine-page-surface')), matching: find.text('My'), ), findsNothing, ); final glow = tester.widget( find.byKey(const Key('mine-page-glow')), ); final glowDecoration = glow.decoration as BoxDecoration; final radialGlow = glowDecoration.gradient! as RadialGradient; expect(radialGlow.center, const Alignment(0, -0.95)); expect(radialGlow.radius, 0.9); expect(radialGlow.colors, const [ AppColors.mineGradientGlow, AppColors.transparent, ]); for (final oldShortcut in const [ Key('mine-watchlist'), Key('mine-history-shortcut'), Key('mine-downloads-shortcut'), ]) { expect(find.byKey(oldShortcut), findsNothing); } const entryKeys = [ Key('mine-wallet'), Key('mine-downloads'), Key('mine-history'), Key('mine-watch-preferences'), Key('mine-help'), Key('mine-language'), Key('mine-settings'), Key('mine-notices'), ]; expect(entryKeys, hasLength(8)); for (final entryKey in entryKeys) { expect(find.byKey(entryKey), findsOneWidget); } const coloredIcons = [ ( Key('mine-wallet'), Icons.account_balance_wallet_outlined, AppColors.mineIconWallet, ), ( Key('mine-downloads'), Icons.download_outlined, AppColors.mineIconDownloads, ), (Key('mine-history'), Icons.history_rounded, AppColors.mineIconHistory), ( Key('mine-watch-preferences'), Icons.tune_rounded, AppColors.mineIconPreferences, ), ( Key('mine-help'), Icons.support_agent_rounded, AppColors.mineIconSupport, ), ( Key('mine-language'), Icons.language_rounded, AppColors.mineIconLanguage, ), ( Key('mine-settings'), Icons.settings_outlined, AppColors.mineIconSettings, ), ( Key('mine-notices'), Icons.notifications_none_rounded, AppColors.mineIconNotices, ), ]; for (final entry in coloredIcons) { final icon = tester.widget( find.descendant( of: find.byKey(entry.$1), matching: find.byIcon(entry.$2), ), ); expect(icon.color, entry.$3); expect(icon.size, AppDimens.mineGridIcon); } for (var index = 0; index < 8; index++) { expect(find.byKey(Key('mine-menu-cell-$index')), findsOneWidget); } expect( find.descendant( of: find.byKey(const Key('mine-menu-cell-7')), matching: find.byIcon(Icons.notifications_none_rounded), ), findsOneWidget, ); expect( find.descendant( of: find.byKey(const Key('mine-menu')), matching: find.byType(ListTile), ), findsNothing, ); final positions = [ for (final entryKey in entryKeys) tester.getTopLeft(find.byKey(entryKey)), ]; expect( positions.take(4).map((position) => position.dy).toSet(), hasLength(1), ); expect( positions.skip(4).map((position) => position.dy).toSet(), hasLength(1), ); expect(positions[1].dx, greaterThan(positions[0].dx)); expect(positions[2].dx, greaterThan(positions[1].dx)); expect(positions[3].dx, greaterThan(positions[2].dx)); expect(positions[5].dx, greaterThan(positions[4].dx)); expect(positions[6].dx, greaterThan(positions[5].dx)); expect(tester.getTopLeft(find.byKey(const Key('mine-menu'))).dx, 14); final menu = tester.widget(find.byKey(const Key('mine-menu'))); final menuDecoration = menu.decoration! as BoxDecoration; expect(menuDecoration.color, AppColors.mineGridSurface); expect( menuDecoration.borderRadius, BorderRadius.circular(AppDimens.mineGridRadius), ); expect(menuDecoration.border, isNull); expect(menuDecoration.boxShadow, isNull); final table = tester.widget(find.byKey(const Key('mine-menu-grid'))); final border = table.border!; expect(border.horizontalInside.color, AppColors.mineGridDivider); expect(border.verticalInside.color, AppColors.mineGridDivider); final vip = tester.widget(find.byKey(const Key('mine-vip'))); final vipDecoration = vip.decoration! as BoxDecoration; expect((vipDecoration.gradient! as LinearGradient).colors, const [ AppColors.vipGoldStart, AppColors.vipGoldEnd, ]); expect(vipDecoration.boxShadow, isNull); final order = [ tester.getTopLeft(find.byKey(const Key('mine-user-row'))).dy, tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy, tester.getTopLeft(find.byKey(const Key('mine-stats'))).dy, tester.getTopLeft(find.byKey(const Key('mine-menu'))).dy, tester.getTopLeft(find.byKey(const Key('mine-more-services'))).dy, ]; expect(order, [...order]..sort()); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('mine-login'))); await tester.pumpAndSettle(); expect(find.byType(AuthPage), findsOneWidget); }); testWidgets('should_show_device_counts_when_signed_out', (tester) async { await _pumpApp( tester, signedIn: false, history: InMemoryWatchHistoryRepository(seed: [_recent]), ); await _openMine(tester); expect(_statValue(tester, const Key('mine-stat-following')), '3'); expect(_statValue(tester, const Key('mine-stat-watch-records')), '1'); expect(_statValue(tester, const Key('mine-stat-coins')), '—'); expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); }); testWidgets('should_switch_mine_counts_when_login_identity_changes', ( tester, ) async { final catalog = FakeCatalogRepository(); final favorites = _IdentityMineFavoriteRepository( device: catalog.dramas.take(2).toList(), account: [catalog.dramas[2]], ); final history = _IdentityMineWatchHistoryRepository( device: [_recent, _secondRecent], account: [_accountRecent], ); await _pumpApp( tester, signedIn: false, favorite: favorites, history: history, ); await _openMine(tester); expect(_statValue(tester, const Key('mine-stat-following')), '2'); expect(_statValue(tester, const Key('mine-stat-watch-records')), '2'); expect(_statValue(tester, const Key('mine-stat-coins')), '—'); final auth = Provider.of( tester.element(find.byKey(const Key('mine-page-surface'))), 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(_statValue(tester, const Key('mine-stat-following')), '1'); expect(_statValue(tester, const Key('mine-stat-watch-records')), '1'); expect(_statValue(tester, const Key('mine-stat-coins')), '320'); 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(_statValue(tester, const Key('mine-stat-following')), '2'); expect(_statValue(tester, const Key('mine-stat-watch-records')), '2'); expect(_statValue(tester, const Key('mine-stat-coins')), '—'); expect(find.text('Device history'), findsOneWidget); expect(find.text('Account history'), findsNothing); }); testWidgets('should_refresh_watch_count_after_history_is_cleared', ( tester, ) async { final history = InMemoryWatchHistoryRepository(seed: [_recent]); await _pumpApp(tester, signedIn: false, history: history); await _openMine(tester); expect(_statValue(tester, const Key('mine-stat-watch-records')), '1'); await tester.tap(find.byKey(const Key('mine-history'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('watch-records-clear'))); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Clear')); await tester.pumpAndSettle(); expect(find.byKey(const Key('watch-records-empty')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(_statValue(tester, const Key('mine-stat-watch-records')), '0'); }); testWidgets('should_render_signed_in_user_without_floating_mini_player', ( tester, ) async { await _pumpApp( tester, signedIn: true, history: InMemoryWatchHistoryRepository(seed: [_recent]), ); await _openMine(tester); expect(find.text('Viewer'), findsOneWidget); expect(find.text('UID: U-100'), findsOneWidget); expect(find.byKey(const Key('mine-edit')), findsOneWidget); expect(find.byKey(const Key('mine-login')), findsNothing); expect(find.byKey(const Key('mine-mini-player')), findsNothing); expect(find.byType(FloatingActionButton), findsNothing); expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); expect(find.byKey(const Key('mine-continue-all')), findsOneWidget); expect(find.text('Recent drama'), findsOneWidget); expect(find.text('Episode 3'), findsWidgets); String statValue(Key key) { return tester .widget( find .descendant(of: find.byKey(key), matching: find.byType(Text)) .first, ) .data!; } expect(statValue(const Key('mine-stat-following')), '3'); expect(statValue(const Key('mine-stat-watch-records')), '1'); expect(statValue(const Key('mine-stat-coins')), '320'); final signedInOrder = [ tester.getTopLeft(find.byKey(const Key('mine-user-row'))).dy, tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy, tester.getTopLeft(find.byKey(const Key('mine-continue-watching'))).dy, tester.getTopLeft(find.byKey(const Key('mine-stats'))).dy, tester.getTopLeft(find.byKey(const Key('mine-menu'))).dy, tester.getTopLeft(find.byKey(const Key('mine-more-services'))).dy, ]; expect(signedInOrder, [...signedInOrder]..sort()); expect(find.byKey(const Key('mine-notices')), findsOneWidget); await tester.tap(find.byKey(const Key('mine-edit'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('account-profile-page')), findsOneWidget); expect(find.byKey(const Key('mine-edit-works')), findsNothing); expect(find.byKey(const Key('mine-edit-creator')), findsNothing); expect(tester.takeException(), isNull); }); testWidgets('should_open_more_services_and_about_page', (tester) async { await _pumpApp(tester, signedIn: false); await _openMine(tester); await tester.tap(find.byKey(const Key('mine-common-questions'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-placeholder-page')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-about'))); await tester.pumpAndSettle(); expect(find.byType(MineAboutPage), findsOneWidget); expect(find.byKey(const Key('mine-about-page')), findsOneWidget); expect(find.text('Version 1.0.0'), findsOneWidget); expect( find.text( AppLocalizations.of( tester.element(find.byType(MineAboutPage)), )!.aboutDescription, ), findsOneWidget, ); }); testWidgets('should_open_history_tab_from_continue_watching_all', ( tester, ) async { await _pumpApp( tester, signedIn: true, history: InMemoryWatchHistoryRepository(seed: [_recent]), ); await _openMine(tester); await tester.tap(find.byKey(const Key('mine-continue-all'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('following-page')), findsOneWidget); expect(find.byKey(const Key('following-history-grid')), findsOneWidget); }); testWidgets('should_open_player_when_tapping_continue_watching_card', ( tester, ) async { await _pumpApp( tester, signedIn: true, history: InMemoryWatchHistoryRepository(seed: [_recent]), ); await _openMine(tester); await tester.tap(find.byKey(const Key('mine-continue-city-lights-demo'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-scaffold')), findsOneWidget); // 继续观看直接进播放页,不应该先落到历史列表 expect(find.byKey(const Key('following-page')), findsNothing); }); testWidgets('should_open_vip_from_mine_without_floating_player', ( tester, ) async { await _pumpApp( tester, signedIn: true, history: InMemoryWatchHistoryRepository(seed: [_recent]), ); await _openMine(tester); await tester.tap(find.byKey(const Key('mine-vip-open'))); await tester.pumpAndSettle(); expect(find.byType(VipPage), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-mini-player')), findsNothing); expect(find.byType(FloatingActionButton), findsNothing); }); testWidgets('should_expose_eight_menu_destinations_in_order', (tester) async { tester.view.physicalSize = const Size(390, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final opened = []; var languageOpened = 0; await tester.pumpWidget( MaterialApp( theme: AppTheme.dark(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Scaffold( body: SingleChildScrollView( padding: const EdgeInsets.all(AppDimens.pageHorizontal), child: MineMenuList( onOpen: opened.add, onLanguage: () => languageOpened++, ), ), ), ), ); const rows = [ (Key('mine-wallet'), MineDestination.wallet), (Key('mine-downloads'), MineDestination.downloads), (Key('mine-history'), MineDestination.history), (Key('mine-watch-preferences'), MineDestination.preferences), (Key('mine-help'), MineDestination.help), (Key('mine-notices'), MineDestination.notices), ]; for (final row in rows) { await tester.tap(find.byKey(row.$1)); await tester.pump(); expect(opened.last, row.$2); } await tester.tap(find.byKey(const Key('mine-language'))); await tester.pump(); expect(languageOpened, 1); await tester.tap(find.byKey(const Key('mine-settings'))); await tester.pump(); expect(opened.last, MineDestination.settings); }); } const _recent = WatchProgress( dramaId: 'city-lights-demo', episodeId: 'city-lights-demo-3', dramaTitle: 'Recent drama', dramaCover: '/files/recent.jpg', episodeNumber: 3, totalEpisodes: 8, position: Duration(seconds: 30), duration: Duration(minutes: 2), ); const _secondRecent = WatchProgress( dramaId: 'vengeance-demo', episodeId: 'vengeance-demo-1', dramaTitle: 'Device history', episodeNumber: 1, totalEpisodes: 6, position: Duration(seconds: 15), duration: Duration(minutes: 2), ); const _accountRecent = WatchProgress( dramaId: 'the-signal-demo', episodeId: 'the-signal-demo-2', dramaTitle: 'Account history', episodeNumber: 2, totalEpisodes: 7, position: Duration(seconds: 20), duration: Duration(minutes: 2), ); String _statValue(WidgetTester tester, Key key) { return tester .widget( find.descendant(of: find.byKey(key), matching: find.byType(Text)).first, ) .data!; } Future _openMine(WidgetTester tester) async { await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); } Future _pumpApp( WidgetTester tester, { required bool signedIn, WatchHistoryRepository? history, FavoriteRepository? favorite, CommerceRepository? commerce, }) async { tester.view.physicalSize = const Size(390, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); SharedPreferencesAsyncPlatform.instance = signedIn ? InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'access', 'auth.refreshToken': 'refresh', 'auth.user': '{"uid":"U-100","nickname":"Viewer","avatarUrl":""}', }) : InMemorySharedPreferencesAsync.empty(); final catalog = FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: FakeCategoryRepository(catalog: catalog), watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), favoriteRepository: favorite ?? InMemoryFavoriteRepository( saved: catalog.dramas.map((drama) => drama.id).toSet(), cards: catalog.dramas, ), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), commerceRepository: commerce ?? const _MineWalletCommerceRepository(), ), ); await tester.pumpAndSettle(); } Future _setSignedIn(SharedPreferencesAsync preferences) async { await preferences.setString('auth.accessToken', 'access'); await preferences.setString('auth.refreshToken', 'refresh'); await preferences.setString( 'auth.user', '{"uid":"U-100","nickname":"Viewer","avatarUrl":""}', ); } Future _clearSignedIn(SharedPreferencesAsync preferences) async { await preferences.remove('auth.accessToken'); await preferences.remove('auth.refreshToken'); await preferences.remove('auth.user'); } class _IdentityMineFavoriteRepository implements FavoriteRepository { _IdentityMineFavoriteRepository({ required List device, required List account, }) : _cards = {'device': device, 'account': account}; final Map> _cards; String scope = 'device'; @override Future> page({int pageIndex = 1, int pageSize = 20}) async { 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 _IdentityMineWatchHistoryRepository implements WatchHistoryRepository { _IdentityMineWatchHistoryRepository({ required List device, required List account, }) : _records = {'device': device, 'account': account}; final Map> _records; String scope = 'device'; @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 { 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; } class _MineWalletCommerceRepository implements CommerceRepository { const _MineWalletCommerceRepository(); @override Future fetchWallet() async { return const WalletSnapshot( wallet: CoinWallet( coinBalance: 320, totalRechargedCoin: 500, totalConsumedCoin: 180, totalRefundedCoin: 0, ), ); } @override Future unlock(String episodeId) async { return const UnlockResult( unlocked: true, accessType: 'UNLOCKED', coinCost: 0, coinBalance: 320, ); } }