Files
psysonic/src/test
Frank Stellmacher 377675ae94 test(ui): MiniPlayer + FullscreenPlayer (§4.5 regression) (Phase F5c) (#548)
MiniPlayer.test.tsx (4): mounts without throwing, renders the
always-present titlebar controls (Pin + Open main window — Close is
Linux-only and lives on the manual smoke list per pick 4a), click on
Open main window / Close does not throw, clicking the Pin button
flips the alwaysOnTop label between "Unpin" and "Pin on top". Bridge
contract (mini:ready / mini:sync, geometry persistence) deferred to
B-tier phase B5 -- jsdom does not model two webviews.

FullscreenPlayer.test.tsx (9): renders the labelled Fullscreen Player
dialog + Close Fullscreen control. Control wiring: Close calls
onClose, Stop calls stop, Previous calls previous, Next calls next,
Repeat cycles via toggleRepeat.

§4.5 of the v2 plan -- useCachedUrl(coverUrl, coverKey, false)
regression. Mocks the CachedImage module so the call args are
observable. Pins:
  - the 500 px cover-art call passes opt=false (no fetchUrl fallback;
    prevents double crossfade fetchUrl -> blobUrl);
  - the 300 px art-box call passes opt=true (default behaviour).
A refactor that "tidies up" the useCachedUrl call sites would silently
regress the FS player cover; this test makes it loud.

Harness fix: vi.mock for @tauri-apps/api/event now returns an async
emit that resolves -- components chain .catch() on emit which crashed
on the bare vi.fn() return value during first-render useEffect.
Benefits any future component test that mounts something using emit.

Frontend suite: 399 -> 412 tests (+13). F5 (and Phases F0-F6) complete.
2026-05-11 23:24:40 +02:00
..

Frontend test framework

Vitest + jsdom + @testing-library/react. Existing util tests in src/utils/*.test.ts keep working; this folder hosts the harness for store, hook, component and (eventually) integration tests.

Layout

src/test/
  setup.ts                      # global: jest-dom, cleanup, vi.mock for tauri/*,
                                # localStorage polyfill, browser-mock install
  mocks/
    tauri.ts                    # programmable invoke() + listen() helpers,
                                # tauriMockListenerCount(event) for lifecycle tests
    subsonic.ts                 # realistic Subsonic fixture data
    browser.ts                  # ResizeObserver / IntersectionObserver /
                                # matchMedia / clipboard / object URL mocks
  helpers/
    factories.ts                # makeTrack / makeTracks / makeSubsonicSong /
                                # makeServer / makeAuthState / makeQueueState
    storeReset.ts               # resetPlayerStore / resetAuthStore /
                                # resetPreviewStore / resetOrbitStore /
                                # resetAllStores
    renderWithProviders.tsx     # render() wrapped with MemoryRouter + i18n
                                # (en-pinned by default)
  README.md                     # this file

Running tests

npm test                       # one-shot run
npm run test:watch             # watch mode
npm run test:coverage          # with v8 coverage → ./coverage/

Where tests go

  • Co-located with the unit under test: Foo.tsxFoo.test.tsx, barStore.tsbarStore.test.ts. Mirrors the existing util test layout and avoids a parallel directory tree.
  • Vitest picks them up via include: src/**/*.test.{ts,tsx} in vitest.config.ts.

Mocking Tauri

@tauri-apps/api/core and @tauri-apps/api/event are mocked globally in setup.ts. Configure per-test behaviour via the helpers in mocks/tauri.ts:

import { onInvoke, emitTauriEvent, invokeMock, tauriMockListenerCount } from '@/test/mocks/tauri';

beforeEach(() => {
  onInvoke('audio_play', () => undefined);
});

it('responds to engine events', () => {
  emitTauriEvent('audio:progress', { id: 't1', currentTime: 42 });
  expect(invokeMock).toHaveBeenCalledWith('audio_play', { id: 't1' });
});

it('does not double-register listeners on re-init', () => {
  // ... call init logic twice, assert listener count is 1
  expect(tauriMockListenerCount('audio:progress')).toBe(1);
});

Unhandled invoke() calls throw a descriptive error — tests are honest about which commands they exercise. Handlers + listeners are auto-cleared between tests.

Mocking Subsonic / HTTP

Hoist the mock in the test file (vitest limitation — factory functions can't import helper modules at hoist time), then inject realistic fixture data from mocks/subsonic.ts:

import { vi, describe, it, beforeEach, expect } from 'vitest';
vi.mock('@/api/subsonic');
import { getAlbum, buildStreamUrl } from '@/api/subsonic';
import { sampleAlbumWithSongs, mockStreamUrl } from '@/test/mocks/subsonic';

beforeEach(() => {
  vi.mocked(getAlbum).mockResolvedValue(sampleAlbumWithSongs);
  vi.mocked(buildStreamUrl).mockImplementation(mockStreamUrl);
});

For broader integration tests that touch many endpoints we may introduce MSW later. The framework is intentionally MSW-free right now to keep the dep surface small until we need it.

Resetting stores

Zustand stores are module-level singletons and leak state across tests unless explicitly reset. setup.ts already clears localStorage between tests, but the in-memory getState() snapshot survives.

Use helpers/storeReset.ts:

import { resetPlayerStore, resetAllStores } from '@/test/helpers/storeReset';

describe('myFeature', () => {
  beforeEach(resetPlayerStore);
  // or, for cross-store tests:
  beforeEach(resetAllStores);
});

Each reset replaces the live state with the snapshot captured at module import time. Action references are preserved (they're closed over the original set/get, stable across setState).

i18n language is pinned to en

renderWithProviders calls i18n.changeLanguage('en') synchronously before render, so getByText('Settings') finds the English label regardless of the developer's local language preference. Tests that want to assert against another translation pass { language: 'de' }:

renderWithProviders(<MyComponent />, { language: 'de' });

Rationale: option 5a from the pre-refactor testing plan (2026-05-11). Without a fixed test language, every translation edit risks flipping a green test red on a contributor's machine.

Patterns

Pure utilities

Direct import + assert (see src/utils/dynamicColors.test.ts). No setup needed beyond import { describe, it, expect } from 'vitest'.

Zustand stores

  • Import the hook, drive it via useFooStore.getState().
  • Reset state in a beforeEach via storeReset.ts.
  • Stub Tauri side effects via onInvoke().
  • Use emitTauriEvent() to drive event-driven state transitions.

See src/store/previewStore.test.ts for the reference pattern.

Components

  • renderWithProviders(<MyComponent />) from helpers/renderWithProviders.
  • Prefer getByRole({ name: ... }) over getByText when a semantic role exists — the role survives translation tweaks and refactors that move labels into different elements.
  • Fall back to data-testid only when the DOM provides no semantic anchor.
  • Use userEvent (not fireEvent) for click / type / keyboard, with the exception of keydown on window for global shortcut paths.

See src/components/CoverLightbox.test.tsx.

Hooks

Wrap in renderHook() from @testing-library/react. Provide custom wrappers when the hook reads from a provider.

What to NOT mock

  • Real Zustand stores. The whole point of characterization tests is to exercise the actual state graph. Mock only at the system boundary (Tauri / network / browser APIs).
  • The router. MemoryRouter via renderWithProviders is fine — don't stub useNavigate etc. unless a test specifically inspects navigation.
  • react-i18next. I18nextProvider with the real i18n.ts instance is cheap and avoids tests that lie about labels.

What to NOT snapshot

  • Large rendered trees from renderWithProviders. Translation edits, CSS class renames, or unrelated child-component refactors flip the snapshot for reasons unrelated to the unit under test. Assert on specific observable behaviour instead — a button is enabled, an aria-label is present, a class is set.
  • Zustand state snapshots that include action functions. Function identity changes break the snapshot without any behavioural reason.

Coverage gates

  • vitest run --coverage writes coverage/coverage-summary.json which the hot-path gate consumes.
  • .github/frontend-hot-path-files.txt lists the files held to ≥70% line coverage by scripts/check-frontend-hot-path-coverage.sh.
  • CI runs both. The gate is soft today (continue-on-error: true) — it flips to a hard PR-blocker at the start of M4 in the pre-refactor testing plan. Mirrors the backend rust-tests rollout.

Process isolation

vitest.config.ts pins pool: 'forks' + isolate: true. Each test file runs in its own forked process with a fresh module graph. ~20% slower locally than the default thread pool but avoids the fake-timer + module-mock + Zustand-global flake class that surfaces around suite-size 30+.