Files
plezy/test/widgets/media_context_menu_test.dart
T
edde746 5fe1e6dc65 refactor(ui): delete unwired log output, dead notifier filters, and never-passed widget params
MemoryLogOutput extended LogOutput but was never wired as a Logger output (storage happens in the printer); DeletionNotifier/WatchStateNotifier's forServer/forItem filtered streams and WatchStateEvent.mediaType had no production consumers (tests now filter .stream directly); context.hiddenLibraries/profileSettings and toPlexUrl had zero call sites; MonoTokens.splashFactory was never read; OptimizedMediaImage's enableTranscoding/cacheKey chains, FocusableMediaCard.width/height/forceGridMode, TvBrowseRail's constant-zero gap functions, and media_image_helper's scaleFactor were never varied by any caller. The sha1 LRU stays (rebuild-hot artwork URLs); TvRailTrailing.none and the mono-theme copyWith stay live (lerp delegates to copyWith). Also carries the MusicPlayContext.id argument drops in the music screens and remaining sweep test updates.
2026-08-17 19:02:25 +02:00

2010 lines
72 KiB
Dart

import '../test_helpers/paged_fakes.dart';
import 'dart:async';
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/navigation/profile_navigation_scope.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_file_info.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_part.dart';
import 'package:plezy/media/media_playlist.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/metadata_edit/metadata_edit_adapters.dart';
import 'package:plezy/models/plex/plex_home_user.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/catalog_sources_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/providers/offline_mode_provider.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/music/album_detail_screen.dart';
import 'package:plezy/screens/music/artist_detail_screen.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/catalog/catalog_source.dart';
import 'package:plezy/utils/deletion_notifier.dart';
import 'package:plezy/utils/external_ids.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/media_server_timeouts.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/file_info_bottom_sheet.dart';
import 'package:plezy/widgets/media_context_menu.dart';
import 'package:provider/provider.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/http_fixtures.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/profile_stack.dart';
import '../test_helpers/stub_music_playback_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('isAdminActionAllowedForMediaItem', () {
test('blocks non-admin Plex Home users on Plex items', () {
final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false));
expect(
isAdminActionAllowedForMediaItem(isOwnerOrAdmin: true, itemBackend: MediaBackend.plex, activeProfile: profile),
isFalse,
);
});
test('does not apply Plex Home role to Jellyfin items', () {
final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false));
expect(
isAdminActionAllowedForMediaItem(
isOwnerOrAdmin: true,
itemBackend: MediaBackend.jellyfin,
activeProfile: profile,
),
isTrue,
);
});
test('allows Plex admin Home users on Plex items', () {
final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: true));
expect(
isAdminActionAllowedForMediaItem(isOwnerOrAdmin: true, itemBackend: MediaBackend.plex, activeProfile: profile),
isTrue,
);
});
});
group('isMediaDeletionAllowed', () {
test('Jellyfin follows the server answer, not the admin bit', () {
// Jellyfin's own check (`BaseItem.IsAuthorizedToDelete`) never consults
// `IsAdministrator` for library items, and only the first user created on
// a server gets `EnableContentDeletion` for free.
expect(
isMediaDeletionAllowed(
itemBackend: MediaBackend.jellyfin,
resolvedItemPermission: false,
isAdminActionAllowed: true,
),
isFalse,
);
expect(
isMediaDeletionAllowed(
itemBackend: MediaBackend.jellyfin,
resolvedItemPermission: true,
isAdminActionAllowed: false,
),
isTrue,
);
});
test('Jellyfin fails closed when the permission is unknown', () {
expect(
isMediaDeletionAllowed(
itemBackend: MediaBackend.jellyfin,
resolvedItemPermission: null,
isAdminActionAllowed: true,
),
isFalse,
);
});
test('Emby follows the server answer, not the admin bit', () {
expect(
isMediaDeletionAllowed(
itemBackend: MediaBackend.emby,
resolvedItemPermission: false,
isAdminActionAllowed: true,
),
isFalse,
);
expect(
isMediaDeletionAllowed(
itemBackend: MediaBackend.emby,
resolvedItemPermission: true,
isAdminActionAllowed: false,
),
isTrue,
);
});
test('Emby fails closed when the permission is unknown', () {
expect(
isMediaDeletionAllowed(
itemBackend: MediaBackend.emby,
resolvedItemPermission: null,
isAdminActionAllowed: true,
),
isFalse,
);
});
test('Plex keeps its account-level gate', () {
expect(
isMediaDeletionAllowed(
itemBackend: MediaBackend.plex,
resolvedItemPermission: null,
isAdminActionAllowed: true,
),
isTrue,
);
expect(
isMediaDeletionAllowed(
itemBackend: MediaBackend.plex,
resolvedItemPermission: null,
isAdminActionAllowed: false,
),
isFalse,
);
});
test('an item with no backend marker is never deletable', () {
expect(
isMediaDeletionAllowed(itemBackend: null, resolvedItemPermission: true, isAdminActionAllowed: true),
isFalse,
);
});
});
group('MediaContextMenu delete gate', () {
testWidgets('hides delete for an administrator the server refuses (issue #1749)', (tester) async {
final menuKey = await _pumpJellyfinMovieMenu(
tester,
isAdministrator: true,
handler: (_) async => _canDeleteResponse('movie-1', false),
);
await _openMenu(tester, menuKey);
expect(find.text(t.mediaMenu.deleteMovieFromServer), findsNothing);
});
testWidgets('shows delete for a non-admin the server permits', (tester) async {
final requests = <Uri>[];
final menuKey = await _pumpJellyfinMovieMenu(
tester,
isAdministrator: false,
requests: requests,
handler: (_) async => _canDeleteResponse('movie-1', true),
);
await _openMenu(tester, menuKey);
expect(find.text(t.mediaMenu.deleteMovieFromServer), findsOneWidget);
final probes = requests.where((uri) => uri.queryParameters['Fields'] == 'CanDelete').toList();
expect(probes, hasLength(1));
expect(probes.single.queryParameters['ids'], 'movie-1');
});
testWidgets('shows delete for a library-granted user inside the grant', (tester) async {
final menuKey = await _pumpJellyfinMovieMenu(
tester,
isAdministrator: false,
itemId: 'movie-in-grant',
handler: _libraryGrantedToOneMovie,
);
await _openMenu(tester, menuKey);
expect(find.text(t.mediaMenu.deleteMovieFromServer), findsOneWidget);
});
testWidgets('hides delete for a library-granted user outside the grant', (tester) async {
final menuKey = await _pumpJellyfinMovieMenu(
tester,
isAdministrator: false,
itemId: 'movie-outside-grant',
handler: _libraryGrantedToOneMovie,
);
await _openMenu(tester, menuKey);
expect(find.text(t.mediaMenu.deleteMovieFromServer), findsNothing);
});
testWidgets('hides delete when the permission probe fails', (tester) async {
final menuKey = await _pumpJellyfinMovieMenu(
tester,
isAdministrator: true,
handler: (_) async => http.Response('boom', 500),
);
await _openMenu(tester, menuKey);
expect(find.text(t.mediaMenu.deleteMovieFromServer), findsNothing);
expect(tester.takeException(), isNull);
// The menu itself still opened; only the destructive entry is missing.
expect(find.text(t.mediaMenu.fileInfo), findsOneWidget);
});
testWidgets('hides delete without probing while the item server is offline', (tester) async {
final requests = <Uri>[];
final menuKey = await _pumpJellyfinMovieMenu(
tester,
isAdministrator: true,
serverOnline: false,
requests: requests,
handler: (_) async => _canDeleteResponse('movie-1', true),
);
await _openMenu(tester, menuKey);
expect(find.text(t.mediaMenu.deleteMovieFromServer), findsNothing);
expect(requests.where((uri) => uri.queryParameters['Fields'] == 'CanDelete'), isEmpty);
});
testWidgets('still opens the menu, without delete, when the probe hangs', (tester) async {
final menuKey = await _pumpJellyfinMovieMenu(
tester,
isAdministrator: true,
handler: (_) => Completer<http.Response>().future,
);
menuKey.currentState!.showContextMenu(tester.element(find.text('delete target')));
await tester.pump();
expect(find.text(t.mediaMenu.fileInfo), findsNothing, reason: 'the menu waits for the permission answer');
await tester.pump(MediaServerTimeouts.jellyfinDeletePermission + const Duration(milliseconds: 1));
await tester.pumpAndSettle();
expect(find.text(t.mediaMenu.fileInfo), findsOneWidget);
expect(find.text(t.mediaMenu.deleteMovieFromServer), findsNothing);
});
});
group('MediaContextMenu delete confirmation', () {
testWidgets('names the episode, never the show, at every step (issue #1781)', (tester) async {
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
item: _episode(id: 'ep-2', index: 2, file: '/tv/bb/S01E02.mkv'),
handler: _deleteFlowHandler(
episodes: [_episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv')],
),
);
await _openMenu(tester, menuKey);
expect(find.text(t.mediaMenu.deleteEpisodeFromServer), findsOneWidget);
expect(find.text(t.mediaMenu.deleteShowFromServer), findsNothing);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer));
await tester.pumpAndSettle();
final dialog = find.byType(AlertDialog);
expect(dialog, findsOneWidget);
expect(find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteEpisodeTitle)), findsOneWidget);
expect(
find.descendant(
of: dialog,
matching: find.text(t.mediaMenu.confirmDeleteTarget(title: 'Breaking Bad · S1 E2 · Cat in the Bag')),
),
findsOneWidget,
reason: 'the confirmation must name the episode; displayTitle would render only "Breaking Bad"',
);
expect(find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteEpisodeConfirm)), findsOneWidget);
expect(find.text(t.mediaMenu.deleteMultipleWarning), findsNothing);
});
testWidgets('a show delete says so and states the episode count', (tester) async {
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
item: testMediaItem(
id: 'show-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.show,
title: 'Breaking Bad',
serverId: 'srv-1',
leafCount: 62,
),
handler: _deleteFlowHandler(episodes: const []),
);
await _openMenu(tester, menuKey);
expect(find.text(t.mediaMenu.deleteShowFromServer), findsOneWidget);
expect(find.text(t.mediaMenu.deleteEpisodeFromServer), findsNothing);
await tester.tap(find.text(t.mediaMenu.deleteShowFromServer));
await tester.pumpAndSettle();
final dialog = find.byType(AlertDialog);
expect(find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteShowTitle)), findsOneWidget);
expect(
find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteEpisodeCountWarning(n: 62))),
findsOneWidget,
);
expect(find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteShowConfirm)), findsOneWidget);
});
testWidgets('cancelling issues no delete request', (tester) async {
final requests = <Uri>[];
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
requests: requests,
item: _episode(id: 'ep-2', index: 2, file: '/tv/bb/S01E02.mkv'),
handler: _deleteFlowHandler(
episodes: [_episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv')],
),
);
await _openMenu(tester, menuKey);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer));
await tester.pumpAndSettle();
await tester.tap(find.text(t.common.cancel));
await tester.pumpAndSettle();
expect(requests.where((uri) => uri.path.contains('/Items/ep-2')), isEmpty);
});
testWidgets('confirming deletes exactly the focused episode', (tester) async {
final deleted = <String>[];
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
item: _episode(id: 'ep-2', index: 2, file: '/tv/bb/S01E02.mkv'),
handler: _deleteFlowHandler(
episodes: [
_episodeJson(id: 'ep-1', index: 1, path: '/tv/bb/S01E01.mkv'),
_episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv'),
_episodeJson(id: 'ep-3', index: 3, path: '/tv/bb/S01E03.mkv'),
],
onDelete: deleted.add,
),
);
await _confirmDelete(tester, menuKey, t.mediaMenu.deleteEpisodeConfirm);
expect(deleted, ['ep-2'], reason: 'siblings in the same folder must be untouched');
});
testWidgets('warns about episodes sharing the file and reports them deleted', (tester) async {
const sharedFile = '/tv/bb/S01E02-E03.mkv';
final events = <String>[];
final subscription = DeletionNotifier().stream.listen((event) => events.add(event.itemId));
addTearDown(subscription.cancel);
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
item: _episode(id: 'ep-2', index: 2, file: sharedFile),
handler: _deleteFlowHandler(
episodes: [
_episodeJson(id: 'ep-1', index: 1, path: '/tv/bb/S01E01.mkv'),
_episodeJson(id: 'ep-2', index: 2, path: sharedFile),
_episodeJson(id: 'ep-3', index: 3, path: sharedFile),
],
),
);
await _openMenu(tester, menuKey);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer));
await tester.pumpAndSettle();
expect(find.textContaining(t.mediaMenu.deleteSharedFileHeading(n: 1)), findsOneWidget);
expect(find.textContaining('S1 E3'), findsOneWidget);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeConfirm));
await tester.pumpAndSettle();
expect(events, containsAll(<String>['ep-2', 'ep-3']));
expect(events, isNot(contains('ep-1')));
});
testWidgets('a long shared-file list scrolls instead of overflowing a phone dialog', (tester) async {
const sharedFile = '/tv/bb/S01E01-E08.mkv';
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
item: _episode(id: 'ep-1', index: 1, file: sharedFile),
handler: _deleteFlowHandler(
episodes: [for (var i = 1; i <= 8; i++) _episodeJson(id: 'ep-$i', index: i, path: sharedFile)],
),
);
await _openMenu(tester, menuKey);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer));
await tester.pumpAndSettle();
expect(find.textContaining(t.mediaMenu.deleteSharedFileHeading(n: 7)), findsOneWidget);
expect(tester.takeException(), isNull, reason: 'the warning block must scroll, not overflow');
});
testWidgets('system back during the impact probe keeps the spinner and the screen', (tester) async {
// The spinner is documented non-dismissible, but barrierDismissible
// alone does not stop system back. If back popped it, the probe's
// cleanup would pop the screen underneath instead.
final gate = Completer<void>();
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
onPushedRoute: true,
item: _episode(id: 'ep-2', index: 2, file: '/tv/bb/S01E02.mkv'),
handler: (request) async {
if (request.url.queryParameters.containsKey('ParentId')) {
await gate.future;
return jsonResponse({
'Items': [_episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv')],
'TotalRecordCount': 1,
});
}
if (request.url.queryParameters['Fields'] == 'CanDelete') {
return _canDeleteResponse(request.url.queryParameters['ids'] ?? '', true);
}
if (request.url.path.contains('/Seasons')) return jsonResponse({'Items': <Object?>[]});
return jsonResponse({'Items': <Object?>[]});
},
);
await _openMenu(tester, menuKey);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.binding.handlePopRoute();
// Bounded pumps only: pumpAndSettle would run the fake clock past
// MediaServerTimeouts.deleteImpactProbe and close the spinner by
// timeout, hiding whatever back actually did.
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(CircularProgressIndicator), findsOneWidget, reason: 'the spinner is non-dismissible');
expect(find.text('home screen'), findsNothing, reason: 'the screen under the spinner must survive back');
gate.complete();
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('home screen'), findsNothing);
expect(
find.descendant(of: find.byType(AlertDialog), matching: find.text(t.mediaMenu.deleteEpisodeConfirm)),
findsOneWidget,
);
});
testWidgets('warns when the episode is split across several files', (tester) async {
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
item: _episode(
id: 'ep-2',
index: 2,
versions: [
MediaVersion(
id: 'v-1',
parts: const [
MediaPart(id: 'p-1', file: '/tv/bb/S01E02-cd1.mkv'),
MediaPart(id: 'p-2', file: '/tv/bb/S01E02-cd2.mkv'),
],
),
],
),
handler: _deleteFlowHandler(
episodes: [_episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02-cd1.mkv')],
),
);
await _openMenu(tester, menuKey);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer));
await tester.pumpAndSettle();
expect(find.textContaining(t.mediaMenu.deleteMultiPartWarning(n: 2)), findsOneWidget);
});
testWidgets('refuses to imply single-file scope when the server withholds paths', (tester) async {
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
item: _episode(
id: 'ep-2',
index: 2,
versions: const [
MediaVersion(
id: 'v-1',
parts: [MediaPart(id: 'p-1')],
),
],
),
handler: _deleteFlowHandler(
episodes: [_episodeJson(id: 'ep-2', index: 2, path: null)],
details: {'ep-2': _episodeJson(id: 'ep-2', index: 2, path: null)},
),
);
await _openMenu(tester, menuKey);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer));
await tester.pumpAndSettle();
final dialog = find.byType(AlertDialog);
expect(
find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteScopeUnverifiedNoFileInfo)),
findsOneWidget,
);
expect(
find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteAnyway)),
findsOneWidget,
reason: 'an unverified scope must not offer the confident per-kind confirm label',
);
expect(find.text(t.mediaMenu.deleteEpisodeConfirm), findsNothing);
});
testWidgets('recovers scope from the detail endpoint when the browse row omitted paths', (tester) async {
// PlexMappers synthesizes a single file-less part for a thin row, and
// Jellyfin resume rows can arrive without MediaSources. Treating that as
// "server withholds paths" would make the danger warning the default
// even though the detail endpoint answers.
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
item: _episode(
id: 'ep-2',
index: 2,
versions: const [
MediaVersion(
id: 'v-1',
parts: [MediaPart(id: 'p-1')],
),
],
),
handler: _deleteFlowHandler(
episodes: [
_episodeJson(id: 'ep-1', index: 1, path: '/tv/bb/S01E01.mkv'),
_episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv'),
],
details: {'ep-2': _episodeJson(id: 'ep-2', index: 2, path: '/tv/bb/S01E02.mkv')},
),
);
await _openMenu(tester, menuKey);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer));
await tester.pumpAndSettle();
final dialog = find.byType(AlertDialog);
expect(find.descendant(of: dialog, matching: find.text(t.mediaMenu.deleteEpisodeConfirm)), findsOneWidget);
expect(find.text(t.mediaMenu.deleteAnyway), findsNothing);
expect(find.text(t.mediaMenu.deleteScopeUnverifiedNoFileInfo), findsNothing);
expect(find.text(t.mediaMenu.deleteScopeUnverifiedProbeFailed), findsNothing);
});
testWidgets('resolves a path-less sibling row before ruling out a shared file', (tester) async {
// The sibling browse row carries no path. Trusting it would report the
// delete as exclusive while the server destroys ep-3 along with it.
const sharedFile = '/tv/bb/S01E02-E03.mkv';
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
item: _episode(id: 'ep-2', index: 2, file: sharedFile),
handler: _deleteFlowHandler(
episodes: [
_episodeJson(id: 'ep-2', index: 2, path: sharedFile),
_episodeJson(id: 'ep-3', index: 3, path: null),
],
details: {'ep-3': _episodeJson(id: 'ep-3', index: 3, path: sharedFile)},
),
);
await _openMenu(tester, menuKey);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer));
await tester.pumpAndSettle();
expect(find.textContaining(t.mediaMenu.deleteSharedFileHeading(n: 1)), findsOneWidget);
expect(find.textContaining('S1 E3'), findsOneWidget);
});
testWidgets('flags an unverified scope when the sibling lookup fails, and still deletes', (tester) async {
final deleted = <String>[];
final menuKey = await _pumpJellyfinItemMenu(
tester,
isAdministrator: true,
item: _episode(id: 'ep-2', index: 2, file: '/tv/bb/S01E02.mkv'),
handler: _deleteFlowHandler(episodes: null, onDelete: deleted.add),
);
await _openMenu(tester, menuKey);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer));
await tester.pumpAndSettle();
expect(find.text(t.mediaMenu.deleteScopeUnverifiedProbeFailed), findsOneWidget);
await tester.tap(find.text(t.mediaMenu.deleteAnyway));
await tester.pumpAndSettle();
expect(deleted, ['ep-2']);
});
});
group('supportsMetadataEdit', () {
test('allows Jellyfin video metadata edit through capability gate', () {
final client = JellyfinClient.forTesting(
connection: _jellyfinConnection(),
httpClient: MockClient((_) async => http.Response('', 204)),
);
addTearDown(client.close);
expect(supportsMetadataEdit(client, MediaKind.movie), isTrue);
expect(supportsMetadataEdit(client, MediaKind.show), isTrue);
expect(supportsMetadataEdit(client, MediaKind.track), isFalse);
});
});
group('MediaContextMenu actions', () {
testWidgets('audio playlist play and shuffle actions use music playback', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final tracks = [
testMediaItem(
id: 'track-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track One',
serverId: 'srv-1',
),
testMediaItem(
id: 'track-2',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track Two',
serverId: 'srv-1',
),
];
final client = _AudioPlaylistClient(tracks);
final music = _RecordingMusicPlaybackService();
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = testMultiServerProvider(manager);
final stack = await ProfileStack.create(withStorage: false);
addTearDown(() async {
await stack.dispose();
music.dispose();
multiServerProvider.dispose();
manager.dispose();
});
final menuKey = GlobalKey<MediaContextMenuState>();
const playlist = MediaPlaylist(
id: 'playlist-1',
backend: MediaBackend.jellyfin,
title: 'Road Trip',
playlistType: 'audio',
serverId: 'srv-1',
);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
ChangeNotifierProvider<MusicPlaybackService>.value(value: music),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: Center(
child: MediaContextMenu(
key: menuKey,
item: playlist,
child: const SizedBox(width: 120, height: 80, child: Text('audio target')),
),
),
),
),
),
),
);
menuKey.currentState!.showContextMenu(tester.element(find.text('audio target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.common.play));
await tester.pumpAndSettle();
expect(music.playedTracks, tracks);
expect(music.playedContext?.title, playlist.title);
expect(music.playedContext?.kind, MusicPlayContextKind.playlist);
expect(music.shuffle, isFalse);
menuKey.currentState!.showContextMenu(tester.element(find.text('audio target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.mediaMenu.shufflePlay));
await tester.pumpAndSettle();
expect(music.callCount, 2);
expect(music.playedTracks, tracks);
expect(music.shuffle, isTrue);
final staleFetchGate = Completer<void>();
client.fetchGate = staleFetchGate;
menuKey.currentState!.showContextMenu(tester.element(find.text('audio target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.common.play));
await tester.pump();
final newerTrack = testMediaItem(
id: 'newer-track',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Newer Track',
serverId: 'srv-1',
);
await music.playFromList(
tracks: [newerTrack],
playContext: const MusicPlayContext(title: 'Newer Queue', kind: MusicPlayContextKind.tracks),
);
staleFetchGate.complete();
await tester.pumpAndSettle();
expect(music.callCount, 3, reason: 'the stale playlist fetch must not start a fourth queue');
expect(music.playedTracks, [newerTrack]);
expect(tester.takeException(), isNull);
});
testWidgets('Jellyfin video playlist context Play exposes cancellable loading', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final client = _AudioPlaylistClient([
testMediaItem(
id: 'movie-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'srv-1',
),
])..blockWithAbort = true;
final playback = PlaybackStateProvider();
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = testMultiServerProvider(manager);
final stack = await ProfileStack.create(withStorage: false);
addTearDown(() async {
playback.dispose();
await stack.dispose();
multiServerProvider.dispose();
manager.dispose();
});
final menuKey = GlobalKey<MediaContextMenuState>();
const playlist = MediaPlaylist(
id: 'playlist-video',
backend: MediaBackend.jellyfin,
title: 'Video Playlist',
playlistType: 'video',
serverId: 'srv-1',
);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: Center(
child: MediaContextMenu(
key: menuKey,
item: playlist,
child: const SizedBox(width: 120, height: 80, child: Text('video target')),
),
),
),
),
),
),
);
menuKey.currentState!.showContextMenu(tester.element(find.text('video target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.common.play));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text(t.common.cancel), findsOneWidget);
expect(client.activeAbort, isNotNull);
await tester.tap(find.text(t.common.cancel));
await tester.pumpAndSettle();
expect(client.activeAbort!.isAborted, isTrue);
expect(playback.isQueueActive, isFalse);
expect(find.text('video target'), findsOneWidget);
expect(find.byType(SnackBar), findsNothing);
});
testWidgets('file info client resolution failure shows an error without popping another route', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final manager = MultiServerManager();
final multiServerProvider = testMultiServerProvider(manager);
final stack = await ProfileStack.create(withStorage: false);
addTearDown(() async {
await stack.dispose();
multiServerProvider.dispose();
manager.dispose();
});
final menuKey = GlobalKey<MediaContextMenuState>();
final item = testMediaItem(
id: 'movie-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'missing-server',
);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: Center(
child: MediaContextMenu(
key: menuKey,
item: item,
child: const SizedBox(width: 120, height: 80, child: Text('target')),
),
),
),
),
),
),
);
menuKey.currentState!.showContextMenu(tester.element(find.text('target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.mediaMenu.fileInfo));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byType(SnackBar), findsOneWidget);
expect(find.text('target'), findsOneWidget);
});
testWidgets('playlist picker filters playlists by title', (tester) async {
final playlists = [
for (var i = 0; i < 10; i++) (id: '$i', title: 'Alpha $i'),
(id: 'gamma', title: 'Gamma Nights'),
];
final menuKey = await _pumpPlexMovieMenu(tester, playlists);
await _openPlaylistPicker(tester, menuKey);
final textField = tester.widget<TextField>(find.byType(TextField));
textField.controller!.text = 'gamma';
textField.onChanged!('gamma');
await tester.pumpAndSettle();
expect(find.text('Gamma Nights'), findsOneWidget);
expect(find.text('Alpha 0'), findsNothing);
expect(find.text(t.common.createNew), findsOneWidget);
});
testWidgets('playlist picker wires TV focus, D-pad down, and back', (tester) async {
final playlists = [for (var i = 0; i < 10; i++) (id: '$i', title: 'Playlist $i')];
final menuKey = await _pumpPlexMovieMenu(tester, playlists);
await _openPlaylistPicker(tester, menuKey);
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.focusNode!.hasFocus, isTrue);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(Focus.of(tester.element(find.text(t.common.createNew))).hasFocus, isTrue);
await tester.sendKeyDownEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text(t.playlists.selectPlaylist), findsNothing);
expect(find.text('picker target'), findsOneWidget);
});
testWidgets('the music menu gives Instant Mix an icon it shares with no sibling row', (tester) async {
final track = testMediaItem(
id: 'track-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track',
parentId: 'album-1',
parentTitle: 'Album',
grandparentId: 'artist-1',
grandparentTitle: 'Artist',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: const []);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('mini-player menu target')));
await tester.pumpAndSettle();
expect(find.text(t.music.instantMix), findsOneWidget);
expect(find.byIcon(Symbols.wand_stars_rounded), findsOneWidget);
// #1629: the fader glyph reads as an equalizer, and the neighbouring
// rows must stay tellable apart at a glance on a TV.
expect(find.byIcon(Symbols.instant_mix_rounded), findsNothing);
expect(find.byIcon(Symbols.tune_rounded), findsNothing);
final musicIcons = tester
.widgetList<Icon>(find.byType(Icon))
.map((icon) => icon.icon)
.whereType<IconData>()
.toList();
expect(
musicIcons.where((icon) => icon == Symbols.wand_stars_rounded),
hasLength(1),
reason: 'Instant Mix must not reuse the glyph of Play, Play next, Add to queue, or Go to artist',
);
});
testWidgets('track album action uses the profile navigator from the sibling menu overlay', (tester) async {
final track = testMediaItem(
id: 'track-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track',
parentId: 'album-1',
parentTitle: 'Album',
grandparentId: 'artist-1',
grandparentTitle: 'Artist',
serverId: 'srv-1',
);
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.album,
title: 'Album',
parentId: 'artist-1',
parentTitle: 'Artist',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: [album]);
await _selectSiblingMusicMenuAction(tester, harness, t.music.goToAlbum);
expect(find.byType(AlbumDetailScreen), findsOneWidget);
expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue);
expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse);
expect(
Provider.of<MusicPlaybackService>(tester.element(find.byType(AlbumDetailScreen)), listen: false),
same(harness.music),
);
});
testWidgets('album artist action uses the profile navigator from the sibling menu overlay', (tester) async {
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.album,
title: 'Album',
parentId: 'artist-1',
parentTitle: 'Artist',
serverId: 'srv-1',
);
final artist = testMediaItem(
id: 'artist-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.artist,
title: 'Artist',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: album, relatedItems: [artist]);
await _selectSiblingMusicMenuAction(tester, harness, t.music.goToArtist);
expect(find.byType(ArtistDetailScreen), findsOneWidget);
expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue);
expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse);
expect(
Provider.of<MusicPlaybackService>(tester.element(find.byType(ArtistDetailScreen)), listen: false),
same(harness.music),
);
});
testWidgets('track artist action uses the profile navigator from the sibling menu overlay', (tester) async {
final track = testMediaItem(
id: 'track-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track',
parentId: 'album-1',
parentTitle: 'Album',
grandparentId: 'artist-1',
grandparentTitle: 'Artist',
serverId: 'srv-1',
);
final artist = testMediaItem(
id: 'artist-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.artist,
title: 'Artist',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: [artist]);
await _selectSiblingMusicMenuAction(tester, harness, t.music.goToArtist);
expect(find.byType(ArtistDetailScreen), findsOneWidget);
expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue);
expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse);
expect(
Provider.of<MusicPlaybackService>(tester.element(find.byType(ArtistDetailScreen)), listen: false),
same(harness.music),
);
});
testWidgets('a slow music enqueue does not append to a newer queue session', (tester) async {
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.album,
title: 'Album',
serverId: 'srv-1',
);
final albumTrack = testMediaItem(
id: 'album-track',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Album Track',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: album, relatedItems: [albumTrack]);
final fetchGate = Completer<void>();
harness.client.albumTracksGate = fetchGate;
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('mini-player menu target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.music.playNext));
await tester.pump();
final newerTrack = testMediaItem(
id: 'newer-track',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Newer Track',
serverId: 'srv-1',
);
await harness.music.playFromList(
tracks: [newerTrack],
playContext: const MusicPlayContext(title: 'Newer Queue', kind: MusicPlayContextKind.tracks),
);
fetchGate.complete();
await tester.pumpAndSettle();
expect(harness.music.addedNext, isEmpty);
expect(harness.music.playedTracks, [newerTrack]);
expect(tester.takeException(), isNull);
});
testWidgets('track file info fetches the tapped track and renders its audio-only sheet', (tester) async {
const trackPath = '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac';
final track = testMediaItem(
id: 'track-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Ready Lets Go',
parentId: 'album-1',
parentTitle: 'Geogaddi',
grandparentId: 'artist-1',
grandparentTitle: 'Boards of Canada',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: const []);
harness.client.fileInfo = const MediaFileInfo(
versions: [
MediaFileVersion(
container: 'flac',
parts: [
MediaFilePart(
filePath: trackPath,
fileSize: 35651584,
streams: [MediaStreamDetails(kind: MediaStreamKind.audio, ordinal: 1, codec: 'flac', channels: 2)],
),
],
),
],
);
await _selectSiblingMusicMenuAction(tester, harness, t.mediaMenu.fileInfo);
expect(harness.client.fileInfoRequests, [same(track)]);
expect(find.byType(FileInfoBottomSheet), findsOneWidget);
expect(find.text('Ready Lets Go'), findsOneWidget);
expect(find.text(trackPath), findsOneWidget);
expect(find.text(t.fileInfo.audio), findsOneWidget);
expect(find.text(t.fileInfo.video), findsNothing);
// The loading dialog must be gone and no error snackbar raised.
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.byType(SnackBar), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('album menu omits file info because containers carry no media sources', (tester) async {
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.album,
title: 'Geogaddi',
parentId: 'artist-1',
parentTitle: 'Boards of Canada',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: album, relatedItems: const []);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('mini-player menu target')));
await tester.pumpAndSettle();
expect(find.text(t.music.playNext), findsOneWidget);
expect(find.text(t.mediaMenu.fileInfo), findsNothing);
expect(harness.client.fileInfoRequests, isEmpty);
});
});
group('watchlist entry', () {
testWidgets('cold open offers Add and adds to the single capable source', (tester) async {
final source = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1'));
final harness = await _pumpWatchlistMenu(tester, sources: [source], guids: ['imdb://tt1']);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target')));
await tester.pumpAndSettle();
expect(find.text(t.explore.addToWatchlist), findsOneWidget);
expect(find.text(t.explore.removeFromWatchlist), findsNothing);
await tester.tap(find.text(t.explore.addToWatchlist));
await tester.pumpAndSettle();
expect(source.mutations.map((m) => m.add), [true]);
expect(source.mutations.single.ids.imdb, 'tt1');
expect(find.text(t.explore.addedToWatchlist), findsOneWidget);
// Opening also kicked the membership snapshot load for the next open.
expect(source.ensureLoadedCalls, greaterThan(0));
});
testWidgets('offers Remove once cached membership is known, and removes', (tester) async {
final source = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1'))
..membership = true;
final harness = await _pumpWatchlistMenu(tester, sources: [source]);
await harness.catalogSources.watchlistCandidatesFor(
harness.item,
client: _SeedIdsClient(const ExternalIds(imdb: 'tt1')),
);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target')));
await tester.pumpAndSettle();
expect(find.text(t.explore.removeFromWatchlist), findsOneWidget);
await tester.tap(find.text(t.explore.removeFromWatchlist));
await tester.pumpAndSettle();
expect(source.mutations.map((m) => m.add), [false]);
expect(find.text(t.explore.removedFromWatchlist), findsOneWidget);
});
testWidgets('hides the entry when the item resolved in no capable source', (tester) async {
final source = _MenuWatchlistSource(CatalogSourceId.mal, 'MAL'); // resolves null: out of domain
final harness = await _pumpWatchlistMenu(tester, sources: [source]);
await harness.catalogSources.watchlistCandidatesFor(
harness.item,
client: _SeedIdsClient(const ExternalIds(imdb: 'tt1')),
);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target')));
await tester.pumpAndSettle();
expect(find.text(t.mediaMenu.markAsWatched), findsOneWidget);
expect(find.text(t.explore.addToWatchlist), findsNothing);
expect(find.text(t.explore.removeFromWatchlist), findsNothing);
});
testWidgets('reports when the tapped item matches no watchlist', (tester) async {
final source = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1'));
// The metadata answer carries no Guid entries: no external ids.
final harness = await _pumpWatchlistMenu(tester, sources: [source]);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.explore.addToWatchlist));
await tester.pumpAndSettle();
expect(source.mutations, isEmpty);
expect(find.text(t.explore.watchlistNoMatch), findsOneWidget);
});
testWidgets('several capable sources open a per-source chooser', (tester) async {
final trakt = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1'));
final simkl = _MenuWatchlistSource(CatalogSourceId.simkl, 'Simkl', resolveTo: const CatalogItemIds(imdb: 'tt1'))
..membership = true;
final harness = await _pumpWatchlistMenu(tester, sources: [trakt, simkl]);
await harness.catalogSources.watchlistCandidatesFor(
harness.item,
client: _SeedIdsClient(const ExternalIds(imdb: 'tt1')),
);
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target')));
await tester.pumpAndSettle();
// Membership known-true on one source labels the entry Remove.
await tester.tap(find.text(t.explore.removeFromWatchlist));
await tester.pumpAndSettle();
// The chooser names each source with its own pending action.
expect(find.text('Trakt'), findsOneWidget);
expect(find.text('Simkl'), findsOneWidget);
expect(find.text(t.explore.addToWatchlist), findsOneWidget);
expect(find.text(t.explore.removeFromWatchlist), findsOneWidget);
await tester.tap(find.text('Simkl'));
await tester.pumpAndSettle();
expect(simkl.mutations.map((m) => m.add), [false]);
expect(trakt.mutations, isEmpty);
expect(find.text(t.explore.removedFromWatchlist), findsOneWidget);
});
});
}
Future<GlobalKey<MediaContextMenuState>> _pumpPlexMovieMenu(
WidgetTester tester,
List<({String id, String title})> playlists,
) async {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
final client = testPlexClient(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: '1',
),
serverId: ServerId('plex-1'),
httpClient: MockClient((request) async {
if (request.url.path != '/playlists') return http.Response('not found', 404);
return http.Response(
jsonEncode({
'MediaContainer': {
'size': playlists.length,
'totalSize': playlists.length,
'Metadata': [
for (final playlist in playlists)
{
'ratingKey': playlist.id,
'key': '/playlists/${playlist.id}/items',
'type': 'playlist',
'playlistType': 'video',
'title': playlist.title,
'smart': false,
},
],
},
}),
200,
headers: {'content-type': 'application/json'},
);
}),
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = testMultiServerProvider(manager);
final stack = await ProfileStack.create(db: db, withStorage: false);
addTearDown(() async {
await stack.dispose();
multiServerProvider.dispose();
manager.dispose();
await db.close();
});
final menuKey = GlobalKey<MediaContextMenuState>();
final item = testMediaItem(
id: 'movie-1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'plex-1',
);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: Center(
child: MediaContextMenu(
key: menuKey,
item: item,
child: const SizedBox(width: 120, height: 80, child: Text('picker target')),
),
),
),
),
),
),
);
return menuKey;
}
/// Jellyfin answer for the per-item delete-permission probe.
http.Response _canDeleteResponse(String id, bool canDelete) => jsonResponse({
'Items': [
{'Id': id, 'CanDelete': canDelete},
],
});
/// A user holding `EnableContentDeletionFromFolders` for one library only: the
/// server answers per item, which is the only way that grant reaches a client.
Future<http.Response> _libraryGrantedToOneMovie(http.Request request) async {
final id = request.url.queryParameters['ids'] ?? '';
return _canDeleteResponse(id, id == 'movie-in-grant');
}
/// Jellyfin episode fixture. [file] populates the inline media source so the
/// impact probe resolves without a detail refetch; pass [versions] instead to
/// model a split or path-less item.
MediaItem _episode({required String id, required int index, String? file, List<MediaVersion>? versions}) {
return testMediaItem(
id: id,
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
title: 'Cat in the Bag',
parentId: 'season-1',
parentIndex: 1,
index: index,
grandparentId: 'show-1',
grandparentTitle: 'Breaking Bad',
serverId: 'srv-1',
mediaVersions:
versions ??
[
MediaVersion(
id: 'v-$id',
parts: [MediaPart(id: 'p-$id', file: file)],
),
],
);
}
Map<String, Object?> _episodeJson({required String id, required int index, required String? path}) => {
'Id': id,
'Name': 'Episode $index',
'Type': 'Episode',
'IndexNumber': index,
'ParentIndexNumber': 1,
'SeriesId': 'show-1',
'SeriesName': 'Breaking Bad',
'SeasonId': 'season-1',
'MediaSources': [
{'Id': 'ms-$id', 'Path': ?path},
],
};
/// Answers every request the delete flow makes: the CanDelete probe, the
/// season-children lookup, per-item detail refetches, and the DELETE itself.
///
/// A null [episodes] makes the children lookup fail, which is the
/// probe-failure path. [details] is keyed by item id and models the detail
/// endpoint carrying file paths that the browse row omitted.
Future<http.Response> Function(http.Request) _deleteFlowHandler({
required List<Map<String, Object?>>? episodes,
Map<String, Map<String, Object?>> details = const {},
void Function(String id)? onDelete,
}) {
return (request) async {
final path = request.url.path;
final lastSegment = path.split('/').last;
if (request.method == 'DELETE') {
onDelete?.call(lastSegment);
return http.Response('', 204);
}
if (request.url.queryParameters['Fields'] == 'CanDelete') {
return _canDeleteResponse(request.url.queryParameters['ids'] ?? '', true);
}
// fetchChildren probes /Shows/{id}/Seasons first and falls through to the
// generic ParentId query when it comes back empty.
if (path.contains('/Seasons')) return jsonResponse({'Items': <Object?>[]});
if (request.url.queryParameters.containsKey('ParentId')) {
if (episodes == null) return http.Response('boom', 500);
return jsonResponse({'Items': episodes, 'TotalRecordCount': episodes.length});
}
final detail = details[lastSegment];
if (detail != null) return jsonResponse(detail);
return jsonResponse({'Items': <Object?>[]});
};
}
Future<void> _confirmDelete(WidgetTester tester, GlobalKey<MediaContextMenuState> menuKey, String confirmLabel) async {
await _openMenu(tester, menuKey);
await tester.tap(find.text(t.mediaMenu.deleteEpisodeFromServer));
await tester.pumpAndSettle();
await tester.tap(find.text(confirmLabel));
await tester.pumpAndSettle();
}
Future<GlobalKey<MediaContextMenuState>> _pumpJellyfinMovieMenu(
WidgetTester tester, {
required bool isAdministrator,
required Future<http.Response> Function(http.Request request) handler,
String itemId = 'movie-1',
bool serverOnline = true,
List<Uri>? requests,
}) {
return _pumpJellyfinItemMenu(
tester,
isAdministrator: isAdministrator,
handler: handler,
serverOnline: serverOnline,
requests: requests,
item: testMediaItem(
id: itemId,
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'srv-1',
),
);
}
Future<GlobalKey<MediaContextMenuState>> _pumpJellyfinItemMenu(
WidgetTester tester, {
required bool isAdministrator,
required Future<http.Response> Function(http.Request request) handler,
required MediaItem item,
bool serverOnline = true,
List<Uri>? requests,
/// Push the menu onto a route above a home screen, so the navigator has
/// something below the menu that a stray pop could reach.
bool onPushedRoute = false,
}) async {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final client = JellyfinClient.forTesting(
connection: testJellyfinConnection(isAdministrator: isAdministrator),
httpClient: MockClient((request) async {
requests?.add(request.url);
return handler(request);
}),
);
final manager = MultiServerManager()..debugRegisterJellyfinClientForTesting(client, online: serverOnline);
final multiServerProvider = testMultiServerProvider(manager);
final offlineMode = OfflineModeProvider(manager);
final stack = await ProfileStack.create(withStorage: false);
addTearDown(() async {
await stack.dispose();
offlineMode.dispose();
multiServerProvider.dispose();
manager.dispose();
});
final menuKey = GlobalKey<MediaContextMenuState>();
final navigatorKey = GlobalKey<NavigatorState>();
final menuScreen = Scaffold(
body: Center(
child: MediaContextMenu(
key: menuKey,
item: item,
child: const SizedBox(width: 120, height: 80, child: Text('delete target')),
),
),
);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
ChangeNotifierProvider<OfflineModeProvider>.value(value: offlineMode),
],
child: MaterialApp(
navigatorKey: navigatorKey,
theme: monoTheme(dark: true),
home: onPushedRoute ? const Scaffold(body: Center(child: Text('home screen'))) : menuScreen,
),
),
),
);
if (onPushedRoute) {
unawaited(navigatorKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => menuScreen)));
await tester.pumpAndSettle();
}
return menuKey;
}
Future<void> _openMenu(WidgetTester tester, GlobalKey<MediaContextMenuState> menuKey) async {
menuKey.currentState!.showContextMenu(tester.element(find.text('delete target')));
await tester.pumpAndSettle();
}
Future<void> _openPlaylistPicker(WidgetTester tester, GlobalKey<MediaContextMenuState> menuKey) async {
menuKey.currentState!.showContextMenu(tester.element(find.text('picker target')));
await tester.pumpAndSettle();
await tester.tap(find.text(t.common.addTo));
await tester.pumpAndSettle();
await tester.tap(find.text(t.playlists.playlist));
await tester.pumpAndSettle();
expect(find.text(t.playlists.selectPlaylist), findsOneWidget);
}
class _AudioPlaylistClient implements MediaServerClient {
final List<MediaItem> tracks;
Completer<void>? fetchGate;
bool blockWithAbort = false;
AbortController? activeAbort;
_AudioPlaylistClient(this.tracks);
@override
ServerId get serverId => ServerId('srv-1');
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
@override
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async {
if (blockWithAbort) {
activeAbort = abort;
if (abort == null) {
await Completer<void>().future;
} else {
await abort.trigger;
abort.throwIfAborted();
}
}
await fetchGate?.future;
return fakeLibraryPage(tracks, start: start, size: size);
}
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _RecordingMusicPlaybackService extends StubMusicPlaybackService {
List<MediaItem>? playedTracks;
final List<MediaItem> addedNext = [];
MusicPlayContext? playedContext;
bool? shuffle;
int callCount = 0;
@override
Future<void> playFromList({
required List<MediaItem> tracks,
MediaItem? startTrack,
required MusicPlayContext playContext,
bool shuffle = false,
}) async {
await super.playFromList(tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle);
callCount++;
playedTracks = tracks;
playedContext = playContext;
this.shuffle = shuffle;
}
@override
void addNext(List<MediaItem> tracks) {
addedNext.addAll(tracks);
}
}
class _RelatedMusicClient implements MediaServerClient {
_RelatedMusicClient(Iterable<MediaItem> items)
: _items = {for (final item in items) item.id: item},
albumTracks = items.where((item) => item.kind == MediaKind.track).toList();
final Map<String, MediaItem> _items;
final List<MediaItem> albumTracks;
Completer<void>? albumTracksGate;
@override
ServerId get serverId => ServerId('srv-1');
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
@override
Future<MediaItem?> fetchItem(String id) async => _items[id];
@override
Future<List<MediaItem>> fetchAlbumTracks(String albumId) async {
await albumTracksGate?.future;
return albumTracks;
}
@override
Future<List<MediaItem>> fetchArtistAlbums(MediaItem artist) async => const [];
/// Items the menu asked file info for, in call order — the track menu must
/// resolve the client for the tapped item, not for whatever is playing.
final List<MediaItem> fileInfoRequests = [];
MediaFileInfo? fileInfo;
@override
Future<MediaFileInfo?> getFileInfo(MediaItem item) async {
fileInfoRequests.add(item);
return fileInfo;
}
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _SiblingMusicMenuHarness {
const _SiblingMusicMenuHarness({
required this.rootNavigatorKey,
required this.profileNavigatorKey,
required this.menuKey,
required this.music,
required this.client,
});
final GlobalKey<NavigatorState> rootNavigatorKey;
final GlobalKey<NavigatorState> profileNavigatorKey;
final GlobalKey<MediaContextMenuState> menuKey;
final _RecordingMusicPlaybackService music;
final _RelatedMusicClient client;
}
Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu(
WidgetTester tester, {
required MediaItem item,
required List<MediaItem> relatedItems,
}) async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
LocaleSettings.setLocaleSync(AppLocale.en);
final db = AppDatabase.forTesting(NativeDatabase.memory());
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 = _RelatedMusicClient(relatedItems);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = testMultiServerProvider(manager);
final stack = await ProfileStack.create(db: db, withStorage: false);
final music = _RecordingMusicPlaybackService();
final rootNavigatorKey = GlobalKey<NavigatorState>();
final profileNavigatorKey = GlobalKey<NavigatorState>();
final menuKey = GlobalKey<MediaContextMenuState>();
addTearDown(() async {
downloadProvider.dispose();
downloadManager.dispose();
await stack.dispose();
music.dispose();
multiServerProvider.dispose();
manager.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
navigatorKey: rootNavigatorKey,
theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.macOS),
home: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
ChangeNotifierProvider<MusicPlaybackService>.value(value: music),
],
child: ProfileNavigationScope(
navigatorKey: profileNavigatorKey,
routeObserver: RouteObserver<PageRoute<dynamic>>(),
mainScaffoldMessengerKey: GlobalKey<ScaffoldMessengerState>(),
child: Stack(
fit: StackFit.expand,
children: [
Navigator(
key: profileNavigatorKey,
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Center(child: Text('profile content'))),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Material(
child: MediaContextMenu(
key: menuKey,
item: item,
child: const SizedBox(
width: 180,
height: 64,
child: Center(child: Text('mini-player menu target')),
),
),
),
),
],
),
),
),
),
),
);
return _SiblingMusicMenuHarness(
rootNavigatorKey: rootNavigatorKey,
profileNavigatorKey: profileNavigatorKey,
menuKey: menuKey,
music: music,
client: client,
);
}
Future<void> _selectSiblingMusicMenuAction(
WidgetTester tester,
_SiblingMusicMenuHarness harness,
String actionLabel,
) async {
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('mini-player menu target')));
await tester.pumpAndSettle();
await tester.tap(find.text(actionLabel));
await tester.pumpAndSettle();
}
PlexHomeUser _homeUser({required bool admin}) {
return PlexHomeUser(
id: 0,
uuid: 'home-user',
title: 'Home User',
username: null,
email: null,
friendlyName: null,
thumb: 'https://plex.tv/users/home-user/avatar',
hasPassword: false,
restricted: false,
updatedAt: null,
admin: admin,
guest: false,
protected: false,
);
}
JellyfinConnection _jellyfinConnection() {
return JellyfinConnection(
id: 'srv-1/user-1',
baseUrl: 'https://jf.example.com',
serverName: 'Home',
serverMachineId: 'srv-1',
userId: 'user-1',
userName: 'edde',
accessToken: 'tok',
deviceId: 'dev',
isAdministrator: true,
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
}
class _MenuWatchlistSource implements CatalogSource {
_MenuWatchlistSource(this.id, this.displayName, {this.resolveTo});
@override
final CatalogSourceId id;
@override
final String displayName;
final CatalogItemIds? resolveTo;
bool? membership;
int ensureLoadedCalls = 0;
final List<({MediaKind kind, CatalogItemIds ids, bool add})> mutations = [];
@override
bool get supportsWatchlist => true;
@override
Future<void> ensureWatchlistLoaded() async {
ensureLoadedCalls++;
}
@override
bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => membership;
@override
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async => resolveTo;
@override
Future<void> addToWatchlist(MediaKind kind, CatalogItemIds ids) async =>
mutations.add((kind: kind, ids: ids, add: true));
@override
Future<void> removeFromWatchlist(MediaKind kind, CatalogItemIds ids) async =>
mutations.add((kind: kind, ids: ids, add: false));
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _WatchlistSourcesProvider extends CatalogSourcesProvider {
_WatchlistSourcesProvider(this.sources);
final List<CatalogSource> sources;
@override
List<CatalogSource> get connectedSources => sources;
}
class _SeedIdsClient implements MediaServerClient {
_SeedIdsClient(this.ids);
final ExternalIds ids;
@override
Future<ExternalIds> fetchExternalIds(String itemId) async => ids;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Pumps a Plex movie's context menu with [sources] connected as catalog
/// sources. The Plex MockClient answers the external-id metadata fetch with
/// [guids] (empty: the item carries no external ids).
Future<({GlobalKey<MediaContextMenuState> menuKey, MediaItem item, CatalogSourcesProvider catalogSources})>
_pumpWatchlistMenu(WidgetTester tester, {required List<CatalogSource> sources, List<String> guids = const []}) async {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
final client = testPlexClient(
serverId: ServerId('plex-1'),
httpClient: MockClient((request) async {
if (request.url.path == '/library/metadata/movie-1') {
return jsonResponse({
'MediaContainer': {
'Metadata': [
{
'ratingKey': 'movie-1',
'type': 'movie',
'title': 'Movie',
if (guids.isNotEmpty)
'Guid': [
for (final guid in guids) {'id': guid},
],
},
],
},
});
}
return http.Response('not found', 404);
}),
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = testMultiServerProvider(manager);
final offlineMode = OfflineModeProvider(manager);
final catalogSources = _WatchlistSourcesProvider(sources);
final stack = await ProfileStack.create(db: db, withStorage: false);
addTearDown(() async {
await stack.dispose();
catalogSources.dispose();
offlineMode.dispose();
multiServerProvider.dispose();
manager.dispose();
await db.close();
});
final menuKey = GlobalKey<MediaContextMenuState>();
final item = testMediaItem(id: 'movie-1', kind: MediaKind.movie, title: 'Movie', serverId: 'plex-1');
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
ChangeNotifierProvider<OfflineModeProvider>.value(value: offlineMode),
ChangeNotifierProvider<CatalogSourcesProvider>.value(value: catalogSources),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: Center(
child: MediaContextMenu(
key: menuKey,
item: item,
child: const SizedBox(width: 120, height: 80, child: Text('watchlist target')),
),
),
),
),
),
),
);
return (menuKey: menuKey, item: item, catalogSources: catalogSources);
}