feat(series): multi-field search, sort-direction toggle, apply placement

Toolbar feedback round: the series search box now matches whatever the
main search matches for a group — series NAME, any book's AUTHOR, and
any book's TITLE inside the series (WHERE EXISTS per group-row, so
counts stay true); the separate author input is gone from the toolbar
(the author= param stays live for the JSON API and deep links). The
sort control becomes a base-key select (Date added/Name/Author/Books/
Progress/Last read) plus a chevron direction toggle that flips and
resubmits; sort/dir compose through ResolveSeriesSort (full registry
keys keep working for back-compat, unknown base keys fall to recent).
The search/apply button moved next to the search box as requested, and
default URLs stay clean (sort/dir only emitted once the user chooses).
This commit is contained in:
John O'Keefe committed 2026-10-27 19:05:23 -04:00
1 parent 14285ff314
commit 96c1228805
11 files changed
+504 -149

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
const BASE = "http://localhost:8765";
const STAMP = Date.now();
const EMAIL = `e2e-search-${STAMP}@example.com`;
const USERNAME = `e2e-search-${STAMP}`;
const PASSWORD = "E2e-Playwright-2026!";
let token = "";
const consoleFailures: string[] = [];
function watchPage(page: Page): void {
page.on("pageerror", (err) => {
consoleFailures.push(`pageerror: ${err.message}`);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleFailures.push(`console.error: ${msg.text()}`);
}
});
}
function expectNoClientErrors(): void {
const bad = consoleFailures.filter((f) =>
/(is not defined|is not a function|headerSearch|alpine)/i.test(f),
);
expect(bad, `uncaught client errors:\n${bad.join("\n")}`).toEqual([]);
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const res = await request.post("/api/auth/login", {
data: { login: EMAIL, password: PASSWORD },
});
expect(res.status(), "login should succeed").toBe(200);
const body = await res.json();
return body.access_token as string;
}
test.describe.configure({ mode: "serial" });
test.describe("Header search + audio scrubber (client wiring)", () => {
let page: Page;
test.beforeAll(async ({ browser, request }) => {
const reg = await request.post("/api/auth/register", {
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
});
expect([201, 200, 409]).toContain(reg.status());
token = await apiLogin(request);
expect(token).toBeTruthy();
page = await browser.newPage();
watchPage(page);
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
});
test.afterAll(async () => {
expectNoClientErrors();
await page?.close();
});
test("header search dropdown opens on typing (regression: tree-shaken module)", async () => {
await page.goto("/bookshelf");
await expect(page.locator("#header-search")).toBeVisible();
// The regression this pins: search.ts (which registers the
// headerSearch Alpine component) was tree-shaken from the bundle,
// so typing did nothing — no request, no dropdown. The component
// must be registered (x-data resolves) and the htmx swap must land.
await page.fill("#header-search", "a");
// Below the 2-char minimum: no results may appear.
await page.waitForTimeout(700);
await expect(page.locator("#search-results")).toBeHidden();
await page.fill("#header-search", "al");
const results = page.locator("#search-results");
await expect(results).toBeVisible({ timeout: 10_000 });
await expect(results.locator(".search-result-item").first()).toBeVisible({
timeout: 10_000,
});
});
test("audio scrubber: grabbing the handle does not teleport (plain and Shift)", async ({ request }) => {
// Find an audiobook in the shared dev library (plain media list,
// then filter client-side — the search endpoint's facet behavior
// with empty q isn't a listing API).
const res = await request.get("/api/media-items", {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(200);
const body = (await res.json()) as { data?: Array<{ id: string; format_group?: string }> };
const rows = (body.data ?? []).filter((r) => r.format_group === "audiobook");
test.skip(rows.length === 0, "no audiobooks in the dev library");
const bookId = rows[0].id;
await page.goto(`/readers/${bookId}`);
const scrubArea = page.locator("#scrub-area");
await expect(scrubArea).toBeVisible();
// The fresh user has never played this book — park the playhead at
// 30% via the progress API so the grab tests run mid-book.
const put = await request.put(`/api/media-items/${bookId}/progress`, {
headers: { Authorization: `Bearer ${token}` },
data: { percentage: 0.3, device_id: "e2e-scrubber" },
});
expect([200, 204]).toContain(put.status());
await page.reload();
await expect(scrubArea).toBeVisible();
const box = await scrubArea.boundingBox();
test.skip(!box, "scrub bar not laid out");
const y = box!.y + box!.height / 2;
// Where is the thumb right now? The native range input mirrors the
// playhead; read its aria-valuenow + max to compute the thumb X.
const thumbX = await page.evaluate(() => {
const input = document.getElementById("audio-scrub") as HTMLInputElement;
const area = document.getElementById("scrub-area")!;
const rect = area.getBoundingClientRect();
const value = Number(input.value || 0);
const max = Number(input.max || 1);
return rect.left + (value / max) * rect.width;
});
const readScrubValue = () =>
page.evaluate(() => Number((document.getElementById("audio-scrub") as HTMLInputElement).value));
const before = await readScrubValue();
expect(before).toBeGreaterThan(0);
// --- plain grab: pointerdown ON the thumb must not move it ---
await page.mouse.move(thumbX, y);
await page.mouse.down();
await page.waitForTimeout(150);
const atGrab = await readScrubValue();
expect(Math.abs(atGrab - before)).toBeLessThan(
Math.max(1, before * 0.0005),
"grabbing the handle must not jump the playhead",
);
// Drag left by 60px: the handle follows RELATIVELY.
await page.mouse.move(thumbX - 60, y, { steps: 5 });
await page.waitForTimeout(100);
const afterDrag = await readScrubValue();
expect(afterDrag).toBeLessThan(atGrab, "dragging left scrubs backward");
await page.mouse.up();
await page.waitForTimeout(400); // seek commits
// --- Shift+grab: the precision rail appears, no teleport ---
const railBefore = await readScrubValue();
// Re-locate the thumb after the commit.
const thumbX2 = await page.evaluate(() => {
const input = document.getElementById("audio-scrub") as HTMLInputElement;
const area = document.getElementById("scrub-area")!;
const rect = area.getBoundingClientRect();
const value = Number(input.value || 0);
const max = Number(input.max || 1);
return rect.left + (value / max) * rect.width;
});
await page.keyboard.down("Shift");
await page.mouse.move(thumbX2, y);
await page.mouse.down();
await page.waitForTimeout(150);
const railAtGrab = await readScrubValue();
expect(Math.abs(railAtGrab - railBefore)).toBeLessThan(
Math.max(1, railBefore * 0.0005),
"Shift+grab must not teleport either",
);
// The rail is the feedback for Shift+grab — it must be visible.
await expect(page.locator('div[x-show="railVisible"]')).toBeVisible();
await page.keyboard.up("Shift");
await page.mouse.up();
});
});