Files
yellowjacket/frontend/test/stores/queue-store.test.ts
T
logan 5ca6cad45a
Build & publish Arch package / arch-package (push) Successful in 2m8s
CI / check (push) Failing after 1m56s
CI / e2e (push) Skipped
Search index maintenance / maintain-index (push) Successful in 13s
feat(harness): agent-drivable dev harness and CI that gates
A coding agent could develop this repo's Go packages and could not
develop the application: every path to running YellowJacket ended in a
blocking GTK window, so 265 bound methods, 46 events, 33 component
directories and 13 stores had exactly one form of verification
available — `tsc --noEmit`.

The unlock is that `wails dev`'s dev server on :34115 serves the real
frontend with the real generated bindings against the same Go backend a
desktop window attaches to, so a plain Chromium under Xvfb gets a fully
functional app. Four test tiers now exist, cheapest first:

- `make ui-test` — 313 Vitest tests in a real browser in ~2 s, no app,
  no backend, no display. Works because `frontend/wailsjs/` is a pure
  passthrough to `window.go`/`window.runtime`, so faking just those two
  globals runs the real bindings and the real store code.
- `make test` — services in-process, asserting on the payload the
  frontend would receive, via a new `events.Emit` wrapper.
- `make dev-headless` + `playwright-cli` — the real app, driven
  interactively, with an event bridge on `window.__yjEvents` and a
  dev-only control surface at `/__test/`.
- `make e2e` — 19 of those flows frozen as Playwright specs.

`events.Emit(ctx, …)` replaces all 35 direct `runtime.EventsEmit` call
sites: wails' `getEvents` `log.Fatalf`s on any context without its
runtime, so those paths could not run under test and a background
worker could take the app down. Four packages had each hand-rolled the
same guard; nine more guarded on `ctx != nil`, which does not help.
`TestNoDirectRuntimeEmits` fails the build on a new one.

Fixtures are generated, not committed (`make testdata`), and seeds are
built by *running the app* — never by hand-writing config and DB rows,
which would be a second description of a valid YJ_HOME.

`.gitea/workflows/ci.yml` is the first workflow here that tests
anything; the other three only package, so `gitea_ci` reported only
packaging jobs and misled anyone asking whether a push was healthy.
Both jobs were prototyped to green in a bare ubuntu:24.04 container
before the YAML was written, which immediately caught `make lint`
linting three configurations that nothing builds: all three passes
omitted `webkit2_41`, so wails resolved webkit2gtk-4.0 — which Arch
still ships and Ubuntu 24.04 dropped.

Operational instructions live in `.pi/skills/yellowjacket-dev/`,
measured discoveries in `.planning/NOTES.md`, and architecture in
`CLAUDE.md` — split by tense, not by topic, because a topical split
gives every new fact two plausible homes. `make skill-check` fails a
commit if the skill cites a make target that does not exist.
2026-08-10 23:20:42 -04:00

285 lines
7.3 KiB
TypeScript

