878 lines
30 KiB
Dart
878 lines
30 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/database/app_database.dart';
|
|
import 'package:plezy/focus/key_event_utils.dart';
|
|
import 'package:plezy/i18n/strings.g.dart';
|
|
import 'package:plezy/media/ids.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_playlist.dart';
|
|
import 'package:plezy/media/media_server_client.dart';
|
|
import 'package:plezy/media/server_capabilities.dart';
|
|
import 'package:plezy/providers/download_provider.dart';
|
|
import 'package:plezy/providers/multi_server_provider.dart';
|
|
import 'package:plezy/providers/playback_state_provider.dart';
|
|
import 'package:plezy/screens/playlist/playlist_detail_screen.dart';
|
|
import 'package:plezy/screens/playlist/playlist_item_card.dart';
|
|
import 'package:plezy/services/data_aggregation_service.dart';
|
|
import 'package:plezy/services/download_manager_service.dart';
|
|
import 'package:plezy/services/download_storage_service.dart';
|
|
import 'package:plezy/services/jellyfin_api_cache.dart';
|
|
import 'package:plezy/services/multi_server_manager.dart';
|
|
import 'package:plezy/services/playlist_items_loader.dart';
|
|
import 'package:plezy/services/plex_api_cache.dart';
|
|
import 'package:plezy/services/settings_service.dart';
|
|
import 'package:plezy/theme/mono_theme.dart';
|
|
import 'package:plezy/utils/media_navigation_helper.dart';
|
|
import 'package:plezy/utils/media_server_http_client.dart';
|
|
import 'package:plezy/utils/platform_detector.dart';
|
|
import 'package:plezy/widgets/media_card.dart';
|
|
import 'package:plezy/widgets/focusable_media_card.dart';
|
|
import 'package:plezy/widgets/media_card_sliver_layout.dart';
|
|
import 'package:plezy/widgets/optimized_media_image.dart';
|
|
import 'package:plezy/widgets/overlay_sheet.dart';
|
|
import 'package:plezy/utils/media_image_helper.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../test_helpers/media_items.dart';
|
|
import '../test_helpers/paged_fakes.dart';
|
|
import '../test_helpers/prefs.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUp(() {
|
|
resetSharedPreferencesForTest();
|
|
SettingsService.resetForTesting();
|
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
|
TvDetectionService.debugSetAppleTVOverride(false);
|
|
BackKeyCoordinator.clear();
|
|
});
|
|
|
|
tearDown(() {
|
|
TvDetectionService.debugSetAppleTVOverride(null);
|
|
BackKeyCoordinator.clear();
|
|
});
|
|
|
|
testWidgets('loads playlist continuation pages from an unmodifiable first page', (tester) async {
|
|
final items = List.generate(
|
|
playlistItemsPageSize + 5,
|
|
(index) => testMediaItem(
|
|
id: 'item_$index',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.movie,
|
|
title: 'Item $index',
|
|
serverId: 'server_1',
|
|
serverName: 'Server',
|
|
),
|
|
);
|
|
final harness = await _createHarness(items);
|
|
|
|
await tester.pumpWidget(
|
|
harness.wrap(const SizedBox(width: 1280, height: 720, child: PlaylistDetailScreen(playlist: _playlist))),
|
|
);
|
|
|
|
for (var i = 0; i < 10 && harness.client.requestedStarts.length < 2; i++) {
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
}
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(harness.client.requestedStarts, [0, playlistItemsPageSize]);
|
|
expect(harness.client.requestedSizes, [playlistItemsPageSize, playlistItemsPageSize]);
|
|
expect(tester.takeException(), isNull);
|
|
|
|
await tester.drag(find.byType(CustomScrollView), const Offset(0, -30000));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Item ${playlistItemsPageSize + 4}'), findsOneWidget);
|
|
expect(find.textContaining('Unsupported operation'), findsNothing);
|
|
expect(find.text(t.common.retry), findsNothing);
|
|
});
|
|
|
|
testWidgets('editable audio playlist rows render square track artwork', (tester) async {
|
|
final harness = await _createHarness([
|
|
testMediaItem(
|
|
id: 'track_1',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.track,
|
|
title: 'Track 1',
|
|
serverId: 'server_1',
|
|
serverName: 'Server',
|
|
),
|
|
]);
|
|
|
|
await tester.pumpWidget(
|
|
harness.wrap(const SizedBox(width: 1280, height: 720, child: PlaylistDetailScreen(playlist: _audioPlaylist))),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
final card = find.byType(PlaylistItemCard);
|
|
final artwork = find.descendant(of: card, matching: find.byType(ClipRRect));
|
|
expect(tester.getSize(artwork), const Size.square(60));
|
|
expect(
|
|
tester
|
|
.widget<OptimizedMediaImage>(find.descendant(of: card, matching: find.byType(OptimizedMediaImage)))
|
|
.imageType,
|
|
ImageType.square,
|
|
);
|
|
});
|
|
|
|
testWidgets('read-only audio playlists use square grid geometry and cards', (tester) async {
|
|
final harness = await _createHarness([
|
|
testMediaItem(
|
|
id: 'track_1',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.track,
|
|
title: 'Track 1',
|
|
serverId: 'server_1',
|
|
serverName: 'Server',
|
|
),
|
|
]);
|
|
TvDetectionService.debugSetAppleTVOverride(true);
|
|
await SettingsService.instance.write(SettingsService.tvFullCardLayout, true);
|
|
|
|
await tester.pumpWidget(
|
|
harness.wrap(
|
|
const SizedBox(width: 1280, height: 720, child: PlaylistDetailScreen(playlist: _smartAudioPlaylist)),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
final layout = tester.widget<MediaCardSliverLayout>(find.byType(MediaCardSliverLayout));
|
|
expect(layout.shape, CardShape.square);
|
|
expect(layout.fullBleedImage, isFalse);
|
|
expect(tester.widget<FocusableMediaCard>(find.byType(FocusableMediaCard)).cardShapeOverride, CardShape.square);
|
|
});
|
|
|
|
testWidgets('keeps partial playlist pages and retries from the failed offset', (tester) async {
|
|
final items = _mediaItems(playlistItemsPageSize * 2 + 5);
|
|
final harness = await _createHarness(items, failOnceAt: playlistItemsPageSize);
|
|
|
|
await tester.pumpWidget(
|
|
harness.wrap(const SizedBox(width: 1280, height: 720, child: PlaylistDetailScreen(playlist: _playlist))),
|
|
);
|
|
|
|
for (var i = 0; i < 10 && find.text(t.common.retry).evaluate().isEmpty; i++) {
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
}
|
|
|
|
await tester.drag(find.byType(CustomScrollView), const Offset(0, -30000));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(harness.client.requestedStarts, [0, playlistItemsPageSize]);
|
|
expect(find.text(t.common.retry), findsOneWidget);
|
|
expect(find.text('Item ${playlistItemsPageSize - 1}'), findsOneWidget);
|
|
|
|
await tester.tap(find.text(t.common.retry));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(harness.client.requestedStarts, [
|
|
0,
|
|
playlistItemsPageSize,
|
|
playlistItemsPageSize,
|
|
playlistItemsPageSize * 2,
|
|
]);
|
|
expect(find.text(t.common.retry), findsNothing);
|
|
|
|
await tester.drag(find.byType(CustomScrollView), const Offset(0, -50000));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Item ${playlistItemsPageSize * 2 + 4}'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('iOS top safe-area tap scrolls long playlists to top', (tester) async {
|
|
final items = _mediaItems(playlistItemsPageSize + 5);
|
|
final harness = await _createHarness(items);
|
|
|
|
await tester.pumpWidget(
|
|
harness.wrap(
|
|
const MediaQuery(
|
|
data: MediaQueryData(padding: EdgeInsets.only(top: 25)),
|
|
child: SizedBox(width: 390, height: 844, child: PlaylistDetailScreen(playlist: _playlist)),
|
|
),
|
|
platform: TargetPlatform.iOS,
|
|
),
|
|
);
|
|
|
|
for (var i = 0; i < 10 && harness.client.requestedStarts.length < 2; i++) {
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
}
|
|
await tester.pumpAndSettle();
|
|
|
|
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
|
await tester.drag(find.byType(CustomScrollView), const Offset(0, -3000));
|
|
await tester.pumpAndSettle();
|
|
expect(scrollable.position.pixels, greaterThan(0));
|
|
|
|
await tester.tapAt(const Offset(20, 10));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(scrollable.position.pixels, 0);
|
|
});
|
|
|
|
testWidgets('pushed iOS playlist route allows native pop gesture', (tester) async {
|
|
final harness = await _createHarness(_mediaItems(1));
|
|
MaterialPageRoute<void>? playlistRoute;
|
|
|
|
await tester.pumpWidget(
|
|
harness.wrap(
|
|
Builder(
|
|
builder: (context) => TextButton(
|
|
onPressed: () {
|
|
playlistRoute = MaterialPageRoute<void>(builder: (_) => const PlaylistDetailScreen(playlist: _playlist));
|
|
Navigator.of(context).push(playlistRoute!);
|
|
},
|
|
child: const Text('Open playlist'),
|
|
),
|
|
),
|
|
platform: TargetPlatform.iOS,
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.text('Open playlist'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(playlistRoute, isNotNull);
|
|
expect(playlistRoute!.popGestureEnabled, isTrue);
|
|
});
|
|
|
|
testWidgets('playlist adaptive sheet stays in-tree and system or D-pad Back closes it before the route', (
|
|
tester,
|
|
) async {
|
|
TvDetectionService.debugSetAppleTVOverride(true);
|
|
final harness = await _createHarness(_mediaItems(2));
|
|
await _pushPlaylistRoute(tester, harness);
|
|
|
|
final sheetResult = OverlaySheetController.showAdaptive<void>(
|
|
tester.element(find.byType(PlaylistItemCard).first),
|
|
builder: (_) => const SizedBox(height: 120, child: Center(child: Text('Playlist sheet'))),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
final sheet = find.text('Playlist sheet');
|
|
expect(sheet, findsOneWidget);
|
|
expect(find.ancestor(of: sheet, matching: find.byType(OverlaySheetHost)), findsOneWidget);
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
|
|
// A route-level Back can accompany the same TV remote press before the
|
|
// coordinator's one-frame marker is cleared.
|
|
await tester.binding.handlePopRoute();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(sheet, findsNothing);
|
|
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
|
|
await expectLater(sheetResult, completion(isNull));
|
|
|
|
final systemSheetResult = OverlaySheetController.showAdaptive<void>(
|
|
tester.element(find.byType(PlaylistItemCard).first),
|
|
builder: (_) => const SizedBox(height: 120, child: Center(child: Text('Playlist system sheet'))),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Playlist system sheet'), findsOneWidget);
|
|
|
|
await tester.binding.handlePopRoute();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Playlist system sheet'), findsNothing);
|
|
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
|
|
await expectLater(systemSheetResult, completion(isNull));
|
|
});
|
|
|
|
testWidgets('playlist move mode restores order and cancels before system Back can exit the route', (tester) async {
|
|
final harness = await _createHarness(_mediaItems(2));
|
|
await _pushPlaylistRoute(tester, harness);
|
|
|
|
final listFocus = find.byWidgetPredicate(
|
|
(widget) => widget is Focus && widget.focusNode?.debugLabel == 'playlist_list',
|
|
);
|
|
expect(listFocus, findsOneWidget);
|
|
tester.widget<Focus>(listFocus).focusNode!.requestFocus();
|
|
await tester.pump();
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
|
await tester.pump();
|
|
expect(tester.widget<PlaylistItemCard>(find.byType(PlaylistItemCard).first).isMoving, isTrue);
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
|
await tester.pump();
|
|
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0']);
|
|
|
|
await tester.binding.handlePopRoute();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
|
|
expect(_visiblePlaylistItemIds(tester), ['item_0', 'item_1']);
|
|
expect(tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)).every((card) => !card.isMoving), isTrue);
|
|
});
|
|
|
|
testWidgets('successful playlist deletion pops true from the detail route', (tester) async {
|
|
final harness = await _createHarness(_mediaItems(1), deleteResult: true);
|
|
bool? routeResult;
|
|
|
|
await tester.pumpWidget(
|
|
harness.wrap(
|
|
Builder(
|
|
builder: (context) => TextButton(
|
|
onPressed: () async {
|
|
routeResult = await Navigator.of(
|
|
context,
|
|
).push<bool>(MaterialPageRoute(builder: (_) => const PlaylistDetailScreen(playlist: _playlist)));
|
|
},
|
|
child: const Text('Open playlist'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.tap(find.text('Open playlist'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await _choosePlaylistDeletion(tester, confirm: true);
|
|
|
|
expect(routeResult, isTrue);
|
|
expect(harness.client.deleteCalls, 1);
|
|
expect(find.byType(PlaylistDetailScreen), findsNothing);
|
|
});
|
|
|
|
testWidgets('media navigation maps a successful playlist deletion to listRefreshNeeded', (tester) async {
|
|
final harness = await _createHarness(_mediaItems(1), deleteResult: true);
|
|
MediaNavigationResult? navigationResult;
|
|
|
|
await tester.pumpWidget(
|
|
harness.wrap(
|
|
Builder(
|
|
builder: (context) => TextButton(
|
|
onPressed: () async {
|
|
navigationResult = await navigateToMediaItem(context, _playlist);
|
|
},
|
|
child: const Text('Navigate to playlist'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.tap(find.text('Navigate to playlist'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await _choosePlaylistDeletion(tester, confirm: true);
|
|
|
|
expect(navigationResult, MediaNavigationResult.listRefreshNeeded);
|
|
expect(harness.client.deleteCalls, 1);
|
|
});
|
|
|
|
testWidgets('playlist card reload callback runs exactly once after successful deletion', (tester) async {
|
|
final harness = await _createHarness(_mediaItems(1), deleteResult: true);
|
|
var reloads = 0;
|
|
await tester.pumpWidget(
|
|
harness.wrap(
|
|
Scaffold(
|
|
body: SizedBox(
|
|
width: 640,
|
|
child: MediaCard(item: _playlist, forceListMode: true, onListRefresh: () => reloads++),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
tester.state<MediaCardState>(find.byType(MediaCard)).handleTap();
|
|
await tester.pumpAndSettle();
|
|
await _choosePlaylistDeletion(tester, confirm: true);
|
|
|
|
expect(reloads, 1);
|
|
expect(harness.client.deleteCalls, 1);
|
|
await tester.pump();
|
|
expect(reloads, 1);
|
|
});
|
|
|
|
testWidgets('cancelled and failed deletion plus a non-delete pop never reload the playlist card', (tester) async {
|
|
final harness = await _createHarness(_mediaItems(1), deleteResult: false);
|
|
var reloads = 0;
|
|
await tester.pumpWidget(
|
|
harness.wrap(
|
|
Scaffold(
|
|
body: SizedBox(
|
|
width: 640,
|
|
child: MediaCard(item: _playlist, forceListMode: true, onListRefresh: () => reloads++),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
tester.state<MediaCardState>(find.byType(MediaCard)).handleTap();
|
|
await tester.pumpAndSettle();
|
|
|
|
await _choosePlaylistDeletion(tester, confirm: false);
|
|
expect(reloads, 0);
|
|
expect(harness.client.deleteCalls, 0);
|
|
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
|
|
|
|
await _choosePlaylistDeletion(tester, confirm: true);
|
|
expect(reloads, 0);
|
|
expect(harness.client.deleteCalls, 1);
|
|
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
|
|
|
|
Navigator.of(tester.element(find.byType(PlaylistDetailScreen))).pop();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(PlaylistDetailScreen), findsNothing);
|
|
expect(find.byType(MediaCard), findsOneWidget);
|
|
expect(reloads, 0);
|
|
});
|
|
testWidgets('Jellyfin playlist Play shows cancellable loading and commits no queue on cancel', (tester) async {
|
|
final items = [
|
|
testMediaItem(
|
|
id: 'jf-movie',
|
|
backend: MediaBackend.jellyfin,
|
|
kind: MediaKind.movie,
|
|
title: 'Jellyfin Movie',
|
|
serverId: 'server_1',
|
|
),
|
|
];
|
|
final harness = await _createHarness(items, backend: MediaBackend.jellyfin);
|
|
await tester.pumpWidget(
|
|
harness.wrap(const SizedBox(width: 1280, height: 720, child: PlaylistDetailScreen(playlist: _jellyfinPlaylist))),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
harness.client.blockRequests = true;
|
|
|
|
await tester.tap(find.byTooltip(t.common.play).first);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
|
|
expect(find.byType(AlertDialog), findsOneWidget);
|
|
expect(find.text(t.common.cancel), findsOneWidget);
|
|
expect(harness.client.activeAbort, isNotNull);
|
|
await tester.tap(find.text(t.common.cancel));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(harness.client.activeAbort!.isAborted, isTrue);
|
|
expect(harness.playbackState.isQueueActive, isFalse);
|
|
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
|
|
expect(find.byType(SnackBar), findsNothing);
|
|
});
|
|
|
|
testWidgets('pending D-pad move disables playlist mutations and restores the exact order on rejection', (
|
|
tester,
|
|
) async {
|
|
final harness = await _createHarness(_mediaItems(3));
|
|
await _pushPlaylistRoute(tester, harness);
|
|
|
|
await _startFirstItemMoveDown(tester);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
|
await tester.pump();
|
|
|
|
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']);
|
|
expect(harness.client.moveRequests, hasLength(1));
|
|
expect(harness.client.activeMutationCount, 1);
|
|
expect(
|
|
tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)),
|
|
everyElement(
|
|
isA<PlaylistItemCard>()
|
|
.having((card) => card.canReorder, 'canReorder', isFalse)
|
|
.having((card) => card.onRemove, 'onRemove', isNull),
|
|
),
|
|
);
|
|
expect(find.byType(ReorderableDragStartListener), findsNothing);
|
|
|
|
harness.client.completeMove(0, false);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(_visiblePlaylistItemIds(tester), ['item_0', 'item_1', 'item_2']);
|
|
expect(harness.client.activeMutationCount, 0);
|
|
expect(
|
|
tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)),
|
|
everyElement(
|
|
isA<PlaylistItemCard>()
|
|
.having((card) => card.canReorder, 'canReorder', isTrue)
|
|
.having((card) => card.onRemove, 'onRemove', isNotNull),
|
|
),
|
|
);
|
|
|
|
await _startFirstItemMoveDown(tester);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
|
await tester.pump();
|
|
harness.client.completeMove(1, true, applyToServer: true);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']);
|
|
expect(harness.client.authoritativeItemIds, ['item_1', 'item_0', 'item_2']);
|
|
expect(harness.client.peakMutationCount, 1);
|
|
});
|
|
|
|
testWidgets('ambiguous playlist move failure refetches authoritative order before reopening edits', (tester) async {
|
|
final harness = await _createHarness(_mediaItems(3));
|
|
await _pushPlaylistRoute(tester, harness);
|
|
|
|
await _startFirstItemMoveDown(tester);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
|
await tester.pump();
|
|
expect(harness.client.moveRequests, hasLength(1));
|
|
|
|
harness.client.failMove(0, applyToServer: true);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(harness.client.requestedStarts, [0, 0]);
|
|
expect(harness.client.authoritativeItemIds, ['item_1', 'item_0', 'item_2']);
|
|
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']);
|
|
expect(harness.client.activeMutationCount, 0);
|
|
expect(tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)).every((card) => card.canReorder), isTrue);
|
|
});
|
|
|
|
testWidgets('playlist remove rejection restores its snapshot and ambiguous failure refetches', (tester) async {
|
|
final harness = await _createHarness(_mediaItems(3));
|
|
await _pushPlaylistRoute(tester, harness);
|
|
|
|
await tester.tap(find.byTooltip(t.playlists.removeItem).first);
|
|
await tester.pump();
|
|
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_2']);
|
|
expect(harness.client.removeRequests, hasLength(1));
|
|
expect(
|
|
tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)).every((card) => card.onRemove == null),
|
|
isTrue,
|
|
);
|
|
|
|
harness.client.completeRemove(0, false);
|
|
await tester.pumpAndSettle();
|
|
expect(_visiblePlaylistItemIds(tester), ['item_0', 'item_1', 'item_2']);
|
|
|
|
await tester.tap(find.byTooltip(t.playlists.removeItem).first);
|
|
await tester.pump();
|
|
harness.client.failRemove(1, applyToServer: true);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(harness.client.requestedStarts, [0, 0]);
|
|
expect(harness.client.authoritativeItemIds, ['item_1', 'item_2']);
|
|
expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_2']);
|
|
expect(harness.client.activeMutationCount, 0);
|
|
expect(harness.client.peakMutationCount, 1);
|
|
});
|
|
}
|
|
|
|
Future<void> _startFirstItemMoveDown(WidgetTester tester) async {
|
|
final listFocus = find.byWidgetPredicate(
|
|
(widget) => widget is Focus && widget.focusNode?.debugLabel == 'playlist_list',
|
|
);
|
|
tester.widget<Focus>(listFocus).focusNode!.requestFocus();
|
|
await tester.pump();
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
|
await tester.pump();
|
|
}
|
|
|
|
Future<void> _pushPlaylistRoute(WidgetTester tester, _PlaylistHarness harness) async {
|
|
await tester.pumpWidget(
|
|
harness.wrap(
|
|
Builder(
|
|
builder: (context) => TextButton(
|
|
onPressed: () => Navigator.of(
|
|
context,
|
|
).push<void>(MaterialPageRoute(builder: (_) => const PlaylistDetailScreen(playlist: _playlist))),
|
|
child: const Text('Open playlist'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.tap(find.text('Open playlist'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(PlaylistDetailScreen), findsOneWidget);
|
|
}
|
|
|
|
Future<void> _choosePlaylistDeletion(WidgetTester tester, {required bool confirm}) async {
|
|
await tester.tap(find.byTooltip(t.playlists.delete));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(AlertDialog), findsOneWidget);
|
|
|
|
await tester.tap(find.text(confirm ? t.common.delete : t.common.cancel));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
List<String> _visiblePlaylistItemIds(WidgetTester tester) {
|
|
return tester.widgetList<PlaylistItemCard>(find.byType(PlaylistItemCard)).map((card) => card.item.id).toList();
|
|
}
|
|
|
|
const _playlist = MediaPlaylist(
|
|
id: 'playlist_1',
|
|
backend: MediaBackend.plex,
|
|
title: 'Long Playlist',
|
|
playlistType: 'video',
|
|
serverId: 'server_1',
|
|
serverName: 'Server',
|
|
);
|
|
|
|
const _jellyfinPlaylist = MediaPlaylist(
|
|
id: 'playlist_jf',
|
|
backend: MediaBackend.jellyfin,
|
|
title: 'Jellyfin Playlist',
|
|
playlistType: 'video',
|
|
serverId: 'server_1',
|
|
serverName: 'Server',
|
|
);
|
|
|
|
const _audioPlaylist = MediaPlaylist(
|
|
id: 'audio_playlist_1',
|
|
backend: MediaBackend.plex,
|
|
title: 'Audio Playlist',
|
|
playlistType: 'audio',
|
|
serverId: 'server_1',
|
|
serverName: 'Server',
|
|
);
|
|
|
|
const _smartAudioPlaylist = MediaPlaylist(
|
|
id: 'smart_audio_playlist_1',
|
|
backend: MediaBackend.plex,
|
|
title: 'Smart Audio Playlist',
|
|
playlistType: 'audio',
|
|
smart: true,
|
|
serverId: 'server_1',
|
|
serverName: 'Server',
|
|
);
|
|
|
|
List<MediaItem> _mediaItems(int count) {
|
|
return List.generate(
|
|
count,
|
|
(index) => testMediaItem(
|
|
id: 'item_$index',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.movie,
|
|
title: 'Item $index',
|
|
serverId: 'server_1',
|
|
serverName: 'Server',
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<_PlaylistHarness> _createHarness(
|
|
List<MediaItem> items, {
|
|
int? failOnceAt,
|
|
bool deleteResult = false,
|
|
MediaBackend backend = MediaBackend.plex,
|
|
}) async {
|
|
await SettingsService.getInstance();
|
|
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
PlexApiCache.initialize(db);
|
|
JellyfinApiCache.initialize(db);
|
|
|
|
final downloadManager = DownloadManagerService(
|
|
database: db,
|
|
storageService: DownloadStorageService.instance,
|
|
clientResolver: (serverId, {clientScopeId}) => null,
|
|
);
|
|
downloadManager.recoveryFuture = Future<void>.value();
|
|
final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
|
await downloadProvider.ensureInitialized();
|
|
|
|
final client = _PagedPlaylistClient(items, failOnceAt: failOnceAt, deleteResult: deleteResult, backend: backend);
|
|
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
|
|
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
|
final playbackState = PlaybackStateProvider();
|
|
|
|
addTearDown(() async {
|
|
downloadProvider.dispose();
|
|
downloadManager.dispose();
|
|
multiServerProvider.dispose();
|
|
playbackState.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
return _PlaylistHarness(
|
|
client: client,
|
|
multiServerProvider: multiServerProvider,
|
|
downloadProvider: downloadProvider,
|
|
playbackState: playbackState,
|
|
);
|
|
}
|
|
|
|
class _PlaylistHarness {
|
|
final _PagedPlaylistClient client;
|
|
final MultiServerProvider multiServerProvider;
|
|
final DownloadProvider downloadProvider;
|
|
final PlaybackStateProvider playbackState;
|
|
|
|
const _PlaylistHarness({
|
|
required this.client,
|
|
required this.multiServerProvider,
|
|
required this.downloadProvider,
|
|
required this.playbackState,
|
|
});
|
|
|
|
Widget wrap(Widget child, {TargetPlatform platform = TargetPlatform.android}) {
|
|
return TranslationProvider(
|
|
child: MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
|
|
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playbackState),
|
|
],
|
|
child: MaterialApp(
|
|
theme: monoTheme(dark: true).copyWith(platform: platform),
|
|
home: child,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PagedPlaylistClient implements MediaServerClient {
|
|
final List<MediaItem> items;
|
|
final int? failOnceAt;
|
|
final bool deleteResult;
|
|
final MediaBackend _backend;
|
|
bool blockRequests = false;
|
|
AbortController? activeAbort;
|
|
final List<int?> requestedStarts = [];
|
|
final List<int?> requestedSizes = [];
|
|
final List<_MoveRequest> moveRequests = [];
|
|
final List<_RemoveRequest> removeRequests = [];
|
|
int activeMutationCount = 0;
|
|
int peakMutationCount = 0;
|
|
int deleteCalls = 0;
|
|
bool _hasFailed = false;
|
|
|
|
factory _PagedPlaylistClient(
|
|
List<MediaItem> items, {
|
|
int? failOnceAt,
|
|
bool deleteResult = false,
|
|
MediaBackend backend = MediaBackend.plex,
|
|
}) => _PagedPlaylistClient._(items, failOnceAt, deleteResult, backend);
|
|
|
|
_PagedPlaylistClient._(this.items, this.failOnceAt, this.deleteResult, this._backend);
|
|
|
|
List<String> get authoritativeItemIds => items.map((item) => item.id).toList();
|
|
|
|
@override
|
|
ServerId get serverId => ServerId('server_1');
|
|
|
|
@override
|
|
String? get serverName => 'Server';
|
|
|
|
@override
|
|
MediaBackend get backend => _backend;
|
|
|
|
@override
|
|
ServerCapabilities get capabilities =>
|
|
_backend == MediaBackend.jellyfin ? ServerCapabilities.jellyfin : ServerCapabilities.plex;
|
|
|
|
@override
|
|
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async {
|
|
requestedStarts.add(start);
|
|
requestedSizes.add(size);
|
|
if (blockRequests) {
|
|
activeAbort = abort;
|
|
if (abort == null) {
|
|
await Completer<void>().future;
|
|
} else {
|
|
await abort.trigger;
|
|
abort.throwIfAborted();
|
|
}
|
|
}
|
|
|
|
final offset = start ?? 0;
|
|
if (!_hasFailed && offset == failOnceAt) {
|
|
_hasFailed = true;
|
|
throw StateError('temporary continuation failure');
|
|
}
|
|
return fakeLibraryPage(items, start: start, size: size);
|
|
}
|
|
|
|
@override
|
|
Future<bool> movePlaylistItem({
|
|
required String playlistId,
|
|
required MediaItem item,
|
|
required int newIndex,
|
|
required MediaItem? afterItem,
|
|
}) async {
|
|
final request = _MoveRequest(item: item, newIndex: newIndex);
|
|
moveRequests.add(request);
|
|
activeMutationCount++;
|
|
peakMutationCount = activeMutationCount > peakMutationCount ? activeMutationCount : peakMutationCount;
|
|
try {
|
|
return await request.result.future;
|
|
} finally {
|
|
activeMutationCount--;
|
|
}
|
|
}
|
|
|
|
void completeMove(int index, bool result, {bool applyToServer = false}) {
|
|
final request = moveRequests[index];
|
|
if (applyToServer) _applyMove(request);
|
|
request.result.complete(result);
|
|
}
|
|
|
|
void failMove(int index, {bool applyToServer = false}) {
|
|
final request = moveRequests[index];
|
|
if (applyToServer) _applyMove(request);
|
|
request.result.completeError(StateError('connection closed after playlist move'), StackTrace.current);
|
|
}
|
|
|
|
void _applyMove(_MoveRequest request) {
|
|
final oldIndex = items.indexWhere((item) => item.id == request.item.id);
|
|
if (oldIndex < 0) return;
|
|
final item = items.removeAt(oldIndex);
|
|
items.insert(request.newIndex.clamp(0, items.length), item);
|
|
}
|
|
|
|
@override
|
|
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) async {
|
|
final request = _RemoveRequest(item);
|
|
removeRequests.add(request);
|
|
activeMutationCount++;
|
|
peakMutationCount = activeMutationCount > peakMutationCount ? activeMutationCount : peakMutationCount;
|
|
try {
|
|
return await request.result.future;
|
|
} finally {
|
|
activeMutationCount--;
|
|
}
|
|
}
|
|
|
|
void completeRemove(int index, bool result, {bool applyToServer = false}) {
|
|
final request = removeRequests[index];
|
|
if (applyToServer) _applyRemove(request);
|
|
request.result.complete(result);
|
|
}
|
|
|
|
void failRemove(int index, {bool applyToServer = false}) {
|
|
final request = removeRequests[index];
|
|
if (applyToServer) _applyRemove(request);
|
|
request.result.completeError(StateError('connection closed after playlist removal'), StackTrace.current);
|
|
}
|
|
|
|
void _applyRemove(_RemoveRequest request) {
|
|
items.removeWhere((item) => item.id == request.item.id);
|
|
}
|
|
|
|
@override
|
|
Future<bool> deletePlaylist(MediaPlaylist playlist) async {
|
|
deleteCalls++;
|
|
return deleteResult;
|
|
}
|
|
|
|
@override
|
|
void close() {}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _MoveRequest {
|
|
final MediaItem item;
|
|
final int newIndex;
|
|
final Completer<bool> result = Completer<bool>();
|
|
|
|
_MoveRequest({required this.item, required this.newIndex});
|
|
}
|
|
|
|
class _RemoveRequest {
|
|
final MediaItem item;
|
|
final Completer<bool> result = Completer<bool>();
|
|
|
|
_RemoveRequest(this.item);
|
|
}
|