// 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/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/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_action_grid.dart'; import 'package:fruitshorts/pages/store/vip_page.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_header_vip_quick_actions_and_services', ( 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 nickname = tester.widget( find.byKey(const Key('mine-nickname')), ); expect(nickname.style?.fontSize, AppDimens.mineUserName); expect(nickname.style?.fontWeight, FontWeight.w400); 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-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.followingGlow, AppColors.followingGlowMid, AppColors.appBackground, ]); expect(background.stops, const [0, 0.14, 0.3]); // 统一操作宫格第一排:钱包 / 收藏 / 离线缓存 / 观看历史。 const quickActions = [ (Key('mine-wallet'), Icons.account_balance_wallet_outlined), (Key('mine-favorites'), Icons.star_outline_rounded), (Key('mine-downloads'), Icons.download_for_offline_outlined), (Key('mine-history'), Icons.history_rounded), ]; for (final action in quickActions) { final icon = tester.widget( find.descendant( of: find.byKey(action.$1), matching: find.byIcon(action.$2), ), ); expect(icon.color, AppColors.mineGridIconAccent); expect(icon.size, AppDimens.mineGridIcon); expect( find.descendant( of: find.byKey(const Key('mine-menu')), matching: find.byKey(action.$1), ), findsOneWidget, ); } // 「更多服务」宫格:联系客服 / 语言 / 设置 / 通知。 const services = [ ( Key('mine-help'), Key('mine-help-icon-surface'), Icons.support_agent_outlined, ), ( Key('mine-language'), Key('mine-language-icon-surface'), Icons.language_rounded, ), ( Key('mine-settings'), Key('mine-settings-icon-surface'), Icons.settings_outlined, ), ( Key('mine-notices'), Key('mine-notices-icon-surface'), Icons.notifications_none_rounded, ), ]; for (final service in services) { expect(find.byKey(service.$1), findsOneWidget); final icon = tester.widget( find.descendant( of: find.byKey(service.$1), matching: find.byIcon(service.$3), ), ); expect(icon.color, AppColors.mineGridIconAccent); expect(icon.size, AppDimens.mineGridIcon); expect( tester.getSize(find.byKey(service.$2)), const Size.square(AppDimens.mineGridIconSurface), ); } expect(find.byKey(const Key('mine-menu')), findsOneWidget); expect(tester.getTopLeft(find.byKey(const Key('mine-menu'))).dx, 14); expect( tester.getSize(find.byKey(const Key('mine-menu'))).height, AppDimens.mineGridHeight, ); final serviceLefts = services .map((service) => tester.getTopLeft(find.byKey(service.$1)).dx) .toList(); expect(serviceLefts, [...serviceLefts]..sort()); expect(serviceLefts.toSet(), hasLength(services.length)); expect( tester.getTopLeft(find.byKey(const Key('mine-help'))).dy, greaterThan(tester.getTopLeft(find.byKey(const Key('mine-wallet'))).dy), ); 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), ); final vip = tester.widget(find.byKey(const Key('mine-vip'))); final vipDecoration = vip.decoration! as BoxDecoration; expect((vipDecoration.gradient! as LinearGradient).colors, const [ AppColors.vipCardStart, AppColors.vipCardMiddle, AppColors.vipCardEnd, ]); expect(vipDecoration.border, isNull); expect(vipDecoration.boxShadow, isNotEmpty); // 卡片高度由内容撑开,身份头部与底部 CTA 都保留安全内边距。 final vipRect = tester.getRect(find.byKey(const Key('mine-vip'))); final emblemRect = tester.getRect(find.byKey(const Key('mine-vip-emblem'))); final benefitsRect = tester.getRect( find.byKey(const Key('mine-vip-benefits')), ); final topInset = emblemRect.top - vipRect.top; final bottomInset = vipRect.bottom - benefitsRect.bottom; expect(topInset, greaterThanOrEqualTo(AppDimens.mineVipPadding)); expect(bottomInset, greaterThanOrEqualTo(AppDimens.mineVipPadding)); expect(find.byKey(const Key('mine-vip-emblem')), findsOneWidget); expect( tester.getSize(find.byKey(const Key('mine-vip-open'))).height, greaterThanOrEqualTo(AppDimens.mineVipButtonHeight), ); const benefitKeys = [ Key('mine-vip-benefit-0'), Key('mine-vip-benefit-1'), Key('mine-vip-benefit-2'), Key('mine-vip-benefit-3'), ]; final benefitTops = benefitKeys .map((key) => tester.getTopLeft(find.byKey(key))) .toList(); expect( benefitTops.take(2).map((position) => position.dy).toSet(), hasLength(1), ); expect( benefitTops.skip(2).map((position) => position.dy).toSet(), hasLength(1), ); expect(benefitTops[2].dy, greaterThan(benefitTops[0].dy)); expect( benefitTops.take(2).map((position) => position.dx).toSet(), hasLength(2), ); 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-wallet'))).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_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-notices')), findsOneWidget); await tester.tap(find.byKey(const Key('mine-edit'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('account-profile-page')), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('should_open_help_and_about_from_mine', (tester) async { await _pumpApp(tester, signedIn: false); await _openMine(tester); await tester.tap(find.byKey(const Key('mine-help'))); 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-settings'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('settings-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('Your mini business'), findsOneWidget); expect( find.text( AppLocalizations.of( tester.element(find.byType(MineAboutPage)), )!.aboutDescription, ), findsOneWidget, ); }); testWidgets('should_open_watch_records_from_quick_actions', (tester) async { await _pumpApp( tester, signedIn: true, history: InMemoryWatchHistoryRepository(seed: [_recent]), ); await _openMine(tester); await tester.tap(find.byKey(const Key('mine-history'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('watch-records-page')), findsOneWidget); }); 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_quick_actions_and_service_destinations', ( 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; var favoritesOpened = 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: Column( children: [ MineActionGrid( primaryActions: MinePrimaryActions( onWallet: () => opened.add(MineDestination.wallet), onFavorites: () => favoritesOpened++, onDownloads: () => opened.add(MineDestination.downloads), onHistory: () => opened.add(MineDestination.history), ), serviceActions: MineServiceActions( onOpen: opened.add, onLanguage: () => languageOpened++, ), ), ], ), ), ), ), ); const quickActions = [ (Key('mine-wallet'), MineDestination.wallet), (Key('mine-downloads'), MineDestination.downloads), (Key('mine-history'), MineDestination.history), ]; for (final action in quickActions) { await tester.tap(find.byKey(action.$1)); await tester.pump(); expect(opened.last, action.$2); } await tester.tap(find.byKey(const Key('mine-favorites'))); await tester.pump(); expect(favoritesOpened, 1); const services = [ (Key('mine-help'), MineDestination.help), (Key('mine-notices'), MineDestination.notices), (Key('mine-settings'), MineDestination.settings), ]; for (final service in services) { await tester.tap(find.byKey(service.$1)); await tester.pump(); expect(opened.last, service.$2); } await tester.tap(find.byKey(const Key('mine-language'))); await tester.pump(); expect(languageOpened, 1); }); } 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), ); 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(); } 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, ); } }