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>
This commit is contained in:
@@ -1,7 +1,48 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
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', () => {
|
||||
@@ -49,4 +90,147 @@ describe('TapZone', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user