Explore only reached search through an app-bar icon that pushed a separate screen. Touch and pointer builds now carry the field inline under the app bar: results replace the shelves while the query is non-empty and the shelves return when it clears. TV keeps pushing CatalogSearchScreen, since a text field cannot share the spotlight scaffold with the bottom-pinned browse rail and the on-screen keyboard. Pull-to-refresh and the toolbar refresh action re-run the live query instead of reloading hidden rows, and switching catalog source re-runs the query against the new source rather than leaving the previous source's results under its name.
393 lines
14 KiB
Dart
393 lines
14 KiB
Dart
import 'dart:ui' show SemanticsAction;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.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/screens/explore_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 'package:plezy/utils/platform_detector.dart';
|
|
import 'package:plezy/widgets/catalog_source_logo.dart';
|
|
import 'package:plezy/widgets/search_input_field.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../test_helpers/prefs.dart';
|
|
|
|
class _FakeCatalogSource implements CatalogSource, CatalogHubSource {
|
|
_FakeCatalogSource(this.id, this.displayName, this.itemId, {this.providerHubTitle});
|
|
|
|
@override
|
|
final CatalogSourceId id;
|
|
|
|
@override
|
|
final String displayName;
|
|
|
|
final int? itemId;
|
|
final String? providerHubTitle;
|
|
final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier();
|
|
|
|
/// Search bookkeeping: [searchTitles] overrides the single default hit so a
|
|
/// suite can assert the empty-result state.
|
|
final searchQueries = <String>[];
|
|
List<String>? searchTitles;
|
|
bool searchFails = false;
|
|
|
|
@override
|
|
List<CatalogRowId> get supportedRows => const [CatalogRowId.popularMovies];
|
|
|
|
@override
|
|
bool get supportsWatchlist => false;
|
|
|
|
@override
|
|
Listenable get watchlistChanges => _watchlistChanges;
|
|
|
|
@override
|
|
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async {
|
|
return CatalogPage(
|
|
items: [
|
|
if (itemId case final itemId?)
|
|
CatalogItem(
|
|
source: id,
|
|
kind: MediaKind.movie,
|
|
title: '$displayName Movie',
|
|
ids: CatalogItemIds(tmdb: itemId),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
|
|
searchQueries.add(query);
|
|
if (searchFails) throw Exception('search boom');
|
|
return [
|
|
for (final title in searchTitles ?? ['$displayName search: $query'])
|
|
CatalogItem(
|
|
source: id,
|
|
kind: MediaKind.movie,
|
|
title: title,
|
|
ids: CatalogItemIds(slug: title),
|
|
),
|
|
];
|
|
}
|
|
|
|
@override
|
|
Future<List<CatalogHub>> fetchHubs({int limit = 25}) async {
|
|
final title = providerHubTitle;
|
|
if (title == null) return const [];
|
|
return [
|
|
CatalogHub(
|
|
id: 'plex-recommendation',
|
|
title: title,
|
|
page: CatalogPage(
|
|
items: [
|
|
CatalogItem(
|
|
source: id,
|
|
kind: MediaKind.show,
|
|
title: 'Plex Recommendation',
|
|
ids: const CatalogItemIds(plex: 'plex-recommendation'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
@override
|
|
Future<CatalogPage> fetchHub(String id, {int page = 1, int limit = 25}) async => const CatalogPage(items: []);
|
|
|
|
@override
|
|
void dispose() => _watchlistChanges.dispose();
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _FakeCatalogSourcesProvider extends CatalogSourcesProvider {
|
|
_FakeCatalogSourcesProvider(this.sources);
|
|
|
|
final List<CatalogSource> sources;
|
|
CatalogSourceId? _activeId;
|
|
|
|
@override
|
|
List<CatalogSource> get connectedSources => sources;
|
|
|
|
@override
|
|
CatalogSource? get activeSource {
|
|
for (final source in sources) {
|
|
if (source.id == _activeId) return source;
|
|
}
|
|
return sources.isEmpty ? null : sources.first;
|
|
}
|
|
|
|
@override
|
|
Future<void> setActiveSource(CatalogSourceId id) async {
|
|
if (_activeId == id) return;
|
|
_activeId = id;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<_FakeCatalogSourcesProvider> _pumpExplore(
|
|
WidgetTester tester, {
|
|
int? traktItemId = 1,
|
|
int? malItemId = 2,
|
|
bool? tv,
|
|
}) async {
|
|
if (tv != null) TvDetectionService.debugSetAppleTVOverride(tv);
|
|
tester.view.devicePixelRatio = 1;
|
|
tester.view.physicalSize = const Size(1280, 720);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
|
|
final trakt = _FakeCatalogSource(CatalogSourceId.trakt, 'Trakt', traktItemId);
|
|
final mal = _FakeCatalogSource(CatalogSourceId.mal, 'MyAnimeList', malItemId);
|
|
final anilist = _FakeCatalogSource(CatalogSourceId.anilist, 'AniList', 3);
|
|
final simkl = _FakeCatalogSource(CatalogSourceId.simkl, 'Simkl', 4);
|
|
final plex = _FakeCatalogSource(CatalogSourceId.plex, 'Plex', 5, providerHubTitle: 'Trending on Plex');
|
|
final seerr = _FakeCatalogSource(CatalogSourceId.seerr, 'Seerr', 6);
|
|
final sources = _FakeCatalogSourcesProvider([trakt, mal, anilist, simkl, plex, seerr]);
|
|
final explore = ExploreProvider(sources);
|
|
addTearDown(explore.dispose);
|
|
addTearDown(sources.dispose);
|
|
addTearDown(trakt.dispose);
|
|
addTearDown(mal.dispose);
|
|
addTearDown(anilist.dispose);
|
|
addTearDown(simkl.dispose);
|
|
addTearDown(plex.dispose);
|
|
addTearDown(seerr.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
TranslationProvider(
|
|
child: MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<CatalogSourcesProvider>.value(value: sources),
|
|
ChangeNotifierProvider<ExploreProvider>.value(value: explore),
|
|
],
|
|
child: MaterialApp(theme: monoTheme(dark: true), home: const ExploreScreen()),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
return sources;
|
|
}
|
|
|
|
_FakeCatalogSource _fakeSource(_FakeCatalogSourcesProvider sources, CatalogSourceId id) =>
|
|
sources.sources.firstWhere((source) => source.id == id) as _FakeCatalogSource;
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUpAll(() {
|
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
|
});
|
|
|
|
setUp(() async {
|
|
resetSharedPreferencesForTest();
|
|
SettingsService.resetForTesting();
|
|
await SettingsService.getInstance();
|
|
TvDetectionService.debugSetAppleTVOverride(true);
|
|
});
|
|
|
|
tearDown(() {
|
|
TvDetectionService.debugSetAppleTVOverride(null);
|
|
});
|
|
|
|
testWidgets('TV source switcher is reachable from the browse rail and changes source', (tester) async {
|
|
final sources = await _pumpExplore(tester);
|
|
tester.state<ExploreScreenState>(find.byType(ExploreScreen)).focusActiveTabIfReady();
|
|
await tester.pump();
|
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
|
|
expect(find.byTooltip(t.explore.selectSource), findsOneWidget);
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
|
await tester.pump();
|
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ExploreSourceSwitcher');
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('MyAnimeList'), findsOneWidget);
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(sources.activeSource?.id, CatalogSourceId.mal);
|
|
expect(find.text('MyAnimeList'), findsOneWidget);
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
|
await tester.pump();
|
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
|
|
});
|
|
|
|
testWidgets('source switcher announces the active source as its value', (tester) async {
|
|
TvDetectionService.debugSetAppleTVOverride(false);
|
|
final semantics = tester.ensureSemantics();
|
|
final sources = await _pumpExplore(tester);
|
|
|
|
var finder = find.bySemanticsLabel(t.explore.selectSource);
|
|
expect(finder, findsOneWidget);
|
|
final node = tester.getSemantics(finder);
|
|
var data = node.getSemanticsData();
|
|
expect(data.value, 'Trakt');
|
|
expect(data.flagsCollection.isButton, isTrue);
|
|
expect(data.hasAction(SemanticsAction.tap), isTrue);
|
|
|
|
node.owner!.performAction(node.id, SemanticsAction.tap);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('MyAnimeList'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(sources.activeSource?.id, CatalogSourceId.mal);
|
|
finder = find.bySemanticsLabel(t.explore.selectSource);
|
|
expect(finder, findsOneWidget);
|
|
data = tester.getSemantics(finder).getSemanticsData();
|
|
expect(data.value, 'MyAnimeList');
|
|
expect(data.hasAction(SemanticsAction.tap), isTrue);
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('source switcher exposes every catalog source with its brand logo', (tester) async {
|
|
final sources = await _pumpExplore(tester);
|
|
tester.state<ExploreScreenState>(find.byType(ExploreScreen)).focusActiveTabIfReady();
|
|
await tester.pump();
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
|
await tester.pumpAndSettle();
|
|
|
|
for (final name in ['Trakt', 'MyAnimeList', 'AniList', 'Simkl', 'Plex', 'Seerr']) {
|
|
expect(find.text(name), findsAtLeast(1));
|
|
}
|
|
expect(find.byType(CatalogSourceLogo), findsAtLeast(6));
|
|
|
|
await tester.tap(find.text('AniList'));
|
|
await tester.pumpAndSettle();
|
|
expect(sources.activeSource?.id, CatalogSourceId.anilist);
|
|
expect(find.text('AniList'), findsOneWidget);
|
|
expect(find.text('AniList Movie'), findsAtLeast(1));
|
|
});
|
|
|
|
testWidgets('Plex provider-defined hub renders as an Explore shelf', (tester) async {
|
|
final sources = await _pumpExplore(tester);
|
|
|
|
await sources.setActiveSource(CatalogSourceId.plex);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(find.text('Trending on Plex'), findsOneWidget);
|
|
expect(find.text('Plex Recommendation'), findsAtLeast(1));
|
|
});
|
|
|
|
testWidgets('TV source switcher remains focused when the active source has no rows', (tester) async {
|
|
final sources = await _pumpExplore(tester, traktItemId: null);
|
|
|
|
tester.state<ExploreScreenState>(find.byType(ExploreScreen)).focusActiveTabIfReady();
|
|
await tester.pump();
|
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ExploreSourceSwitcher');
|
|
expect(find.byTooltip(t.explore.selectSource), findsOneWidget);
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
|
await tester.pumpAndSettle();
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(sources.activeSource?.id, CatalogSourceId.mal);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
|
await tester.pump();
|
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
|
|
});
|
|
|
|
group('inline search', () {
|
|
testWidgets('results replace the shelves while the query is live and the shelves return on clear', (tester) async {
|
|
final sources = await _pumpExplore(tester, tv: false);
|
|
final trakt = _fakeSource(sources, CatalogSourceId.trakt);
|
|
|
|
expect(find.text(t.explore.searchHint(source: 'Trakt')), findsOneWidget);
|
|
// The pushed search route is TV-only now; the inline field replaces it.
|
|
expect(find.byTooltip(t.common.search), findsNothing);
|
|
expect(find.text(t.explore.rows.popularMovies), findsOneWidget);
|
|
|
|
await tester.enterText(find.byType(TextField), 'incep');
|
|
await tester.pump(const Duration(milliseconds: 600));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(trakt.searchQueries, ['incep']);
|
|
expect(find.text(t.explore.rows.popularMovies), findsNothing);
|
|
expect(find.text('Trakt search: incep'), findsOneWidget);
|
|
|
|
await tester.enterText(find.byType(TextField), '');
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Trakt search: incep'), findsNothing);
|
|
expect(find.text(t.explore.rows.popularMovies), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a query with no hits keeps the shelves hidden behind the empty state', (tester) async {
|
|
final sources = await _pumpExplore(tester, tv: false);
|
|
_fakeSource(sources, CatalogSourceId.trakt).searchTitles = const [];
|
|
|
|
await tester.enterText(find.byType(TextField), 'zzz');
|
|
await tester.pump(const Duration(milliseconds: 600));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text(t.explore.searchEmpty(query: 'zzz')), findsOneWidget);
|
|
expect(find.text(t.explore.rows.popularMovies), findsNothing);
|
|
});
|
|
|
|
testWidgets('a failed search shows the failure state and recovers on the next query', (tester) async {
|
|
final sources = await _pumpExplore(tester, tv: false);
|
|
final trakt = _fakeSource(sources, CatalogSourceId.trakt)..searchFails = true;
|
|
|
|
await tester.enterText(find.byType(TextField), 'abc');
|
|
await tester.pump(const Duration(milliseconds: 600));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text(t.explore.searchFailed), findsOneWidget);
|
|
|
|
trakt.searchFails = false;
|
|
await tester.enterText(find.byType(TextField), 'abcd');
|
|
await tester.pump(const Duration(milliseconds: 600));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text(t.explore.searchFailed), findsNothing);
|
|
expect(find.text('Trakt search: abcd'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('switching source re-runs the live query against the new source', (tester) async {
|
|
final sources = await _pumpExplore(tester, tv: false);
|
|
|
|
await tester.enterText(find.byType(TextField), 'abc');
|
|
await tester.pump(const Duration(milliseconds: 600));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Trakt search: abc'), findsOneWidget);
|
|
|
|
await sources.setActiveSource(CatalogSourceId.mal);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(_fakeSource(sources, CatalogSourceId.mal).searchQueries, ['abc']);
|
|
expect(find.text('MyAnimeList search: abc'), findsOneWidget);
|
|
expect(find.text('Trakt search: abc'), findsNothing);
|
|
});
|
|
|
|
testWidgets('the field sits between the app bar and the first shelf at phone width', (tester) async {
|
|
await _pumpExplore(tester, tv: false);
|
|
tester.view.physicalSize = const Size(390, 844);
|
|
await tester.pumpAndSettle();
|
|
|
|
final title = tester.getRect(find.text('Trakt'));
|
|
final field = tester.getRect(find.byType(SearchInputField));
|
|
final shelf = tester.getRect(find.text(t.explore.rows.popularMovies));
|
|
|
|
expect(field.top, greaterThan(title.bottom));
|
|
expect(field.bottom, lessThan(shelf.top));
|
|
expect(field.width, lessThanOrEqualTo(390));
|
|
});
|
|
});
|
|
}
|