From 13a14f3e85ad31872a570e3fa82fa06f2da32595 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 18:24:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(theater):=20=E5=89=A7=E5=9C=BA=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=88=97=E8=A1=A8=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=88=86?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 35 +++ lib/core/data/remote/catalog_api.dart | 5 +- .../data/repositories/catalog_repository.dart | 9 +- .../services/theater_feed_controller.dart | 187 +++++++++++++ lib/pages/discover/discover_view.dart | 88 +++++- test/catalog/catalog_controller_test.dart | 1 + test/catalog/feed_page_test.dart | 2 + .../services/home_feed_controller_test.dart | 1 + .../theater_feed_controller_test.dart | 180 +++++++++++++ test/discover/home_all_dramas_grid_test.dart | 253 +++++++++++++++++- .../home/catalog_controller_refresh_test.dart | 1 + test/home/home_startup_splash_test.dart | 2 + test/home/k28_a_navigation_test.dart | 16 +- test/support/fake_catalog.dart | 1 + test/watch/watch_records_controller_test.dart | 1 + test/watch/watch_records_page_test.dart | 8 +- 16 files changed, 769 insertions(+), 21 deletions(-) create mode 100644 docs/plans/PROGRESS.md create mode 100644 lib/core/data/services/theater_feed_controller.dart create mode 100644 test/core/data/services/theater_feed_controller_test.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md new file mode 100644 index 0000000..94d26f2 --- /dev/null +++ b/docs/plans/PROGRESS.md @@ -0,0 +1,35 @@ +# 开发进度 + +## 2026-10-21 K96 剧场页服务端分类分页 + +- 已完整读取 K96 `docs/plans/INBOX.md`。 +- 已从最新 `origin/develop` 创建 ReelShort `feat/k96-theater-feed`。 +- 计划见 FruitServer `docs/plans/2026-10-21-K96-theater-feed.md`。 +- 客户端将新增 TheaterFeedController,并让剧场「全部 / 分类」改用 + `/app/feed` 服务端游标分页;不改首页 K94/K95 已交付逻辑。 +- 已完成 `CatalogApi.feed` / `CatalogRepository.fetchFeed` 可选 `categoryId`, + 非空才拼请求参数。 +- 已新增 `TheaterFeedController`:按分类独立缓存,缓存先渲染后静默刷新, + 10 秒超时、游标追加去重、失败保留旧数据、失败分类切回自动重试。 +- 剧场页「全部」和分类 chips 已改用 theater feed;滚动剩余不足 400px 触发 + `loadMore`,下拉刷新同时执行 categories 与 theater 刷新,搜索模式保持独立。 +- 已补分类参数、缓存切换、分页去重、hasMore 停止、失败保留、失败重试、 + 分类点击请求和滚动加载 widget 测试。 + +门禁原始输出: + +```text +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:52 +553: All tests passed! + +$ flutter build apk --debug +Running Gradle task 'assembleDebug'... 24.8s +✓ Built build/app/outputs/flutter-apk/app-debug.apk +``` + +解读:静态分析零告警,553 项全量测试通过,debug APK 构建成功; +`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 diff --git a/lib/core/data/remote/catalog_api.dart b/lib/core/data/remote/catalog_api.dart index d32508d..8cfd60a 100644 --- a/lib/core/data/remote/catalog_api.dart +++ b/lib/core/data/remote/catalog_api.dart @@ -18,9 +18,10 @@ class CatalogApi { return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed'))); } - /// 内容类型推荐流;types 为空表示推荐全部,cursor 为上一页末条。 + /// 推荐流;types/categoryId 为空表示不过滤,cursor 为上一页末条。 Future feed({ List types = const [], + String? categoryId, String? cursor, int size = 10, }) async { @@ -30,6 +31,8 @@ class CatalogApi { '/app/feed', query: { if (types.isNotEmpty) 'types': types.join(','), + if (categoryId != null && categoryId.isNotEmpty) + 'categoryId': categoryId, if (cursor != null && cursor.isNotEmpty) 'cursor': cursor, 'size': '$size', }, diff --git a/lib/core/data/repositories/catalog_repository.dart b/lib/core/data/repositories/catalog_repository.dart index a8d2e0e..ff4695c 100644 --- a/lib/core/data/repositories/catalog_repository.dart +++ b/lib/core/data/repositories/catalog_repository.dart @@ -16,6 +16,7 @@ abstract interface class CatalogRepository { Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, }); @@ -37,9 +38,15 @@ class HttpCatalogRepository implements CatalogRepository { @override Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, - }) => _api.feed(types: types, cursor: cursor, size: size); + }) => _api.feed( + types: types, + categoryId: categoryId, + cursor: cursor, + size: size, + ); @override Future fetchDrama(String dramaId) => _api.dramaDetail(dramaId); diff --git a/lib/core/data/services/theater_feed_controller.dart b/lib/core/data/services/theater_feed_controller.dart new file mode 100644 index 0000000..0343bd6 --- /dev/null +++ b/lib/core/data/services/theater_feed_controller.dart @@ -0,0 +1,187 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../data/models/drama.dart'; +import '../../data/repositories/catalog_repository.dart'; + +/// 剧场页服务端分类列表:按 categoryId 独立缓存、刷新和游标分页。 +class TheaterFeedController extends ChangeNotifier { + TheaterFeedController( + this._repository, { + this.requestTimeout = defaultRequestTimeout, + }); + + static const int pageSize = 20; + static const Duration defaultRequestTimeout = Duration(seconds: 10); + + final CatalogRepository _repository; + final Duration requestTimeout; + final Map _states = {}; + String? _categoryId; + bool _initialized = false; + bool _disposed = false; + + String? get categoryId => _categoryId; + String? get lastError => _state(_categoryId).lastError; + List get dramas => _state(_categoryId).dramas; + String? get cursor => _state(_categoryId).cursor; + bool get hasMore => _state(_categoryId).hasMore; + bool get loaded => _state(_categoryId).loaded; + bool get loadingMore => _state(_categoryId).loadingMore; + bool get loadingFirstPage => !loaded && _state(_categoryId).loading; + + /// 同步切换分类;有缓存先渲染,无缓存时等待或发起首屏请求。 + Future select(String? next) async { + if (_initialized && next == _categoryId) return; + _categoryId = next; + _initialized = true; + _notify(); + + final state = _state(next); + if (state.dramas.isNotEmpty) { + if (!state.loading) { + unawaited(_refresh(next, keepData: true)); + } + return; + } + if (state.loading) { + final inFlight = state.inFlight; + if (inFlight != null) await inFlight; + return; + } + await _refresh(next, keepData: false); + } + + /// 刷新当前分类;失败时保留已有列表。 + Future refreshCurrent() async { + final state = _state(_categoryId); + if (state.loading) { + return await state.inFlight ?? false; + } + return _refresh(_categoryId, keepData: true); + } + + /// 加载当前分类下一页并去重追加。 + Future loadMore() async { + final categoryId = _categoryId; + final state = _state(categoryId); + if (!state.hasMore || state.loadingMore || state.cursor == null) return; + final sequence = ++state.requestSeq; + state.loadingMore = true; + _notifyIfCurrent(categoryId); + try { + final page = await _repository + .fetchFeed( + categoryId: categoryId, + cursor: state.cursor, + size: pageSize, + ) + .timeout(requestTimeout); + if (!_acceptSequence(categoryId, sequence)) return; + final seen = state.dramas.map((drama) => drama.id).toSet(); + final appended = page.dramas + .where((drama) => seen.add(drama.id)) + .toList(); + state + ..dramas = List.unmodifiable([...state.dramas, ...appended]) + ..cursor = page.nextCursor + ..hasMore = page.hasMore + ..loaded = true + ..lastError = null; + } catch (error) { + if (_acceptSequence(categoryId, sequence)) { + state.lastError = error.toString(); + } + } finally { + state.loadingMore = false; + _notifyIfCurrent(categoryId); + } + } + + Future _refresh(String? categoryId, {required bool keepData}) { + final state = _state(categoryId); + final operation = _performRefresh(categoryId, keepData: keepData); + state.inFlight = operation; + unawaited(_clearInFlight(state, operation)); + return operation; + } + + Future _performRefresh( + String? categoryId, { + required bool keepData, + }) async { + final state = _state(categoryId); + final sequence = ++state.requestSeq; + state + ..loading = true + ..lastError = null; + _notifyIfCurrent(categoryId); + try { + final page = await _repository + .fetchFeed(categoryId: categoryId, size: pageSize) + .timeout(requestTimeout); + if (!_acceptSequence(categoryId, sequence)) return false; + state + ..dramas = List.unmodifiable(page.dramas) + ..cursor = page.nextCursor + ..hasMore = page.hasMore + ..loaded = true + ..loading = false + ..lastError = null; + _notifyIfCurrent(categoryId); + return true; + } catch (error) { + if (!_acceptSequence(categoryId, sequence)) return false; + if (!keepData) state.dramas = const []; + state + ..loaded = true + ..loading = false + ..lastError = error.toString(); + _notifyIfCurrent(categoryId); + return false; + } + } + + Future _clearInFlight( + _CategoryFeedState state, + Future operation, + ) async { + await operation; + if (identical(state.inFlight, operation)) state.inFlight = null; + } + + _CategoryFeedState _state(String? categoryId) => + _states.putIfAbsent(categoryId, _CategoryFeedState.new); + + bool _acceptSequence(String? categoryId, int sequence) => + !_disposed && _state(categoryId).requestSeq == sequence; + + void _notifyIfCurrent(String? categoryId) { + if (!_disposed && categoryId == _categoryId) _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +class _CategoryFeedState { + List dramas = const []; + String? cursor; + bool hasMore = true; + bool loaded = false; + bool loading = false; + bool loadingMore = false; + String? lastError; + int requestSeq = 0; + Future? inFlight; +} diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index d713627..ef3771a 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -8,9 +8,11 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/search_repository.dart'; import '../../core/data/services/catalog_retry.dart'; import '../../core/data/services/category_controller.dart'; +import '../../core/data/services/theater_feed_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; @@ -70,6 +72,7 @@ class DiscoverPage extends StatefulWidget { class _DiscoverPageState extends State { late final DiscoverLogic logic; + late final TheaterFeedController theater; final ValueNotifier _headerPinned = ValueNotifier(false); final ScrollController _categoryScroll = ScrollController(); final Map _categoryAnchors = {}; @@ -82,13 +85,25 @@ class _DiscoverPageState extends State { void initState() { super.initState(); logic = DiscoverBinding().create(search: context.read()); + theater = TheaterFeedController(context.read()); logic.scroll.addListener(_onScroll); + theater.addListener(_onTheaterChanged); + unawaited(theater.select(null)); if (widget.focusSearch) _focusOnFirstFrame(); } void _onScroll() { final pinned = logic.scroll.hasClients && logic.scroll.offset > 0; if (pinned != _headerPinned.value) _headerPinned.value = pinned; + if (logic.scroll.hasClients && + !logic.hasSearchQuery && + logic.scroll.position.extentAfter < 400) { + unawaited(theater.loadMore()); + } + } + + void _onTheaterChanged() { + if (mounted) setState(() {}); } void _focusOnFirstFrame() { @@ -111,6 +126,8 @@ class _DiscoverPageState extends State { @override void dispose() { logic.scroll.removeListener(_onScroll); + theater.removeListener(_onTheaterChanged); + theater.dispose(); _categoryScroll.dispose(); _headerPinned.dispose(); logic.dispose(); @@ -169,7 +186,8 @@ class _DiscoverPageState extends State { child: AppPullToRefresh( key: const Key('discover-refresh'), edgeOffset: _stickyHeaderHeight + 8, - onRefresh: categories.load, + onRefresh: () => + Future.wait([categories.load(), theater.refreshCurrent()]), child: ListView( controller: logic.scroll, physics: AppPullToRefresh.physics, @@ -195,7 +213,7 @@ class _DiscoverPageState extends State { bool _showStickyHeader(CategoryController categories) { if (logic.hasSearchQuery || categories.error != null) return false; - return !(categories.loading && categories.feed.sections.isEmpty); + return !(categories.loading && categories.categories.isEmpty); } Widget _stickyHeader(AppLocalizations l10n, CategoryController categories) { @@ -244,7 +262,7 @@ class _DiscoverPageState extends State { ), ]; } - if (categories.loading && categories.feed.sections.isEmpty) { + if (categories.loading && categories.categories.isEmpty) { return [_block(const CircularProgressIndicator(strokeWidth: 2))]; } final error = categories.error; @@ -269,16 +287,35 @@ class _DiscoverPageState extends State { ), ]; } - return _theaterBlocks(l10n, categories); + if (theater.loadingFirstPage) { + return [_block(const CircularProgressIndicator(strokeWidth: 2))]; + } + final theaterError = theater.lastError; + if (theaterError != null && theater.dramas.isEmpty) { + return [ + _block( + EmptyState( + key: const Key('home-categories-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, theaterError, l10n.catalogError), + subtitle: l10n.emptyHint, + actionLabel: l10n.retry, + onAction: () => unawaited(_retryTheater()), + footer: kDebugMode + ? CatalogDebugInfo( + error: theaterError, + onEditEndpoint: () => unawaited(_editTheaterEndpoint()), + ) + : null, + ), + ), + ]; + } + return _theaterBlocks(l10n); } - List _theaterBlocks( - AppLocalizations l10n, - CategoryController categories, - ) { - final dramas = logic.selectedCategoryId == null - ? categories.feed.dramas - : categories.dramasOf(logic.selectedCategoryId!); + List _theaterBlocks(AppLocalizations l10n) { + final dramas = theater.dramas; return [ _toolbar(l10n), if (dramas.isEmpty) @@ -293,6 +330,17 @@ class _DiscoverPageState extends State { ) else DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama), + if (theater.loadingMore) + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center( + child: SizedBox.square( + key: Key('theater-load-more'), + dimension: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), ]; } @@ -386,6 +434,7 @@ class _DiscoverPageState extends State { void _selectCategory(String? categoryId) { logic.selectCategory(categoryId); + unawaited(theater.select(categoryId)); final id = categoryId ?? 'all'; _ensuredCategoryId = id; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -523,6 +572,23 @@ class _DiscoverPageState extends State { return retryCatalogLoad(reload: categories.load); } + Future _retryTheater() async { + await retryCatalogLoad( + reload: () async { + await theater.refreshCurrent(); + }, + ); + } + + Future _editTheaterEndpoint() { + return showCatalogEndpointEditor( + context, + reload: () async { + await theater.refreshCurrent(); + }, + ); + } + Future _editCategoriesEndpoint(CategoryController categories) { return showCatalogEndpointEditor(context, reload: categories.load); } diff --git a/test/catalog/catalog_controller_test.dart b/test/catalog/catalog_controller_test.dart index 9227dae..078bfd4 100644 --- a/test/catalog/catalog_controller_test.dart +++ b/test/catalog/catalog_controller_test.dart @@ -140,6 +140,7 @@ class ScriptedCatalogRepository implements CatalogRepository { @override Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, }) async => const FeedPage(); diff --git a/test/catalog/feed_page_test.dart b/test/catalog/feed_page_test.dart index a8a35b6..dc457c5 100644 --- a/test/catalog/feed_page_test.dart +++ b/test/catalog/feed_page_test.dart @@ -40,6 +40,7 @@ void main() { final page = await api.feed( types: const ['ANIME', 'LIVE'], + categoryId: '7', cursor: '9', size: 20, ); @@ -47,6 +48,7 @@ void main() { expect(adapter.requests.single.uri.path, '/app/feed'); expect(adapter.requests.single.queryParameters, { 'types': 'ANIME,LIVE', + 'categoryId': '7', 'cursor': '9', 'size': '20', }); diff --git a/test/core/data/services/home_feed_controller_test.dart b/test/core/data/services/home_feed_controller_test.dart index 87213c6..1a6cdd5 100644 --- a/test/core/data/services/home_feed_controller_test.dart +++ b/test/core/data/services/home_feed_controller_test.dart @@ -298,6 +298,7 @@ class _ScriptedFeedRepository implements CatalogRepository { @override Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, }) { diff --git a/test/core/data/services/theater_feed_controller_test.dart b/test/core/data/services/theater_feed_controller_test.dart new file mode 100644 index 0000000..38a1cfa --- /dev/null +++ b/test/core/data/services/theater_feed_controller_test.dart @@ -0,0 +1,180 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.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/services/theater_feed_controller.dart'; + +void main() { + test('should_request_all_and_selected_category_with_expected_ids', () async { + final repository = _ScriptedTheaterRepository() + ..enqueue(() async => FeedPage(dramas: [_drama('all')])) + ..enqueue(() async => FeedPage(dramas: [_drama('category')])); + final controller = TheaterFeedController(repository); + + await controller.select(null); + await controller.select('7'); + + expect(repository.calls.map((call) => call.categoryId), [null, '7']); + expect(controller.dramas.single.id, 'category'); + controller.dispose(); + }); + + test('should_keep_cached_category_visible_while_refreshing', () async { + final refresh = Completer(); + final repository = _ScriptedTheaterRepository() + ..enqueue(() async => FeedPage(dramas: [_drama('all-old')])) + ..enqueue(() async => FeedPage(dramas: [_drama('category')])) + ..enqueue(() => refresh.future); + final controller = TheaterFeedController(repository); + + await controller.select(null); + await controller.select('7'); + await controller.select(null); + + expect(controller.dramas.single.id, 'all-old'); + refresh.complete(FeedPage(dramas: [_drama('all-new')])); + await pumpEventQueue(); + + expect(controller.dramas.single.id, 'all-new'); + controller.dispose(); + }); + + test('should_append_unique_items_and_stop_when_has_more_is_false', () async { + final repository = _ScriptedTheaterRepository() + ..enqueue( + () async => FeedPage( + dramas: [_drama('a'), _drama('b')], + nextCursor: 'next', + hasMore: true, + ), + ) + ..enqueue( + () async => + FeedPage(dramas: [_drama('b'), _drama('c')], hasMore: false), + ); + final controller = TheaterFeedController(repository); + await controller.select('7'); + + await controller.loadMore(); + await controller.loadMore(); + + expect(controller.dramas.map((drama) => drama.id), ['a', 'b', 'c']); + expect(repository.calls, hasLength(2)); + expect(repository.calls.last.categoryId, '7'); + expect(repository.calls.last.cursor, 'next'); + controller.dispose(); + }); + + test('should_keep_old_data_when_refresh_fails', () async { + final repository = _ScriptedTheaterRepository() + ..enqueue(() async => FeedPage(dramas: [_drama('old')])) + ..enqueue(() async => throw StateError('offline')); + final controller = TheaterFeedController(repository); + await controller.select('7'); + + final refreshed = await controller.refreshCurrent(); + + expect(refreshed, isFalse); + expect(controller.dramas.single.id, 'old'); + expect(controller.lastError, contains('offline')); + controller.dispose(); + }); + + test( + 'should_retry_automatically_when_returning_to_failed_category', + () async { + final repository = _ScriptedTheaterRepository() + ..enqueue(() async => FeedPage(dramas: [_drama('all')])) + ..enqueue(() async => throw StateError('offline')) + ..enqueue(() async => FeedPage(dramas: [_drama('category-8')])) + ..enqueue(() async => FeedPage(dramas: [_drama('category-7-retry')])); + final controller = TheaterFeedController(repository); + + await controller.select(null); + await controller.select('7'); + expect(controller.lastError, contains('offline')); + await controller.select('8'); + await controller.select('7'); + + expect(controller.dramas.single.id, 'category-7-retry'); + expect(repository.calls, hasLength(4)); + controller.dispose(); + }, + ); + + test('should_finish_loading_with_error_when_request_times_out', () async { + final pending = Completer(); + final repository = _ScriptedTheaterRepository() + ..enqueue(() => pending.future); + final controller = TheaterFeedController( + repository, + requestTimeout: const Duration(milliseconds: 10), + ); + + await controller.select(null); + + expect(controller.loadingFirstPage, isFalse); + expect(controller.loaded, isTrue); + expect(controller.lastError, isNotNull); + controller.dispose(); + }); + + test('should_not_report_first_page_loading_without_request', () { + final controller = TheaterFeedController(_ScriptedTheaterRepository()); + + expect(controller.loaded, isFalse); + expect(controller.loadingFirstPage, isFalse); + controller.dispose(); + }); +} + +class TheaterFeedCall { + const TheaterFeedCall({ + required this.categoryId, + required this.cursor, + required this.size, + }); + + final String? categoryId; + final String? cursor; + final int size; +} + +class _ScriptedTheaterRepository implements CatalogRepository { + final List calls = []; + final List Function()> _responses = []; + + void enqueue(Future Function() response) { + _responses.add(response); + } + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) { + calls.add( + TheaterFeedCall(categoryId: categoryId, cursor: cursor, size: size), + ); + return _responses.removeAt(0)(); + } + + @override + Future fetchHomeFeed() async => const HomeFeed(); + + @override + Future fetchDiscoverFeed() async => const HomeFeed(); + + @override + Future fetchDrama(String dramaId) async => _drama(dramaId); +} + +Drama _drama(String id) => + Drama(id: id, title: id, synopsis: id, posterUrl: ''); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 38c4860..e29650f 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/content_category.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/category_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; @@ -72,11 +73,20 @@ void main() { testWidgets('should_filter_grid_when_category_chip_is_tapped', ( tester, ) async { - await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + final catalog = _CategoryTheaterCatalog(); + await _pumpTheater( + tester, + catalogRepository: catalog, + categoryRepository: _GridCategoryRepository(), + ); await tester.tap(find.byKey(const Key('theater-category-7'))); await tester.pumpAndSettle(); + expect( + catalog.requestedCategoryIds.where((categoryId) => categoryId == '7'), + hasLength(1), + ); expect( find.byKey(const Key('home-drama-card-city-lights-demo')), findsOneWidget, @@ -92,6 +102,9 @@ void main() { ) async { await _pumpTheater( tester, + catalogRepository: _SinglePageTheaterCatalog( + dramas: _tallDiscoverFeed.dramas, + ), categoryRepository: _GridCategoryRepository( discoverFeed: _tallDiscoverFeed, ), @@ -121,6 +134,7 @@ void main() { testWidgets('should_show_empty_and_retry_states', (tester) async { await _pumpTheater( tester, + catalogRepository: _SinglePageTheaterCatalog(dramas: const []), categoryRepository: _GridCategoryRepository( discoverFeed: const HomeFeed(), ), @@ -144,6 +158,18 @@ void main() { expect(find.textContaining('discover feed unavailable'), findsOneWidget); }); + testWidgets('should_show_retry_when_theater_feed_fails', (tester) async { + await _pumpTheater( + tester, + catalogRepository: _FailingTheaterCatalog(), + categoryRepository: _GridCategoryRepository(), + ); + + expect(find.byKey(const Key('home-categories-error')), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(find.textContaining('theater feed unavailable'), findsWidgets); + }); + testWidgets('should_show_search_results_when_text_changes', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); @@ -161,19 +187,63 @@ void main() { ); }); + testWidgets('should_load_next_theater_page_near_scroll_bottom', ( + tester, + ) async { + final catalog = _PagedTheaterCatalog([ + FeedPage(dramas: _pageDramas(0, 20), nextCursor: '20', hasMore: true), + FeedPage(dramas: _pageDramas(20, 20)), + ]); + await _pumpTheater( + tester, + catalogRepository: catalog, + categoryRepository: _GridCategoryRepository(), + ); + final position = tester + .state( + find + .descendant( + of: find.byKey(const Key('discover-refresh')), + matching: find.byType(Scrollable), + ) + .first, + ) + .position; + + position.jumpTo(position.maxScrollExtent); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(catalog.calls, 2); + expect(catalog.requestedCursors, [null, '20']); + expect(find.byKey(const Key('home-drama-card-page-39')), findsOneWidget); + }); + testWidgets('should_refresh_feed_when_pulled_down', (tester) async { final counter = _CountingCategoryRepository( _GridCategoryRepository(discoverFeed: _tallDiscoverFeed), ); - await _pumpTheater(tester, categoryRepository: counter); + final theaterCounter = _CountingTheaterCatalog( + dramas: _tallDiscoverFeed.dramas, + ); + await _pumpTheater( + tester, + catalogRepository: theaterCounter, + categoryRepository: counter, + ); - final before = counter.categoryLoads + counter.feedLoads; + final beforeCategories = counter.categoryLoads + counter.feedLoads; + final beforeTheater = theaterCounter.feedCalls; await tester.drag(_verticalFeed, const Offset(0, 320)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); await tester.pumpAndSettle(); - expect(counter.categoryLoads + counter.feedLoads, greaterThan(before)); + expect( + counter.categoryLoads + counter.feedLoads, + greaterThan(beforeCategories), + ); + expect(theaterCounter.feedCalls, beforeTheater + 1); expect(find.byType(RefreshProgressIndicator), findsNothing); }); @@ -205,6 +275,9 @@ void main() { testWidgets('should_pin_theater_filters_when_scrolling', (tester) async { await _pumpTheater( tester, + catalogRepository: _SinglePageTheaterCatalog( + dramas: _tallDiscoverFeed.dramas, + ), categoryRepository: _GridCategoryRepository( discoverFeed: _tallDiscoverFeed, ), @@ -243,6 +316,9 @@ void main() { testWidgets('should_keep_category_row_stable_across_scrolls', (tester) async { await _pumpTheater( tester, + catalogRepository: _SinglePageTheaterCatalog( + dramas: _tallDiscoverFeed.dramas, + ), categoryRepository: _GridCategoryRepository( discoverFeed: _tallDiscoverFeed, ), @@ -357,12 +433,13 @@ final Finder _verticalFeed = find.byWidgetPredicate( Future _pumpTheater( WidgetTester tester, { required CategoryRepository categoryRepository, + FakeCatalogRepository? catalogRepository, }) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final catalog = FakeCatalogRepository(); + final catalog = catalogRepository ?? FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, @@ -469,3 +546,169 @@ class _CountingCategoryRepository implements CategoryRepository { return delegate.fetchDiscoverFeed(); } } + +class _TheaterOnlyCatalog extends FakeCatalogRepository { + _TheaterOnlyCatalog({super.dramas}); + + final FakeCatalogRepository _homeDelegate = FakeCatalogRepository(); + + @override + Future fetchHomeFeed() => _homeDelegate.fetchHomeFeed(); + + @override + Future fetchDiscoverFeed() => _homeDelegate.fetchDiscoverFeed(); + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) { + return _homeDelegate.fetchFeed( + types: types, + categoryId: categoryId, + cursor: cursor, + size: size, + ); + } +} + +class _SinglePageTheaterCatalog extends _TheaterOnlyCatalog { + _SinglePageTheaterCatalog({required List dramas}) + : super(dramas: dramas); + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) async { + if (size != 20) { + return super.fetchFeed( + types: types, + categoryId: categoryId, + cursor: cursor, + size: size, + ); + } + return FeedPage(dramas: dramas.take(size).toList()); + } +} + +class _CategoryTheaterCatalog extends _TheaterOnlyCatalog { + final List requestedCategoryIds = []; + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) async { + if (size != 20) { + return super.fetchFeed( + types: types, + categoryId: categoryId, + cursor: cursor, + size: size, + ); + } + requestedCategoryIds.add(categoryId); + final rows = categoryId == '7' + ? dramas.where((drama) => drama.id == 'city-lights-demo').toList() + : dramas; + return FeedPage(dramas: rows.take(size).toList()); + } +} + +class _CountingTheaterCatalog extends _TheaterOnlyCatalog { + _CountingTheaterCatalog({required List dramas}) + : super(dramas: dramas); + + int feedCalls = 0; + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) { + if (size != 20) { + return super.fetchFeed( + types: types, + categoryId: categoryId, + cursor: cursor, + size: size, + ); + } + feedCalls++; + return super.fetchFeed( + types: types, + categoryId: categoryId, + cursor: cursor, + size: size, + ); + } +} + +class _PagedTheaterCatalog extends _TheaterOnlyCatalog { + _PagedTheaterCatalog(this.pages); + + final List pages; + final List requestedCursors = []; + + int get calls => requestedCursors.length; + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) async { + if (size != 20) { + return super.fetchFeed( + types: types, + categoryId: categoryId, + cursor: cursor, + size: size, + ); + } + requestedCursors.add(cursor); + return pages.removeAt(0); + } +} + +class _FailingTheaterCatalog extends _TheaterOnlyCatalog { + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) { + if (size == 20) { + return Future.error(StateError('theater feed unavailable')); + } + return super.fetchFeed( + types: types, + categoryId: categoryId, + cursor: cursor, + size: size, + ); + } +} + +List _pageDramas(int start, int count) => [ + for (var index = start; index < start + count; index++) + Drama( + id: 'page-$index', + title: 'Page $index', + synopsis: 'Page $index synopsis', + posterUrl: '', + genre: 'Drama', + ), +]; diff --git a/test/home/catalog_controller_refresh_test.dart b/test/home/catalog_controller_refresh_test.dart index fd8352e..be1e803 100644 --- a/test/home/catalog_controller_refresh_test.dart +++ b/test/home/catalog_controller_refresh_test.dart @@ -63,6 +63,7 @@ class _FakeCatalog implements CatalogRepository { @override Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, }) async => const FeedPage(); diff --git a/test/home/home_startup_splash_test.dart b/test/home/home_startup_splash_test.dart index 374e357..d5f4666 100644 --- a/test/home/home_startup_splash_test.dart +++ b/test/home/home_startup_splash_test.dart @@ -162,6 +162,7 @@ class _EmptyCatalogRepository implements CatalogRepository { @override Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, }) async => const FeedPage(); @@ -188,6 +189,7 @@ class _PendingCatalogRepository implements CatalogRepository { @override Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, }) async => FeedPage(dramas: _feed.dramas); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 5b04f60..0998a3e 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -518,6 +518,7 @@ class _PendingAnimeCatalog extends FakeCatalogRepository { @override Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, }) { @@ -525,7 +526,12 @@ class _PendingAnimeCatalog extends FakeCatalogRepository { animeCalls++; return anime.future; } - return super.fetchFeed(types: types, cursor: cursor, size: size); + return super.fetchFeed( + types: types, + categoryId: categoryId, + cursor: cursor, + size: size, + ); } } @@ -533,12 +539,18 @@ class _EmptyAnimeCatalog extends FakeCatalogRepository { @override Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, }) { if (types.contains('ANIME')) { return Future.value(const FeedPage(dramas: [])); } - return super.fetchFeed(types: types, cursor: cursor, size: size); + return super.fetchFeed( + types: types, + categoryId: categoryId, + cursor: cursor, + size: size, + ); } } diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 28d6156..ed400a2 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -22,6 +22,7 @@ class FakeCatalogRepository implements CatalogRepository, SearchRepository { @override Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, }) async { diff --git a/test/watch/watch_records_controller_test.dart b/test/watch/watch_records_controller_test.dart index 7266b92..105f17d 100644 --- a/test/watch/watch_records_controller_test.dart +++ b/test/watch/watch_records_controller_test.dart @@ -269,6 +269,7 @@ class ScriptedWatchCatalog implements CatalogRepository { @override Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, }) async => const FeedPage(); diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index 8e6410d..90bd1a0 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -275,9 +275,15 @@ class _FailingWatchCatalog implements CatalogRepository { @override Future fetchFeed({ List types = const [], + String? categoryId, String? cursor, int size = 10, - }) => _delegate.fetchFeed(types: types, cursor: cursor, size: size); + }) => _delegate.fetchFeed( + types: types, + categoryId: categoryId, + cursor: cursor, + size: size, + ); @override Future fetchDrama(String dramaId) async {