Explore shelf cards drew a poster, a title and a year. An audit of all six catalog sources found the rest was lost at two boundaries — the wire-to-DTO mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the grid card fell through every branch of buildMetadataSubtitle to the year-only case, while the list card used by search already composed certification, runtime and rating from fields the synthesized MediaItem already held. Extend CatalogItem with the neutral facts every provider had been dropping: attributed rating sources, leaderboard ranks that keep their season window, audience counters that keep their timeframe, broadcast slots, next-episode air times, server availability and request state, exact release dates, alternate titles, format, source material, studios, countries, languages, credits, tags, links, artwork variants, play state, gallery art and background prose. Replace fetchCast and fetchRelated with one fetchDetail returning the enriched item, its cast, its recommendations and labelled franchise relations without adding a request: sources needing two calls keep two and run them concurrently with isolated failures. Map those fields in all six sources, widening only field selections that cost no extra round trip — MAL's fields list, AniList's selection set and a bounded row cast that lets detail skip its character call, Trakt's guest stars, Seerr's language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub artwork widens only on TV, where the spotlight is its only consumer, because it doubles the payload. Render them: a rating-first caption and bounded badges on the shelf card, labelled sections on the detail screen, provider hub styles and result counts on shelves, and logo, banner and accent art in the TV spotlight. Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
312 lines
10 KiB
Dart
312 lines
10 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/i18n/strings.g.dart';
|
|
import 'package:plezy/media/media_kind.dart';
|
|
import 'package:plezy/models/catalog/catalog_item.dart';
|
|
import 'package:plezy/providers/catalog_sources_provider.dart';
|
|
import 'package:plezy/providers/explore_provider.dart';
|
|
import 'package:plezy/services/catalog/catalog_source.dart';
|
|
import 'package:plezy/utils/external_ids.dart';
|
|
|
|
/// Minimal controllable source: rows resolve immediately unless [gate] is
|
|
/// set, in which case fetches park on completers the test releases.
|
|
class _FakeSource implements CatalogSource {
|
|
_FakeSource(this.id, {this.rows = const [CatalogRowId.watchlist, CatalogRowId.trendingMovies]});
|
|
|
|
@override
|
|
final CatalogSourceId id;
|
|
final List<CatalogRowId> rows;
|
|
final watchlist = WatchlistChangeNotifier();
|
|
|
|
bool gate = false;
|
|
final pending = <Completer<CatalogPage>>[];
|
|
final fetches = <CatalogRowId, int>{};
|
|
final failedRows = <CatalogRowId>{};
|
|
|
|
CatalogPage _page(CatalogRowId row) => CatalogPage(
|
|
items: [
|
|
CatalogItem(
|
|
source: id,
|
|
kind: MediaKind.movie,
|
|
title: '${id.name}:${row.name}',
|
|
ids: const CatalogItemIds(tmdb: 1),
|
|
),
|
|
],
|
|
);
|
|
|
|
@override
|
|
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) {
|
|
fetches[row] = (fetches[row] ?? 0) + 1;
|
|
if (failedRows.contains(row)) throw StateError('${row.name} failed');
|
|
if (gate) {
|
|
final completer = Completer<CatalogPage>();
|
|
pending.add(completer);
|
|
return completer.future;
|
|
}
|
|
return Future.value(_page(row));
|
|
}
|
|
|
|
void releaseAll() {
|
|
for (final completer in pending) {
|
|
completer.complete(const CatalogPage(items: []));
|
|
}
|
|
pending.clear();
|
|
}
|
|
|
|
@override
|
|
String get displayName => id.name;
|
|
@override
|
|
List<CatalogRowId> get supportedRows => rows;
|
|
@override
|
|
bool get supportsWatchlist => true;
|
|
@override
|
|
Listenable get watchlistChanges => watchlist;
|
|
@override
|
|
Future<List<CatalogItem>> search(String query, {int limit = 30}) async => const [];
|
|
@override
|
|
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async =>
|
|
CatalogDetail(item: item);
|
|
@override
|
|
Future<void> ensureWatchlistLoaded() async {}
|
|
@override
|
|
bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => null;
|
|
@override
|
|
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async => null;
|
|
@override
|
|
Future<void> addToWatchlist(MediaKind kind, CatalogItemIds ids) async {}
|
|
@override
|
|
Future<void> removeFromWatchlist(MediaKind kind, CatalogItemIds ids) async {}
|
|
@override
|
|
void dispose() => watchlist.dispose();
|
|
}
|
|
|
|
class _FakeHubSource extends _FakeSource implements CatalogHubSource {
|
|
_FakeHubSource(super.id) : super(rows: const [CatalogRowId.watchlist]);
|
|
|
|
final hubPageFetches = <int>[];
|
|
int hubFetches = 0;
|
|
int hubFailuresRemaining = 0;
|
|
bool returnEmptyHubs = false;
|
|
CatalogHubStyle? hubStyle;
|
|
int? hubTotalResults;
|
|
|
|
CatalogItem _hubItem(String title) => CatalogItem(
|
|
source: id,
|
|
kind: MediaKind.movie,
|
|
title: title,
|
|
ids: const CatalogItemIds(plex: 'plex-hub-item'),
|
|
);
|
|
|
|
@override
|
|
Future<List<CatalogHub>> fetchHubs({int limit = 25}) async {
|
|
hubFetches++;
|
|
if (hubFailuresRemaining > 0) {
|
|
hubFailuresRemaining--;
|
|
throw StateError('hub fetch failed');
|
|
}
|
|
if (returnEmptyHubs) return const [];
|
|
return [
|
|
CatalogHub(
|
|
id: 'trending-plex',
|
|
title: 'Trending on Plex',
|
|
style: hubStyle,
|
|
page: CatalogPage(items: [_hubItem('Initial Recommendation')], hasMore: true, totalResults: hubTotalResults),
|
|
),
|
|
];
|
|
}
|
|
|
|
@override
|
|
Future<CatalogPage> fetchHub(String id, {int page = 1, int limit = 25}) async {
|
|
hubPageFetches.add(page);
|
|
return CatalogPage(items: [_hubItem('Recommendation Page $page')], hasMore: page == 1);
|
|
}
|
|
}
|
|
|
|
/// Drives [activeSource] directly; the real provider derives it from the
|
|
/// account providers, which is irrelevant to ExploreProvider's contract.
|
|
class _FakeSourcesProvider extends CatalogSourcesProvider {
|
|
CatalogSource? _current;
|
|
|
|
void setActive(CatalogSource? source) {
|
|
_current = source;
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
CatalogSource? get activeSource => _current;
|
|
}
|
|
|
|
Future<void> _pumpMicrotasks() async {
|
|
for (var i = 0; i < 5; i++) {
|
|
await Future<void>.delayed(Duration.zero);
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
group('ExploreProvider', () {
|
|
late _FakeSourcesProvider sources;
|
|
late ExploreProvider explore;
|
|
|
|
setUp(() {
|
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
|
sources = _FakeSourcesProvider();
|
|
explore = ExploreProvider(sources);
|
|
addTearDown(() {
|
|
explore.dispose();
|
|
sources.dispose();
|
|
});
|
|
});
|
|
|
|
test('source switch during an in-flight load starts the new load instead of coalescing', () async {
|
|
final slow = _FakeSource(CatalogSourceId.trakt)..gate = true;
|
|
final fast = _FakeSource(CatalogSourceId.mal, rows: const [CatalogRowId.popularAnime]);
|
|
addTearDown(() {
|
|
slow.dispose();
|
|
fast.dispose();
|
|
});
|
|
|
|
sources.setActive(slow);
|
|
await _pumpMicrotasks();
|
|
expect(explore.isLoading, isTrue);
|
|
expect(slow.pending, isNotEmpty);
|
|
|
|
// Switch while the old source's rows are still parked.
|
|
sources.setActive(fast);
|
|
await _pumpMicrotasks();
|
|
|
|
expect(explore.state, ExploreLoadState.loaded);
|
|
expect(explore.rowHubs.single.row, CatalogRowId.popularAnime);
|
|
expect(explore.rowHubs.single.hub.items.single.title, 'mal:popularAnime');
|
|
|
|
// The stale pass completing must not clobber the new source's state.
|
|
slow.releaseAll();
|
|
await _pumpMicrotasks();
|
|
expect(explore.state, ExploreLoadState.loaded);
|
|
expect(explore.rowHubs.single.row, CatalogRowId.popularAnime);
|
|
});
|
|
|
|
test('provider-defined hubs retain their titles and page through View All', () async {
|
|
final source = _FakeHubSource(CatalogSourceId.plex);
|
|
addTearDown(source.dispose);
|
|
|
|
sources.setActive(source);
|
|
await _pumpMicrotasks();
|
|
|
|
expect(explore.state, ExploreLoadState.loaded);
|
|
expect(explore.rowHubs, hasLength(2));
|
|
final providerHub = explore.rowHubs.last;
|
|
expect(providerHub.row, isNull);
|
|
expect(providerHub.providerHubId, 'trending-plex');
|
|
expect(providerHub.hub.title, 'Trending on Plex');
|
|
expect(providerHub.hub.items.single.title, 'Initial Recommendation');
|
|
expect(providerHub.hub.more, isTrue);
|
|
|
|
final allItems = await explore.loadAllForHub(providerHub);
|
|
|
|
expect(source.hubPageFetches, [1, 2]);
|
|
expect(allItems.map((item) => item.title), ['Recommendation Page 1', 'Recommendation Page 2']);
|
|
});
|
|
|
|
test('null and shelf styles retain the existing shelf appearance', () async {
|
|
final source = _FakeHubSource(CatalogSourceId.plex);
|
|
addTearDown(source.dispose);
|
|
sources.setActive(source);
|
|
await _pumpMicrotasks();
|
|
|
|
final unstyled = explore.rowHubs.last;
|
|
expect(unstyled.style, isNull);
|
|
|
|
source.hubStyle = CatalogHubStyle.shelf;
|
|
await explore.load();
|
|
final shelf = explore.rowHubs.last;
|
|
|
|
expect(shelf.style, CatalogHubStyle.shelf);
|
|
expect(shelf.hub.title, unstyled.hub.title);
|
|
expect(shelf.hub.type, unstyled.hub.type);
|
|
expect(shelf.hub.items.map((item) => item.title), unstyled.hub.items.map((item) => item.title));
|
|
expect(shelf.hub.more, unstyled.hub.more);
|
|
});
|
|
|
|
test('availability platform hubs are skipped instead of rendered as title posters', () async {
|
|
final source = _FakeHubSource(CatalogSourceId.plex)..hubStyle = CatalogHubStyle.availabilityPlatforms;
|
|
addTearDown(source.dispose);
|
|
sources.setActive(source);
|
|
await _pumpMicrotasks();
|
|
|
|
expect(explore.rowHubs, hasLength(1));
|
|
expect(explore.rowHubs.single.row, CatalogRowId.watchlist);
|
|
expect(explore.rowHubs.single.hub.items.single.title, 'plex:watchlist');
|
|
expect(explore.rowHubs.where((hub) => hub.providerHubId != null), isEmpty);
|
|
});
|
|
|
|
test('provider totalResults reaches the rendered hub without replacing its loaded items', () async {
|
|
final source = _FakeHubSource(CatalogSourceId.plex)..hubTotalResults = 347;
|
|
addTearDown(source.dispose);
|
|
sources.setActive(source);
|
|
await _pumpMicrotasks();
|
|
|
|
final providerHub = explore.rowHubs.last;
|
|
expect(providerHub.totalResults, 347);
|
|
expect(providerHub.hub.size, 347);
|
|
expect(providerHub.hub.items, hasLength(1));
|
|
});
|
|
|
|
test('mutation during the initial load is caught up by ensureFresh', () async {
|
|
final source = _FakeSource(CatalogSourceId.trakt)..gate = true;
|
|
addTearDown(source.dispose);
|
|
|
|
sources.setActive(source);
|
|
await _pumpMicrotasks();
|
|
expect(source.pending, hasLength(2));
|
|
|
|
// A watchlist mutation lands while the full load is still in flight:
|
|
// the pages about to land were fetched pre-mutation.
|
|
source.watchlist.notify();
|
|
source.gate = false;
|
|
source.releaseAll();
|
|
await _pumpMicrotasks();
|
|
expect(explore.state, ExploreLoadState.loaded);
|
|
|
|
final refetchesBefore = source.fetches[CatalogRowId.watchlist] ?? 0;
|
|
explore.ensureFresh();
|
|
await _pumpMicrotasks();
|
|
expect(source.fetches[CatalogRowId.watchlist], refetchesBefore + 1);
|
|
});
|
|
|
|
test('empty successful hubs do not mask every fixed row failing', () async {
|
|
final source = _FakeHubSource(CatalogSourceId.plex)
|
|
..failedRows.add(CatalogRowId.watchlist)
|
|
..returnEmptyHubs = true;
|
|
addTearDown(source.dispose);
|
|
|
|
sources.setActive(source);
|
|
await _pumpMicrotasks();
|
|
|
|
expect(explore.state, ExploreLoadState.error);
|
|
expect(explore.rowHubs, isEmpty);
|
|
});
|
|
|
|
test('mutation retries provider hubs after a partial refresh failure', () async {
|
|
final source = _FakeHubSource(CatalogSourceId.plex);
|
|
addTearDown(source.dispose);
|
|
sources.setActive(source);
|
|
await _pumpMicrotasks();
|
|
expect(source.hubFetches, 1);
|
|
|
|
source.hubFailuresRemaining = 1;
|
|
source.watchlist.notify();
|
|
explore.ensureFresh();
|
|
await _pumpMicrotasks();
|
|
expect(source.hubFetches, 2);
|
|
|
|
// The successful row did not mark the mutation epoch covered while the
|
|
// parallel hub refresh failed, so a subsequent freshness pass retries.
|
|
explore.ensureFresh();
|
|
await _pumpMicrotasks();
|
|
expect(source.hubFetches, 3);
|
|
});
|
|
});
|
|
}
|