440 lines
15 KiB
JavaScript
440 lines
15 KiB
JavaScript
/**
|
|
* Artwork Viewer System Tests
|
|
*
|
|
* Covers the 5 spec-required test cases:
|
|
* 1. Context navigation test — prev/next resolved from sessionStorage
|
|
* 2. Fallback test — API fallback when no sessionStorage context
|
|
* 3. Keyboard test — ← → keys navigate; ESC closes viewer; F opens viewer
|
|
* 4. Mobile swipe test — horizontal swipe triggers navigation
|
|
* 5. Modal test — viewer opens/closes via image click and keyboard
|
|
*/
|
|
import React from 'react'
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function makeCtx(overrides = {}) {
|
|
return JSON.stringify({
|
|
source: 'tag',
|
|
key: 'tag:digital-art',
|
|
ids: [100, 200, 300],
|
|
index: 1,
|
|
ts: Date.now(),
|
|
...overrides,
|
|
})
|
|
}
|
|
|
|
function mockSessionStorage(value) {
|
|
const store = { nav_ctx: value }
|
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => store[key] ?? null)
|
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
|
|
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {})
|
|
}
|
|
|
|
function mockFetch(data, ok = true) {
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok,
|
|
status: ok ? 200 : 404,
|
|
json: async () => data,
|
|
})
|
|
}
|
|
|
|
// ─── 1. Context Navigation Test ───────────────────────────────────────────────
|
|
|
|
describe('Context navigation — useNavContext', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
})
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('resolves prev/next IDs from the same-user API', async () => {
|
|
const apiData = { prev_id: 100, next_id: 300, prev_url: '/art/100', next_url: '/art/300' }
|
|
mockFetch(apiData)
|
|
|
|
const { useNavContext } = await import('../../lib/useNavContext')
|
|
|
|
function Harness() {
|
|
const { getNeighbors } = useNavContext(200)
|
|
const [n, setN] = React.useState(null)
|
|
React.useEffect(() => { getNeighbors().then(setN) }, [getNeighbors])
|
|
return n ? <div data-testid="result">{n.prevId}-{n.nextId}</div> : null
|
|
}
|
|
|
|
render(<Harness />)
|
|
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
|
expect(screen.getByTestId('result').textContent).toBe('100-300')
|
|
})
|
|
|
|
it('returns null neighbors when the artwork has no same-user neighbors', async () => {
|
|
const apiData = { prev_id: null, next_id: null, prev_url: null, next_url: null }
|
|
mockFetch(apiData)
|
|
|
|
const { useNavContext } = await import('../../lib/useNavContext')
|
|
|
|
function Harness() {
|
|
const { getNeighbors } = useNavContext(100)
|
|
const [n, setN] = React.useState(null)
|
|
React.useEffect(() => { getNeighbors().then(setN) }, [getNeighbors])
|
|
return n ? <div data-testid="result">{String(n.prevId)}|{String(n.nextId)}</div> : null
|
|
}
|
|
|
|
render(<Harness />)
|
|
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
|
expect(screen.getByTestId('result').textContent).toBe('null|null')
|
|
})
|
|
})
|
|
|
|
// ─── 2. Fallback Test ─────────────────────────────────────────────────────────
|
|
|
|
describe('Fallback — API navigation when no sessionStorage context', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
})
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('calls /api/artworks/navigation/{id} when sessionStorage is empty', async () => {
|
|
mockSessionStorage(null)
|
|
const apiData = { prev_id: 50, next_id: 150, prev_url: '/art/50', next_url: '/art/150' }
|
|
mockFetch(apiData)
|
|
|
|
const { useNavContext } = await import('../../lib/useNavContext')
|
|
|
|
let result
|
|
function Harness() {
|
|
const { getNeighbors } = useNavContext(100)
|
|
const [n, setN] = React.useState(null)
|
|
React.useEffect(() => {
|
|
getNeighbors().then(setN)
|
|
}, [getNeighbors])
|
|
return n ? <div data-testid="result">{n.prevId}-{n.nextId}</div> : null
|
|
}
|
|
|
|
render(<Harness />)
|
|
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/api/artworks/navigation/100'),
|
|
expect.any(Object)
|
|
)
|
|
expect(screen.getByTestId('result').textContent).toBe('50-150')
|
|
})
|
|
|
|
it('returns null neighbors when API also fails', async () => {
|
|
mockSessionStorage(null)
|
|
global.fetch = vi.fn().mockRejectedValue(new Error('network error'))
|
|
|
|
const { useNavContext } = await import('../../lib/useNavContext')
|
|
|
|
function Harness() {
|
|
const { getNeighbors } = useNavContext(999)
|
|
const [n, setN] = React.useState(null)
|
|
React.useEffect(() => {
|
|
getNeighbors().then(setN)
|
|
}, [getNeighbors])
|
|
return n ? <div data-testid="result">{String(n.prevId)}|{String(n.nextId)}</div> : null
|
|
}
|
|
|
|
render(<Harness />)
|
|
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
|
expect(screen.getByTestId('result').textContent).toBe('null|null')
|
|
})
|
|
})
|
|
|
|
// ─── 3. Keyboard Test ─────────────────────────────────────────────────────────
|
|
|
|
describe('Keyboard navigation', () => {
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
it('ArrowLeft key triggers navigate to previous artwork', async () => {
|
|
// Test the keyboard event logic in isolation (the same logic used in ArtworkNavigator)
|
|
const handler = vi.fn()
|
|
const cleanup = []
|
|
|
|
function KeyTestHarness() {
|
|
React.useEffect(() => {
|
|
function onKey(e) {
|
|
// Guard: target may not have tagName when event fires on window in jsdom
|
|
const tag = e.target?.tagName?.toLowerCase?.() ?? ''
|
|
if (['input', 'textarea', 'select'].includes(tag) || e.target?.isContentEditable) return
|
|
if (e.key === 'ArrowLeft') handler('prev')
|
|
if (e.key === 'ArrowRight') handler('next')
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
cleanup.push(() => window.removeEventListener('keydown', onKey))
|
|
}, [])
|
|
return <div />
|
|
}
|
|
|
|
render(<KeyTestHarness />)
|
|
|
|
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
|
expect(handler).toHaveBeenCalledWith('prev')
|
|
|
|
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
|
expect(handler).toHaveBeenCalledWith('next')
|
|
|
|
cleanup.forEach(fn => fn())
|
|
})
|
|
|
|
it('ESC key closes the viewer modal', async () => {
|
|
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
|
|
|
const onClose = vi.fn()
|
|
const artwork = { id: 1, title: 'Test Art', thumbs: { lg: { url: '/img.jpg' } } }
|
|
|
|
render(
|
|
<ArtworkViewer
|
|
isOpen={true}
|
|
onClose={onClose}
|
|
artwork={artwork}
|
|
presentLg={{ url: '/img.jpg' }}
|
|
presentXl={null}
|
|
/>
|
|
)
|
|
|
|
fireEvent.keyDown(document.body, { key: 'Escape' })
|
|
expect(onClose).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// ─── 4. Mobile Swipe Test ─────────────────────────────────────────────────────
|
|
|
|
describe('Mobile swipe navigation', () => {
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
it('left-to-right swipe fires prev navigation', () => {
|
|
const handler = vi.fn()
|
|
|
|
function SwipeHarness() {
|
|
const touchStartX = React.useRef(null)
|
|
const touchStartY = React.useRef(null)
|
|
|
|
React.useEffect(() => {
|
|
function onStart(e) {
|
|
touchStartX.current = e.touches[0].clientX
|
|
touchStartY.current = e.touches[0].clientY
|
|
}
|
|
function onEnd(e) {
|
|
if (touchStartX.current === null) return
|
|
const dx = e.changedTouches[0].clientX - touchStartX.current
|
|
const dy = e.changedTouches[0].clientY - touchStartY.current
|
|
touchStartX.current = null
|
|
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
|
|
handler(dx > 0 ? 'prev' : 'next')
|
|
}
|
|
}
|
|
window.addEventListener('touchstart', onStart, { passive: true })
|
|
window.addEventListener('touchend', onEnd, { passive: true })
|
|
return () => {
|
|
window.removeEventListener('touchstart', onStart)
|
|
window.removeEventListener('touchend', onEnd)
|
|
}
|
|
}, [])
|
|
|
|
return <div data-testid="swipe-target" />
|
|
}
|
|
|
|
render(<SwipeHarness />)
|
|
|
|
// Simulate swipe right (prev)
|
|
fireEvent(window, new TouchEvent('touchstart', {
|
|
touches: [{ clientX: 200, clientY: 100 }],
|
|
}))
|
|
fireEvent(window, new TouchEvent('touchend', {
|
|
changedTouches: [{ clientX: 260, clientY: 105 }],
|
|
}))
|
|
|
|
expect(handler).toHaveBeenCalledWith('prev')
|
|
})
|
|
|
|
it('right-to-left swipe fires next navigation', () => {
|
|
const handler = vi.fn()
|
|
|
|
function SwipeHarness() {
|
|
const startX = React.useRef(null)
|
|
const startY = React.useRef(null)
|
|
|
|
React.useEffect(() => {
|
|
function onStart(e) { startX.current = e.touches[0].clientX; startY.current = e.touches[0].clientY }
|
|
function onEnd(e) {
|
|
if (startX.current === null) return
|
|
const dx = e.changedTouches[0].clientX - startX.current
|
|
const dy = e.changedTouches[0].clientY - startY.current
|
|
startX.current = null
|
|
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) handler(dx > 0 ? 'prev' : 'next')
|
|
}
|
|
window.addEventListener('touchstart', onStart, { passive: true })
|
|
window.addEventListener('touchend', onEnd, { passive: true })
|
|
return () => { window.removeEventListener('touchstart', onStart); window.removeEventListener('touchend', onEnd) }
|
|
}, [])
|
|
|
|
return <div />
|
|
}
|
|
|
|
render(<SwipeHarness />)
|
|
|
|
fireEvent(window, new TouchEvent('touchstart', {
|
|
touches: [{ clientX: 300, clientY: 100 }],
|
|
}))
|
|
fireEvent(window, new TouchEvent('touchend', {
|
|
changedTouches: [{ clientX: 240, clientY: 103 }],
|
|
}))
|
|
|
|
expect(handler).toHaveBeenCalledWith('next')
|
|
})
|
|
|
|
it('ignores swipe with large vertical component (scroll intent)', () => {
|
|
const handler = vi.fn()
|
|
|
|
function SwipeHarness() {
|
|
const startX = React.useRef(null)
|
|
const startY = React.useRef(null)
|
|
React.useEffect(() => {
|
|
function onStart(e) { startX.current = e.touches[0].clientX; startY.current = e.touches[0].clientY }
|
|
function onEnd(e) {
|
|
if (startX.current === null) return
|
|
const dx = e.changedTouches[0].clientX - startX.current
|
|
const dy = e.changedTouches[0].clientY - startY.current
|
|
startX.current = null
|
|
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) handler('swipe')
|
|
}
|
|
window.addEventListener('touchstart', onStart, { passive: true })
|
|
window.addEventListener('touchend', onEnd, { passive: true })
|
|
return () => { window.removeEventListener('touchstart', onStart); window.removeEventListener('touchend', onEnd) }
|
|
}, [])
|
|
return <div />
|
|
}
|
|
|
|
render(<SwipeHarness />)
|
|
|
|
// Diagonal swipe — large vertical component, should be ignored
|
|
fireEvent(window, new TouchEvent('touchstart', {
|
|
touches: [{ clientX: 100, clientY: 100 }],
|
|
}))
|
|
fireEvent(window, new TouchEvent('touchend', {
|
|
changedTouches: [{ clientX: 200, clientY: 250 }],
|
|
}))
|
|
|
|
expect(handler).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// ─── 5. Modal Test ────────────────────────────────────────────────────────────
|
|
|
|
describe('ArtworkViewer modal', () => {
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
it('does not render when isOpen=false', async () => {
|
|
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
|
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
|
|
|
render(
|
|
<ArtworkViewer isOpen={false} onClose={() => {}} artwork={artwork} presentLg={null} presentXl={null} />
|
|
)
|
|
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
})
|
|
|
|
it('renders with title when isOpen=true', async () => {
|
|
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
|
const artwork = { id: 1, title: 'My Artwork', thumbs: {} }
|
|
|
|
render(
|
|
<ArtworkViewer
|
|
isOpen={true}
|
|
onClose={() => {}}
|
|
artwork={artwork}
|
|
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
|
presentXl={null}
|
|
/>
|
|
)
|
|
|
|
expect(screen.getByRole('dialog')).not.toBeNull()
|
|
expect(screen.getByAltText('My Artwork')).not.toBeNull()
|
|
expect(screen.getByText('My Artwork')).not.toBeNull()
|
|
})
|
|
|
|
it('calls onClose when clicking the backdrop', async () => {
|
|
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
|
const onClose = vi.fn()
|
|
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
|
|
|
const { container } = render(
|
|
<ArtworkViewer
|
|
isOpen={true}
|
|
onClose={onClose}
|
|
artwork={artwork}
|
|
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
|
presentXl={null}
|
|
/>
|
|
)
|
|
|
|
// Click the backdrop (the dialog wrapper itself)
|
|
const dialog = screen.getByRole('dialog')
|
|
fireEvent.click(dialog)
|
|
expect(onClose).toHaveBeenCalled()
|
|
})
|
|
|
|
it('does NOT call onClose when clicking the image', async () => {
|
|
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
|
const onClose = vi.fn()
|
|
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
|
|
|
render(
|
|
<ArtworkViewer
|
|
isOpen={true}
|
|
onClose={onClose}
|
|
artwork={artwork}
|
|
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
|
presentXl={null}
|
|
/>
|
|
)
|
|
|
|
const img = screen.getByRole('img', { name: 'Art' })
|
|
fireEvent.click(img)
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('calls onClose on ESC keydown', async () => {
|
|
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
|
const onClose = vi.fn()
|
|
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
|
|
|
render(
|
|
<ArtworkViewer
|
|
isOpen={true}
|
|
onClose={onClose}
|
|
artwork={artwork}
|
|
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
|
presentXl={null}
|
|
/>
|
|
)
|
|
|
|
fireEvent.keyDown(document.body, { key: 'Escape' })
|
|
expect(onClose).toHaveBeenCalled()
|
|
})
|
|
|
|
it('prefers presentXl over presentLg for image src', async () => {
|
|
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
|
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
|
|
|
render(
|
|
<ArtworkViewer
|
|
isOpen={true}
|
|
onClose={() => {}}
|
|
artwork={artwork}
|
|
presentLg={{ url: 'https://cdn/lg.jpg' }}
|
|
presentXl={{ url: 'https://cdn/xl.jpg' }}
|
|
/>
|
|
)
|
|
|
|
const img = screen.getByRole('img', { name: 'Art' })
|
|
expect(img.getAttribute('src')).toBe('https://cdn/xl.jpg')
|
|
})
|
|
})
|