test: improve recurrence and floating button coverage
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, ref } from 'vue'
|
||||
import FloatingAddButton from './FloatingAddButton.vue'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
|
||||
function pointerEvent(type: string, init: {
|
||||
pointerId?: number
|
||||
clientX?: number
|
||||
clientY?: number
|
||||
pointerType?: string
|
||||
button?: number
|
||||
isPrimary?: boolean
|
||||
} = {}) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperties(event, {
|
||||
pointerId: { value: init.pointerId ?? 1 },
|
||||
clientX: { value: init.clientX ?? 0 },
|
||||
clientY: { value: init.clientY ?? 0 },
|
||||
pointerType: { value: init.pointerType ?? 'touch' },
|
||||
button: { value: init.button ?? 0 },
|
||||
isPrimary: { value: init.isPrimary ?? true },
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
async function mountButton(options: { show?: boolean; label?: string; reducedMotion?: boolean } = {}) {
|
||||
const host = document.createElement('div')
|
||||
document.body.append(host)
|
||||
const show = ref(options.show ?? true)
|
||||
const activations: Array<{ x: number; y: number }> = []
|
||||
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({
|
||||
matches: options.reducedMotion ?? false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
const app = createApp({
|
||||
setup: () => () => h(FloatingAddButton, {
|
||||
show: show.value,
|
||||
label: options.label,
|
||||
onActivate: (origin: { x: number; y: number }) => activations.push(origin),
|
||||
}),
|
||||
})
|
||||
app.mount(host)
|
||||
cleanups.push(() => { app.unmount(); host.remove() })
|
||||
await nextTick()
|
||||
return { host, show, activations, button: () => host.querySelector<HTMLButtonElement>('button') }
|
||||
}
|
||||
|
||||
function setRect(button: HTMLButtonElement, left: number, top: number) {
|
||||
Object.defineProperty(button, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({ left, top, width: 56, height: 56, right: left + 56, bottom: top + 56, x: left, y: top, toJSON: () => ({}) }),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('innerWidth', 390)
|
||||
vi.stubGlobal('innerHeight', 844)
|
||||
document.documentElement.style.setProperty('--safe-area-bottom', '20px')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach((cleanup) => cleanup())
|
||||
document.documentElement.style.removeProperty('--safe-area-bottom')
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('FloatingAddButton', () => {
|
||||
it('renders an accessible native button and emits its visual center when clicked', async () => {
|
||||
const { button, activations } = await mountButton({ label: '新建任务' })
|
||||
const fab = button()!
|
||||
setRect(fab, 100, 200)
|
||||
|
||||
expect(fab.tagName).toBe('BUTTON')
|
||||
expect(fab.getAttribute('aria-label')).toBe('新建任务')
|
||||
fab.focus()
|
||||
expect(document.activeElement).toBe(fab)
|
||||
fab.click()
|
||||
|
||||
expect(activations).toEqual([{ x: 128, y: 228 }])
|
||||
})
|
||||
|
||||
it('reacts to show changes without emitting an activation', async () => {
|
||||
const { button, show, activations } = await mountButton({ show: false })
|
||||
expect(button()).toBeNull()
|
||||
|
||||
show.value = true
|
||||
await nextTick()
|
||||
expect(button()?.getAttribute('aria-label')).toBe('添加')
|
||||
expect(activations).toEqual([])
|
||||
})
|
||||
|
||||
it('drags, clamps, snaps to the nearest edge, and suppresses the trailing click', async () => {
|
||||
const { button, activations } = await mountButton()
|
||||
const fab = button()!
|
||||
setRect(fab, 300, 700)
|
||||
const capture = vi.fn()
|
||||
Object.defineProperty(fab, 'setPointerCapture', { configurable: true, value: capture })
|
||||
|
||||
fab.dispatchEvent(pointerEvent('pointerdown', { pointerId: 7, clientX: 310, clientY: 710 }))
|
||||
fab.dispatchEvent(pointerEvent('pointermove', { pointerId: 7, clientX: 50, clientY: 400 }))
|
||||
await nextTick()
|
||||
|
||||
expect(capture).toHaveBeenCalledWith(7)
|
||||
expect(fab.classList.contains('dragging')).toBe(true)
|
||||
expect(fab.style.left).toBe('40px')
|
||||
expect(fab.style.top).toBe('390px')
|
||||
|
||||
fab.dispatchEvent(pointerEvent('pointerup', { pointerId: 7, clientX: 50, clientY: 400 }))
|
||||
await nextTick()
|
||||
expect(fab.classList.contains('dragging')).toBe(false)
|
||||
expect(fab.classList.contains('snapping')).toBe(true)
|
||||
expect(fab.style.left).toBe('14px')
|
||||
expect(fab.style.top).toBe('390px')
|
||||
|
||||
fab.click()
|
||||
expect(activations).toEqual([])
|
||||
vi.advanceTimersByTime(180)
|
||||
fab.click()
|
||||
expect(activations).toEqual([{ x: 328, y: 728 }])
|
||||
|
||||
vi.advanceTimersByTime(40)
|
||||
await nextTick()
|
||||
expect(fab.classList.contains('snapping')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start dragging for secondary mouse or non-primary pointers', async () => {
|
||||
const { button } = await mountButton()
|
||||
const fab = button()!
|
||||
setRect(fab, 100, 200)
|
||||
|
||||
fab.dispatchEvent(pointerEvent('pointerdown', { pointerType: 'mouse', button: 2 }))
|
||||
fab.dispatchEvent(pointerEvent('pointermove', { clientX: 300, clientY: 500 }))
|
||||
fab.dispatchEvent(pointerEvent('pointerdown', { pointerId: 2, isPrimary: false }))
|
||||
fab.dispatchEvent(pointerEvent('pointermove', { pointerId: 2, clientX: 300, clientY: 500 }))
|
||||
await nextTick()
|
||||
|
||||
expect(fab.classList.contains('dragging')).toBe(false)
|
||||
expect(fab.getAttribute('style')).toBeNull()
|
||||
})
|
||||
|
||||
it('snaps without animation when reduced motion is requested', async () => {
|
||||
const { button } = await mountButton({ reducedMotion: true })
|
||||
const fab = button()!
|
||||
setRect(fab, 250, 300)
|
||||
|
||||
fab.dispatchEvent(pointerEvent('pointerdown', { clientX: 260, clientY: 310 }))
|
||||
fab.dispatchEvent(pointerEvent('pointerup', { clientX: 280, clientY: 310 }))
|
||||
await nextTick()
|
||||
|
||||
expect(fab.style.left).toBe('320px')
|
||||
expect(fab.style.top).toBe('300px')
|
||||
expect(fab.classList.contains('snapping')).toBe(false)
|
||||
})
|
||||
|
||||
it('re-snaps a placed button after the viewport is resized', async () => {
|
||||
const { button } = await mountButton()
|
||||
const fab = button()!
|
||||
setRect(fab, 250, 300)
|
||||
fab.dispatchEvent(pointerEvent('pointerdown', { clientX: 260, clientY: 310 }))
|
||||
fab.dispatchEvent(pointerEvent('pointerup', { clientX: 280, clientY: 310 }))
|
||||
await nextTick()
|
||||
expect(fab.style.left).toBe('320px')
|
||||
|
||||
vi.stubGlobal('innerWidth', 300)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await nextTick()
|
||||
|
||||
expect(fab.style.left).toBe('230px')
|
||||
expect(fab.style.top).toBe('300px')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user