Files
plezy/test/providers/explore_provider_test.dart
T

267 lines
8.7 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_cast_member.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<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [];
@override
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async => const [];
@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;
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: 'because-watchlisted',
title: 'Because You Watchlisted Inception',
page: CatalogPage(items: [_hubItem('Initial Recommendation')], hasMore: true),
),
];
}
@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, 'because-watchlisted');
expect(providerHub.hub.title, 'Because You Watchlisted Inception');
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('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 watchlist-derived 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);
});
});
}