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:
1 parent
14285ff314
commit
96c1228805
11 files changed
+504
-149
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user