import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/services/category_controller.dart'; import 'package:fruitshorts/core/data/services/home_feed_controller.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/home/home_feed_channel.dart'; import 'package:fruitshorts/pages/recommend/recommend_page.dart'; import 'package:provider/provider.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; void main() { testWidgets('should_show_skeleton_before_feed_is_loaded', (tester) async { await _pumpRecommend(tester, active: false); expect(find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-empty')), findsNothing); expect(find.text('Retry'), findsNothing); }); testWidgets('should_refresh_once_when_parent_rebuilds_repeatedly', ( tester, ) async { final fixture = await _pumpRecommend(tester, active: true); expect(fixture.controller.refreshCalls, 1); for (var index = 0; index < 20; index++) { fixture.harnessKey.currentState!.rebuildSameInputs(); await tester.pump(); } expect(tester.takeException(), isNull); expect(fixture.controller.refreshCalls, 1); }); testWidgets('should_refresh_once_when_active_turns_on', (tester) async { final fixture = await _pumpRecommend(tester, active: false); expect(fixture.controller.refreshCalls, 0); fixture.harnessKey.currentState!.update(active: true); await tester.pump(); expect(fixture.controller.refreshCalls, 1); fixture.harnessKey.currentState!.rebuildSameInputs(); await tester.pump(); expect(fixture.controller.refreshCalls, 1); }); testWidgets('should_refresh_again_when_data_source_changes', (tester) async { final fixture = await _pumpRecommend(tester, active: true); expect(fixture.controller.refreshCalls, 1); fixture.harnessKey.currentState!.update(active: false); await tester.pump(); expect(fixture.controller.refreshCalls, 1); fixture.harnessKey.currentState!.update(active: true); await tester.pump(); expect(fixture.controller.refreshCalls, 2); }); testWidgets('should_auto_retry_three_times_then_show_network_error', ( tester, ) async { final fixture = await _pumpFailingRecommend(tester); expect(fixture.controller.refreshCalls, 1); expect(find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-empty')), findsNothing); for (var attempt = 0; attempt < 3; attempt++) { await tester.pump(const Duration(milliseconds: 1500)); await tester.pump(); expect(fixture.controller.refreshCalls, attempt + 2); if (attempt < 2) { expect( find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget, ); expect(find.byKey(const Key('recommend-feed-empty')), findsNothing); } } final l10n = AppLocalizations.of( tester.element(find.byType(RecommendPage)), )!; expect(fixture.controller.refreshCalls, 4); expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget); expect(find.text(l10n.networkError), findsOneWidget); expect(find.text(l10n.retry), findsNothing); }); testWidgets('should_cancel_pending_auto_retry_when_disposed', (tester) async { final fixture = await _pumpFailingRecommend(tester); expect(fixture.controller.refreshCalls, 1); await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); await tester.pump(const Duration(seconds: 2)); expect(fixture.controller.refreshCalls, 1); }); testWidgets( 'should_show_no_stories_without_retry_when_loaded_feed_is_empty', (tester) async { final fixture = await _pumpEmptyRecommend(tester); final l10n = AppLocalizations.of( tester.element(find.byType(RecommendPage)), )!; expect(fixture.controller.refreshCalls, 1); expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget); expect(find.text(l10n.noStories), findsOneWidget); expect(find.text(l10n.retry), findsNothing); }, ); testWidgets('should_not_refresh_after_dispose_when_channel_changes', ( tester, ) async { final fixture = await _pumpRecommend(tester, active: true); expect(fixture.controller.refreshCalls, 1); fixture.harnessKey.currentState!.update(active: true); final selection = fixture.controller.selectChannel(HomeFeedChannel.anime); await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); await selection; await tester.pump(); expect(fixture.controller.refreshCalls, 1); }); } Future<_RecommendFixture> _pumpRecommend( WidgetTester tester, { required bool active, }) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final catalog = FakeCatalogRepository(); final controller = _RecordingHomeFeedController(catalog); final categories = CategoryController( FakeCategoryRepository(catalog: catalog), ); final harnessKey = GlobalKey<_RecommendHarnessState>(); final fixture = _RecommendFixture( controller: controller, categoryController: categories, harnessKey: harnessKey, ); addTearDown(controller.dispose); addTearDown(categories.dispose); await tester.pumpWidget(fixture.app(active: active)); await tester.pump(); return fixture; } Future<_StatefulRecommendFixture> _pumpFailingRecommend( WidgetTester tester, ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final catalog = FakeCatalogRepository(); final controller = _StatefulHomeFeedController(catalog); final categories = CategoryController( FakeCategoryRepository(catalog: catalog), ); addTearDown(controller.dispose); addTearDown(categories.dispose); final fixture = _StatefulRecommendFixture( controller: controller, categoryController: categories, ); await tester.pumpWidget(fixture.app()); await tester.pump(); await tester.pump(const Duration(milliseconds: 1)); return fixture; } Future<_StatefulRecommendFixture> _pumpEmptyRecommend( WidgetTester tester, ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final catalog = FakeCatalogRepository(); final controller = _StatefulHomeFeedController(catalog, fail: false); final categories = CategoryController( FakeCategoryRepository(catalog: catalog), ); addTearDown(controller.dispose); addTearDown(categories.dispose); final fixture = _StatefulRecommendFixture( controller: controller, categoryController: categories, ); await tester.pumpWidget(fixture.app()); await tester.pump(); await tester.pump(const Duration(milliseconds: 1)); return fixture; } class _RecommendFixture { const _RecommendFixture({ required this.controller, required this.categoryController, required this.harnessKey, }); final _RecordingHomeFeedController controller; final CategoryController categoryController; final GlobalKey<_RecommendHarnessState> harnessKey; Widget app({required bool active}) { return MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: ChangeNotifierProvider.value( value: categoryController, child: _RecommendHarness( key: harnessKey, controller: controller, active: active, ), ), ); } } class _StatefulRecommendFixture { const _StatefulRecommendFixture({ required this.controller, required this.categoryController, }); final _StatefulHomeFeedController controller; final CategoryController categoryController; Widget app() { return MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: ChangeNotifierProvider.value( value: categoryController, child: RecommendPage( feedController: controller, active: true, videoOnly: true, onSearch: () {}, ), ), ); } } class _RecommendHarness extends StatefulWidget { const _RecommendHarness({ super.key, required this.controller, required this.active, }); final HomeFeedController controller; final bool active; @override State<_RecommendHarness> createState() => _RecommendHarnessState(); } class _RecommendHarnessState extends State<_RecommendHarness> { late HomeFeedController _controller; late bool _active; @override void initState() { super.initState(); _controller = widget.controller; _active = widget.active; } void rebuildSameInputs() { setState(() {}); } void update({required bool active}) { setState(() { _active = active; }); } @override Widget build(BuildContext context) { return RecommendPage( feedController: _controller, active: _active, videoOnly: true, onSearch: () {}, ); } } class _RecordingHomeFeedController extends HomeFeedController { _RecordingHomeFeedController(super.repository); int refreshCalls = 0; @override Future refreshCurrent() async { refreshCalls++; notifyListeners(); return true; } } class _StatefulHomeFeedController extends HomeFeedController { _StatefulHomeFeedController(super.repository, {this.fail = true}); final bool fail; int refreshCalls = 0; bool _loaded = false; bool _isLoading = false; String? _lastError; @override bool get loaded => _loaded; @override bool get isLoading => _isLoading; @override String? get lastError => _lastError; @override Future refreshCurrent() async { refreshCalls++; _isLoading = true; _lastError = null; notifyListeners(); await Future.delayed(Duration.zero); _isLoading = false; _loaded = true; if (fail) _lastError = 'network error'; notifyListeners(); return !fail; } }