feat(explore): Explore tab with catalog detail, search, and Seerr requests
This commit is contained in:
@@ -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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user