// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/config/app_config.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/catalog_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/catalog_controller.dart'; import 'package:fruitshorts/core/data/services/category_controller.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_haptics.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(); AppConfig.clearRuntimeBaseUrl(); AppConfig.ready = Future.value(); }); tearDown(() { AppConfig.clearRuntimeBaseUrl(); AppConfig.ready = Future.value(); }); testWidgets('should_show_feed_skeleton_without_an_in_app_splash', ( tester, ) async { final repository = _PendingCatalogRepository(); await _pumpApp(tester, repository); await tester.pump(); expect(find.byKey(const Key('home-tabs')), findsOneWidget); expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); expect(find.byKey(const Key('startup-splash')), findsNothing); expect(find.byType(CircularProgressIndicator), findsNothing); repository.complete(_feed); await _pumpForAppData(tester); await _pumpUntilFound( tester, find.byKey(const Key('recommend-feed-player')), ); expect(find.byKey(const Key('startup-splash')), findsNothing); expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); }); testWidgets('should_show_catalog_error_inside_feed_without_full_overlay', ( tester, ) async { await _pumpApp(tester, _FailingCatalogRepository()); await _pumpForAppData(tester); // 首页 feed 失败后会先自动重试 3 次(每 1.5s),重试用尽才展示错误态。 await _pumpUntilFound( tester, find.byKey(const Key('recommend-feed-empty')), step: const Duration(milliseconds: 1600), frames: 8, ); expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsNothing); }); testWidgets('should_show_catalog_empty_state_inside_feed', (tester) async { await _pumpApp(tester, _EmptyCatalogRepository()); await _pumpForAppData(tester); await _pumpUntilFound(tester, find.byKey(const Key('recommend-feed-empty'))); expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); }); } Future _pumpApp(WidgetTester tester, CatalogRepository repository) async { final catalog = FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, catalogRepository: repository, categoryRepository: FakeCategoryRepository(catalog: repository), watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), haptics: FakeHaptics(), ), ); await tester.pump(); } Future _pumpUntilFound( WidgetTester tester, Finder finder, { Duration step = const Duration(milliseconds: 16), int frames = 40, }) async { for (var frame = 0; frame < frames; frame++) { if (finder.evaluate().isNotEmpty) return; await tester.pump(step); } await tester.pump(); } Future _pumpForAppData(WidgetTester tester) async { await tester.runAsync( () => Future.delayed(Duration.zero), ); for (var frame = 0; frame < 40; frame++) { final root = find.byKey(const Key('home-tabs')); if (root.evaluate().isNotEmpty) { final context = tester.element(root); final catalog = context.read(); final categories = context.read(); if (catalog.settled && !catalog.loading && !categories.loading) { await tester.pump(); return; } } await tester.pump(const Duration(milliseconds: 16)); } await tester.pump(); } final _feed = HomeFeed( sections: [ FeedSection(code: 'hot', title: 'Hot', dramas: [demoDramas.first]), ], ); class _EmptyCatalogRepository implements CatalogRepository { @override Future fetchHomeFeed() async => const HomeFeed(); @override Future fetchDiscoverFeed() async => const HomeFeed(); @override Future fetchFeed({ List types = const [], String? categoryId, String? cursor, int size = 10, }) async => const FeedPage(); @override Future fetchDrama(String dramaId) async => _feed.dramas.first; } class _PendingCatalogRepository implements CatalogRepository { final Completer _home = Completer(); final Completer _page = Completer(); void complete(HomeFeed feed) { if (!_home.isCompleted) _home.complete(feed); if (!_page.isCompleted) _page.complete(FeedPage(dramas: feed.dramas)); } @override Future fetchHomeFeed() => _home.future; @override Future fetchDiscoverFeed() async => _feed; @override Future fetchFeed({ List types = const [], String? categoryId, String? cursor, int size = 10, }) => _page.future; @override Future fetchDrama(String dramaId) async => _feed.dramas.first; } class _FailingCatalogRepository implements CatalogRepository { @override Future fetchHomeFeed() async => throw StateError('offline'); @override Future fetchDiscoverFeed() async => throw StateError('offline'); @override Future fetchFeed({ List types = const [], String? categoryId, String? cursor, int size = 10, }) async => throw StateError('offline'); @override Future fetchDrama(String dramaId) async => throw StateError('offline'); }