From 0b7b5df30cdf73ffb309f05d757553fdf3b87417 Mon Sep 17 00:00:00 2001
From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
Date: Wed, 24 Jun 2026 17:49:13 +0200
Subject: [PATCH] test(ui): smoke-test the #1165 hook-guard split and
recent-search rename (#1168)
* test(cover): smoke-test CoverArtImage hook-guard split (#1165)
* test(search): smoke-test MobileSearchOverlay recent-search (#1165)
---
src/components/MobileSearchOverlay.test.tsx | 42 +++++++
src/cover/CoverArtImage.test.tsx | 130 ++++++++++++++++++++
2 files changed, 172 insertions(+)
create mode 100644 src/components/MobileSearchOverlay.test.tsx
create mode 100644 src/cover/CoverArtImage.test.tsx
diff --git a/src/components/MobileSearchOverlay.test.tsx b/src/components/MobileSearchOverlay.test.tsx
new file mode 100644
index 00000000..dd06e3a9
--- /dev/null
+++ b/src/components/MobileSearchOverlay.test.tsx
@@ -0,0 +1,42 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithProviders } from '../test/helpers/renderWithProviders';
+import MobileSearchOverlay from './MobileSearchOverlay';
+import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
+
+// The overlay's only behaviour-bearing change in PR #1165 was renaming the
+// recent-search handler `useRecent` → `applyRecentSearch` (it was a plain
+// function mis-flagged as a hook). Smoke-test that the recent-search path still
+// applies the term to the live-search store. Heavy collaborators are stubbed.
+vi.mock('../hooks/useShareSearch', () => ({ useShareSearch: () => ({ shareMatch: null }) }));
+vi.mock('../api/subsonicSearch', () => ({
+ search: vi.fn(() => Promise.resolve({ artists: [], albums: [], songs: [] })),
+}));
+vi.mock('../cover/AlbumCoverArtImage', () => ({ AlbumCoverArtImage: () => null }));
+vi.mock('../cover/ArtistCoverArtImage', () => ({ ArtistCoverArtImage: () => null }));
+vi.mock('../cover/CoverArtImage', () => ({ CoverArtImage: () => null }));
+
+const RECENT_KEY = 'psysonic_recent_searches';
+
+describe('MobileSearchOverlay — recent search (applyRecentSearch, PR #1165)', () => {
+ beforeEach(() => {
+ useLiveSearchScopeStore.setState({ query: '', scope: null, undoStack: [] });
+ localStorage.setItem(RECENT_KEY, JSON.stringify(['first query', 'second query']));
+ });
+
+ it('lists stored recent searches in the empty state', () => {
+ renderWithProviders();
+ expect(screen.getByText('first query')).toBeInTheDocument();
+ expect(screen.getByText('second query')).toBeInTheDocument();
+ });
+
+ it('applies a recent search term to the live-search store on click', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.click(screen.getByText('first query'));
+
+ expect(useLiveSearchScopeStore.getState().query).toBe('first query');
+ });
+});
diff --git a/src/cover/CoverArtImage.test.tsx b/src/cover/CoverArtImage.test.tsx
new file mode 100644
index 00000000..9ec5f5a7
--- /dev/null
+++ b/src/cover/CoverArtImage.test.tsx
@@ -0,0 +1,130 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { CoverArtImage } from './CoverArtImage';
+import { useCoverArt } from './useCoverArt';
+import type { CoverArtRef } from './types';
+import { COVER_SCOPE_ACTIVE } from './types';
+
+// The hook-guard split (PR #1165) moved every hook into `CoverArtImageResolved`,
+// which only mounts with a non-null `coverRef`. The wrapper itself runs no hooks.
+// We mock the hook and the side-effecting cover modules so the test exercises
+// only the wrapper's branching + prop forwarding, deterministically.
+vi.mock('./useCoverArt', () => ({ useCoverArt: vi.fn() }));
+vi.mock('./ensureQueue', () => ({
+ coverEnsureQueued: vi.fn(() => Promise.resolve({ hit: false })),
+ coverEnsureReprioritize: vi.fn(),
+}));
+vi.mock('./prefetchRegistry', () => ({ coverPrefetchBumpPriority: vi.fn() }));
+vi.mock('./reachability', () => ({ coverServerReachable: () => false }));
+
+const mockedUseCoverArt = vi.mocked(useCoverArt);
+
+function ref(overrides: Partial = {}): CoverArtRef {
+ return {
+ cacheKind: 'album',
+ cacheEntityId: 'al-1',
+ fetchCoverArtId: 'al-1',
+ serverScope: COVER_SCOPE_ACTIVE,
+ ...overrides,
+ };
+}
+
+function setHandle(src: string, provisional = !src) {
+ mockedUseCoverArt.mockReturnValue({
+ src,
+ storageKey: 'k',
+ cacheKey: 'k',
+ tier: 128,
+ provisional,
+ onImgError: vi.fn(),
+ });
+}
+
+describe('CoverArtImage — hook-guard split (PR #1165)', () => {
+ beforeEach(() => {
+ mockedUseCoverArt.mockReset();
+ setHandle('');
+ });
+
+ it('renders a provisional placeholder and runs no hooks when coverRef is missing', () => {
+ render(
+ ,
+ );
+ const placeholder = screen.getByRole('img', { name: 'My album' });
+ expect(placeholder.tagName).toBe('DIV');
+ expect(placeholder).toHaveAttribute('data-cover-provisional', 'true');
+ expect(placeholder).toHaveClass('cover-x');
+ // The whole point of the split: the wrapper must not touch the hook.
+ expect(mockedUseCoverArt).not.toHaveBeenCalled();
+ });
+
+ it('forwards passthrough props on the missing-ref placeholder', () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId('ph')).toHaveAttribute('aria-label', '');
+ });
+
+ it('renders an
with the resolved src once coverRef is present', () => {
+ setHandle('blob:cover-src', false);
+ render(
+ ,
+ );
+ const img = screen.getByRole('img', { name: 'My album' });
+ expect(img.tagName).toBe('IMG');
+ expect(img).toHaveAttribute('src', 'blob:cover-src');
+ expect(img).toHaveClass('cover-x');
+ expect(mockedUseCoverArt).toHaveBeenCalled();
+ });
+
+ it('renders a provisional placeholder with a ref present but no src yet', () => {
+ setHandle('', true);
+ render(
+ ,
+ );
+ const placeholder = screen.getByRole('img');
+ expect(placeholder.tagName).toBe('DIV');
+ expect(placeholder).toHaveAttribute('data-cover-provisional', 'true');
+ });
+
+ it('renders correctly when coverRef toggles null↔present', () => {
+ // The guard split keeps the hook-bearing body (CoverArtImageResolved) on a
+ // separate element type from the placeholder div, so toggling coverRef swaps
+ // the element rather than changing one component's hook count. (The old
+ // early-return-before-hooks shape was a static rules-of-hooks lint violation,
+ // not a runtime crash in React 19 — this is regression coverage either way.)
+ setHandle('blob:cover-src', false);
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByRole('img').tagName).toBe('DIV');
+
+ // null → present: placeholder div → resolved
.
+ rerender(
+ ,
+ );
+ expect(screen.getByRole('img').tagName).toBe('IMG');
+
+ // present → null: hook-bearing body unmounts cleanly back to the placeholder.
+ rerender(
+ ,
+ );
+ expect(screen.getByRole('img').tagName).toBe('DIV');
+ });
+});