Files
plezy/test/services/music/music_queue_controller_test.dart
edde746 f63d0fe49e fix(music): shuffle the head of a shuffled queue too
Starting a music playlist, album, or artist on shuffle always opened on
the list's first track: MusicQueueController.load anchored _order[cursor]
and shuffled only the rest, and _startQueue collapsed "no start track"
into startIndex 0, so the anchor was always the head.

Anchoring is right for the two callers that do have a track which must
play first -- the now-playing shuffle toggle, and a load with an explicit
start track -- so make "no explicit start" representable instead of
inferring it from the index: load takes int? startIndex and shuffles the
whole list, head included, when it is null. A start track the list turns
out not to contain now drops the anchor rather than falling back to 0.

Video playback was never affected: Plex shuffles server-side via
/playQueues and Jellyfin already shuffles its full local list.

The queue's Random is injectable so the service-level regression is
deterministic without depending on the SDK's seeded-PRNG sequence.

Close #1811
2026-08-06 06:11:12 +02:00

246 lines
8.3 KiB
Dart

import 'dart:math';
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/services/music/music_playback_service.dart';
import 'package:plezy/services/music/music_queue_controller.dart';
import '../../test_helpers/media_items.dart';
MediaItem _track(String id) =>
testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.track, title: 'Track $id', serverId: 'srv');
List<String> _ids(List<MediaItem> items) => [for (final i in items) i.id];
void main() {
final tracks = [for (var i = 0; i < 6; i++) _track('t$i')];
MusicQueueController controller({int seed = 42}) => MusicQueueController(random: Random(seed));
group('load', () {
test('starts at startIndex in canonical order', () {
final q = controller()..load(tracks, startIndex: 2);
expect(_ids(q.queue), ['t0', 't1', 't2', 't3', 't4', 't5']);
expect(q.cursor, 2);
expect(q.current!.id, 't2');
expect(q.shuffled, isFalse);
});
test('shuffle anchors the start track first', () {
final q = controller()..load(tracks, startIndex: 3, shuffle: true);
expect(q.shuffled, isTrue);
expect(q.cursor, 0);
expect(q.current!.id, 't3');
expect(_ids(q.queue).first, 't3');
expect(_ids(q.queue).toSet(), _ids(tracks).toSet());
});
test('shuffle without a start index randomizes the head too (#1811)', () {
// A shuffle launch has no track that must play first, so the head must
// be drawn from the whole list. Anchoring the default index 0 made
// every shuffled playlist open on its first track.
final heads = <String>{};
for (var seed = 0; seed < 8; seed++) {
final q = controller(seed: seed)..load(tracks, shuffle: true);
expect(q.shuffled, isTrue);
expect(q.cursor, 0);
expect(_ids(q.queue).toSet(), _ids(tracks).toSet());
heads.add(q.current!.id);
}
expect(heads.length, greaterThan(1), reason: 'head stayed pinned to one track across seeds');
});
test('empty load leaves an idle queue', () {
final q = controller()..load(const []);
expect(q.isEmpty, isTrue);
expect(q.cursor, -1);
expect(q.current, isNull);
expect(q.nextIndex(), isNull);
});
});
group('advancement (nextIndex/previousIndex)', () {
test('repeat off walks forward and ends after the last track', () {
final q = controller()..load(tracks, startIndex: 4);
expect(q.nextIndex(), 5);
q.jumpTo(5);
expect(q.nextIndex(), isNull);
expect(q.nextIndex(manual: true), isNull);
});
test('repeat all wraps both directions', () {
final q = controller()..load(tracks, startIndex: 5);
q.repeatMode = MusicRepeatMode.all;
expect(q.nextIndex(), 0);
q.jumpTo(0);
expect(q.previousIndex(), 5);
});
test('repeat one repeats naturally but steps on manual next', () {
final q = controller()..load(tracks, startIndex: 1);
q.repeatMode = MusicRepeatMode.one;
expect(q.nextIndex(), 1);
expect(q.nextIndex(manual: true), 2);
});
test('repeat one on the last track ends on manual next', () {
final q = controller()..load(tracks, startIndex: 5);
q.repeatMode = MusicRepeatMode.one;
expect(q.nextIndex(), 5);
expect(q.nextIndex(manual: true), isNull);
});
test('previousIndex steps back and stops at the head with repeat off', () {
final q = controller()..load(tracks, startIndex: 1);
expect(q.previousIndex(), 0);
q.jumpTo(0);
expect(q.previousIndex(), isNull);
});
});
group('jumpTo', () {
test('moves the cursor within bounds only', () {
final q = controller()..load(tracks);
q.jumpTo(4);
expect(q.current!.id, 't4');
q.jumpTo(99);
expect(q.cursor, 4);
q.jumpTo(-1);
expect(q.cursor, 4);
});
});
group('addNext / addToEnd', () {
test('addNext inserts directly after the current track', () {
final q = controller()..load(tracks, startIndex: 2);
q.addNext([_track('n1'), _track('n2')]);
expect(_ids(q.queue), ['t0', 't1', 't2', 'n1', 'n2', 't3', 't4', 't5']);
expect(q.current!.id, 't2');
});
test('addToEnd appends after everything', () {
final q = controller()..load(tracks, startIndex: 2);
q.addToEnd([_track('e1')]);
expect(_ids(q.queue).last, 'e1');
expect(q.current!.id, 't2');
});
test('added tracks survive an unshuffle in canonical order', () {
final q = controller()..load(tracks, startIndex: 0, shuffle: true);
q.addToEnd([_track('e1')]);
q.toggleShuffle(); // off — canonical = insertion order
expect(_ids(q.queue), ['t0', 't1', 't2', 't3', 't4', 't5', 'e1']);
});
});
group('removeAt', () {
test('before the cursor shifts the cursor back', () {
final q = controller()..load(tracks, startIndex: 3);
final wasCurrent = q.removeAt(1);
expect(wasCurrent, isFalse);
expect(q.current!.id, 't3');
expect(q.cursor, 2);
expect(_ids(q.queue), ['t0', 't2', 't3', 't4', 't5']);
});
test('after the cursor leaves the cursor alone', () {
final q = controller()..load(tracks, startIndex: 3);
expect(q.removeAt(5), isFalse);
expect(q.current!.id, 't3');
expect(q.cursor, 3);
});
test('at the cursor keeps the cursor on the following track', () {
final q = controller()..load(tracks, startIndex: 3);
expect(q.removeAt(3), isTrue);
expect(q.cursor, 3);
expect(q.current!.id, 't4');
});
test('at the cursor on the last track clamps back', () {
final q = controller()..load(tracks, startIndex: 5);
expect(q.removeAt(5), isTrue);
expect(q.cursor, 4);
expect(q.current!.id, 't4');
});
test('removing the only track empties the queue', () {
final q = controller()..load([_track('solo')]);
expect(q.removeAt(0), isTrue);
expect(q.isEmpty, isTrue);
expect(q.cursor, -1);
});
});
group('reorder (move)', () {
test('moving the current track moves the cursor with it', () {
final q = controller()..load(tracks, startIndex: 2);
q.move(2, 4);
expect(q.cursor, 4);
expect(q.current!.id, 't2');
expect(_ids(q.queue), ['t0', 't1', 't3', 't4', 't2', 't5']);
});
test('moving an entry across the cursor adjusts it', () {
final q = controller()..load(tracks, startIndex: 2);
q.move(0, 5);
expect(q.cursor, 1);
expect(q.current!.id, 't2');
q.move(5, 0);
expect(q.cursor, 2);
expect(q.current!.id, 't2');
});
test('moving entries on one side keeps the cursor', () {
final q = controller()..load(tracks, startIndex: 2);
q.move(3, 5);
expect(q.cursor, 2);
expect(q.current!.id, 't2');
});
});
group('toggleShuffle', () {
test('on: current track anchors first, rest shuffled after', () {
final q = controller()..load(tracks, startIndex: 2);
q.toggleShuffle();
expect(q.shuffled, isTrue);
expect(q.cursor, 0);
expect(q.current!.id, 't2');
expect(_ids(q.queue).toSet(), _ids(tracks).toSet());
});
test('off: canonical order restored, cursor follows current', () {
final q = controller()..load(tracks, startIndex: 2);
q.toggleShuffle();
q.jumpTo(3); // some shuffled position
final current = q.current!.id;
q.toggleShuffle();
expect(q.shuffled, isFalse);
expect(_ids(q.queue), ['t0', 't1', 't2', 't3', 't4', 't5']);
expect(q.current!.id, current);
});
});
group('clearUpcoming', () {
test('drops everything after the current track', () {
final q = controller()..load(tracks, startIndex: 2);
q.clearUpcoming();
expect(_ids(q.queue), ['t0', 't1', 't2']);
expect(q.current!.id, 't2');
expect(q.nextIndex(), isNull);
});
test('while shuffled also drops the canonical items', () {
final q = controller()..load(tracks, startIndex: 0, shuffle: true);
final kept = _ids(q.queue.sublist(0, 2));
q.jumpTo(1);
q.clearUpcoming();
expect(_ids(q.queue), kept);
q.toggleShuffle();
expect(_ids(q.queue).toSet(), kept.toSet());
expect(q.current!.id, kept[1]);
});
});
}