diff --git a/lib/features/discover_details/bloc/discover_details_bloc.dart b/lib/features/discover_details/bloc/discover_details_bloc.dart index 842e787..7f8135a 100644 --- a/lib/features/discover_details/bloc/discover_details_bloc.dart +++ b/lib/features/discover_details/bloc/discover_details_bloc.dart @@ -3,6 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_event.dart'; import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_state.dart'; +import 'package:calibre_web_companion/features/discover_details/data/models/category_feed_model.dart'; +import 'package:calibre_web_companion/features/discover_details/data/models/discover_feed_model.dart'; import 'package:calibre_web_companion/features/discover_details/data/repositories/discover_details_repository.dart'; class DiscoverDetailsBloc @@ -14,6 +16,8 @@ class DiscoverDetailsBloc on(_onLoadBooks); on(_onLoadCategories); on(_onLoadBooksFromPath); + on(_onLoadMoreDiscoverBooks); + on(_onLoadMoreDiscoverCategories); } Future _onLoadBooks( @@ -125,4 +129,76 @@ class DiscoverDetailsBloc ); } } + + Future _onLoadMoreDiscoverBooks( + LoadMoreDiscoverBooks event, + Emitter emit, + ) async { + final currentFeed = state.bookFeed; + final nextPageUrl = currentFeed?.nextPageUrl; + + if (state.isLoadingMore || currentFeed == null || nextPageUrl == null) { + return; + } + + emit(state.copyWith(isLoadingMore: true)); + + try { + final page = await repository.loadBooksFromPath(nextPageUrl); + + final existingIds = currentFeed.books.map((b) => b.id).toSet(); + final newBooks = page.books.where((b) => !existingIds.contains(b.id)); + + emit( + state.copyWith( + bookFeed: DiscoverFeedModel( + books: [...currentFeed.books, ...newBooks], + nextPageUrl: _nextOrStop(nextPageUrl, page.nextPageUrl), + ), + isLoadingMore: false, + ), + ); + } catch (e) { + emit(state.copyWith(isLoadingMore: false)); + } + } + + Future _onLoadMoreDiscoverCategories( + LoadMoreDiscoverCategories event, + Emitter emit, + ) async { + final currentFeed = state.categoryFeed; + final nextPageUrl = currentFeed?.nextPageUrl; + + if (state.isLoadingMore || currentFeed == null || nextPageUrl == null) { + return; + } + + emit(state.copyWith(isLoadingMore: true)); + + try { + final page = await repository.loadCategoriesFromPath(nextPageUrl); + + final existingIds = currentFeed.categories.map((c) => c.id).toSet(); + final newCategories = page.categories.where( + (c) => !existingIds.contains(c.id), + ); + + emit( + state.copyWith( + categoryFeed: CategoryFeed( + categories: [...currentFeed.categories, ...newCategories], + nextPageUrl: _nextOrStop(nextPageUrl, page.nextPageUrl), + ), + isLoadingMore: false, + ), + ); + } catch (e) { + emit(state.copyWith(isLoadingMore: false)); + } + } + + /// Guards against servers that keep pointing at the page we just loaded + String? _nextOrStop(String loadedUrl, String? nextUrl) => + nextUrl == loadedUrl ? null : nextUrl; } diff --git a/lib/features/discover_details/bloc/discover_details_event.dart b/lib/features/discover_details/bloc/discover_details_event.dart index dfae308..e9d1662 100644 --- a/lib/features/discover_details/bloc/discover_details_event.dart +++ b/lib/features/discover_details/bloc/discover_details_event.dart @@ -40,6 +40,14 @@ class LoadBooksFromPath extends DiscoverDetailsEvent { List get props => [fullPath]; } +class LoadMoreDiscoverBooks extends DiscoverDetailsEvent { + const LoadMoreDiscoverBooks(); +} + +class LoadMoreDiscoverCategories extends DiscoverDetailsEvent { + const LoadMoreDiscoverCategories(); +} + class NavigateToBook extends DiscoverDetailsEvent { final DiscoverDetailsModel book; diff --git a/lib/features/discover_details/bloc/discover_details_state.dart b/lib/features/discover_details/bloc/discover_details_state.dart index 902a917..07a0993 100644 --- a/lib/features/discover_details/bloc/discover_details_state.dart +++ b/lib/features/discover_details/bloc/discover_details_state.dart @@ -16,6 +16,7 @@ class DiscoverDetailsState extends Equatable { final BookViewModel? bookDetails; final String? loadingBookId; final bool isNotFound; + final bool isLoadingMore; const DiscoverDetailsState({ this.status = DiscoverDetailsStatus.initial, @@ -27,8 +28,12 @@ class DiscoverDetailsState extends Equatable { this.bookDetails, this.loadingBookId, this.isNotFound = false, + this.isLoadingMore = false, }); + bool get hasMoreBooks => bookFeed?.nextPageUrl != null; + bool get hasMoreCategories => categoryFeed?.nextPageUrl != null; + DiscoverDetailsState copyWith({ DiscoverDetailsStatus? status, DiscoverFeedModel? bookFeed, @@ -39,6 +44,7 @@ class DiscoverDetailsState extends Equatable { BookViewModel? bookDetails, String? loadingBookId, bool? isNotFound, + bool? isLoadingMore, }) { return DiscoverDetailsState( status: status ?? this.status, @@ -50,6 +56,7 @@ class DiscoverDetailsState extends Equatable { bookDetails: bookDetails ?? this.bookDetails, loadingBookId: loadingBookId ?? this.loadingBookId, isNotFound: isNotFound ?? this.isNotFound, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, ); } @@ -64,5 +71,6 @@ class DiscoverDetailsState extends Equatable { bookDetails, loadingBookId, isNotFound, + isLoadingMore, ]; } diff --git a/lib/features/discover_details/data/datasources/discover_details_remote_datasource.dart b/lib/features/discover_details/data/datasources/discover_details_remote_datasource.dart index 022e19d..83933c9 100644 --- a/lib/features/discover_details/data/datasources/discover_details_remote_datasource.dart +++ b/lib/features/discover_details/data/datasources/discover_details_remote_datasource.dart @@ -50,7 +50,7 @@ class DiscoverDetailsRemoteDatasource { return DiscoverFeedModel( books: books, - nextPageUrl: jsonData['nextPageUrl'], + nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']), ); } catch (e) { logger.e('Error loading books: $e'); @@ -116,7 +116,7 @@ class DiscoverDetailsRemoteDatasource { return CategoryFeed( categories: categories, - nextPageUrl: jsonData['nextPageUrl'], + nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']), ); } catch (e) { throw Exception('Failed to load categories: $e'); @@ -126,15 +126,7 @@ class DiscoverDetailsRemoteDatasource { Future loadBooksFromPath(String fullPath) async { logger.d('Loading books from path: $fullPath'); try { - String endpoint = fullPath; - final baseUrl = apiService.getBaseUrl(); - - if (endpoint.startsWith(baseUrl)) { - endpoint = endpoint.substring(baseUrl.length); - } else if (baseUrl.endsWith('/api/v1/opds') && - endpoint.startsWith('/api/v1/opds')) { - endpoint = endpoint.replaceFirst('/api/v1/opds', ''); - } + final endpoint = _toEndpoint(fullPath); final jsonData = await apiService.getXmlAsJson( endpoint: endpoint, @@ -159,13 +151,9 @@ class DiscoverDetailsRemoteDatasource { ) .toList(); - for (final book in books) { - logger.d(book.coverUrl); - } - return DiscoverFeedModel( books: books, - nextPageUrl: jsonData['nextPageUrl'], + nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']), ); } catch (e) { logger.e('Error loading books from path: $e'); @@ -173,6 +161,20 @@ class DiscoverDetailsRemoteDatasource { } } + Future loadCategoriesFromPath(String fullPath) async { + try { + final jsonData = await apiService.getXmlAsJson( + endpoint: _toEndpoint(fullPath), + authMethod: AuthMethod.auto, + ); + + return _parseCategoryFeed(jsonData); + } catch (e) { + logger.e('Error loading categories from path: $e'); + throw Exception('Failed to load categories from path: $e'); + } + } + Future loadCategoriesgeneric(String path) async { try { final jsonData = await apiService.getXmlAsJson( @@ -202,7 +204,38 @@ class DiscoverDetailsRemoteDatasource { categories.sort((a, b) => a.title.compareTo(b.title)); - return CategoryFeed(categories: categories); + return CategoryFeed( + categories: categories, + nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']), + ); + } + + String _toEndpoint(String fullPath) { + String endpoint = fullPath; + final baseUrl = apiService.getBaseUrl(); + + if (endpoint.startsWith(baseUrl)) { + endpoint = endpoint.substring(baseUrl.length); + } else if (baseUrl.endsWith('/api/v1/opds') && + endpoint.startsWith('/api/v1/opds')) { + endpoint = endpoint.replaceFirst('/api/v1/opds', ''); + } + + return endpoint; + } + + String? _parseNextPageUrl(dynamic links) { + if (links == null) return null; + final linkList = links is List ? links : [links]; + + for (final link in linkList) { + if (link is! Map) continue; + final rel = (link['_rel'] ?? link['rel'])?.toString(); + if (rel != 'next') continue; + final href = (link['_href'] ?? link['href'])?.toString(); + if (href != null && href.isNotEmpty) return href; + } + return null; } String _getBookListPath(DiscoverType type, String? subPath) { diff --git a/lib/features/discover_details/data/repositories/discover_details_repository.dart b/lib/features/discover_details/data/repositories/discover_details_repository.dart index ed79dc3..d062d4a 100644 --- a/lib/features/discover_details/data/repositories/discover_details_repository.dart +++ b/lib/features/discover_details/data/repositories/discover_details_repository.dart @@ -44,4 +44,13 @@ class DiscoverDetailsRepository { rethrow; } } + + Future loadCategoriesFromPath(String fullPath) async { + try { + final categories = await dataSource.loadCategoriesFromPath(fullPath); + return categories; + } catch (e) { + rethrow; + } + } } diff --git a/lib/features/discover_details/presentation/pages/discover_details_page.dart b/lib/features/discover_details/presentation/pages/discover_details_page.dart index b8c52e3..87abde3 100644 --- a/lib/features/discover_details/presentation/pages/discover_details_page.dart +++ b/lib/features/discover_details/presentation/pages/discover_details_page.dart @@ -162,13 +162,18 @@ class DiscoverDetailsPage extends StatelessWidget { if (state.isShowingBooks && state.bookFeed != null && state.bookFeed!.books.isNotEmpty) { - return _buildBookGrid(context, state.bookFeed!); + return _buildBookGrid(context, state, state.bookFeed!, localizations); } if (state.isShowingCategories && state.categoryFeed != null && state.categoryFeed!.categories.isNotEmpty) { - return _buildCategoryList(context, state.categoryFeed!); + return _buildCategoryList( + context, + state, + state.categoryFeed!, + localizations, + ); } return _buildEmptyState(context, localizations); @@ -221,52 +226,127 @@ class DiscoverDetailsPage extends StatelessWidget { bool get _isSeriesView => fullPath != null && fullPath!.contains('/series/'); - Widget _buildBookGrid(BuildContext context, DiscoverFeedModel feed) { - return BlocBuilder( - builder: (context, state) { - final viewState = context.watch().state; - final seriesView = _isSeriesView; + Widget _buildBookGrid( + BuildContext context, + DiscoverDetailsState state, + DiscoverFeedModel feed, + AppLocalizations localizations, + ) { + final viewState = context.watch().state; + final seriesView = _isSeriesView; - if (viewState.isListView) { - return ListView.builder( - padding: const EdgeInsets.all(16.0), - itemCount: feed.books.length, - itemBuilder: - (context, index) => _buildBookListTile( - context, - feed.books[index], - state, - seriesNumber: seriesView ? index + 1 : null, - ), - ); - } - - return GridView.builder( - padding: const EdgeInsets.all(16.0), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: viewState.columnCount, - childAspectRatio: viewState.columnCount <= 2 ? 0.7 : 0.9, - crossAxisSpacing: 16.0, - mainAxisSpacing: 16.0, + return _buildPaginatedScrollView( + context: context, + state: state, + localizations: localizations, + hasMore: state.hasMoreBooks, + onLoadMore: + () => context.read().add( + const LoadMoreDiscoverBooks(), ), - itemCount: feed.books.length, - itemBuilder: (context, index) { - final book = feed.books[index]; - return BookCard( - bookId: book.id, - coverUrl: book.coverUrl, - title: book.title, - authors: book.authors, - isLoading: state.loadingBookId == book.id, - onTap: () => _openBook(context, book), - topLeftBadge: seriesView ? '${index + 1}' : null, - ); - }, - ); - }, + sliver: SliverPadding( + padding: const EdgeInsets.all(16.0), + sliver: + viewState.isListView + ? SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildBookListTile( + context, + feed.books[index], + state, + seriesNumber: seriesView ? index + 1 : null, + ), + childCount: feed.books.length, + ), + ) + : SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: viewState.columnCount, + childAspectRatio: viewState.columnCount <= 2 ? 0.7 : 0.9, + crossAxisSpacing: 16.0, + mainAxisSpacing: 16.0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final book = feed.books[index]; + return BookCard( + bookId: book.id, + coverUrl: book.coverUrl, + title: book.title, + authors: book.authors, + isLoading: state.loadingBookId == book.id, + onTap: () => _openBook(context, book), + topLeftBadge: seriesView ? '${index + 1}' : null, + ); + }, childCount: feed.books.length), + ), + ), ); } + /// Scrollable feed that pulls the next page in as the end comes into reach + Widget _buildPaginatedScrollView({ + required BuildContext context, + required DiscoverDetailsState state, + required AppLocalizations localizations, + required bool hasMore, + required VoidCallback onLoadMore, + required Widget sliver, + }) { + return NotificationListener( + onNotification: (notification) { + if (hasMore && + !state.isLoadingMore && + notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 600) { + onLoadMore(); + } + return false; + }, + child: CustomScrollView( + slivers: [ + sliver, + SliverToBoxAdapter( + child: _buildPaginationFooter( + context, + state, + localizations, + hasMore: hasMore, + onLoadMore: onLoadMore, + ), + ), + ], + ), + ); + } + + Widget _buildPaginationFooter( + BuildContext context, + DiscoverDetailsState state, + AppLocalizations localizations, { + required bool hasMore, + required VoidCallback onLoadMore, + }) { + if (state.isLoadingMore) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ); + } + if (hasMore) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Center( + child: OutlinedButton.icon( + onPressed: onLoadMore, + icon: const Icon(Icons.expand_more_rounded), + label: Text(localizations.loadMore), + ), + ), + ); + } + return const SizedBox(height: 16); + } + Widget _buildBookListTile( BuildContext context, DiscoverDetailsModel book, @@ -396,17 +476,31 @@ class DiscoverDetailsPage extends StatelessWidget { ); } - Widget _buildCategoryList(BuildContext context, CategoryFeed feed) { - return ListView.builder( - itemCount: feed.categories.length, - itemBuilder: (context, index) { - final category = feed.categories[index]; - return CategoryListItem( - category: category, - type: categoryType ?? CategoryType.category, - onTap: () => _navigateToCategoryOrBooks(context, category), - ); - }, + Widget _buildCategoryList( + BuildContext context, + DiscoverDetailsState state, + CategoryFeed feed, + AppLocalizations localizations, + ) { + return _buildPaginatedScrollView( + context: context, + state: state, + localizations: localizations, + hasMore: state.hasMoreCategories, + onLoadMore: + () => context.read().add( + const LoadMoreDiscoverCategories(), + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final category = feed.categories[index]; + return CategoryListItem( + category: category, + type: categoryType ?? CategoryType.category, + onTap: () => _navigateToCategoryOrBooks(context, category), + ); + }, childCount: feed.categories.length), + ), ); } diff --git a/test/features/discover_details/discover_details_integration_test.dart b/test/features/discover_details/discover_details_integration_test.dart index 8e28459..9efea3c 100644 --- a/test/features/discover_details/discover_details_integration_test.dart +++ b/test/features/discover_details/discover_details_integration_test.dart @@ -98,4 +98,32 @@ void main() { expect(feed, isNotNull); expect(feed.categories, isA()); }); + + test('loadBooks() exposes the next link and that page loads', () async { + await setUpDataSource(); + + final first = await dataSource.loadBooks(DiscoverType.newlyAdded); + if (first.nextPageUrl == null) { + return; + } + + final second = await dataSource.loadBooksFromPath(first.nextPageUrl!); + final firstIds = first.books.map((b) => b.id).toSet(); + + expect(second.books, isNotEmpty); + expect(second.books.any((b) => !firstIds.contains(b.id)), isTrue); + }); + + test('loadCategoriesFromPath() follows a category next link', () async { + await setUpDataSource(); + + final first = await dataSource.loadCategories(CategoryType.author); + if (first.nextPageUrl == null) { + return; + } + + final second = await dataSource.loadCategoriesFromPath(first.nextPageUrl!); + + expect(second.categories, isNotEmpty); + }); } diff --git a/test/features/discover_details/discover_details_pagination_test.dart b/test/features/discover_details/discover_details_pagination_test.dart new file mode 100644 index 0000000..7e44b43 --- /dev/null +++ b/test/features/discover_details/discover_details_pagination_test.dart @@ -0,0 +1,215 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:calibre_web_companion/features/discover/blocs/discover_event.dart'; +import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_bloc.dart'; +import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_event.dart'; +import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_state.dart'; +import 'package:calibre_web_companion/features/discover_details/data/datasources/discover_details_remote_datasource.dart'; +import 'package:calibre_web_companion/features/discover_details/data/models/category_feed_model.dart'; +import 'package:calibre_web_companion/features/discover_details/data/models/category_model.dart'; +import 'package:calibre_web_companion/features/discover_details/data/models/discover_details_model.dart'; +import 'package:calibre_web_companion/features/discover_details/data/models/discover_feed_model.dart'; +import 'package:calibre_web_companion/features/discover_details/data/repositories/discover_details_repository.dart'; + +class _FakeRepository implements DiscoverDetailsRepository { + final Map bookPages; + final Map categoryPages; + final List requestedPaths = []; + + _FakeRepository({ + this.bookPages = const {}, + this.categoryPages = const {}, + }); + + @override + DiscoverDetailsRemoteDatasource get dataSource => throw UnimplementedError(); + + @override + Future loadBooksFromPath(String fullPath) async { + requestedPaths.add(fullPath); + final page = bookPages[fullPath]; + if (page == null) throw Exception('404 for $fullPath'); + return page; + } + + @override + Future loadCategoriesFromPath(String fullPath) async { + requestedPaths.add(fullPath); + final page = categoryPages[fullPath]; + if (page == null) throw Exception('404 for $fullPath'); + return page; + } + + @override + Future loadBooks( + DiscoverType type, { + String? subPath, + }) async => throw UnimplementedError(); + + @override + Future loadCategories( + CategoryType type, { + String? subPath, + }) async => categoryPages['first'] ?? const CategoryFeed(categories: []); +} + +DiscoverDetailsModel book(String id) => + DiscoverDetailsModel(id: id, uuid: 'uuid-$id', title: id, authors: 'A'); + +Future loaded(DiscoverDetailsBloc bloc) => + bloc.stream.firstWhere((s) => s.status == DiscoverDetailsStatus.loaded); + +Future settled(DiscoverDetailsBloc bloc) => + bloc.stream.firstWhere((s) => !s.isLoadingMore); + +void main() { + test('load more appends the next page and carries its next link', () async { + final repository = _FakeRepository( + bookPages: { + '/opds/series/1': DiscoverFeedModel( + books: [book('1'), book('2')], + nextPageUrl: '/opds/series/1?offset=2', + ), + '/opds/series/1?offset=2': DiscoverFeedModel( + books: [book('3')], + nextPageUrl: '/opds/series/1?offset=4', + ), + }, + ); + final bloc = DiscoverDetailsBloc(repository: repository); + + bloc.add(const LoadBooksFromPath('/opds/series/1')); + final first = await loaded(bloc); + expect(first.bookFeed!.books, hasLength(2)); + expect(first.hasMoreBooks, isTrue); + + bloc.add(const LoadMoreDiscoverBooks()); + final second = await settled(bloc); + + expect(second.bookFeed!.books.map((b) => b.id), ['1', '2', '3']); + expect(second.bookFeed!.nextPageUrl, '/opds/series/1?offset=4'); + expect(second.status, DiscoverDetailsStatus.loaded); + + await bloc.close(); + }); + + test('load more drops books that are already in the feed', () async { + final repository = _FakeRepository( + bookPages: { + '/opds/new': DiscoverFeedModel( + books: [book('1'), book('2')], + nextPageUrl: '/opds/new?offset=2', + ), + '/opds/new?offset=2': DiscoverFeedModel(books: [book('2'), book('3')]), + }, + ); + final bloc = DiscoverDetailsBloc(repository: repository); + + bloc.add(const LoadBooksFromPath('/opds/new')); + await loaded(bloc); + + bloc.add(const LoadMoreDiscoverBooks()); + final state = await settled(bloc); + + expect(state.bookFeed!.books.map((b) => b.id), ['1', '2', '3']); + expect(state.hasMoreBooks, isFalse); + + await bloc.close(); + }); + + test('a next link pointing at the page just loaded ends pagination', () async { + final repository = _FakeRepository( + bookPages: { + '/opds/new': DiscoverFeedModel( + books: [book('1')], + nextPageUrl: '/opds/new?offset=1', + ), + '/opds/new?offset=1': DiscoverFeedModel( + books: [book('2')], + nextPageUrl: '/opds/new?offset=1', + ), + }, + ); + final bloc = DiscoverDetailsBloc(repository: repository); + + bloc.add(const LoadBooksFromPath('/opds/new')); + await loaded(bloc); + + bloc.add(const LoadMoreDiscoverBooks()); + final state = await settled(bloc); + + expect(state.bookFeed!.books, hasLength(2)); + expect(state.hasMoreBooks, isFalse); + + await bloc.close(); + }); + + test('load more without a next link does not hit the repository', () async { + final repository = _FakeRepository( + bookPages: {'/opds/new': DiscoverFeedModel(books: [book('1')])}, + ); + final bloc = DiscoverDetailsBloc(repository: repository); + + bloc.add(const LoadBooksFromPath('/opds/new')); + await loaded(bloc); + + bloc.add(const LoadMoreDiscoverBooks()); + await Future.delayed(Duration.zero); + + expect(repository.requestedPaths, ['/opds/new']); + + await bloc.close(); + }); + + test('a failing next page keeps the books already shown', () async { + final repository = _FakeRepository( + bookPages: { + '/opds/new': DiscoverFeedModel( + books: [book('1')], + nextPageUrl: '/opds/new?offset=1', + ), + }, + ); + final bloc = DiscoverDetailsBloc(repository: repository); + + bloc.add(const LoadBooksFromPath('/opds/new')); + await loaded(bloc); + + bloc.add(const LoadMoreDiscoverBooks()); + final state = await settled(bloc); + + expect(state.bookFeed!.books, hasLength(1)); + expect(state.status, DiscoverDetailsStatus.loaded); + + await bloc.close(); + }); + + test('categories paginate the same way as books', () async { + final repository = _FakeRepository( + categoryPages: { + 'first': const CategoryFeed( + categories: [CategoryModel(id: '/opds/series/1', title: 'A Series')], + nextPageUrl: '/opds/series/letter/A?offset=1', + ), + '/opds/series/letter/A?offset=1': const CategoryFeed( + categories: [CategoryModel(id: '/opds/series/2', title: 'B Series')], + ), + }, + ); + final bloc = DiscoverDetailsBloc(repository: repository); + + bloc.add(const LoadCategories(CategoryType.series, subPath: 'letter/A')); + await loaded(bloc); + + bloc.add(const LoadMoreDiscoverCategories()); + final state = await settled(bloc); + + expect(state.categoryFeed!.categories.map((c) => c.title), [ + 'A Series', + 'B Series', + ]); + expect(state.hasMoreCategories, isFalse); + + await bloc.close(); + }); +}