test: improve recurrence and floating button coverage
ci / gitleaks (push) Successful in 9s
ci / docker (push) Successful in 3m35s

This commit is contained in:
2026-09-19 13:57:09 +08:00
parent 9c0dc3a31f
commit dc2c5ff9c0
2 changed files with 705 additions and 0 deletions
@@ -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')
})
})