feedBack/tests/js/play_queue_shuffle.test.js
OmikronApex 425f72b33f feat(v3): playlist shuffle toggle
Crossing-arrows toggle next to Play all / Play album on the playlist
detail page. When on, playQueue.start Fisher-Yates-shuffles the queue
once at start — on a copy, so the stored playlist order is untouched —
swapping per-slot album arrangements in lockstep so each slot keeps its
pinned arrangement (#685 contract preserved). Prev-less queue semantics
are unchanged: auto-advance simply walks the shuffled order.

Preference is global, persisted as localStorage v3PlaylistShuffle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:07:47 +02:00

87 lines
3.5 KiB
JavaScript

// playQueue.start({ shuffle: true }): the queue is Fisher-Yates-shuffled ONCE
// at start. Per-slot arrangements must swap in lockstep with their files
// (albums pass arrangements aligned by index, #685), the caller's arrays must
// not be mutated, and shuffle:false / absent must preserve order. Extract the
// playQueue IIFE from app.js and drive it against a playSong stub.
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
function makeQueue() {
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
const start = src.indexOf('window.feedBack.playQueue = (function () {');
assert.ok(start !== -1, 'playQueue IIFE found in app.js');
const end = src.indexOf('})();', start);
assert.ok(end !== -1, 'playQueue IIFE terminator found');
const iife = src.slice(start, end + 5);
const played = [];
const sandbox = {
window: {
feedBack: {},
playSong: (fn, arr, opts) => played.push({ fn: decodeURIComponent(fn), arr, opts }),
fbNotify: null,
},
};
// eslint-disable-next-line no-new-func
new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent);
return { q: sandbox.window.feedBack.playQueue, played };
}
function drain(q, played) {
while (q.hasNext()) q.advance();
return played.map((p) => p.fn);
}
test('shuffle: same multiset, order from the seeded RNG, arrangements follow files', () => {
const files = ['a.sloppak', 'b.sloppak', 'c.sloppak', 'd.sloppak'];
const arrs = [0, 1, 2, 3]; // arrangement i belongs to files[i]
const origRandom = Math.random;
try {
// Deterministic RNG so the expected order is checkable.
let calls = 0;
const seq = [0.1, 0.9, 0.5];
Math.random = () => seq[calls++ % seq.length];
const { q, played } = makeQueue();
q.start(files.slice(), { arrangements: arrs.slice(), shuffle: true });
const order = drain(q, played);
assert.deepStrictEqual(order.slice().sort(), files.slice().sort()); // nothing lost/duplicated
// Each played file carries the arrangement it started with.
played.forEach((p) => {
assert.strictEqual(p.arr, arrs[files.indexOf(p.fn)]);
});
} finally {
Math.random = origRandom;
}
});
test('shuffle can change the order', () => {
const origRandom = Math.random;
try {
Math.random = () => 0; // j = 0 every swap → deterministic rotation, ≠ input order
const { q, played } = makeQueue();
q.start(['a', 'b', 'c'], { shuffle: true });
const order = drain(q, played);
assert.notDeepStrictEqual(order, ['a', 'b', 'c']);
} finally {
Math.random = origRandom;
}
});
test('no shuffle opt preserves order and caller arrays are never mutated', () => {
const files = ['a', 'b', 'c'];
const arrs = [2, 0, 1];
const { q, played } = makeQueue();
q.start(files, { arrangements: arrs });
assert.deepStrictEqual(drain(q, played), ['a', 'b', 'c']);
assert.deepStrictEqual(files, ['a', 'b', 'c']);
assert.deepStrictEqual(arrs, [2, 0, 1]);
// shuffle:true must also leave the caller's arrays alone (start slices).
const { q: q2 } = makeQueue();
q2.start(files, { arrangements: arrs, shuffle: true });
assert.deepStrictEqual(files, ['a', 'b', 'c']);
assert.deepStrictEqual(arrs, [2, 0, 1]);
});