feat(harness): agent-drivable dev harness and CI that gates
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

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.
This commit is contained in:
2026-08-10 23:20:42 -04:00
parent 65333857e2
commit 5ca6cad45a
117 changed files with 14585 additions and 262 deletions
+344
View File
@@ -0,0 +1,344 @@
/**
* The download store is mostly event-driven refreshes plus a set of
* pure formatters the downloads list and the picker share — they exist
* so those two views cannot disagree about what a state is called, and
* that only holds if both are tested against the same expectations.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import {
downloadStore,
isDownloadTerminal,
stateLabel,
scorePercent,
candidateSummary,
formatBytes,
type DownloadView,
type DownloadCandidate,
type DownloadProvider,
type Request,
} from '@store/download-store';
import { Events } from '../../src/events';
import {
emit,
calls,
stub,
flush,
lastArgs,
resetHarness,
} from '@test/support/harness';
function view(id: string, state: string): DownloadView {
return { id, state } as DownloadView;
}
function provider(id: number, enabled: boolean): DownloadProvider {
return { id, name: `p${id}`, enabled, kind: 'test' } as DownloadProvider;
}
function request(overrides: Partial<Request>): Request {
return {
id: 1,
mbid: 'abc',
entity: 'release-group',
state: 'wanted',
...overrides,
} as Request;
}
function candidate(overrides: Partial<DownloadCandidate>): DownloadCandidate {
return { id: 'c', totalSize: 0, origin: '', ...overrides } as DownloadCandidate;
}
describe('download formatters', () => {
it('treats complete, cancelled and failed as terminal', () => {
expect(
['complete', 'cancelled', 'failed', 'grabbing'].map((s) =>
isDownloadTerminal(view('d', s)),
),
).toEqual([true, true, true, false]);
});
it('labels every lifecycle state in the users terms', () => {
expect([
stateLabel('found'),
stateLabel('grabbing'),
stateLabel('importing'),
]).toEqual(['Waiting for you to choose', 'Downloading', 'Importing']);
});
it('passes an unknown state through rather than showing a blank', () => {
expect(stateLabel('teleporting')).toBe('teleporting');
});
it('rounds a score to whole percent', () => {
expect([scorePercent(0), scorePercent(0.876), scorePercent(1)]).toEqual([
'0%',
'88%',
'100%',
]);
});
it('scales bytes to the largest unit that fits', () => {
expect([
formatBytes(512),
formatBytes(1024),
formatBytes(5.5 * 1024 * 1024),
formatBytes(20 * 1024 * 1024 * 1024),
]).toEqual(['512 B', '1.0 KB', '5.5 MB', '20 GB']);
});
it('renders no size at all rather than "0 B"', () => {
expect([formatBytes(0), formatBytes(-1)]).toEqual(['', '']);
});
it('summarises a single-format candidate', () => {
expect(
candidateSummary(
candidate({
files: [
{ isAudio: true, format: 'flac' },
{ isAudio: true, format: 'flac' },
{ isAudio: false, format: 'jpg' },
],
totalSize: 300 * 1024 * 1024,
origin: 'Example',
} as Partial<DownloadCandidate>),
),
).toBe('FLAC · 2 tracks · 300 MB · Example');
});
it('flags a mixed-format candidate instead of naming one format', () => {
expect(
candidateSummary(
candidate({
files: [
{ isAudio: true, format: 'flac' },
{ isAudio: true, format: 'mp3' },
],
} as Partial<DownloadCandidate>),
),
).toBe('Mixed formats · 2 tracks');
});
it('singularises a one-track candidate', () => {
expect(
candidateSummary(
candidate({
files: [{ isAudio: true, format: 'mp3' }],
} as Partial<DownloadCandidate>),
),
).toBe('MP3 · 1 track');
});
it('survives a candidate with no file list at all', () => {
expect(candidateSummary(candidate({}))).toBe('');
});
});
describe('download store: event-driven refresh', () => {
beforeEach(async () => {
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
stub('download.Service.ProviderKinds', []);
await downloadStore.init();
await flush();
resetHarness();
stub('download.Service.ListProviders', [
provider(1, true),
provider(2, false),
]);
stub('download.Service.ListDownloads', [
view('a', 'grabbing'),
view('b', 'complete'),
]);
stub('download.Service.ListRequests', [
request({ id: 1, mbid: 'abc', state: 'wanted' }),
request({ id: 2, mbid: 'def', state: 'satisfied' }),
request({ id: 3, mbid: 'ghi', entity: 'artist' }),
]);
});
it('reloads providers when the backend says they changed', async () => {
emit(Events.DownloadProvidersChanged);
await flush();
expect(downloadStore.providers).toHaveLength(2);
});
it('reloads downloads on DownloadsChanged', async () => {
emit(Events.DownloadsChanged);
await flush();
expect(downloadStore.downloads.map((d) => d.id)).toEqual(['a', 'b']);
});
it('reloads requests, which change without the user doing anything', async () => {
// A background reconcile pass expands an artist or retires a want,
// so the list is push-driven rather than fetched on mount.
emit(Events.RequestsChanged);
await flush();
expect(downloadStore.requests).toHaveLength(3);
});
it('offers downloading only when a provider is enabled', async () => {
emit(Events.DownloadProvidersChanged);
await flush();
const withEnabled = downloadStore.available;
stub('download.Service.ListProviders', [provider(2, false)]);
emit(Events.DownloadProvidersChanged);
await flush();
expect([withEnabled, downloadStore.available]).toEqual([true, false]);
});
it('separates active downloads from finished ones', async () => {
emit(Events.DownloadsChanged);
await flush();
expect(downloadStore.activeDownloads.map((d) => d.id)).toEqual(['a']);
});
it('separates outstanding requests and artist subscriptions', async () => {
emit(Events.RequestsChanged);
await flush();
expect({
active: downloadStore.activeRequests.map((r) => r.id),
subscriptions: downloadStore.subscriptions.map((r) => r.id),
}).toEqual({ active: [1, 3], subscriptions: [3] });
});
it('survives a null list, which Go sends when nothing exists', async () => {
stub('download.Service.ListDownloads', null);
emit(Events.DownloadsChanged);
await flush();
expect(downloadStore.downloads).toEqual([]);
});
it('keeps the last good list when a refresh fails', async () => {
emit(Events.DownloadsChanged);
await flush();
stub('download.Service.ListDownloads', () => {
throw new Error('backend down');
});
emit(Events.DownloadsChanged);
await flush();
expect(downloadStore.downloads).toHaveLength(2);
});
it('coalesces three refreshes into one notification', async () => {
let notifications = 0;
const off = downloadStore.subscribe(() => {
notifications += 1;
});
emit(Events.DownloadProvidersChanged);
emit(Events.DownloadsChanged);
emit(Events.RequestsChanged);
await flush();
off();
expect(notifications).toBe(1);
});
});
describe('download store: request lookup', () => {
beforeEach(async () => {
stub('download.Service.ListRequests', [
request({ id: 1, mbid: 'abc-123', state: 'wanted' }),
]);
emit(Events.RequestsChanged);
await flush();
resetHarness();
stub('download.Service.ListRequests', [
request({ id: 1, mbid: 'abc-123', state: 'wanted' }),
]);
});
it('answers from the cached list, synchronously enough to render with', () => {
expect([
downloadStore.isRequested('abc-123'),
downloadStore.isRequested('nope'),
]).toEqual([true, false]);
});
it('normalises case and whitespace in the MBID it is given', () => {
expect(downloadStore.isRequested(' ABC-123 ')).toBe(true);
});
it('returns the request itself for the caller that needs its state', () => {
expect(downloadStore.requestFor('abc-123')?.id).toBe(1);
});
});
describe('download store: writes refresh what they changed', () => {
beforeEach(async () => {
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
await flush();
resetHarness();
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
});
it('refreshes providers after adding one', async () => {
stub('download.Service.AddProvider', 5);
await expect(
downloadStore.addProvider('sab', 'Local', { url: 'http://x' }),
).resolves.toBe(5);
expect(calls().map((c) => c.path)).toEqual([
'download.Service.AddProvider',
'download.Service.ListProviders',
]);
});
it('refreshes downloads after picking a candidate', async () => {
await downloadStore.pick('d1', 'c1');
expect([
lastArgs('download.Service.Pick'),
calls('download.Service.ListDownloads'),
]).toEqual([['d1', 'c1'], [{ path: 'download.Service.ListDownloads', args: [50] }]]);
});
it('refreshes requests after removing one', async () => {
await downloadStore.removeRequest(3);
expect(calls().map((c) => c.path)).toEqual([
'download.Service.RemoveRequest',
'download.Service.ListRequests',
]);
});
it('refreshes both lists after a manual reconcile, since it can start downloads', async () => {
stub('download.Service.ReconcileRequests', { added: 1 });
await downloadStore.reconcileRequests();
expect(calls().map((c) => c.path).sort()).toEqual([
'download.Service.ListDownloads',
'download.Service.ListRequests',
'download.Service.ReconcileRequests',
]);
});
it('propagates a provider test failure, which is the users only clue', async () => {
stub('download.Service.TestProvider', () => {
throw new Error('connection refused');
});
await expect(downloadStore.testProvider(1)).rejects.toThrow(
'connection refused',
);
});
});
@@ -0,0 +1,194 @@
/**
* Favourites is the one store that updates optimistically: the heart
* fills before Go has agreed. So the interesting cases are the reverts,
* and the set of playlist events that force a reload — a default
* playlist edited elsewhere has to show up here.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { favoritesStore } from '@store/favorites-store';
import { Events } from '../../src/events';
import {
emit,
calls,
stub,
stubFailure,
flush,
lastArgs,
resetHarness,
} from '@test/support/harness';
const PATHS = ['/music/a.mp3', '/music/b.mp3'];
function stubReads(paths: string[] = PATHS): void {
stub('playlist.Service.GetDefaultPlaylistTrackPaths', paths);
stub('playlist.Service.GetDefaultPlaylistInfo', { Name: 'Loved' });
stub('config.Config.GetFavoritesPlaylistID', 3);
stub('config.Config.GetFavoritesIconStyle', 'star');
stub('config.Config.GetPinDefaultPlaylist', false);
}
/** Push a config change and let the reloads it triggers settle. */
async function reload(paths: string[] = PATHS): Promise<void> {
stubReads(paths);
emit(Events.FavoritesConfigChanged, {
PlaylistID: 3,
IconStyle: 'star',
PinDefault: false,
});
await flush();
resetHarness();
stubReads(paths);
}
describe('favorites store: cached membership', () => {
beforeEach(async () => {
await reload();
});
it('reports membership by file path', () => {
expect([
favoritesStore.isFavorited('/music/a.mp3'),
favoritesStore.isFavorited('/music/z.mp3'),
]).toEqual([true, false]);
});
it('requires every path for a multi-selection to count as favourited', () => {
expect([
favoritesStore.allFavorited(PATHS),
favoritesStore.allFavorited([...PATHS, '/music/z.mp3']),
]).toEqual([true, false]);
});
it('treats an empty selection as not favourited, so the button is not lit for nothing', () => {
expect(favoritesStore.allFavorited([])).toBe(false);
});
it('adopts the config the backend pushed', () => {
expect([
favoritesStore.getPlaylistId(),
favoritesStore.getIconStyle(),
favoritesStore.getPinDefault(),
]).toEqual([3, 'star', false]);
});
it('resolves the playlist name from the backend', () => {
expect(favoritesStore.getPlaylistName()).toBe('Loved');
});
});
describe('favorites store: optimistic writes', () => {
beforeEach(async () => {
await reload();
});
it('fills the heart before the backend answers', () => {
void favoritesStore.toggleFavorite('/music/z.mp3');
expect(favoritesStore.isFavorited('/music/z.mp3')).toBe(true);
});
it('reverts an add the backend rejected', async () => {
stubFailure('playlist.Service.ToggleDefaultPlaylistTrack');
await favoritesStore.toggleFavorite('/music/z.mp3');
expect(favoritesStore.isFavorited('/music/z.mp3')).toBe(false);
});
it('reverts a removal the backend rejected', async () => {
stubFailure('playlist.Service.ToggleDefaultPlaylistTrack');
await favoritesStore.toggleFavorite('/music/a.mp3');
expect(favoritesStore.isFavorited('/music/a.mp3')).toBe(true);
});
it('adds a batch optimistically and forwards the whole list', async () => {
await favoritesStore.addToFavorites(['/music/y.mp3', '/music/z.mp3']);
expect([
favoritesStore.allFavorited(['/music/y.mp3', '/music/z.mp3']),
lastArgs('playlist.Service.AddToDefaultPlaylist'),
]).toEqual([true, [['/music/y.mp3', '/music/z.mp3']]]);
});
it('removes a batch optimistically', async () => {
await favoritesStore.removeFromFavorites(['/music/a.mp3']);
expect(favoritesStore.isFavorited('/music/a.mp3')).toBe(false);
});
it('resyncs from the backend when a batch write fails, rather than guessing', async () => {
stubFailure('playlist.Service.AddToDefaultPlaylist');
await favoritesStore.addToFavorites(['/music/z.mp3']);
await flush();
expect(
calls('playlist.Service.GetDefaultPlaylistTrackPaths'),
).toHaveLength(1);
});
});
describe('favorites store: reacting to playlist changes', () => {
beforeEach(async () => {
await reload();
});
it('reloads when the default playlist itself changed', async () => {
emit(Events.PlaylistTracksChanged, 3);
await flush();
expect(
calls('playlist.Service.GetDefaultPlaylistTrackPaths'),
).toHaveLength(1);
});
it('ignores changes to some other playlist', async () => {
emit(Events.PlaylistTracksChanged, 99);
await flush();
expect(
calls('playlist.Service.GetDefaultPlaylistTrackPaths'),
).toHaveLength(0);
});
it('reloads after a restore, which rewrites every playlist', async () => {
emit(Events.PlaylistsRestored);
await flush();
expect(
calls('playlist.Service.GetDefaultPlaylistTrackPaths'),
).toHaveLength(1);
});
it('re-reads the name when a playlist is renamed', async () => {
stub('playlist.Service.GetDefaultPlaylistInfo', { Name: 'Renamed' });
emit(Events.PlaylistRenamed, 3);
await flush();
expect(favoritesStore.getPlaylistName()).toBe('Renamed');
});
it('falls back to "Favorites" when no default playlist is configured', async () => {
await favoritesStore.setDefaultPlaylist(0);
expect(favoritesStore.getPlaylistName()).toBe('Favorites');
});
it('persists a changed icon style', async () => {
await favoritesStore.setIconStyle('heart');
expect([
favoritesStore.getIconStyle(),
lastArgs('config.Config.SetFavoritesIconStyle'),
]).toEqual(['heart', ['heart']]);
});
it('persists the pin setting', async () => {
await favoritesStore.setPinDefault(true);
expect(lastArgs('config.Config.SetPinDefaultPlaylist')).toEqual([true]);
});
});
+273
View File
@@ -0,0 +1,273 @@
/**
* The job store mirrors the backend registry from full snapshots, so
* the derivations on top of it — which jobs count as active, whether
* the indicator should be up, the linger after the last job finishes —
* are where the behaviour lives.
*/
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
import {
jobStore,
isTerminal,
isActive,
isIndeterminate,
progressFraction,
type Job,
} from '@store/job-store';
import { Events } from '../../src/events';
import { emit, calls, stub, flush } from '@test/support/harness';
function job(overrides: Partial<Job> & { id: string }): Job {
return {
kind: 'library-scan',
state: 'running',
title: 'Scanning',
current: 0,
total: 0,
...overrides,
} as Job;
}
/** Push a full snapshot, which is all the backend ever sends. */
function snapshot(jobs: Job[]): void {
emit(Events.JobsChanged, jobs);
}
describe('job predicates', () => {
it('treats complete, cancelled and error as terminal', () => {
const states = ['complete', 'cancelled', 'error'] as const;
expect(
states.map((state) => isTerminal(job({ id: state, state }))),
).toEqual([true, true, true]);
});
it('treats every in-flight state, including paused, as active', () => {
const states = ['queued', 'running', 'pausing', 'paused', 'cancelling'];
expect(states.map((state) => isActive(job({ id: state, state })))).toEqual([
true,
true,
true,
true,
true,
]);
});
it('calls a job with no denominator indeterminate', () => {
expect([
isIndeterminate(job({ id: 'a', total: 0 })),
isIndeterminate(job({ id: 'b', total: -1 })),
isIndeterminate(job({ id: 'c', total: 10 })),
]).toEqual([true, true, false]);
});
it('has no progress fraction when indeterminate', () => {
expect(progressFraction(job({ id: 'a', total: 0, current: 5 }))).toBeNull();
});
it('clamps a progress fraction that overshoots its total', () => {
expect([
progressFraction(job({ id: 'a', current: 5, total: 10 })),
progressFraction(job({ id: 'b', current: 30, total: 10 })),
progressFraction(job({ id: 'c', current: -5, total: 10 })),
]).toEqual([0.5, 1, 0]);
});
});
describe('job store: snapshots', () => {
beforeEach(() => {
snapshot([]);
});
it('partitions a snapshot by state', () => {
snapshot([
job({ id: 'r', state: 'running' }),
job({ id: 'q', state: 'queued' }),
job({ id: 'p', state: 'paused' }),
job({ id: 'e', state: 'error' }),
job({ id: 'c', state: 'complete' }),
]);
expect({
working: jobStore.workingJobs.map((j) => j.id),
paused: jobStore.pausedJobs.map((j) => j.id),
failed: jobStore.failedJobs.map((j) => j.id),
finished: jobStore.finishedJobs.map((j) => j.id),
active: jobStore.activeJobs.map((j) => j.id),
}).toEqual({
working: ['r', 'q'],
paused: ['p'],
failed: ['e'],
finished: ['e', 'c'],
active: ['r', 'q', 'p'],
});
});
it('tolerates a null snapshot, which Go sends for an empty registry', () => {
emit(Events.JobsChanged, null);
expect(jobStore.jobs).toEqual([]);
});
it('replaces rather than merges, so a removed job disappears', () => {
snapshot([job({ id: 'a' }), job({ id: 'b' })]);
snapshot([job({ id: 'b' })]);
expect(jobStore.jobs.map((j) => j.id)).toEqual(['b']);
});
it('finds a job by id', () => {
snapshot([job({ id: 'a', title: 'Indexing' })]);
expect(jobStore.getJob('a')?.title).toBe('Indexing');
});
it('fetches the initial snapshot at most once', async () => {
stub('jobs.Service.GetJobs', [job({ id: 'a' })]);
await jobStore.init();
await jobStore.init();
expect(calls('jobs.Service.GetJobs').length).toBeLessThanOrEqual(1);
});
});
describe('job store: indicator linger', () => {
beforeEach(() => {
vi.useFakeTimers();
// Emptying the registry is itself "the last job finished", so it
// starts a linger; run it out before the test begins.
snapshot([]);
vi.advanceTimersByTime(4000);
});
afterEach(() => {
vi.useRealTimers();
});
it('keeps the indicator up briefly after the last job finishes', () => {
snapshot([job({ id: 'a', state: 'running' })]);
snapshot([job({ id: 'a', state: 'complete' })]);
const immediatelyAfter = jobStore.shouldShowIndicator;
vi.advanceTimersByTime(4000);
expect([immediatelyAfter, jobStore.shouldShowIndicator]).toEqual([
true,
false,
]);
});
it('cancels the linger when new work starts', () => {
snapshot([job({ id: 'a', state: 'running' })]);
snapshot([job({ id: 'a', state: 'complete' })]);
snapshot([
job({ id: 'a', state: 'complete' }),
job({ id: 'b', state: 'running' }),
]);
vi.advanceTimersByTime(4000);
// Still up, because b is still working — the linger timer must not
// hide the indicator out from under it.
expect(jobStore.shouldShowIndicator).toBe(true);
});
it('does not linger for a snapshot that was never active', () => {
snapshot([job({ id: 'a', state: 'complete' })]);
expect(jobStore.shouldShowIndicator).toBe(false);
});
});
describe('job store: logs', () => {
beforeEach(() => {
snapshot([]);
});
it('is empty until a log is fetched', () => {
expect(jobStore.cachedLog('a')).toEqual([]);
});
it('caches a fetched log', async () => {
stub('jobs.Service.GetJobLog', [{ level: 'warn', message: 'skipped' }]);
snapshot([job({ id: 'a' })]);
await jobStore.loadLog('a');
expect(jobStore.cachedLog('a')).toHaveLength(1);
});
it('returns an empty log rather than throwing when the fetch fails', async () => {
stub('jobs.Service.GetJobLog', () => {
throw new Error('gone');
});
await expect(jobStore.loadLog('a')).resolves.toEqual([]);
});
it('drops cached logs for jobs the backend has forgotten', async () => {
stub('jobs.Service.GetJobLog', [{ level: 'info', message: 'x' }]);
snapshot([job({ id: 'a' })]);
await jobStore.loadLog('a');
snapshot([job({ id: 'b' })]);
expect(jobStore.cachedLog('a')).toEqual([]);
});
it('forgets a dismissed job log without waiting for the next snapshot', async () => {
stub('jobs.Service.GetJobLog', [{ level: 'info', message: 'x' }]);
snapshot([job({ id: 'a' })]);
await jobStore.loadLog('a');
await jobStore.dismiss('a');
expect(jobStore.cachedLog('a')).toEqual([]);
});
});
describe('job store: controls', () => {
beforeEach(() => {
snapshot([]);
});
it('forwards each control to its bound method with the job id', async () => {
await jobStore.pause('a');
await jobStore.resume('a');
await jobStore.cancel('a');
expect(calls().map((c) => [c.path, c.args])).toEqual([
['jobs.Service.PauseJob', ['a']],
['jobs.Service.ResumeJob', ['a']],
['jobs.Service.CancelJob', ['a']],
]);
});
it('clears logs for every finished job when they are cleared', async () => {
stub('jobs.Service.GetJobLog', [{ level: 'info', message: 'x' }]);
snapshot([job({ id: 'a', state: 'complete' })]);
await jobStore.loadLog('a');
await jobStore.clearFinished();
expect(jobStore.cachedLog('a')).toEqual([]);
});
it('coalesces a burst of snapshots into one notification', async () => {
let notifications = 0;
const off = jobStore.subscribe(() => {
notifications += 1;
});
snapshot([job({ id: 'a', current: 1, total: 10 })]);
snapshot([job({ id: 'a', current: 2, total: 10 })]);
snapshot([job({ id: 'a', current: 3, total: 10 })]);
await flush();
off();
expect(notifications).toBe(1);
});
});
@@ -0,0 +1,400 @@
/**
* The keyboard shortcut service and the store behind it. This is the
* test that most justifies running in a real browser: the service walks
* shadow roots to find the deepest focused element, and no jsdom
* approximation of that is worth trusting.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { buildKeyString } from '../../src/services/keyboard-shortcut-service';
import '../../src/services/keyboard-shortcut-service';
import { shortcutsStore } from '@store/shortcuts-store';
import { Events } from '../../src/events';
import { emit, calls, lastArgs, stub } from '@test/support/harness';
/** Install a binding table, as the backend's config push does. */
function bindings(table: Record<string, string>): void {
emit(Events.ShortcutsConfigChanged, table);
}
/** Send a keydown through the document, where the service listens. */
function press(
key: string,
modifiers: Partial<
Record<'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey', boolean>
> = {},
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...modifiers,
});
document.dispatchEvent(event);
return event;
}
const mounted: HTMLElement[] = [];
/** Append an element to the body and remember to remove it. */
function mount<T extends HTMLElement>(el: T): T {
document.body.append(el);
mounted.push(el);
return el;
}
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.remove();
bindings({});
});
// ===================================================================
describe('buildKeyString', () => {
it('uppercases a bare printable key', () => {
expect(buildKeyString(new KeyboardEvent('keydown', { key: 'n' }))).toBe(
'N',
);
});
it('orders modifiers Ctrl, Alt, Shift regardless of press order', () => {
const e = new KeyboardEvent('keydown', {
key: 'f',
shiftKey: true,
altKey: true,
ctrlKey: true,
});
expect(buildKeyString(e)).toBe('Ctrl+Alt+Shift+F');
});
it('folds Meta into Ctrl so macOS and Linux share one binding table', () => {
const meta = new KeyboardEvent('keydown', { key: 'f', metaKey: true });
const ctrl = new KeyboardEvent('keydown', { key: 'f', ctrlKey: true });
expect(buildKeyString(meta)).toBe(buildKeyString(ctrl));
});
it('aliases arrows and space to their canonical names', () => {
const names = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].map(
(key) => buildKeyString(new KeyboardEvent('keydown', { key })),
);
expect(names).toEqual(['Up', 'Down', 'Left', 'Right', 'Space']);
});
it('leaves multi-character named keys alone', () => {
expect(
buildKeyString(new KeyboardEvent('keydown', { key: 'Escape' })),
).toBe('Escape');
});
it('returns nothing for a bare modifier press', () => {
const bare = ['Control', 'Alt', 'Shift', 'Meta'].map((key) =>
buildKeyString(new KeyboardEvent('keydown', { key })),
);
expect(bare).toEqual(['', '', '', '']);
});
});
// ===================================================================
describe('shortcuts store: lookup', () => {
beforeEach(() => {
bindings({
'player.playPause': 'Space',
'player.next': 'Ctrl+Right',
'tracklist.play': 'Enter',
'tracklist.delete': 'Delete',
});
});
it('resolves an action to its key', () => {
expect(shortcutsStore.getKeyForAction('player.next')).toBe('Ctrl+Right');
});
it('reverse-resolves a key to its global action', () => {
expect(shortcutsStore.getActionForKey('Space')).toBe('player.playPause');
});
it('does not resolve a panel binding from global scope', () => {
// `tracklist.` is a panel prefix: Enter must do nothing unless the
// track list has focus.
expect(shortcutsStore.getActionForKey('Enter')).toBeUndefined();
});
it('resolves a panel binding when the matching scope is supplied', () => {
expect(shortcutsStore.getActionForKey('Enter', 'panel:tracklist')).toBe(
'tracklist.play',
);
});
it('falls back to global inside a panel scope', () => {
expect(shortcutsStore.getActionForKey('Space', 'panel:tracklist')).toBe(
'player.playPause',
);
});
it('reports a conflict, excluding the action being rebound', () => {
expect(
shortcutsStore.findConflict('Space', 'global', 'player.next'),
).toEqual({ action: 'player.playPause', key: 'Space' });
});
it('does not report an action conflicting with itself', () => {
expect(
shortcutsStore.findConflict('Space', 'global', 'player.playPause'),
).toBeNull();
});
});
// ===================================================================
describe('shortcut dispatch: scope', () => {
beforeEach(() => {
bindings({
'player.playPause': 'Space',
'tracklist.play': 'Enter',
});
});
it('dispatches from global scope', () => {
press(' ');
expect(calls('queue.Queue.Play')).toHaveLength(1);
});
it('preventDefaults a key it handled', () => {
expect(press(' ').defaultPrevented).toBe(true);
});
it('leaves an unbound key alone', () => {
expect(press('q').defaultPrevented).toBe(false);
});
it('suppresses shortcuts while a text input has focus', () => {
const input = mount(document.createElement('input'));
input.type = 'text';
input.focus();
press(' ');
expect(calls('queue.Queue.Play')).toHaveLength(0);
});
it('suppresses shortcuts inside a contenteditable', () => {
const div = mount(document.createElement('div'));
div.contentEditable = 'true';
div.tabIndex = 0;
div.focus();
press(' ');
expect(calls('queue.Queue.Play')).toHaveLength(0);
});
it('lets a checkbox through — it is not a text input', () => {
const input = mount(document.createElement('input'));
input.type = 'checkbox';
input.focus();
press(' ');
expect(calls('queue.Queue.Play')).toHaveLength(1);
});
it('blurs the input on Escape, and only on Escape', () => {
const input = mount(document.createElement('input'));
input.type = 'search';
input.focus();
press('Escape');
expect(document.activeElement).not.toBe(input);
});
it('finds a text input nested in a shadow root', () => {
// document.activeElement stops at the shadow host, so a service
// that did not walk the chain would see <div> and fire the shortcut.
const host = mount(document.createElement('div'));
const root = host.attachShadow({ mode: 'open' });
const input = document.createElement('input');
input.type = 'text';
root.append(input);
input.focus();
press(' ');
expect(calls('queue.Queue.Play')).toHaveLength(0);
});
it('resolves a panel scope from a data-shortcut-scope ancestor', () => {
const panel = mount(document.createElement('div'));
const button = document.createElement('button');
panel.dataset['shortcutScope'] = 'tracklist';
panel.append(button);
button.focus();
let fired = 0;
const listener = (): void => {
fired += 1;
};
document.addEventListener('shortcut:tracklist-play', listener);
press('Enter');
document.removeEventListener('shortcut:tracklist-play', listener);
expect(fired).toBe(1);
});
it('crosses a shadow boundary looking for the panel scope', () => {
const panel = mount(document.createElement('div'));
const inner = document.createElement('div');
const root = inner.attachShadow({ mode: 'open' });
const button = document.createElement('button');
panel.dataset['shortcutScope'] = 'tracklist';
panel.append(inner);
root.append(button);
button.focus();
let fired = 0;
const listener = (): void => {
fired += 1;
};
document.addEventListener('shortcut:tracklist-play', listener);
press('Enter');
document.removeEventListener('shortcut:tracklist-play', listener);
expect(fired).toBe(1);
});
});
// ===================================================================
describe('shortcut dispatch: actions', () => {
it('toggles between pause and play based on cached player state', () => {
bindings({ 'player.playPause': 'Space' });
emit(Events.PlaybackStateChanged, { state: 'playing' });
press(' ');
emit(Events.PlaybackStateChanged, { state: 'paused' });
press(' ');
expect(calls().map((c) => c.path)).toEqual([
'player.Player.Pause',
'queue.Queue.Play',
]);
});
it('steps the volume by a fixed amount in each direction', () => {
bindings({ 'player.volumeUp': 'Up', 'player.volumeDown': 'Down' });
press('ArrowUp');
press('ArrowDown');
expect(calls('player.Player.ChangeVolume').map((c) => c.args)).toEqual([
[5],
[-5],
]);
});
it('clamps a forward seek to the track length', async () => {
bindings({ 'player.seekForward': 'Right' });
stub('player.Player.CurrentPositionSeconds', 98);
stub('player.Player.TrackLengthInSeconds', 100);
press('ArrowRight');
await new Promise<void>((r) => {
setTimeout(r, 0);
});
expect(lastArgs('player.Player.Seek')).toEqual([100]);
});
it('clamps a backward seek at zero', async () => {
bindings({ 'player.seekBack': 'Left' });
stub('player.Player.CurrentPositionSeconds', 2);
press('ArrowLeft');
await new Promise<void>((r) => {
setTimeout(r, 0);
});
expect(lastArgs('player.Player.Seek')).toEqual([0]);
});
it('toggles the queue panel open and closed', () => {
bindings({ 'nav.queue': 'Q' });
const panel = mount(document.createElement('div'));
panel.id = 'queue-panel';
press('q');
const opened = panel.hasAttribute('open');
press('q');
expect([opened, panel.hasAttribute('open')]).toEqual([true, false]);
});
it('broadcasts select-all as a document event', () => {
bindings({ 'app.selectAll': 'Ctrl+A' });
let fired = 0;
const listener = (): void => {
fired += 1;
};
document.addEventListener('shortcut:select-all', listener);
press('a', { ctrlKey: true });
document.removeEventListener('shortcut:select-all', listener);
expect(fired).toBe(1);
});
it('ignores an action name the dispatcher does not know', () => {
bindings({ 'player.teleport': 'T' });
press('t');
expect(calls()).toEqual([]);
});
});
// ===================================================================
describe('shortcuts store: writes', () => {
it('sends a single rebind to the backend', async () => {
await shortcutsStore.updateBinding('player.next', 'Ctrl+N');
expect(lastArgs('config.Config.SetShortcut')).toEqual([
'player.next',
'Ctrl+N',
]);
});
it('sends a whole table at once', async () => {
await shortcutsStore.setAll({ 'player.next': 'N' });
expect(lastArgs('config.Config.SetShortcuts')).toEqual([
{ 'player.next': 'N' },
]);
});
it('does not update its cache optimistically', async () => {
bindings({ 'player.next': 'Ctrl+Right' });
await shortcutsStore.updateBinding('player.next', 'Ctrl+N');
// The backend is the only writer; the cache waits for the
// ShortcutsConfigChanged push.
expect(shortcutsStore.getKeyForAction('player.next')).toBe('Ctrl+Right');
});
});
+287
View File
@@ -0,0 +1,287 @@
/**
* The library store is the frontend's read cache for the whole
* catalogue: four collections, each fetched once and held until
* something invalidates them. The interesting behaviour is not the
* fetching but the caching — deduplicating concurrent readers, throwing
* everything away on a rescan, and swapping to the by-library bindings
* when a filter is active.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { libraryStore } from '@store/library-store';
import { Events } from '../../src/events';
import {
emit,
calls,
stub,
flush,
lastArgs,
resetHarness,
} from '@test/support/harness';
const TRACKS = [{ ID: 1, Title: 'One' }];
const ALBUMS = [{ ID: 1, Name: 'Album', ArtistName: 'Artist' }];
const OTHER_ALBUMS = [{ ID: 2, Name: 'Other', ArtistName: 'Other Artist' }];
const ARTISTS = [{ ID: 1, Name: 'Artist' }];
const GENRES = [{ Name: 'Ambient', Count: 3 }];
const LIBRARIES = [{ id: 7, name: 'Music' }, { id: 8, name: 'Field' }];
/** Stub every read binding the store can reach. Unstubbed bindings
* resolve undefined, which the store would cache as if it were data. */
function stubReads(): void {
stub('library.Library.GetAllTracks', TRACKS);
stub('library.Library.GetAllAlbums', ALBUMS);
stub('library.Library.GetAllArtists', ARTISTS);
stub('library.Library.GetAllGenresWithCounts', GENRES);
stub('library.Library.GetAllTracksByLibrary', TRACKS);
stub('library.Library.GetAllAlbumsByLibrary', ALBUMS);
stub('library.Library.GetAllArtistsByLibrary', ARTISTS);
stub('library.Library.GetAllGenresWithCountsByLibrary', GENRES);
stub('library.Library.GetAlbumsByArtist', ALBUMS);
stub('library.Library.GetAlbumsByArtistByLibrary', ALBUMS);
stub('library.Library.GetAllLibrariesWithTrackCounts', LIBRARIES);
}
/**
* Drop the cache and let the eager refetch settle, so each test starts
* from the same place. The store has no reset of its own; a scan
* completing is how the app itself clears it.
*/
async function reload(): Promise<void> {
stubReads();
emit(Events.LibraryScanComplete);
await flush();
// The eager refetch the invalidation kicks off is recorded like any
// other call; clear it, or every count in every test is off by one.
resetHarness();
stubReads();
}
describe('library store: caching', () => {
beforeEach(async () => {
await reload();
});
it('serves a second read from cache without touching the backend', async () => {
await libraryStore.getTracks();
expect(calls('library.Library.GetAllTracks')).toHaveLength(0);
});
it('deduplicates concurrent first reads into one backend call', async () => {
emit(Events.LibraryScanComplete);
const [a, b] = await Promise.all([
libraryStore.getArtists(),
libraryStore.getArtists(),
]);
expect([a, b, calls('library.Library.GetAllArtists').length]).toEqual([
ARTISTS,
ARTISTS,
1,
]);
});
it('exposes cached collections synchronously once loaded', () => {
expect([
libraryStore.getCachedTracks(),
libraryStore.getCachedAlbums(),
libraryStore.cachedArtists,
libraryStore.getCachedGenres(),
]).toEqual([TRACKS, ALBUMS, ARTISTS, GENRES]);
});
it('refetches everything when a scan completes', async () => {
emit(Events.LibraryScanComplete);
await flush();
expect(calls().map((c) => c.path).sort()).toEqual([
'library.Library.GetAllAlbums',
'library.Library.GetAllArtists',
'library.Library.GetAllGenresWithCounts',
'library.Library.GetAllTracks',
]);
});
it('refetches when a track is retagged', async () => {
emit(Events.TrackMetadataChanged, { filePath: '/a.mp3' });
await flush();
expect(calls('library.Library.GetAllTracks')).toHaveLength(1);
});
it('resets scroll positions on invalidation, so a shorter list is not scrolled past its end', async () => {
libraryStore.setScrollPosition('albums', 4200);
emit(Events.LibraryScanComplete);
await flush();
expect(libraryStore.getScrollPosition('albums')).toBe(0);
});
it('bumps the change generation for data, not for loading flags', async () => {
const before = libraryStore.changeGeneration;
await libraryStore.getTracks(); // cached: no change
const afterCachedRead = libraryStore.changeGeneration;
emit(Events.LibraryScanComplete);
await flush();
expect([
afterCachedRead === before,
libraryStore.changeGeneration > before,
]).toEqual([true, true]);
});
});
describe('library store: library filter', () => {
beforeEach(async () => {
libraryStore.setSelectedLibrary(null);
await reload();
});
it('switches to the by-library bindings when a filter is set', async () => {
libraryStore.setSelectedLibrary(7);
await flush();
expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([7]);
});
it('ignores a redundant selection instead of invalidating', async () => {
libraryStore.setSelectedLibrary(null);
await flush();
expect(calls()).toEqual([]);
});
it('answers the cached artist-albums query only when unfiltered', async () => {
const unfiltered = libraryStore.getAlbumsByArtistNameCached('Artist');
libraryStore.setSelectedLibrary(7);
await flush();
// With a filter active the cache is already library-scoped, so the
// store declines to answer and forces a backend query instead.
expect([
unfiltered,
libraryStore.getAlbumsByArtistNameCached('Artist'),
]).toEqual([ALBUMS, null]);
});
it('scopes an artist drill-down to the selected library', async () => {
libraryStore.setSelectedLibrary(8);
await libraryStore.getAlbumsByArtist(3);
expect(lastArgs('library.Library.GetAlbumsByArtistByLibrary')).toEqual([
3, 8,
]);
});
});
describe('library store: default library id', () => {
beforeEach(async () => {
libraryStore.setSelectedLibrary(null);
await reload();
});
it('prefers the active filter', async () => {
libraryStore.setSelectedLibrary(8);
await expect(libraryStore.getDefaultLibraryId()).resolves.toBe(8);
});
it('falls back to the first known library, never to zero', async () => {
// id 0 never exists and trips the download_requests foreign key.
await expect(libraryStore.getDefaultLibraryId()).resolves.toBe(7);
});
it('returns null when there are no libraries at all', async () => {
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
emit(Events.LibraryRemoved, { id: 7 });
await expect(libraryStore.getDefaultLibraryId()).resolves.toBeNull();
});
it('caches the library list until a library is added or renamed', async () => {
const fetched = (): number =>
calls('library.Library.GetAllLibrariesWithTrackCounts').length;
// The list survives a rescan — only library CRUD changes it.
emit(Events.LibraryAdded, { id: 9 });
await libraryStore.getLibraries();
const afterAdd = fetched();
await libraryStore.getLibraries();
const afterCachedRead = fetched();
emit(Events.LibraryRenamed, { id: 7, name: 'Renamed' });
await libraryStore.getLibraries();
expect([afterAdd, afterCachedRead, fetched()]).toEqual([1, 1, 2]);
});
});
describe('library store: cover size', () => {
beforeEach(() => {
localStorage.removeItem('cover-grid-size');
libraryStore.setCoverSize(176);
});
it('clamps below the minimum card width', () => {
libraryStore.setCoverSize(10);
expect(libraryStore.getCoverSize()).toBe(100);
});
it('clamps above the maximum card width', () => {
libraryStore.setCoverSize(9000);
expect(libraryStore.getCoverSize()).toBe(350);
});
it('rounds a fractional size, since it becomes a CSS pixel value', () => {
libraryStore.setCoverSize(180.6);
expect(libraryStore.getCoverSize()).toBe(181);
});
it('persists the size for the next session', () => {
libraryStore.setCoverSize(200);
expect(localStorage.getItem('cover-grid-size')).toBe('200');
});
it('does not notify when the clamped size is unchanged', async () => {
libraryStore.setCoverSize(300);
await flush();
let notifications = 0;
const off = libraryStore.subscribe(() => {
notifications += 1;
});
libraryStore.setCoverSize(400); // clamps back to 350 ≠ 300
libraryStore.setCoverSize(9999); // clamps to 350, unchanged
await flush();
off();
expect(notifications).toBe(1);
});
});
describe('library store: albums by artist name', () => {
beforeEach(async () => {
libraryStore.setSelectedLibrary(null);
await reload();
});
it('filters the album cache by artist name', () => {
stub('library.Library.GetAllAlbums', [...ALBUMS, ...OTHER_ALBUMS]);
expect(libraryStore.getAlbumsByArtistNameCached('Artist')).toEqual(ALBUMS);
});
it('returns an empty list, not null, for an artist with no albums', () => {
expect(libraryStore.getAlbumsByArtistNameCached('Nobody')).toEqual([]);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* The player store is a pure projection of backend push events. Its
* whole job is to be a truthful cache, so the tests are about what it
* derives (`isPlaying` from a state string) and what it refuses to
* invent (it never predicts the result of an action).
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { playerStore, type TrackInfo } from '@store/player-store';
import { Events } from '../../src/events';
import { emit, calls, lastArgs, flush } from '@test/support/harness';
const TRACK: TrackInfo = {
fileName: 'one.mp3',
filePath: '/music/one.mp3',
trackLength: 180,
seekPosition: 0,
state: 'playing',
title: 'One',
artist: 'Artist',
album: 'Album',
coverArt: '',
coverArtSmall: '',
coverArtMedium: '',
coverArtLarge: '',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
describe('player store: playback state', () => {
beforeEach(() => {
emit(Events.PlaybackStateChanged, { state: 'stopped' });
emit(Events.TrackChanged, null);
});
it('is playing only for the literal "playing" state', () => {
const seen: boolean[] = [];
for (const state of ['playing', 'paused', 'stopped', 'buffering']) {
emit(Events.PlaybackStateChanged, { state });
seen.push(playerStore.getState().isPlaying);
}
expect(seen).toEqual([true, false, false, false]);
});
it('stops playing when the track finishes', () => {
emit(Events.PlaybackStateChanged, { state: 'playing' });
emit(Events.PlaybackFinished);
expect(playerStore.getState().isPlaying).toBe(false);
});
it('caches the current track', () => {
emit(Events.TrackChanged, TRACK);
expect(playerStore.getState().currentTrack).toEqual(TRACK);
});
it('normalises an absent track to null rather than undefined', () => {
emit(Events.TrackChanged, TRACK);
emit(Events.TrackChanged, undefined);
expect(playerStore.getState().currentTrack).toBeNull();
});
it('keeps the cached track across a pause', () => {
emit(Events.TrackChanged, TRACK);
emit(Events.PlaybackStateChanged, { state: 'paused' });
expect(playerStore.getState().currentTrack).toEqual(TRACK);
});
it('tracks volume pushed back from Go', () => {
emit(Events.VolumeChanged, 42);
expect(playerStore.getState().volume).toBe(42);
});
it('replaces state rather than mutating it, so a saved reference is stable', () => {
emit(Events.VolumeChanged, 10);
const before = playerStore.getState();
emit(Events.VolumeChanged, 20);
expect(before.volume).toBe(10);
});
it('coalesces a burst into a single notification', async () => {
let notifications = 0;
const off = playerStore.subscribe(() => {
notifications += 1;
});
emit(Events.VolumeChanged, 1);
emit(Events.VolumeChanged, 2);
emit(Events.PlaybackStateChanged, { state: 'playing' });
await flush();
off();
expect(notifications).toBe(1);
});
});
describe('player store: actions', () => {
it('forwards each action to its bound method', () => {
playerStore.pause();
playerStore.loadTrack('/music/one.mp3');
playerStore.seek(30);
playerStore.setVolume(60);
expect(calls().map((c) => c.path)).toEqual([
'player.Player.Pause',
'player.Player.LoadFile',
'player.Player.Seek',
'player.Player.SetVolume',
]);
});
it('sends the volume as an integer percentage, not a fraction', () => {
// player.UserVolume is an int in Go; a float never settles its
// callback. See .planning/NOTES.md.
playerStore.setVolume(42);
expect(lastArgs('player.Player.SetVolume')).toEqual([42]);
});
it('does not optimistically change cached volume', () => {
emit(Events.VolumeChanged, 50);
playerStore.setVolume(80);
expect(playerStore.getState().volume).toBe(50);
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* The playlist store caches one list and invalidates it on six
* different events. The distinction worth testing is `invalidate` vs
* `refetch`: one drops the cache (consumers render empty until the
* fetch lands), the other holds the stale list until the new one
* arrives. Using the wrong one shows up as a flash of empty list.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { playlistStore } from '@store/playlist-store';
import { Events } from '../../src/events';
import { emit, calls, stub, flush, resetHarness } from '@test/support/harness';
const PLAYLISTS = [
{ ID: 1, Name: 'Morning', Tracks: [] },
{ ID: 2, Name: 'Evening', Tracks: [] },
];
async function reload(): Promise<void> {
stub('playlist.Service.GetAllPlaylistsWithTracks', PLAYLISTS);
playlistStore.invalidate();
await flush();
resetHarness();
stub('playlist.Service.GetAllPlaylistsWithTracks', PLAYLISTS);
}
describe('playlist store: caching', () => {
beforeEach(async () => {
await reload();
});
it('serves a second read from cache', async () => {
await playlistStore.getPlaylists();
expect(calls()).toEqual([]);
});
it('deduplicates concurrent first reads', async () => {
playlistStore.invalidate();
await Promise.all([
playlistStore.getPlaylists(),
playlistStore.getPlaylists(),
]);
expect(calls('playlist.Service.GetAllPlaylistsWithTracks')).toHaveLength(1);
});
it('exposes the cache synchronously for render', () => {
expect(playlistStore.getCachedPlaylists()).toEqual(PLAYLISTS);
});
it('normalises a null list to an empty one', async () => {
stub('playlist.Service.GetAllPlaylistsWithTracks', null);
playlistStore.invalidate();
await flush();
expect(playlistStore.getCachedPlaylists()).toEqual([]);
});
it('holds the stale list across a refetch, so the view does not flash empty', async () => {
const pending = playlistStore.refetch();
const during = playlistStore.getCachedPlaylists();
await pending;
expect(during).toEqual(PLAYLISTS);
});
it('drops the cache on invalidate, which is the difference from refetch', () => {
playlistStore.invalidate();
expect(playlistStore.getCachedPlaylists()).toBeNull();
});
it('resets the scroll position when the list is invalidated', async () => {
playlistStore.setScrollPosition(900);
playlistStore.invalidate();
await flush();
expect(playlistStore.getScrollPosition()).toBe(0);
});
});
describe('playlist store: invalidating events', () => {
beforeEach(async () => {
await reload();
});
it('refetches for every event that can change a playlist', async () => {
const events = [
Events.PlaylistCreated,
Events.PlaylistDeleted,
Events.PlaylistRenamed,
Events.PlaylistTracksChanged,
Events.PlaylistsRestored,
Events.LibraryScanComplete,
];
for (const name of events) {
emit(name, 1);
await flush();
}
expect(
calls('playlist.Service.GetAllPlaylistsWithTracks'),
).toHaveLength(events.length);
});
});
+284
View File
@@ -0,0 +1,284 @@
/**
* 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']);
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* The theme store is the only store that writes to the document: it
* derives a whole custom-property ramp from two settings and applies it
* to :root, where every shadow root inherits it. Asserting on the
* computed values of the real document is the point of running in a
* browser at all.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { themeStore } from '@store/theme-store';
import { Events } from '../../src/events';
import { emit, lastArgs } from '@test/support/harness';
/** Push a theme, as the backend's config change event does. */
function applyTheme(AccentColor: string, BackgroundShade: string): void {
emit(Events.ThemeConfigChanged, { AccentColor, BackgroundShade });
}
function cssVar(name: string): string {
return document.documentElement.style.getPropertyValue(name).trim();
}
describe('theme store: derived variables', () => {
beforeEach(() => {
applyTheme('#ffd43b', 'dark');
});
it('caches the pushed theme', () => {
expect(themeStore.getState()).toEqual({
accentColor: '#ffd43b',
backgroundShade: 'dark',
});
});
it('sets the accent verbatim', () => {
expect(cssVar('--yj-accent')).toBe('#ffd43b');
});
it('derives a lighter hover accent and a darker muted one', () => {
expect([cssVar('--yj-accent-hover'), cssVar('--yj-accent-muted')]).toEqual([
'#ffda58',
'#806a1e',
]);
});
it('derives translucent accent backgrounds as rgba, for layering', () => {
expect(cssVar('--yj-accent-bg')).toBe('rgba(255, 212, 59, 0.1)');
});
it('expands a three-digit hex before deriving from it', () => {
applyTheme('#fff', 'dark');
expect(cssVar('--yj-accent-hover')).toBe('#ffffff');
});
it('swaps the whole background ramp with the shade', () => {
applyTheme('#ffd43b', 'darker');
const darker = cssVar('--yj-bg-surface');
applyTheme('#ffd43b', 'light');
expect([darker, cssVar('--yj-bg-surface')]).toEqual(['#121212', '#f8f9fa']);
});
it('keeps semantic colours fixed across shades', () => {
const dark = cssVar('--yj-error');
applyTheme('#ffd43b', 'light');
expect([dark, cssVar('--yj-error')]).toEqual(['#e03131', '#e03131']);
});
});
describe('theme store: document integration', () => {
it('flags dark shades to Web Awesome, which otherwise renders white surfaces', () => {
applyTheme('#ffd43b', 'dark');
const darkFlagged = document.documentElement.classList.contains('wa-dark');
applyTheme('#ffd43b', 'light');
expect([
darkFlagged,
document.documentElement.classList.contains('wa-dark'),
]).toEqual([true, false]);
});
it('sets color-scheme so native controls and scrollbars match', () => {
applyTheme('#ffd43b', 'light');
const light = document.documentElement.style.colorScheme;
applyTheme('#ffd43b', 'darker');
expect([light, document.documentElement.style.colorScheme]).toEqual([
'light',
'dark',
]);
});
it('bridges the surface ramp onto Web Awesome custom properties', () => {
applyTheme('#ffd43b', 'darker');
expect([
cssVar('--wa-color-surface-default'),
cssVar('--wa-color-surface-raised'),
cssVar('--wa-color-surface-lowered'),
]).toEqual(['#000000', '#121212', '#1e1e1e']);
});
it('is inherited through a shadow root', () => {
applyTheme('#ff0000', 'dark');
const host = document.createElement('div');
const root = host.attachShadow({ mode: 'open' });
const inner = document.createElement('span');
inner.style.color = 'var(--yj-accent)';
root.append(inner);
document.body.append(host);
const colour = getComputedStyle(inner).color;
host.remove();
expect(colour).toBe('rgb(255, 0, 0)');
});
});
describe('theme store: writes', () => {
it('sends a new accent to the backend rather than applying it locally', async () => {
applyTheme('#ffd43b', 'dark');
await themeStore.setAccentColor('#00ff00');
// The backend is the writer; the store waits for ThemeConfigChanged.
expect([
lastArgs('config.Config.SetThemeAccentColor'),
themeStore.getState().accentColor,
]).toEqual([['#00ff00'], '#ffd43b']);
});
it('sends a new shade to the backend', async () => {
await themeStore.setBackgroundShade('light');
expect(lastArgs('config.Config.SetThemeBackgroundShade')).toEqual([
'light',
]);
});
});
+189
View File
@@ -0,0 +1,189 @@
/**
* The three small stores behind view chrome: the global search term,
* the track list's column set, and the explore cache that keeps detail
* pages from re-fetching what a search already returned.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { searchStore } from '@store/search-store';
import { trackListStore } from '@store/tracklist-store';
import { exploreCache } from '@store/explore-cache';
import { Events } from '../../src/events';
import { emit, lastCall, flush } from '@test/support/harness';
describe('search store', () => {
beforeEach(() => {
searchStore.setTerm('');
searchStore.setCurrentView('tracks');
});
it('holds the term', () => {
searchStore.setTerm('bowie');
expect(searchStore.getTerm()).toBe('bowie');
});
it('does not notify when the term is unchanged', () => {
let notifications = 0;
const off = searchStore.subscribe(() => {
notifications += 1;
});
searchStore.setTerm('bowie');
searchStore.setTerm('bowie');
off();
expect(notifications).toBe(1);
});
it('notifies synchronously — the search box has no batching to hide behind', () => {
let notified = false;
const off = searchStore.subscribe(() => {
notified = true;
});
searchStore.setTerm('x');
off();
expect(notified).toBe(true);
});
it('knows which views the search box applies to', () => {
const searchable = [
'tracks',
'albums',
'playlists',
'playlist-details',
'artists',
'genres',
].map((view) => {
searchStore.setCurrentView(view);
return searchStore.isSearchableView();
});
expect(searchable.every(Boolean)).toBe(true);
});
it('hides the search box on views it cannot filter', () => {
const results = ['settings', 'explore', 'jobs', 'downloads'].map((view) => {
searchStore.setCurrentView(view);
return searchStore.isSearchableView();
});
expect(results).toEqual([false, false, false, false]);
});
});
describe('track list store', () => {
it('starts from the default column set', () => {
expect(trackListStore.getState().columnIds.length).toBeGreaterThan(0);
});
it('adopts the column order the backend pushes', () => {
emit(Events.TrackListConfigChanged, {
columns: [{ id: 'title' }, { id: 'artist' }],
});
expect(trackListStore.getState().columnIds).toEqual(['title', 'artist']);
});
it('sends columns back as objects, the shape the Go binding expects', async () => {
await trackListStore.setColumns(['title', 'album']);
// A bare string array would be a type mismatch, and a Wails binding
// called with wrong argument types never settles its callback.
expect(lastCall('config.Config.SetTrackListColumns')?.args).toEqual([
[{ id: 'title' }, { id: 'album' }],
]);
});
it('does not apply a column change until the backend confirms it', async () => {
emit(Events.TrackListConfigChanged, { columns: [{ id: 'title' }] });
await trackListStore.setColumns(['title', 'album', 'year']);
await flush();
expect(trackListStore.getState().columnIds).toEqual(['title']);
});
});
describe('explore cache', () => {
it('round-trips an artist by mbid', () => {
exploreCache.setArtist('mbid-1', { mbid: 'mbid-1', name: 'Bowie' });
expect(exploreCache.getArtist('mbid-1')?.name).toBe('Bowie');
});
it('misses cleanly for an unknown mbid', () => {
expect(exploreCache.getArtist('nothing-here')).toBeUndefined();
});
it('refuses to key anything under an empty mbid', () => {
// An empty key would collide across every unidentified entity.
exploreCache.setArtist('', { mbid: '', name: 'Unknown' });
exploreCache.setAlbum('', { mbid: '', title: 'X', artistName: 'Y' });
expect([exploreCache.getArtist(''), exploreCache.getAlbum('')]).toEqual([
undefined,
undefined,
]);
});
it('overwrites an entry with richer data from a later fetch', () => {
exploreCache.setArtist('mbid-2', { mbid: 'mbid-2', name: 'Eno' });
exploreCache.setArtist('mbid-2', {
mbid: 'mbid-2',
name: 'Eno',
imageURL: 'http://x/eno.jpg',
});
expect(exploreCache.getArtist('mbid-2')?.imageURL).toBe('http://x/eno.jpg');
});
it('caches an artists release groups and top tracks separately', () => {
exploreCache.setArtistAlbums('mbid-3', [{ mbid: 'rg-1' }] as never);
exploreCache.setArtistTopTracks('mbid-3', [{ mbid: 'rec-1' }] as never);
expect([
exploreCache.getArtistAlbums('mbid-3')?.length,
exploreCache.getArtistTopTracks('mbid-3')?.length,
]).toEqual([1, 1]);
});
it('populates artists and albums from one search result', () => {
exploreCache.populateFromSearch(
[{ mbid: 'a-1', name: 'Artist', _imageSmall: 's.jpg' }],
[
{
mbid: 'rg-1',
title: 'Album',
artistCredit: 'Artist',
_coverArt: 'c.jpg',
firstReleaseDate: '1977',
},
],
);
expect([
exploreCache.getArtist('a-1')?.imageSmall,
exploreCache.getAlbum('rg-1')?.year,
exploreCache.getAlbum('rg-1')?.artistName,
]).toEqual(['s.jpg', '1977', 'Artist']);
});
it('defaults a missing artist credit to empty rather than undefined', () => {
exploreCache.populateFromSearch([], [{ mbid: 'rg-2', title: 'Untitled' }]);
expect(exploreCache.getAlbum('rg-2')?.artistName).toBe('');
});
it('skips search entries that carry no mbid', () => {
exploreCache.populateFromSearch(
[{ name: 'Nameless' }],
[{ title: 'Nameless' }],
);
expect(exploreCache.getArtist('')).toBeUndefined();
});
});