Files
plezy/test/services/play_queue_launcher_test.dart
T
edde746 a1d8ab5210 fix(plex): surface instant mix failures instead of silently doing nothing
Tapping Instant Mix on Plex often did nothing: the /playQueues POST used no
endpoint failover, createPlayQueue swallowed every error into null,
fetchInstantMix mapped that to an empty list, playInstantMix returned
silently on empty tracks, and the errors stream's only listener is the
now-playing screen, which is not mounted at tap time (#2141).

The play-queue POST now rides the shared endpoint failover (replaying it is
safe: an orphaned duplicate queue on the server is inert), createPlayQueue
and fetchInstantMix propagate typed errors, playInstantMix reports
started/empty/superseded and throws fetch failures, and the tap site shows
a translated snackbar for a failed or empty mix. A failed collection or
playlist queue launch now also reaches the real failure snackbar instead of
the misleading "no items" path.
2026-08-27 16:59:08 +02:00

183 lines
6.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/models/plex/play_queue_response.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/services/media_list_playback_launcher.dart';
import 'package:plezy/services/play_queue_launcher.dart';
import 'package:plezy/services/plex_client.dart';
import '../test_helpers/media_items.dart';
// Focused orchestration coverage lives here: the network response must be
// published to PlaybackStateProvider before navigation, and a navigation
// failure remains an owned PlayQueueError rather than a reported success.
// Jellyfin cancellation ownership is covered by
// jellyfin_sequential_launcher_test.dart.
class _StubPlexClient implements PlexClient {
_StubPlexClient({this.response});
final PlayQueueResponse? response;
@override
Future<PlayQueueResponse> createPlayQueue({
String? uri,
int? playlistID,
required String type,
String? key,
int shuffle = 0,
int repeat = 0,
int continuous = 0,
String? librarySectionID,
String? librarySectionTitle,
}) async {
return response!;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Future<BuildContext> _pumpContext(WidgetTester tester) async {
late BuildContext capturedContext;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
capturedContext = context;
return const SizedBox.shrink();
},
),
),
),
);
return capturedContext;
}
PlayQueueResponse _queueWith(MediaItem item) {
return PlayQueueResponse(
playQueueID: 73,
playQueueSelectedItemID: 41,
playQueueShuffled: false,
playQueueTotalCount: 1,
items: [item],
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('launchShuffledShow pre-flight guard', () {
testWidgets('returns PlayQueueError when metadata is not a show or season', (tester) async {
// Build a launcher inside an active Element so its `context.mounted`
// returns true. We don't need a Provider tree because the guard runs
// before any context.read.
late BuildContext capturedContext;
await tester.pumpWidget(
Builder(
builder: (context) {
capturedContext = context;
return const SizedBox.shrink();
},
),
);
final launcher = PlexPlayQueueLauncher(context: capturedContext, client: _StubPlexClient());
final result = await launcher.launchShuffledShow(
// movie is not show / season.
metadata: testMediaItem(id: 'rk1', backend: MediaBackend.plex, kind: MediaKind.movie),
showLoadingIndicator: false,
);
expect(result, isA<PlayQueueError>());
final error = (result as PlayQueueError).error;
expect(error.toString(), contains('shows and seasons'));
});
});
group('launchFromCollectionOrPlaylist input guard', () {
testWidgets('returns PlayQueueError for non-collection/playlist input', (tester) async {
late BuildContext capturedContext;
await tester.pumpWidget(
Builder(
builder: (context) {
capturedContext = context;
return const SizedBox.shrink();
},
),
);
final launcher = PlexPlayQueueLauncher(context: capturedContext, client: _StubPlexClient());
// Passing a String — neither a PlexMetadata nor a PlexPlaylist.
final result = await launcher.launchFromCollectionOrPlaylist(item: 'not-a-real-item', shuffle: false);
expect(result, isA<PlayQueueError>());
final error = (result as PlayQueueError).error;
expect(error.toString(), contains('collection or playlist'));
});
});
group('queue application ownership', () {
testWidgets('publishes the Plex queue before navigating to its selected item', (tester) async {
final context = await _pumpContext(tester);
final item = const MediaItem.plex(id: 'movie-1', kind: MediaKind.movie, title: 'Movie', playQueueItemId: 41);
final playbackState = PlaybackStateProvider();
final navigated = <MediaItem>[];
final launcher = PlexPlayQueueLauncher(
context: context,
client: _StubPlexClient(response: _queueWith(item)),
playbackStateForTesting: playbackState,
navigateForTesting: (selected) async {
expect(playbackState.isQueueActive, isTrue);
expect(playbackState.playQueueId, 73);
expect(playbackState.currentQueueItem, same(item));
expect(playbackState.loadedItems.single, same(item));
navigated.add(selected);
},
);
const playlist = MediaPlaylist(id: '12', backend: MediaBackend.plex, title: 'Playlist', playlistType: 'video');
final result = await launcher.launchFromCollectionOrPlaylist(
item: playlist,
shuffle: false,
showLoadingIndicator: false,
);
expect(result, isA<PlayQueueSuccess>());
expect(navigated, hasLength(1));
expect(navigated.single, same(item));
});
testWidgets('navigation failure is returned as PlayQueueError, not success', (tester) async {
final context = await _pumpContext(tester);
final item = const MediaItem.plex(id: 'movie-1', kind: MediaKind.movie, playQueueItemId: 41);
final failure = StateError('navigation failed');
final playbackState = PlaybackStateProvider();
final launcher = PlexPlayQueueLauncher(
context: context,
client: _StubPlexClient(response: _queueWith(item)),
playbackStateForTesting: playbackState,
navigateForTesting: (_) async => throw failure,
);
const playlist = MediaPlaylist(id: '12', backend: MediaBackend.plex, title: 'Playlist', playlistType: 'video');
final result = await launcher.launchFromCollectionOrPlaylist(
item: playlist,
shuffle: false,
showLoadingIndicator: false,
);
expect(result, isA<PlayQueueError>());
expect((result as PlayQueueError).error, same(failure));
expect(playbackState.isQueueActive, isTrue);
expect(playbackState.currentQueueItem, same(item));
});
});
}