Consolidates duplicated logic behind shared implementations — paginated grid tabs, focus chrome, cached remote stores, sheet selection columns, the server artifact store and a test fixture layer — and removes code that had become unreachable. Net reduction of about 5,500 lines with no behaviour change. Where a fix had landed separately in code that moved into a shared helper, the fix was re-applied inside the helper rather than left behind in the copy that went away.
638 lines
25 KiB
Dart
638 lines
25 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:plezy/media/ids.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_server_client.dart';
|
|
import 'package:plezy/media/play_queue.dart';
|
|
import 'package:plezy/models/plex/play_queue_response.dart';
|
|
import 'package:plezy/providers/multi_server_provider.dart';
|
|
import 'package:plezy/providers/playback_state_provider.dart';
|
|
import 'package:plezy/services/data_aggregation_service.dart';
|
|
import 'package:plezy/services/episode_navigation_service.dart';
|
|
import 'package:plezy/services/multi_server_manager.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../test_helpers/media_items.dart';
|
|
import '../test_helpers/multi_server_fixtures.dart';
|
|
|
|
MediaItem _meta(String id, {String? title}) =>
|
|
testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id');
|
|
|
|
MediaItem _jfEpisode(String id, {required String seriesId, ServerId? serverId}) => testMediaItem(
|
|
id: id,
|
|
backend: MediaBackend.jellyfin,
|
|
kind: MediaKind.episode,
|
|
title: 'Episode $id',
|
|
serverId: serverId ?? ServerId('srv-jf'),
|
|
grandparentId: seriesId,
|
|
);
|
|
|
|
MediaItem _jfMovie(String id) => testMediaItem(
|
|
id: id,
|
|
backend: MediaBackend.jellyfin,
|
|
kind: MediaKind.movie,
|
|
title: 'Movie $id',
|
|
serverId: ServerId('srv-jf'),
|
|
);
|
|
|
|
PlexMediaItem _plexMovie(String id, int playQueueItemId) =>
|
|
PlexMediaItem(id: id, kind: MediaKind.movie, title: 'Movie $id', playQueueItemId: playQueueItemId);
|
|
|
|
MediaItem _plexEpisode(String id, {required String seriesId, int? viewCount}) => testMediaItem(
|
|
id: id,
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.episode,
|
|
title: 'Episode $id',
|
|
serverId: 'srv-plex',
|
|
grandparentId: seriesId,
|
|
viewCount: viewCount,
|
|
);
|
|
|
|
/// MultiServerManager subclass that returns a pre-supplied client without
|
|
/// going through the production add-connection flow. The base class doesn't
|
|
/// expose a way to inject clients into its private `_clients` map, so we
|
|
/// override the lookup directly.
|
|
class _StubManager extends MultiServerManager {
|
|
_StubManager(this._client);
|
|
final MediaServerClient? _client;
|
|
@override
|
|
MediaServerClient? getClient(String _) => _client;
|
|
}
|
|
|
|
/// Recording client whose `fetchClientSideEpisodeQueue` is observable —
|
|
/// callers can assert it was (or wasn't) hit.
|
|
class _RecordingClient implements MediaServerClient {
|
|
_RecordingClient({required this.seriesEpisodes, this.clientBackend = MediaBackend.jellyfin, this.fetchError});
|
|
final List<MediaItem> seriesEpisodes;
|
|
final MediaBackend clientBackend;
|
|
final Object? fetchError;
|
|
final List<String> seriesQueueCalls = [];
|
|
|
|
@override
|
|
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async {
|
|
seriesQueueCalls.add(seriesId);
|
|
final error = fetchError;
|
|
if (error != null) throw error;
|
|
return seriesEpisodes;
|
|
}
|
|
|
|
@override
|
|
MediaBackend get backend => clientBackend;
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _ProbeWidget extends StatefulWidget {
|
|
const _ProbeWidget({required this.metadata, required this.onResult});
|
|
|
|
final MediaItem metadata;
|
|
final void Function(AdjacentEpisodes) onResult;
|
|
|
|
@override
|
|
State<_ProbeWidget> createState() => _ProbeWidgetState();
|
|
}
|
|
|
|
class _ProbeWidgetState extends State<_ProbeWidget> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
if (!mounted) return;
|
|
final svc = EpisodeNavigationService();
|
|
final result = await svc.loadAdjacentEpisodes(context: context, metadata: widget.metadata);
|
|
widget.onResult(result);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) =>
|
|
const Directionality(textDirection: TextDirection.ltr, child: SizedBox.shrink());
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('loadAdjacentEpisodes', () {
|
|
testWidgets('returns unavailable when no play queue is active for a standalone movie', (tester) async {
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
final manager = _StubManager(null);
|
|
final serverProvider = testMultiServerProvider(manager);
|
|
addTearDown(serverProvider.dispose);
|
|
|
|
AdjacentEpisodes? result;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
|
|
],
|
|
child: _ProbeWidget(metadata: _jfMovie('42'), onResult: (r) => result = r),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(result, isNotNull);
|
|
expect(result!.nextStatus, QueueNavigationStatus.unavailable);
|
|
expect(result!.previousStatus, QueueNavigationStatus.unavailable);
|
|
expect(result!.hasNext, isFalse);
|
|
expect(result!.hasPrevious, isFalse);
|
|
expect(playback.isQueueActive, isFalse);
|
|
});
|
|
|
|
testWidgets('preserves adjacency for a movie in an active local queue', (tester) async {
|
|
final previous = _jfMovie('movie-1');
|
|
final current = _jfMovie('movie-2');
|
|
final next = _jfMovie('movie-3');
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
playback.setPlaybackFromLocalQueue(
|
|
LocalPlayQueue(
|
|
id: 'jellyfin:playlist-movies',
|
|
items: [previous, current, next],
|
|
currentIndex: 1,
|
|
backendId: MediaBackend.jellyfin.id,
|
|
),
|
|
contextKey: 'playlist-movies',
|
|
);
|
|
final client = _RecordingClient(seriesEpisodes: const []);
|
|
final manager = _StubManager(client);
|
|
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
|
addTearDown(serverProvider.dispose);
|
|
|
|
AdjacentEpisodes? result;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
|
|
],
|
|
child: _ProbeWidget(metadata: current, onResult: (r) => result = r),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(client.seriesQueueCalls, isEmpty);
|
|
expect(playback.isQueueActive, isTrue);
|
|
expect(playback.shuffleContextKey, 'playlist-movies');
|
|
expect(playback.currentQueueItem, same(current));
|
|
expect(playback.currentPlayQueueItemID, 1);
|
|
expect(playback.loadedItems, [previous, current, next]);
|
|
expect(result, isNotNull);
|
|
expect(result!.nextStatus, QueueNavigationStatus.found);
|
|
expect(result!.previousStatus, QueueNavigationStatus.found);
|
|
expect(result!.next, same(next));
|
|
expect(result!.previous, same(previous));
|
|
expect(result!.hasNext, isTrue);
|
|
expect(result!.hasPrevious, isTrue);
|
|
});
|
|
|
|
testWidgets('preserves a local movie collection queue for a current-item clone', (tester) async {
|
|
final previous = _jfMovie('movie-1');
|
|
final storedCurrent = _jfMovie('movie-2');
|
|
final next = _jfMovie('movie-3');
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
playback.setPlaybackFromLocalQueue(
|
|
LocalPlayQueue(
|
|
id: 'jellyfin:collection-movies',
|
|
items: [previous, storedCurrent, next],
|
|
currentIndex: 1,
|
|
backendId: MediaBackend.jellyfin.id,
|
|
),
|
|
contextKey: 'collection-movies',
|
|
);
|
|
final client = _RecordingClient(seriesEpisodes: const []);
|
|
final manager = _StubManager(client);
|
|
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
|
addTearDown(serverProvider.dispose);
|
|
final currentClone = storedCurrent.copyWith(viewOffsetMs: 42);
|
|
|
|
AdjacentEpisodes? result;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
|
|
],
|
|
child: _ProbeWidget(metadata: currentClone, onResult: (r) => result = r),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(client.seriesQueueCalls, isEmpty);
|
|
expect(playback.isQueueActive, isTrue);
|
|
expect(playback.shuffleContextKey, 'collection-movies');
|
|
expect(playback.currentQueueItem, same(storedCurrent));
|
|
expect(playback.currentPlayQueueItemID, 1);
|
|
expect(playback.loadedItems, [previous, storedCurrent, next]);
|
|
expect(result, isNotNull);
|
|
expect(result!.next, same(next));
|
|
expect(result!.previous, same(previous));
|
|
expect(result!.hasNext, isTrue);
|
|
expect(result!.hasPrevious, isTrue);
|
|
});
|
|
|
|
testWidgets('extends a Plex movie queue window for adjacency', (tester) async {
|
|
final previous = _plexMovie('movie-1', 1001);
|
|
final current = _plexMovie('movie-2', 1002);
|
|
final next = _plexMovie('movie-3', 1003);
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
await playback.setPlaybackFromPlayQueue(
|
|
PlayQueueResponse(
|
|
playQueueID: 77,
|
|
playQueueSelectedItemID: 1002,
|
|
playQueueShuffled: false,
|
|
playQueueTotalCount: 3,
|
|
playQueueVersion: 1,
|
|
items: [current],
|
|
),
|
|
'collection-movies',
|
|
);
|
|
var windowFetches = 0;
|
|
String? requestedCenter;
|
|
playback.setPlayQueueWindowFetcher((playQueueId, {center, window = 50}) async {
|
|
windowFetches++;
|
|
requestedCenter = center;
|
|
expect(playQueueId, 77);
|
|
return PlayQueueResponse(
|
|
playQueueID: playQueueId,
|
|
playQueueSelectedItemID: 1002,
|
|
playQueueShuffled: false,
|
|
playQueueTotalCount: 3,
|
|
playQueueVersion: 2,
|
|
items: [previous, current, next],
|
|
);
|
|
});
|
|
final manager = _StubManager(null);
|
|
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
|
addTearDown(serverProvider.dispose);
|
|
|
|
AdjacentEpisodes? result;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
|
|
],
|
|
child: _ProbeWidget(metadata: current, onResult: (r) => result = r),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(windowFetches, 1);
|
|
expect(requestedCenter, '1002');
|
|
expect(playback.isQueueActive, isTrue);
|
|
expect(playback.playQueueId, 77);
|
|
expect(playback.currentQueueItem, same(current));
|
|
expect(playback.currentPlayQueueItemID, 1002);
|
|
expect(playback.loadedItems, [previous, current, next]);
|
|
expect(result, isNotNull);
|
|
expect(result!.nextStatus, QueueNavigationStatus.found);
|
|
expect(result!.previousStatus, QueueNavigationStatus.found);
|
|
expect(result!.next, same(next));
|
|
expect(result!.previous, same(previous));
|
|
expect(result!.hasNext, isTrue);
|
|
expect(result!.hasPrevious, isTrue);
|
|
});
|
|
|
|
testWidgets('does not use an unrelated active queue for a standalone movie', (tester) async {
|
|
final queuedPrevious = _jfMovie('queued-1');
|
|
final queuedCurrent = _jfMovie('queued-2');
|
|
final unrelated = _jfMovie('unrelated');
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
playback.setPlaybackFromLocalQueue(
|
|
LocalPlayQueue(
|
|
id: 'jellyfin:stale-playlist',
|
|
items: [queuedPrevious, queuedCurrent],
|
|
currentIndex: 1,
|
|
backendId: MediaBackend.jellyfin.id,
|
|
),
|
|
contextKey: 'stale-playlist',
|
|
);
|
|
final client = _RecordingClient(seriesEpisodes: const []);
|
|
final manager = _StubManager(client);
|
|
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
|
addTearDown(serverProvider.dispose);
|
|
|
|
AdjacentEpisodes? result;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
|
|
],
|
|
child: _ProbeWidget(metadata: unrelated, onResult: (r) => result = r),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(client.seriesQueueCalls, isEmpty);
|
|
expect(playback.isQueueActive, isTrue);
|
|
expect(playback.shuffleContextKey, 'stale-playlist');
|
|
expect(playback.currentQueueItem, same(queuedCurrent));
|
|
expect(playback.currentPlayQueueItemID, 1);
|
|
expect(playback.loadedItems, [queuedPrevious, queuedCurrent]);
|
|
expect(result, isNotNull);
|
|
expect(result!.nextStatus, QueueNavigationStatus.failed);
|
|
expect(result!.previousStatus, QueueNavigationStatus.failed);
|
|
expect(result!.hasNext, isFalse);
|
|
expect(result!.hasPrevious, isFalse);
|
|
});
|
|
|
|
testWidgets('catches downstream exceptions and reports failed adjacency', (tester) async {
|
|
// Required providers are absent, so context.read throws. The service
|
|
// converts the exception into an explicit failed result.
|
|
AdjacentEpisodes? result;
|
|
await tester.pumpWidget(_ProbeWidget(metadata: _meta('42'), onResult: (r) => result = r));
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(result, isNotNull);
|
|
expect(result!.hasNext, isFalse);
|
|
expect(result!.hasPrevious, isFalse);
|
|
expect(result!.nextStatus, QueueNavigationStatus.failed);
|
|
});
|
|
|
|
testWidgets('preserves an active playlist/collection queue against series rebuild', (tester) async {
|
|
// Reproduces the bug where playing an episode from a Jellyfin playlist
|
|
// had next/prev walking the show's episodes instead of the playlist —
|
|
// [_ensureLocalEpisodeQueue] used to overwrite the launcher-set queue
|
|
// unconditionally. The guard now bails out when contextKey is set to
|
|
// anything other than the seriesId.
|
|
final ep1 = _jfEpisode('ep1', seriesId: 'series-A');
|
|
final ep2 = _jfEpisode('ep2', seriesId: 'series-B');
|
|
final ep3 = _jfEpisode('ep3', seriesId: 'series-A');
|
|
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
playback.setPlaybackFromLocalQueue(
|
|
LocalPlayQueue(
|
|
id: 'jellyfin:playlist-X',
|
|
items: [ep1, ep2, ep3],
|
|
currentIndex: 1,
|
|
backendId: MediaBackend.jellyfin.id,
|
|
),
|
|
contextKey: 'playlist-X',
|
|
);
|
|
|
|
// Stub client returns fake series episodes that *include* ep2 — without
|
|
// the guard, the service would replace the playlist queue with this
|
|
// list and prev/next would point at sibling-X / sibling-Y.
|
|
final client = _RecordingClient(
|
|
seriesEpisodes: [
|
|
_jfEpisode('sibling-X', seriesId: 'series-B'),
|
|
ep2,
|
|
_jfEpisode('sibling-Y', seriesId: 'series-B'),
|
|
],
|
|
);
|
|
final manager = _StubManager(client);
|
|
final serverProvider = testMultiServerProvider(manager);
|
|
addTearDown(serverProvider.dispose);
|
|
|
|
AdjacentEpisodes? result;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
|
|
],
|
|
child: _ProbeWidget(metadata: ep2, onResult: (r) => result = r),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
// Guard short-circuits before the wire fetch.
|
|
expect(client.seriesQueueCalls, isEmpty);
|
|
// Queue items unchanged — still the playlist's three episodes.
|
|
expect(playback.loadedItems.map((e) => e.id), ['ep1', 'ep2', 'ep3']);
|
|
// Prev/next walk the playlist, not the series.
|
|
expect(result, isNotNull);
|
|
expect(result!.next?.id, 'ep3');
|
|
expect(result!.previous?.id, 'ep1');
|
|
});
|
|
|
|
testWidgets('builds a Plex local fallback queue with watched episodes', (tester) async {
|
|
final ep1 = _plexEpisode('ep1', seriesId: 'series-P', viewCount: 1);
|
|
final ep2 = _plexEpisode('ep2', seriesId: 'series-P', viewCount: 1);
|
|
final ep3 = _plexEpisode('ep3', seriesId: 'series-P', viewCount: 1);
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3], clientBackend: MediaBackend.plex);
|
|
final manager = _StubManager(client);
|
|
final serverProvider = testMultiServerProvider(manager);
|
|
addTearDown(serverProvider.dispose);
|
|
|
|
AdjacentEpisodes? result;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
|
|
],
|
|
child: _ProbeWidget(metadata: ep2, onResult: (r) => result = r),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(client.seriesQueueCalls, ['series-P']);
|
|
expect(playback.loadedItems.map((item) => item.id), ['ep1', 'ep2', 'ep3']);
|
|
expect(result!.nextStatus, QueueNavigationStatus.found);
|
|
expect(result!.next?.id, 'ep3');
|
|
expect(result!.previous?.id, 'ep1');
|
|
});
|
|
|
|
testWidgets('distinguishes a fallback fetch failure from the end of a series', (tester) async {
|
|
final current = _plexEpisode('ep2', seriesId: 'series-P');
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
final client = _RecordingClient(
|
|
seriesEpisodes: const [],
|
|
clientBackend: MediaBackend.plex,
|
|
fetchError: StateError('network unavailable'),
|
|
);
|
|
final manager = _StubManager(client);
|
|
final serverProvider = testMultiServerProvider(manager);
|
|
addTearDown(serverProvider.dispose);
|
|
|
|
AdjacentEpisodes? result;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
|
|
],
|
|
child: _ProbeWidget(metadata: current, onResult: (r) => result = r),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(result!.nextStatus, QueueNavigationStatus.failed);
|
|
expect(result!.isEndConfirmed, isFalse);
|
|
expect(playback.isQueueActive, isFalse);
|
|
});
|
|
|
|
testWidgets('confirms the end only after loading a queue containing the current episode', (tester) async {
|
|
final ep1 = _plexEpisode('ep1', seriesId: 'series-P', viewCount: 1);
|
|
final ep2 = _plexEpisode('ep2', seriesId: 'series-P', viewCount: 1);
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
final client = _RecordingClient(seriesEpisodes: [ep1, ep2], clientBackend: MediaBackend.plex);
|
|
final manager = _StubManager(client);
|
|
final serverProvider = testMultiServerProvider(manager);
|
|
addTearDown(serverProvider.dispose);
|
|
|
|
AdjacentEpisodes? result;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
|
|
],
|
|
child: _ProbeWidget(metadata: ep2, onResult: (r) => result = r),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(result!.nextStatus, QueueNavigationStatus.boundary);
|
|
expect(result!.isEndConfirmed, isTrue);
|
|
expect(result!.next, isNull);
|
|
});
|
|
});
|
|
|
|
// ===========================================================
|
|
// loadAdjacentEpisodes: shuffled same-series queue (#1466)
|
|
// ===========================================================
|
|
|
|
group('loadAdjacentEpisodes with a shuffled same-series queue', () {
|
|
// Mirrors JellyfinSequentialLauncher.launchShuffledShow: the full series
|
|
// episode list, locally shuffled, published with contextKey == seriesId.
|
|
// The regression under test: _ensureLocalEpisodeQueue used to rebuild a
|
|
// sequential series queue whenever contextKey == seriesId, so shuffle
|
|
// held for exactly one episode (#1466).
|
|
final ep1 = _jfEpisode('ep1', seriesId: 'series-A');
|
|
final ep2 = _jfEpisode('ep2', seriesId: 'series-A');
|
|
final ep3 = _jfEpisode('ep3', seriesId: 'series-A');
|
|
final ep4 = _jfEpisode('ep4', seriesId: 'series-A');
|
|
final ep5 = _jfEpisode('ep5', seriesId: 'series-A');
|
|
final shuffledOrder = [ep3, ep1, ep5, ep2, ep4];
|
|
const shuffledIds = ['ep3', 'ep1', 'ep5', 'ep2', 'ep4'];
|
|
|
|
// Stub server answers with the sequential list, so an (unwanted) queue
|
|
// rebuild is observable both via seriesQueueCalls and via reordered
|
|
// loadedItems.
|
|
(PlaybackStateProvider, _RecordingClient, MultiServerProvider) buildShuffledSession() {
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
playback.setPlaybackFromLocalQueue(
|
|
LocalPlayQueue(
|
|
id: 'jellyfin:series-A',
|
|
items: shuffledOrder,
|
|
currentIndex: 0,
|
|
shuffled: true,
|
|
backendId: MediaBackend.jellyfin.id,
|
|
),
|
|
contextKey: 'series-A',
|
|
);
|
|
final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3, ep4, ep5]);
|
|
final manager = _StubManager(client);
|
|
final serverProvider = testMultiServerProvider(manager);
|
|
addTearDown(serverProvider.dispose);
|
|
return (playback, client, serverProvider);
|
|
}
|
|
|
|
Future<AdjacentEpisodes?> probe(
|
|
WidgetTester tester,
|
|
PlaybackStateProvider playback,
|
|
MultiServerProvider serverProvider,
|
|
MediaItem metadata,
|
|
) async {
|
|
AdjacentEpisodes? result;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
|
|
],
|
|
child: _ProbeWidget(metadata: metadata, onResult: (r) => result = r),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
return result;
|
|
}
|
|
|
|
testWidgets('preserves the shuffled order instead of rebuilding sequentially', (tester) async {
|
|
final (playback, client, serverProvider) = buildShuffledSession();
|
|
|
|
final result = await probe(tester, playback, serverProvider, ep3);
|
|
|
|
expect(client.seriesQueueCalls, isEmpty);
|
|
expect(playback.loadedItems.map((e) => e.id), shuffledIds);
|
|
expect(playback.isShuffleActive, isTrue);
|
|
expect(playback.shuffleContextKey, 'series-A');
|
|
expect(result!.next?.id, 'ep1');
|
|
expect(result.previous, isNull);
|
|
});
|
|
|
|
testWidgets('continues the shuffled order after advancing to the next episode', (tester) async {
|
|
final (playback, client, serverProvider) = buildShuffledSession();
|
|
// What _reloadMediaInPlace does when the player swaps to the next item.
|
|
playback.setCurrentItem(ep1);
|
|
|
|
final result = await probe(tester, playback, serverProvider, ep1);
|
|
|
|
expect(client.seriesQueueCalls, isEmpty);
|
|
expect(playback.isShuffleActive, isTrue);
|
|
expect(result!.next?.id, 'ep5');
|
|
expect(result.previous?.id, 'ep3');
|
|
});
|
|
|
|
testWidgets('same-episode clone from a source switch does not clobber the queue', (tester) async {
|
|
final (playback, client, serverProvider) = buildShuffledSession();
|
|
|
|
// _switchPlaybackSource reloads with a copyWith clone of the playing
|
|
// item; MediaItem compares by identity, so queue membership misses and
|
|
// only the cursor-globalKey gate keeps the queue alive.
|
|
final result = await probe(tester, playback, serverProvider, ep3.copyWith(viewOffsetMs: 42));
|
|
|
|
expect(client.seriesQueueCalls, isEmpty);
|
|
expect(playback.loadedItems.map((e) => e.id), shuffledIds);
|
|
expect(playback.isShuffleActive, isTrue);
|
|
expect(result!.next?.id, 'ep1');
|
|
});
|
|
|
|
testWidgets('still builds a sequential series queue when no queue is active', (tester) async {
|
|
// Direct episode tap with no launcher: the preserve gates must not get
|
|
// in the way of the normal series-queue build.
|
|
final playback = PlaybackStateProvider();
|
|
addTearDown(playback.dispose);
|
|
final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3, ep4, ep5]);
|
|
final manager = _StubManager(client);
|
|
final serverProvider = testMultiServerProvider(manager);
|
|
addTearDown(serverProvider.dispose);
|
|
|
|
final result = await probe(tester, playback, serverProvider, ep3);
|
|
|
|
expect(client.seriesQueueCalls, ['series-A']);
|
|
expect(playback.loadedItems.map((e) => e.id), ['ep1', 'ep2', 'ep3', 'ep4', 'ep5']);
|
|
expect(playback.isShuffleActive, isFalse);
|
|
expect(result!.next?.id, 'ep4');
|
|
expect(result.previous?.id, 'ep2');
|
|
});
|
|
});
|
|
}
|