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.
161 lines
4.7 KiB
TypeScript
161 lines
4.7 KiB
TypeScript
/**
|
|
* `<track-info>` is the shared row renderer: every list that shows a
|
|
* track goes through it, so its fallbacks (missing title, missing
|
|
* cover, missing duration) are visible in half the app.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import '@components/track-info/track-info';
|
|
import { fixture, shadow, text, update, visual } from '@test/support/render';
|
|
|
|
describe('<track-info>', () => {
|
|
it('renders title, artist and album', async () => {
|
|
const el = await fixture('track-info', {
|
|
trackTitle: 'Ashes to Ashes',
|
|
artist: 'David Bowie',
|
|
album: 'Scary Monsters',
|
|
});
|
|
|
|
expect([text(el, '.title'), text(el, '.secondary')]).toEqual([
|
|
'Ashes to Ashes',
|
|
'David Bowie — Scary Monsters',
|
|
]);
|
|
});
|
|
|
|
it('joins artist and album with an em dash, and omits the separator when one is missing', async () => {
|
|
const el = await fixture('track-info', {
|
|
trackTitle: 'X',
|
|
artist: 'Only Artist',
|
|
});
|
|
|
|
expect(text(el, '.secondary')).toBe('Only Artist');
|
|
});
|
|
|
|
it('omits the secondary line entirely when there is nothing to put in it', async () => {
|
|
const el = await fixture('track-info', { trackTitle: 'X' });
|
|
|
|
expect(shadow(el, '.secondary')).toBeNull();
|
|
});
|
|
|
|
it('falls back to the filename, without its extension, when untitled', async () => {
|
|
// WAV tracks currently scan in untitled, so this path is live.
|
|
const el = await fixture('track-info', {
|
|
filePath: '/music/field/01 - Dawn Chorus.wav',
|
|
});
|
|
|
|
expect(text(el, '.title')).toBe('01 - Dawn Chorus');
|
|
});
|
|
|
|
it('handles a Windows path in the same fallback', async () => {
|
|
const el = await fixture('track-info', {
|
|
filePath: 'C:\\Music\\Album\\Track.mp3',
|
|
});
|
|
|
|
expect(text(el, '.title')).toBe('Track');
|
|
});
|
|
|
|
it('prefers a real title over the filename', async () => {
|
|
const el = await fixture('track-info', {
|
|
trackTitle: 'Real Title',
|
|
filePath: '/music/whatever.mp3',
|
|
});
|
|
|
|
expect(text(el, '.title')).toBe('Real Title');
|
|
});
|
|
|
|
it('renders no title element at all when it has neither title nor path', async () => {
|
|
const el = await fixture('track-info', { artist: 'Someone' });
|
|
|
|
expect(shadow(el, '.title')).toBeNull();
|
|
});
|
|
|
|
it('formats a duration given in milliseconds', async () => {
|
|
const el = await fixture('track-info', {
|
|
trackTitle: 'X',
|
|
duration: '215000',
|
|
});
|
|
|
|
expect(text(el, '.duration')).toBe('03:35');
|
|
});
|
|
|
|
it('shows placeholder dashes for an unparseable duration', async () => {
|
|
const el = await fixture('track-info', {
|
|
trackTitle: 'X',
|
|
duration: 'unknown',
|
|
});
|
|
|
|
expect(text(el, '.duration')).toBe('--:--');
|
|
});
|
|
|
|
it('omits the duration column when there is no duration', async () => {
|
|
const el = await fixture('track-info', { trackTitle: 'X' });
|
|
|
|
expect(shadow(el, '.duration')).toBeNull();
|
|
});
|
|
|
|
it('shows no cover slot at all unless a cover was supplied', async () => {
|
|
const el = await fixture('track-info', { trackTitle: 'X' });
|
|
|
|
expect(shadow(el, '.cover-art')).toBeNull();
|
|
});
|
|
|
|
it('prefers the small cover variant, which is what a row needs', async () => {
|
|
const el = await fixture('track-info', {
|
|
trackTitle: 'X',
|
|
coverArt: '/covers/big.jpg',
|
|
coverArtSmall: '/covers/small.jpg',
|
|
});
|
|
|
|
expect(shadow<HTMLImageElement>(el, '.cover-art img')?.getAttribute('src')).toBe(
|
|
'/covers/small.jpg',
|
|
);
|
|
});
|
|
|
|
it('falls back to the full-size cover when the thumbnail fails to load', async () => {
|
|
const el = await fixture('track-info', {
|
|
trackTitle: 'X',
|
|
coverArt: '/covers/big.jpg',
|
|
coverArtSmall: '/covers/missing.jpg',
|
|
});
|
|
|
|
const img = shadow<HTMLImageElement>(el, '.cover-art img');
|
|
|
|
img?.dispatchEvent(new Event('error'));
|
|
|
|
expect(img?.src).toContain('/covers/big.jpg');
|
|
});
|
|
|
|
it('degrades to the music-note placeholder when both covers fail', async () => {
|
|
const el = await fixture('track-info', {
|
|
trackTitle: 'X',
|
|
coverArtSmall: '/covers/missing.jpg',
|
|
});
|
|
|
|
const img = shadow<HTMLImageElement>(el, '.cover-art img');
|
|
|
|
img?.dispatchEvent(new Event('error'));
|
|
|
|
expect(shadow(el, '.cover-placeholder wa-icon')).not.toBeNull();
|
|
});
|
|
|
|
it('re-renders when a property changes', async () => {
|
|
const el = await fixture('track-info', { trackTitle: 'Before' });
|
|
|
|
await update(el, { trackTitle: 'After' });
|
|
|
|
expect(text(el, '.title')).toBe('After');
|
|
});
|
|
|
|
it('looks the way it did last time', async () => {
|
|
const el = await fixture('track-info', {
|
|
trackTitle: 'Ashes to Ashes',
|
|
artist: 'David Bowie',
|
|
album: 'Scary Monsters',
|
|
duration: '215000',
|
|
});
|
|
|
|
await visual(el, 'track-info');
|
|
expect(el.shadowRoot).not.toBeNull();
|
|
});
|
|
});
|