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.
183 lines
6.4 KiB
Dart
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));
|
|
});
|
|
});
|
|
}
|