feat(explore): Explore tab with catalog detail, search, and Seerr requests

This commit is contained in:
edde746
2026-07-10 07:10:15 +02:00
parent f94b8c6586
commit 04daf89a20
25 changed files with 3403 additions and 250 deletions
+35
View File
@@ -41,5 +41,40 @@ void main() {
NavigationTabId.discover,
);
});
test('online falls back to Home when preferred Explore is unavailable', () {
expect(
NavigationTab.resolveDefaultTab(isOffline: false, hasLiveTv: false, preferredStartup: NavigationTabId.explore),
NavigationTabId.discover,
);
expect(
NavigationTab.resolveDefaultTab(
isOffline: false,
hasLiveTv: false,
hasExplore: true,
preferredStartup: NavigationTabId.explore,
),
NavigationTabId.explore,
);
});
});
group('NavigationTab.getVisibleTabs', () {
test('hides Explore until a catalog source is connected', () {
final without = NavigationTab.getVisibleTabs(isOffline: false);
expect(without.map((tab) => tab.id), isNot(contains(NavigationTabId.explore)));
final with_ = NavigationTab.getVisibleTabs(isOffline: false, hasExplore: true, hasLiveTv: true);
final ids = with_.map((tab) => tab.id).toList();
expect(ids, contains(NavigationTabId.explore));
// Explore sits after Live TV, directly before Search.
expect(ids.indexOf(NavigationTabId.explore), ids.indexOf(NavigationTabId.liveTv) + 1);
expect(ids.indexOf(NavigationTabId.explore), ids.indexOf(NavigationTabId.search) - 1);
});
test('Explore is online-only', () {
final offline = NavigationTab.getVisibleTabs(isOffline: true, hasExplore: true);
expect(offline.map((tab) => tab.id), isNot(contains(NavigationTabId.explore)));
});
});
}
+170
View File
@@ -0,0 +1,170 @@
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>{};
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 (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();
}
/// 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('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);
});
});
}
@@ -0,0 +1,103 @@
import 'package:flutter/material.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/screens/catalog_search_screen.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import '../test_helpers/prefs.dart';
/// Only the members the search screen touches; everything else throws.
class _FakeSearchSource implements CatalogSource {
final queries = <String>[];
bool failNext = false;
@override
CatalogSourceId get id => CatalogSourceId.trakt;
@override
String get displayName => 'Trakt';
@override
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
queries.add(query);
if (failNext) {
failNext = false;
throw Exception('boom');
}
return [
CatalogItem(source: id, kind: MediaKind.movie, title: 'result: $query', ids: const CatalogItemIds(tmdb: 1)),
];
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Future<void> _pump(WidgetTester tester, _FakeSearchSource source) async {
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
theme: monoTheme(dark: true),
home: CatalogSearchScreen(source: source),
),
),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
LocaleSettings.setLocaleSync(AppLocale.en);
});
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
testWidgets('reverting to the last-searched query cancels the pending debounce', (tester) async {
final source = _FakeSearchSource();
await _pump(tester, source);
await tester.enterText(find.byType(TextField), 'abc');
await tester.pump(const Duration(milliseconds: 600));
await tester.pumpAndSettle();
expect(source.queries, ['abc']);
expect(_state(tester).searchResults.single.title, 'result: abc');
// Type ahead, then revert to the shown query before the debounce fires:
// the armed 'abcd' search must never run.
await tester.enterText(find.byType(TextField), 'abcd');
await tester.pump(const Duration(milliseconds: 100));
await tester.enterText(find.byType(TextField), 'abc');
await tester.pump(const Duration(milliseconds: 700));
await tester.pumpAndSettle();
expect(source.queries, ['abc']);
expect(_state(tester).searchResults.single.title, 'result: abc');
});
testWidgets('failed search shows the failure state and recovers on retry', (tester) async {
final source = _FakeSearchSource()..failNext = true;
await _pump(tester, source);
await tester.enterText(find.byType(TextField), 'abc');
await tester.pump(const Duration(milliseconds: 600));
await tester.pumpAndSettle();
expect(find.text(t.explore.searchFailed), findsOneWidget);
await tester.enterText(find.byType(TextField), 'abcd');
await tester.pump(const Duration(milliseconds: 600));
await tester.pumpAndSettle();
expect(_state(tester).searchResults.single.title, 'result: abcd');
expect(find.text(t.explore.searchFailed), findsNothing);
});
}
dynamic _state(WidgetTester tester) => tester.state<State<CatalogSearchScreen>>(find.byType(CatalogSearchScreen));
+269
View File
@@ -0,0 +1,269 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/models/seerr/seerr_session.dart';
import 'package:plezy/services/catalog/seerr_catalog_source.dart';
import 'package:plezy/services/seerr/seerr_client.dart';
import 'package:plezy/services/seerr/seerr_constants.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:plezy/widgets/seerr_request_sheet.dart';
http.Response _json(Object body, {int status = 200}) =>
http.Response(jsonEncode(body), status, headers: {'content-type': 'application/json'});
SeerrCatalogSource _source(MockClient mock, {int permissions = SeerrPermission.request}) {
final client = SeerrClient(
SeerrSession(
baseUrl: 'https://seerr.example.com',
method: SeerrAuthMethod.local,
identifier: 'a@b.c',
secret: 'pw',
cookie: 'cookie',
userId: 1,
permissions: permissions,
displayName: 'Alice',
instanceLabel: 'Seerr',
createdAt: 0,
),
onSessionInvalidated: () {},
httpClient: mock,
);
final source = SeerrCatalogSource(client);
addTearDown(() {
source.dispose();
client.dispose();
});
return source;
}
Map<String, dynamic> _publicSettings() => {
'initialized': true,
'localLogin': true,
'mediaServerLogin': true,
'movie4kEnabled': false,
'series4kEnabled': false,
'partialRequestsEnabled': true,
};
/// Mirrors production: the sheet is opened via [showSeerrRequestSheet] on a
/// pushed route that hosts its own [OverlaySheetHost] (like
/// CatalogItemDetailScreen), so the sheet renders in the host's stack rather
/// than as a route of its own.
Future<void> _pumpSheet(
WidgetTester tester, {
required SeerrCatalogSource source,
required MediaKind kind,
required int tmdbId,
required String title,
}) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => OverlaySheetHost(
canPop: true,
child: Scaffold(
body: Builder(
builder: (context) => Center(
child: TextButton(
onPressed: () => showSeerrRequestSheet(
context,
source: source,
kind: kind,
tmdbId: tmdbId,
title: title,
),
child: const Text('request'),
),
),
),
),
),
),
),
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.text('request'));
await tester.pumpAndSettle();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('TV: disables unavailable seasons, drops specials, posts selected seasons', (tester) async {
Map<String, dynamic>? postedBody;
final mock = MockClient((request) async {
switch (request.url.path) {
case '/api/v1/settings/public':
return _json(_publicSettings());
case '/api/v1/tv/1396':
return _json({
'id': 1396,
'name': 'Breaking Bad',
'seasons': [
{'seasonNumber': 0, 'episodeCount': 5, 'name': 'Specials'},
{'seasonNumber': 1, 'episodeCount': 7, 'name': 'Season 1'},
{'seasonNumber': 2, 'episodeCount': 13, 'name': 'Season 2'},
],
'mediaInfo': {
'status': 4,
'status4k': 1,
'seasons': [
{'seasonNumber': 1, 'status': 5, 'status4k': 1},
],
'requests': [],
},
});
case '/api/v1/request':
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
return _json({'id': 10, 'status': 1}, status: 201);
}
fail('unexpected request ${request.url.path}');
});
final source = _source(mock);
await _pumpSheet(tester, source: source, kind: MediaKind.show, tmdbId: 1396, title: 'Breaking Bad');
expect(find.text('Specials'), findsNothing);
expect(find.text('Season 1'), findsOneWidget);
expect(find.text('Season 2'), findsOneWidget);
// Season 1 is available on the server: checked, disabled, labeled.
expect(find.text('Available'), findsOneWidget);
final season1 = tester.widget<CheckboxListTile>(
find.ancestor(of: find.text('Season 1'), matching: find.byType(CheckboxListTile)),
);
expect(season1.onChanged, isNull);
expect(season1.value, isTrue);
// Nothing selected yet: submit disabled.
final submitFinder = find.widgetWithText(FilledButton, 'Request');
expect(tester.widget<FilledButton>(submitFinder).onPressed, isNull);
await tester.tap(find.text('Season 2'));
await tester.pump();
expect(tester.widget<FilledButton>(submitFinder).onPressed, isNotNull);
await tester.tap(submitFinder);
await tester.pumpAndSettle();
expect(postedBody, {
'mediaType': 'tv',
'mediaId': 1396,
'seasons': [2],
'is4k': false,
});
// The sheet closed but the hosting screen must survive the submit —
// a bare Navigator.pop here would pop the whole detail route.
expect(find.text('Season 2'), findsNothing);
expect(find.text('request'), findsOneWidget);
expect(find.text('Request submitted'), findsOneWidget);
});
testWidgets('movie that is already available offers nothing to request', (tester) async {
final mock = MockClient((request) async {
switch (request.url.path) {
case '/api/v1/settings/public':
return _json(_publicSettings());
case '/api/v1/movie/603':
return _json({
'id': 603,
'title': 'The Matrix',
'mediaInfo': {'status': 5, 'status4k': 1},
});
}
fail('unexpected request ${request.url.path}');
});
final source = _source(mock);
await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 603, title: 'The Matrix');
expect(find.text('Available'), findsOneWidget);
expect(find.byType(FilledButton), findsNothing);
});
testWidgets('advanced permission loads servers and sends destination overrides', (tester) async {
Map<String, dynamic>? postedBody;
final mock = MockClient((request) async {
switch (request.url.path) {
case '/api/v1/settings/public':
return _json(_publicSettings());
case '/api/v1/movie/550':
return _json({'id': 550, 'title': 'Fight Club'});
case '/api/v1/service/radarr':
return _json([
{
'id': 0,
'name': 'Radarr Main',
'is4k': false,
'isDefault': true,
'activeProfileId': 6,
'activeDirectory': '/movies',
},
]);
case '/api/v1/service/radarr/0':
return _json({
'server': {
'id': 0,
'name': 'Radarr Main',
'is4k': false,
'isDefault': true,
'activeProfileId': 6,
'activeDirectory': '/movies',
},
'profiles': [
{'id': 6, 'name': '1080p'},
{'id': 7, 'name': '4K Remux'},
],
'rootFolders': [
{'id': 1, 'path': '/movies'},
],
});
case '/api/v1/request':
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
return _json({'id': 11, 'status': 2}, status: 201);
}
fail('unexpected request ${request.url.path}');
});
final source = _source(mock, permissions: SeerrPermission.admin);
await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 550, title: 'Fight Club');
// Single server: no server picker, but profile/folder pickers show
// the instance defaults.
expect(find.text('Destination server'), findsNothing);
expect(find.text('Quality profile'), findsOneWidget);
expect(find.text('1080p'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Request'));
await tester.pumpAndSettle();
expect(postedBody, {
'mediaType': 'movie',
'mediaId': 550,
'is4k': false,
'serverId': 0,
'profileId': 6,
'rootFolder': '/movies',
});
});
}