Newer
Older
navi-1 / webclient / tests / unit / utils / detailsAnimation.test.js
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)')
  })
})