import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, screen, cleanup } from '@testing-library/svelte'; import TapZone from './TapZone.svelte'; // jsdom does not implement PointerEvent. Construct a MouseEvent of the // correct type and decorate it with the `pointerType` + coords the // component reads — Svelte forwards it to the onpointerdown handler // unchanged, and the guard `e.pointerType === 'touch'` reads our // property. function pointerEvent( type: string, init: { pointerType?: string; clientX?: number; clientY?: number } = {} ): Event { const ev = new MouseEvent(type, { bubbles: true, clientX: init.clientX, clientY: init.clientY }); if (init.pointerType != null) { Object.defineProperty(ev, 'pointerType', { value: init.pointerType, configurable: true }); } return ev; } function pointerDown( el: Element, init: { pointerType: string; clientX: number; clientY: number } ) { el.dispatchEvent(pointerEvent('pointerdown', init)); } function pointerMove( el: Element, init: { clientX: number; clientY: number } ) { el.dispatchEvent(pointerEvent('pointermove', init)); } function pointerUp(el: Element) { el.dispatchEvent(pointerEvent('pointerup')); } afterEach(() => cleanup()); describe('TapZone', () => { it('renders three labelled buttons (Previous / Toggle / Next)', () => { render(TapZone, { props: { onPrev: () => {}, onNext: () => {}, onToggle: () => {} } }); expect(screen.getByRole('button', { name: 'Previous page' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'Toggle controls' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'Next page' })).toBeTruthy(); }); it('fires onPrev when the left zone is tapped', () => { const onPrev = vi.fn(); render(TapZone, { props: { onPrev, onNext: () => {}, onToggle: () => {} } }); screen.getByRole('button', { name: 'Previous page' }).click(); expect(onPrev).toHaveBeenCalledOnce(); }); it('fires onNext when the right zone is tapped', () => { const onNext = vi.fn(); render(TapZone, { props: { onPrev: () => {}, onNext, onToggle: () => {} } }); screen.getByRole('button', { name: 'Next page' }).click(); expect(onNext).toHaveBeenCalledOnce(); }); it('fires onToggle when the center zone is tapped', () => { const onToggle = vi.fn(); render(TapZone, { props: { onPrev: () => {}, onNext: () => {}, onToggle } }); screen.getByRole('button', { name: 'Toggle controls' }).click(); expect(onToggle).toHaveBeenCalledOnce(); }); it('exposes zone-level testids derived from the wrapper testid', () => { render(TapZone, { props: { onPrev: () => {}, onNext: () => {}, onToggle: () => {}, testid: 'reader-tap' } }); expect(screen.getByTestId('reader-tap')).toBeTruthy(); expect(screen.getByTestId('reader-tap-left')).toBeTruthy(); expect(screen.getByTestId('reader-tap-center')).toBeTruthy(); expect(screen.getByTestId('reader-tap-right')).toBeTruthy(); }); describe('long-press', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('fires onLongPress with anchor coords after 450ms touch hold', () => { const onLongPress = vi.fn(); const onNext = vi.fn(); render(TapZone, { props: { onPrev: () => {}, onNext, onToggle: () => {}, onLongPress } }); const right = screen.getByTestId('tap-zone-right'); pointerDown(right, { pointerType: 'touch', clientX: 200, clientY: 300 }); vi.advanceTimersByTime(500); expect(onLongPress).toHaveBeenCalledWith({ x: 200, y: 300 }); // The synthesized click that follows the long-press is // suppressed so the next-page handler doesn't fire. right.click(); expect(onNext).not.toHaveBeenCalled(); }); it('does not fire onLongPress for mouse pointers', () => { const onLongPress = vi.fn(); render(TapZone, { props: { onPrev: () => {}, onNext: () => {}, onToggle: () => {}, onLongPress } }); const center = screen.getByTestId('tap-zone-center'); pointerDown(center, { pointerType: 'mouse', clientX: 100, clientY: 100 }); vi.advanceTimersByTime(1000); expect(onLongPress).not.toHaveBeenCalled(); }); it('cancels long-press when pointer moves beyond tolerance', () => { const onLongPress = vi.fn(); render(TapZone, { props: { onPrev: () => {}, onNext: () => {}, onToggle: () => {}, onLongPress } }); const right = screen.getByTestId('tap-zone-right'); pointerDown(right, { pointerType: 'touch', clientX: 100, clientY: 100 }); pointerMove(right, { clientX: 200, clientY: 100 }); vi.advanceTimersByTime(500); expect(onLongPress).not.toHaveBeenCalled(); }); it('cancels long-press on pointer up before threshold', () => { const onLongPress = vi.fn(); render(TapZone, { props: { onPrev: () => {}, onNext: () => {}, onToggle: () => {}, onLongPress } }); const center = screen.getByTestId('tap-zone-center'); pointerDown(center, { pointerType: 'touch', clientX: 100, clientY: 100 }); pointerUp(center); vi.advanceTimersByTime(500); expect(onLongPress).not.toHaveBeenCalled(); }); it('regular click still fires onNext when no long-press handler', () => { const onNext = vi.fn(); render(TapZone, { props: { onPrev: () => {}, onNext, onToggle: () => {} } }); screen.getByTestId('tap-zone-right').click(); expect(onNext).toHaveBeenCalledOnce(); }); it('suppression auto-expires so the next legitimate tap fires after a slide-off long-press', () => { // Scenario: user long-presses, finger slides off the zone // onto the opened sheet, releases there. No click is // synthesized on the zone, so without the expiry the // suppression would leak and eat the next tap. The 500ms // expiry inside the long-press callback caps the // suppression window. const onLongPress = vi.fn(); const onNext = vi.fn(); render(TapZone, { props: { onPrev: () => {}, onNext, onToggle: () => {}, onLongPress } }); const right = screen.getByTestId('tap-zone-right'); pointerDown(right, { pointerType: 'touch', clientX: 100, clientY: 100 }); // Long-press fires. vi.advanceTimersByTime(450); expect(onLongPress).toHaveBeenCalledOnce(); // Drain the suppression-expiry window without a click — // simulates the user sliding off and releasing elsewhere. vi.advanceTimersByTime(600); // Fresh tap on the zone — must NOT be eaten. right.click(); expect(onNext).toHaveBeenCalledOnce(); }); }); });