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'); + }); +});