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.
285 lines
7.3 KiB
TypeScript
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']);
|
|
});
|
|
});
|