/**
* The queue store's delta reducer is the most intricate pure logic in
* the frontend: four mutation actions arriving as events, applied to a
* cached array that must stay identical to the Go queue's own. `move` in
* particular adjusts its insertion index for elements removed before it,
* and gets that wrong silently.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { queueStore, type QueueTrack } from '@store/queue-store';
import { Events } from '../../src/events';
import { emit, flush, lastArgs, calls } from '@test/support/harness';
function track(n: number): QueueTrack {
return {
id: n,
audioFileId: n,
filePath: `/music/${n}.mp3`,
position: n,
title: `Track ${n}`,
artist: 'Artist',
album: 'Album',
coverArtPath: '',
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
}
/** Titles of the cached queue, the cheapest readable assertion. */
function titles(): string[] {
return queueStore.getState().tracks.map((t) => t.title);
}
/** Push an authoritative full-state sync, as the backend does on
* startup and after SetQueue. */
function sync(tracks: QueueTrack[], currentIndex = 0): void {
emit(Events.QueueChanged, {
tracks,
currentIndex,
shuffleMode: false,
repeatMode: 'off',
sourcePlaylistId: 0,
});
}
describe('queue store: full-state sync', () => {
beforeEach(() => {
sync([]);
});
it('replaces cached state wholesale', () => {
sync([track(1), track(2)], 1);
expect(queueStore.getState()).toEqual({
tracks: [track(1), track(2)],
currentIndex: 1,
shuffleMode: false,
repeatMode: 'off',
sourcePlaylistId: 0,
});
});
it('tolerates a null track list, which Go sends for an empty queue', () => {
emit(Events.QueueChanged, {
tracks: null,
currentIndex: -1,
shuffleMode: false,
repeatMode: 'off',
sourcePlaylistId: 0,
});
expect(queueStore.getState().tracks).toEqual([]);
});
});
describe('queue store: track deltas', () => {
beforeEach(() => {
sync([track(1), track(2), track(3)], 0);
});
it('appends on add', () => {
emit(Events.QueueTracksModified, {
action: 'add',
tracks: [track(4)],
index: 0,
currentIndex: 0,
});
expect(titles()).toEqual(['Track 1', 'Track 2', 'Track 3', 'Track 4']);
});
it('splices at the index on insert', () => {
emit(Events.QueueTracksModified, {
action: 'insert',
tracks: [track(9)],
index: 1,
currentIndex: 0,
});
expect(titles()).toEqual(['Track 1', 'Track 9', 'Track 2', 'Track 3']);
});
it('removes every listed position at once, not one at a time', () => {
// Removing 0 then 2 sequentially would take the wrong second track;
// the reducer must treat the positions as indices into the original.
emit(Events.QueueTracksModified, {
action: 'remove',
positions: [0, 2],
index: 0,
currentIndex: 0,
});
expect(titles()).toEqual(['Track 2']);
});
it('adopts the backend current index from every delta', () => {
emit(Events.QueueTracksModified, {
action: 'remove',
positions: [0],
index: 0,
currentIndex: 1,
});
expect(queueStore.getState().currentIndex).toBe(1);
});
});
describe('queue store: move', () => {
beforeEach(() => {
sync([track(1), track(2), track(3), track(4)], 0);
});
it('moves a track forward, compensating for its own removal', () => {
// Move index 0 to index 2. After removing it, the target shifts
// down by one, so track 1 lands between 2 and 3 — not after 3.
emit(Events.QueueTracksModified, {
action: 'move',
tracks: [track(1)],
positions: [0],
index: 2,
currentIndex: 1,
});
expect(titles()).toEqual(['Track 2', 'Track 1', 'Track 3', 'Track 4']);
});
it('moves a track backward without compensating', () => {
emit(Events.QueueTracksModified, {
action: 'move',
tracks: [track(4)],
positions: [3],
index: 1,
currentIndex: 0,
});
expect(titles()).toEqual(['Track 1', 'Track 4', 'Track 2', 'Track 3']);
});
it('moves a multi-selection, compensating once per element before the target', () => {
emit(Events.QueueTracksModified, {
action: 'move',
tracks: [track(1), track(2)],
positions: [0, 1],
index: 3,
currentIndex: 0,
});
expect(titles()).toEqual(['Track 3', 'Track 1', 'Track 2', 'Track 4']);
});
it('clamps a target past the end of the shortened list', () => {
emit(Events.QueueTracksModified, {
action: 'move',
tracks: [track(1)],
positions: [0],
index: 99,
currentIndex: 3,
});
expect(titles()).toEqual(['Track 2', 'Track 3', 'Track 4', 'Track 1']);
});
});
describe('queue store: mode deltas', () => {
beforeEach(() => {
sync([track(1)], 0);
});
it('applies shuffle and repeat together', () => {
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'one' });
const state = queueStore.getState();
expect([state.shuffleMode, state.repeatMode]).toEqual([true, 'one']);
});
it('leaves the track list untouched', () => {
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'all' });
expect(titles()).toEqual(['Track 1']);
});
it('applies an index-only delta', () => {
emit(Events.QueueIndexChanged, { currentIndex: 7 });
expect(queueStore.getState().currentIndex).toBe(7);
});
});
describe('queue store: subscriber notification', () => {
beforeEach(() => {
sync([]);
});
it('coalesces a burst of events into one notification', async () => {
let notifications = 0;
const unsubscribe = queueStore.subscribe(() => {
notifications += 1;
});
emit(Events.QueueIndexChanged, { currentIndex: 1 });
emit(Events.QueueIndexChanged, { currentIndex: 2 });
emit(Events.QueueIndexChanged, { currentIndex: 3 });
await flush();
unsubscribe();
expect(notifications).toBe(1);
});
it('stops notifying after unsubscribe', async () => {
let notifications = 0;
const unsubscribe = queueStore.subscribe(() => {
notifications += 1;
});
unsubscribe();
emit(Events.QueueIndexChanged, { currentIndex: 1 });
await flush();
expect(notifications).toBe(0);
});
});
describe('queue store: actions reach the backend', () => {
it('forwards setQueue with its default shuffleStart', () => {
queueStore.setQueue(['/a.mp3', '/b.mp3'], 1);
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
['/a.mp3', '/b.mp3'],
1,
false,
]);
});
it('maps each mutation onto its own bound method', () => {
queueStore.addToQueue('/a.mp3');
queueStore.playNext('/b.mp3');
queueStore.removeFromQueue(2);
queueStore.moveTracksInQueue([0, 1], 3);
queueStore.toggleShuffle();
queueStore.cycleRepeat();
queueStore.clearQueue();
expect(calls().map((c) => c.path)).toEqual([
'queue.Queue.AddTrack',
'queue.Queue.InsertNext',
'queue.Queue.RemoveTrack',
'queue.Queue.MoveQueueTracks',
'queue.Queue.ToggleShuffle',
'queue.Queue.CycleRepeat',
'queue.Queue.Clear',
]);
});
it('does not optimistically mutate cached state', () => {
sync([track(1)], 0);
queueStore.clearQueue();
// The backend is the only writer; the cache waits for the event.
expect(titles()).toEqual(['Track 1']);
});
});