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.
190 lines
5.6 KiB
TypeScript
190 lines
5.6 KiB
TypeScript
/**
|
||
* 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 artist’s 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();
|
||
});
|
||
});
|