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>
237 lines
8.3 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|