avocet/web/src/components/EmailCardStack.test.ts

183 lines
8.1 KiB
TypeScript

import { mount } from '@vue/test-utils'
import EmailCardStack from './EmailCardStack.vue'
import { describe, it, expect, vi } from 'vitest'
vi.mock('../composables/useCardAnimation', () => ({
useCardAnimation: vi.fn(() => ({
pickup: vi.fn(),
setDragPosition: vi.fn(),
snapBack: vi.fn(),
animateDismiss: vi.fn(),
updateAura: vi.fn(),
reset: vi.fn(),
})),
}))
import { useCardAnimation } from '../composables/useCardAnimation'
import { nextTick } from 'vue'
const item = {
id: 'abc',
subject: 'Interview at Acme',
body: 'We would like to schedule...',
from: 'hr@acme.com',
date: '2026-03-01',
source: 'imap:test',
}
describe('EmailCardStack', () => {
it('renders the email subject', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
expect(w.text()).toContain('Interview at Acme')
})
it('renders shadow cards for depth effect', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
expect(w.findAll('.card-shadow')).toHaveLength(2)
})
it('calls animateDismiss with type when dismissType prop changes', async () => {
;(useCardAnimation as ReturnType<typeof vi.fn>).mockClear()
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: null } })
const { animateDismiss } = (useCardAnimation as ReturnType<typeof vi.fn>).mock.results[0].value
await w.setProps({ dismissType: 'label' })
await nextTick()
expect(animateDismiss).toHaveBeenCalledWith('label')
})
// JSDOM doesn't implement setPointerCapture — mock it on the element.
// Also use dispatchEvent(new PointerEvent) directly because @vue/test-utils
// .trigger() tries to assign clientX on a MouseEvent (read-only in JSDOM).
function mockPointerCapture(element: Element) {
;(element as any).setPointerCapture = vi.fn()
;(element as any).releasePointerCapture = vi.fn()
}
function fire(element: Element, type: string, init: PointerEventInit) {
element.dispatchEvent(new PointerEvent(type, { bubbles: true, ...init }))
}
it('emits drag-start on pointerdown', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('drag-start')).toBeTruthy()
})
it('emits drag-end on pointerup', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 200, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('drag-end')).toBeTruthy()
})
it('emits discard when released in left zone (x < 7% viewport)', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
// JSDOM window.innerWidth defaults to 1024; 7% = 71.7px
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointermove', { pointerId: 1, clientX: 30, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 30, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeTruthy()
})
it('emits skip when released in right zone (x > 93% viewport)', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
// JSDOM window.innerWidth defaults to 1024; 93% = 952px
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointermove', { pointerId: 1, clientX: 1000, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 1000, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('skip')).toBeTruthy()
})
it('does not emit action on pointerup without movement past zone', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 512, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeFalsy()
expect(w.emitted('skip')).toBeFalsy()
expect(w.emitted('label')).toBeFalsy()
})
// Fling tests — mock performance.now() to control timestamps between events
it('emits discard on fast leftward fling (option B: speed + alignment)', async () => {
let mockTime = 0
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 400, clientY: 310 })
mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 288, clientY: 320 })
// vx = (288-400)/(50-30)*1000 = -5600 px/s, vy ≈ 500 px/s
// speed ≈ 5622 px/s > 600, alignment = 5600/5622 ≈ 0.996 > 0.707 ✓
fire(el, 'pointerup', { pointerId: 1, clientX: 288, clientY: 320 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeTruthy()
vi.restoreAllMocks()
})
it('emits skip on fast rightward fling', async () => {
let mockTime = 0
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 624, clientY: 310 })
mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 736, clientY: 320 })
// vx = (736-624)/(50-30)*1000 = 5600 px/s — mirror of discard case
fire(el, 'pointerup', { pointerId: 1, clientX: 736, clientY: 320 })
await w.vm.$nextTick()
expect(w.emitted('skip')).toBeTruthy()
vi.restoreAllMocks()
})
it('does not fling on diagonal swipe (alignment < 0.707)', async () => {
let mockTime = 0
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 400, clientY: 150 })
mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 288, clientY: 0 })
// vx = -5600 px/s, vy = -7500 px/s, speed ≈ 9356 px/s
// alignment = 5600/9356 ≈ 0.598 < 0.707 — too diagonal ✓
fire(el, 'pointerup', { pointerId: 1, clientX: 288, clientY: 0 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeFalsy()
expect(w.emitted('skip')).toBeFalsy()
vi.restoreAllMocks()
})
it('does not fling on slow movement (speed < threshold)', async () => {
let mockTime = 0
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
mockTime = 100; fire(el, 'pointermove', { pointerId: 1, clientX: 480, clientY: 300 })
mockTime = 200; fire(el, 'pointermove', { pointerId: 1, clientX: 450, clientY: 300 })
// vx = (450-480)/(200-100)*1000 = -300 px/s < 600 threshold
fire(el, 'pointerup', { pointerId: 1, clientX: 450, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeFalsy()
expect(w.emitted('skip')).toBeFalsy()
vi.restoreAllMocks()
})
})