import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setDetailsOpen, detailsToggle } from '@/utils/detailsAnimation.js'
// jsdom has no layout engine, so every height reads 0 and the animated path
// settles immediately — which is what lets these tests assert on the end state
// (the `open` attribute and the absence of leftover inline styles) rather than
// on frames. The motion itself is checked in a real browser.
function makeDetails({ open = false, inDocument = true } = {}) {
const el = document.createElement('details')
el.innerHTML = '<summary>Args</summary><div class="body">body</div>'
if (open) el.setAttribute('open', '')
if (inDocument) document.body.appendChild(el)
return el
}
describe('detailsAnimation', () => {
beforeEach(() => {
vi.useFakeTimers()
document.body.innerHTML = ''
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('detailsToggle flips the open attribute', async () => {
const el = makeDetails()
const summary = el.querySelector('summary')
detailsToggle({ currentTarget: summary })
await vi.advanceTimersByTimeAsync(1000)
expect(el.hasAttribute('open')).toBe(true)
detailsToggle({ currentTarget: summary })
await vi.advanceTimersByTimeAsync(1000)
expect(el.hasAttribute('open')).toBe(false)
})
it('detailsToggle ignores a summary that has no <details> around it', () => {
const loose = document.createElement('summary')
document.body.appendChild(loose)
expect(() => detailsToggle({ currentTarget: loose })).not.toThrow()
expect(loose.hasAttribute('open')).toBe(false)
})
it('settles immediately when there is nothing to animate', () => {
const el = makeDetails()
setDetailsOpen(el, true, { animate: false })
expect(el.hasAttribute('open')).toBe(true)
setDetailsOpen(el, false, { animate: false })
expect(el.hasAttribute('open')).toBe(false)
})
it('tolerates a missing element', () => {
expect(() => setDetailsOpen(null, true)).not.toThrow()
})
it('does not touch an element that is not in the document', () => {
const el = makeDetails({ inDocument: false })
setDetailsOpen(el, true)
expect(el.hasAttribute('open')).toBe(true)
// no inline styles: the card will be laid out from scratch when it mounts
expect(el.style.height).toBe('')
expect(el.style.overflow).toBe('')
})
it('leaves no inline styles behind once the animation is over', async () => {
const el = makeDetails()
setDetailsOpen(el, true)
await vi.advanceTimersByTimeAsync(1000)
expect(el.hasAttribute('open')).toBe(true)
expect(el.style.height).toBe('')
expect(el.style.overflow).toBe('')
expect(el.style.transition).toBe('')
})
it('keeps the last click when toggled mid-flight', async () => {
const el = makeDetails()
const summary = el.querySelector('summary')
detailsToggle({ currentTarget: summary }) // open, animation now pending
await vi.advanceTimersByTimeAsync(0) // let the first run pin its height
detailsToggle({ currentTarget: summary }) // closed again before it settles
await vi.advanceTimersByTimeAsync(1000)
expect(el.hasAttribute('open')).toBe(false)
expect(el.style.height).toBe('')
expect(el.style.transition).toBe('')
})
it('honours prefers-reduced-motion by settling in one frame', () => {
const matchMedia = vi.fn(() => ({ matches: true }))
vi.stubGlobal('matchMedia', matchMedia)
const el = makeDetails()
setDetailsOpen(el, true)
expect(el.hasAttribute('open')).toBe(true)
expect(el.style.transition).toBe('')
expect(matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)')
})
})