Files
plezy/test/screens/libraries/library_browse_tab_test.dart
T

325 lines
12 KiB
Dart

import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/library_filter_result.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/media_sort.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/navigation/main_screen_scope.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/libraries/state_messages.dart';
import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_mappers.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/focusable_filter_chip.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
await StorageService.getInstance();
});
testWidgets('switching libraries retires the old filter phase before it can start a page load', (tester) async {
final sortA = Completer<List<MediaSort>>();
final harness = _BrowseHarness(clientA: _BrowseClient('server-a', 'Library A', sortResponse: sortA.future));
addTearDown(harness.dispose);
await _pumpHarness(tester, harness);
expect(harness.clientA.sortRequestCount, 1);
expect(harness.clientA.pageRequestCount, 0);
harness.selectedLibrary.value = harness.libraryB;
await _pumpRequestFrames(tester);
expect(find.text('Library B'), findsOneWidget);
expect(harness.loadedLibraries, [harness.libraryB.globalKey]);
sortA.complete(const []);
await _pumpRequestFrames(tester);
expect(find.text('Library A'), findsNothing);
expect(find.text('Library B'), findsOneWidget);
expect(harness.clientA.pageRequestCount, 0);
expect(harness.loadedLibraries, [harness.libraryB.globalKey]);
});
testWidgets('accepted page post-frame effects are rejected after a library replacement', (tester) async {
final pageA = Completer<LibraryPage<MediaItem>>();
final clientA = _BrowseClient('server-a', 'Library A')..pageResponses.add(() => pageA.future);
final harness = _BrowseHarness(clientA: clientA);
addTearDown(harness.dispose);
await _pumpHarness(tester, harness, settle: false);
await _pumpUntil(tester, () => clientA.pageRequestCount == 1);
pageA.complete(_pageFor(clientA, 'Library A'));
await tester.idle();
expect(harness.loadedLibraries, isEmpty);
harness.selectedLibrary.value = harness.libraryB;
await _pumpRequestFrames(tester);
expect(find.text('Library A'), findsNothing);
expect(find.text('Library B'), findsOneWidget);
expect(harness.loadedLibraries, [harness.libraryB.globalKey]);
});
testWidgets('unmounting retires pending browse data and callbacks', (tester) async {
final pageA = Completer<LibraryPage<MediaItem>>();
final clientA = _BrowseClient('server-a', 'Library A')..pageResponses.add(() => pageA.future);
final harness = _BrowseHarness(clientA: clientA);
addTearDown(harness.dispose);
await _pumpHarness(tester, harness, settle: false);
await _pumpUntil(tester, () => clientA.pageRequestCount == 1);
await tester.pumpWidget(const SizedBox());
pageA.complete(_pageFor(clientA, 'Library A'));
await tester.pump();
expect(harness.loadedLibraries, isEmpty);
expect(tester.takeException(), isNull);
});
testWidgets('retry accepts an empty current page and returns keyboard focus to library chrome', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final emptyPage = Completer<LibraryPage<MediaItem>>();
final clientA = _BrowseClient('server-a', 'Library A')
..pageResponses.add(() => Future<LibraryPage<MediaItem>>.error(StateError('temporary browse failure')))
..pageResponses.add(() => emptyPage.future);
final harness = _BrowseHarness(clientA: clientA);
addTearDown(harness.dispose);
await _pumpHarness(tester, harness);
expect(find.byType(ErrorStateWidget), findsOneWidget);
final groupingChip = tester.widget<FocusableFilterChip>(find.byType(FocusableFilterChip).first);
groupingChip.focusNode!.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await _pumpUntil(tester, () => clientA.pageRequestCount == 2);
emptyPage.complete(const LibraryPage<MediaItem>(items: [], totalCount: 0));
await _pumpRequestFrames(tester);
expect(find.byType(ErrorStateWidget), findsNothing);
expect(find.byType(EmptyStateWidget), findsOneWidget);
expect(harness.loadedLibraries, [harness.libraryA.globalKey]);
expect(groupingChip.focusNode!.hasFocus, isTrue);
});
testWidgets('mixed library all grouping applies its explicit root kinds', (tester) async {
final client = _BrowseClient('server-a', 'Mixed');
final harness = _BrowseHarness(clientA: client);
addTearDown(harness.dispose);
harness.selectedLibrary.value = JellyfinMappers.library({
'Id': 'mixed-library',
'Name': 'Mixed',
'Type': 'CollectionFolder',
'IsFolder': true,
}, serverId: client.serverId)!;
await _pumpHarness(tester, harness);
expect(client.pageQueries, hasLength(1));
expect(client.pageQueries.single.kind, MediaKind.folder);
expect(client.pageQueries.single.includeKinds, const [MediaKind.movie, MediaKind.show]);
expect(client.pageLibraryKinds.single, MediaKind.folder);
});
}
Future<void> _pumpHarness(WidgetTester tester, _BrowseHarness harness, {bool settle = true}) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>.value(
value: harness.provider,
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: 0,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, _) => [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)),
),
],
body: ValueListenableBuilder<MediaLibrary>(
valueListenable: harness.selectedLibrary,
builder: (context, library, _) => LibraryBrowseTab(
library: library,
canGroupByFolders: true,
isActive: true,
onDataLoaded: () => harness.loadedLibraries.add(library.globalKey),
onBack: () => harness.chromeFocusRequests++,
),
),
),
),
),
),
),
),
);
if (settle) await _pumpRequestFrames(tester);
}
Future<void> _pumpRequestFrames(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 500));
}
Future<void> _pumpUntil(WidgetTester tester, bool Function() condition) async {
for (var i = 0; i < 20 && !condition(); i++) {
await tester.pump(const Duration(milliseconds: 10));
}
expect(condition(), isTrue);
}
LibraryPage<MediaItem> _pageFor(_BrowseClient client, String title) {
return LibraryPage<MediaItem>(
items: [
testMediaItem(
id: '${client.serverId.value}-item',
backend: MediaBackend.jellyfin,
kind: MediaKind.artist,
title: title,
serverId: client.serverId.value,
serverName: client.serverName,
),
],
totalCount: 1,
);
}
class _BrowseHarness {
final _BrowseClient clientA;
late final _BrowseClient clientB;
late final MediaLibrary libraryA;
late final MediaLibrary libraryB;
late final ValueNotifier<MediaLibrary> selectedLibrary;
late final MultiServerManager manager;
late final MultiServerProvider provider;
final List<String> loadedLibraries = [];
var chromeFocusRequests = 0;
_BrowseHarness({required this.clientA}) {
clientB = _BrowseClient('server-b', 'Library B');
libraryA = _libraryFor(clientA);
libraryB = _libraryFor(clientB);
selectedLibrary = ValueNotifier<MediaLibrary>(libraryA);
manager = MultiServerManager()
..debugRegisterClientForTesting(clientA)
..debugRegisterClientForTesting(clientB);
provider = MultiServerProvider(manager, DataAggregationService(manager));
}
MediaLibrary _libraryFor(_BrowseClient client) {
return MediaLibrary(
id: '${client.serverId.value}-library',
backend: MediaBackend.jellyfin,
title: client.itemTitle,
kind: MediaKind.artist,
serverId: client.serverId,
);
}
void dispose() {
selectedLibrary.dispose();
provider.dispose();
manager.dispose();
}
}
class _BrowseClient implements MediaServerClient {
@override
final ServerId serverId;
final String itemTitle;
final Future<List<MediaSort>>? sortResponse;
final Queue<Future<LibraryPage<MediaItem>> Function()> pageResponses = Queue();
var sortRequestCount = 0;
var pageRequestCount = 0;
final List<LibraryQuery> pageQueries = [];
final List<MediaKind?> pageLibraryKinds = [];
_BrowseClient(String serverId, this.itemTitle, {this.sortResponse}) : serverId = ServerId(serverId);
@override
String get serverName => itemTitle;
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
@override
Future<List<MediaSort>> fetchSortOptions(String libraryId, {String? libraryType}) {
sortRequestCount++;
return sortResponse ?? Future.value(const []);
}
@override
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async {
return LibraryFilterResult.empty;
}
@override
Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
String libraryId, {
required LibraryQuery query,
MediaKind? libraryKind,
AbortController? abort,
}) {
pageRequestCount++;
pageQueries.add(query);
pageLibraryKinds.add(libraryKind);
if (pageResponses.isNotEmpty) return pageResponses.removeFirst()();
return Future.value(_pageFor(this, itemTitle));
}
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}