Files
Mangalord/frontend/src/lib/components/TapZone.svelte.test.ts
MechaCat02 6c901e64c9 feat(search): tag-based page search surface + per-page tags & collections
Add the /search surface (Pages / Chapters / Mangas tabs) backed by
per-user page tags and per-page collections: schema (migration 0023),
backend endpoints for page tags/collections and tagged-page aggregations
(with the OCR text-search param reserved at 501), plus the frontend API
clients, library Page-tags tab, collection page sections, page context
menu / AddTagsSheet, and reader long-press wiring. Includes the
continuous-reader navigation fixes (?page=N handling, chapter-reset
timing, back-button pops history) and tag-normalization hardening
accumulated on the branch.

Bump version 0.60.2 -> 0.62.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:51:38 +02:00

237 lines
8.3 KiB
TypeScript

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();
});
});
});