mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
test(frontend): harness expansion + utility coverage push (F0 + F6) (#539)
* test(frontend): expand harness for store/component/contract tests
- factories: makeSubsonicSong, makeServer, makeAuthState, makeQueueState
- storeReset.ts: per-test reset for player/auth/preview/orbit stores
- mocks/subsonic.ts: realistic fixtures + stream/cover URL helpers
- mocks/browser.ts: ResizeObserver/IntersectionObserver/matchMedia/clipboard/object URLs
- mocks/tauri.ts: tauriMockListenerCount for listener-lifecycle regression tests
- renderWithProviders: pin i18n language to 'en' by default; { language } opt-out
- vitest.config: pool 'forks' + isolate to avoid module-mock + Zustand-global flake
- README: documented patterns, store-reset policy, i18n rule, isolation rationale
* test(frontend): bump utility coverage + expand hot-path gate
serverMagicString: 71→100% (encode/decode rejection branches, clipboard
fallback paths). shareLink: 69→97% (all entity kinds, queue trim, orbit
decoder, findServerIdForShareUrl). dynamicColors: 44→100% (extractCoverColors
DOM paths via Image / canvas / fetch mocks).
Gate adds shareLink.ts and dynamicColors.ts — both stable above 95%.
Comments updated for the new floor and the M4 hard-gate handoff.
This commit is contained in:
committed by
GitHub
parent
a228ce1c91
commit
4f9ad07d65
@@ -6,8 +6,12 @@
|
||||
* than on assembling boilerplate.
|
||||
*/
|
||||
import type { Track } from '@/store/playerStore';
|
||||
import type { ServerProfile } from '@/store/authStore';
|
||||
import type { SubsonicSong } from '@/api/subsonic';
|
||||
|
||||
let trackCounter = 0;
|
||||
let songCounter = 0;
|
||||
let serverCounter = 0;
|
||||
|
||||
export function makeTrack(overrides: Partial<Track> = {}): Track {
|
||||
trackCounter += 1;
|
||||
@@ -28,6 +32,56 @@ export function makeTracks(n: number, overridesFn?: (i: number) => Partial<Track
|
||||
return Array.from({ length: n }, (_, i) => makeTrack(overridesFn?.(i) ?? {}));
|
||||
}
|
||||
|
||||
export function makeSubsonicSong(overrides: Partial<SubsonicSong> = {}): SubsonicSong {
|
||||
songCounter += 1;
|
||||
const id = overrides.id ?? `song-${songCounter}`;
|
||||
return {
|
||||
id,
|
||||
title: `Song ${songCounter}`,
|
||||
artist: 'Test Artist',
|
||||
album: 'Test Album',
|
||||
albumId: `album-${songCounter}`,
|
||||
duration: 180,
|
||||
coverArt: id,
|
||||
bitRate: 320,
|
||||
suffix: 'flac',
|
||||
contentType: 'audio/flac',
|
||||
size: 8_000_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeServer(overrides: Partial<ServerProfile> = {}): ServerProfile {
|
||||
serverCounter += 1;
|
||||
return {
|
||||
id: `server-${serverCounter}`,
|
||||
name: `Test Server ${serverCounter}`,
|
||||
url: `https://server-${serverCounter}.test`,
|
||||
username: 'tester',
|
||||
password: 'pw',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal `useAuthStore.setState(...)` partial — extend per test. */
|
||||
export function makeAuthState(opts: { servers?: ServerProfile[]; activeServerId?: string | null } = {}) {
|
||||
const servers = opts.servers ?? [makeServer()];
|
||||
return {
|
||||
servers,
|
||||
activeServerId: opts.activeServerId === undefined ? servers[0]?.id ?? null : opts.activeServerId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal `usePlayerStore.setState(...)` partial for queue characterization tests. */
|
||||
export function makeQueueState(opts: { queue?: Track[]; currentIndex?: number; currentTrack?: Track | null } = {}) {
|
||||
const queue = opts.queue ?? makeTracks(3);
|
||||
const currentIndex = opts.currentIndex ?? 0;
|
||||
const currentTrack = opts.currentTrack === undefined ? (queue[currentIndex] ?? null) : opts.currentTrack;
|
||||
return { queue, currentIndex, currentTrack };
|
||||
}
|
||||
|
||||
export function resetFactoryCounters(): void {
|
||||
trackCounter = 0;
|
||||
songCounter = 0;
|
||||
serverCounter = 0;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
*
|
||||
* Tests that don't need a specific route can call `renderWithProviders(<X />)`.
|
||||
* Tests that need a specific URL pass `{ route: '/album/42' }`.
|
||||
*
|
||||
* **i18n language is pinned to `en` by default.** This keeps `getByText` /
|
||||
* `getByRole({ name })` selectors stable regardless of the developer's local
|
||||
* language preference. A test that wants to assert against a non-English
|
||||
* translation can pass `{ language: 'de' }`. See `src/test/README.md` for
|
||||
* the rationale (rule 5a from the pre-refactor testing plan, 2026-05-11).
|
||||
*/
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { render, type RenderOptions, type RenderResult } from '@testing-library/react';
|
||||
@@ -14,9 +20,15 @@ import i18n from '@/i18n';
|
||||
|
||||
interface WrapperOptions {
|
||||
route?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
function makeWrapper({ route = '/' }: WrapperOptions) {
|
||||
function makeWrapper({ route = '/', language = 'en' }: WrapperOptions) {
|
||||
if (i18n.language !== language) {
|
||||
// Translations are bundled inline in `src/i18n.ts`, so changeLanguage
|
||||
// resolves synchronously — safe to ignore the returned promise.
|
||||
void i18n.changeLanguage(language);
|
||||
}
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
@@ -30,6 +42,6 @@ export function renderWithProviders(
|
||||
ui: ReactElement,
|
||||
options: WrapperOptions & Omit<RenderOptions, 'wrapper'> = {},
|
||||
): RenderResult {
|
||||
const { route, ...rest } = options;
|
||||
return render(ui, { wrapper: makeWrapper({ route }), ...rest });
|
||||
const { route, language, ...rest } = options;
|
||||
return render(ui, { wrapper: makeWrapper({ route, language }), ...rest });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Per-test Zustand store reset helpers.
|
||||
*
|
||||
* 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 — a mutation in
|
||||
* test A is visible to test B unless we reset.
|
||||
*
|
||||
* Strategy: capture each store's initial state at module-import time (when
|
||||
* persist hydration runs against the empty MemoryStorage polyfill in
|
||||
* setup.ts, so we get the static defaults). Each reset replaces the live
|
||||
* state with that captured snapshot, preserving the original action
|
||||
* references (functions are stable across `setState` since they're closed
|
||||
* over `set`/`get` from the original factory call).
|
||||
*
|
||||
* Usage in a test file:
|
||||
*
|
||||
* import { resetPlayerStore, resetAllStores } from '@/test/helpers/storeReset';
|
||||
* beforeEach(resetPlayerStore);
|
||||
* // or for cross-store tests:
|
||||
* beforeEach(resetAllStores);
|
||||
*/
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePreviewStore } from '@/store/previewStore';
|
||||
import { useOrbitStore } from '@/store/orbitStore';
|
||||
|
||||
const INITIAL_PLAYER_STATE = usePlayerStore.getState();
|
||||
const INITIAL_AUTH_STATE = useAuthStore.getState();
|
||||
const INITIAL_PREVIEW_STATE = usePreviewStore.getState();
|
||||
const INITIAL_ORBIT_STATE = useOrbitStore.getState();
|
||||
|
||||
export function resetPlayerStore(): void {
|
||||
usePlayerStore.setState(INITIAL_PLAYER_STATE, true);
|
||||
}
|
||||
|
||||
export function resetAuthStore(): void {
|
||||
useAuthStore.setState(INITIAL_AUTH_STATE, true);
|
||||
}
|
||||
|
||||
export function resetPreviewStore(): void {
|
||||
usePreviewStore.setState(INITIAL_PREVIEW_STATE, true);
|
||||
}
|
||||
|
||||
export function resetOrbitStore(): void {
|
||||
useOrbitStore.setState(INITIAL_ORBIT_STATE, true);
|
||||
}
|
||||
|
||||
export function resetAllStores(): void {
|
||||
resetPlayerStore();
|
||||
resetAuthStore();
|
||||
resetPreviewStore();
|
||||
resetOrbitStore();
|
||||
}
|
||||
Reference in New Issue
Block a user