From 272d0bc67244ec7061867bd745a7118f106033d1 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Wed, 16 Sep 2026 17:37:50 +0800 Subject: [PATCH] test(frontend): text search e2e with txt fixture (B2) --- frontend/e2e/fixtures/e2e-sample-txt.txt | 29 +++++++++++++ frontend/e2e/helpers/api.ts | 17 ++++++-- frontend/e2e/text-search.spec.ts | 55 ++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 frontend/e2e/fixtures/e2e-sample-txt.txt create mode 100644 frontend/e2e/text-search.spec.ts diff --git a/frontend/e2e/fixtures/e2e-sample-txt.txt b/frontend/e2e/fixtures/e2e-sample-txt.txt new file mode 100644 index 0000000..f1aa95c --- /dev/null +++ b/frontend/e2e/fixtures/e2e-sample-txt.txt @@ -0,0 +1,29 @@ +第一章 样本 + +这是第一章的正文,包含标记词 ZEBRA-XC42 用于搜索断言。 +第一章的正文还有许多话,用来把两个章节的标题拉开足够的距离, +因为分章器会丢弃距上一个保留标题不足两百字的行,避免把正文里的提及 +当成目录条目。下面是凑篇幅的段落:书的特点是按章阅读,每章之间用 +明显的编号标题分隔;扫描器把文件落盘后由解析器分卷与分章;阅读器按 +章渲染,长文的滚动与恢复都按章节与章内偏移来记录。ZEBRA-XC42 是 +搜索断言的标记词,本段只出现一次。再写几行凑足两百字以上:风起云 +收,纸上得来终觉浅,绝知此事要躬行。如此五章的信息足够编造一份适合 +端到端测试用的文本样本,昭然有效,勿改内容。 + +第二章 落雨 + +雨点敲窗。第二章也有 zebra-xc42 的小写变体,用于验证大小写不敏感的 +命中;这一章的正文同样要足以拉开与第一章标题的距离,避免被分章器 +当成正文里的目录行丢弃。雨水落在窗上,本章的内容较第一章多几度斜 +面:先有标记词的小写变体,再有凑篇幅的句子。为了保持两百字的间距, +本章正文需要再写几行;夜读时开着夜色主题,正文底色与文字色由阅读 +主题映射而来;行距与边距档位随时可调,搜索结果由防抖输入驱动,命中 +列表以章节 + 前后文片段呈现,点击即可跳到命中章的大致位置。落雨行 +至结尾,此章同样只包含一次标记词,合计两处命中。 + +第三章 风止 + + wind 结尾章,无标记词。风止一章同样凑足两百字,让分章器认全三章。 +结尾章的正文不包含任何搜索断言词,用于验证搜索结果不会多算。风过 +无痕,桌上灯亮;夜读的读者从目录跳章时看到当前章的计数变为 2/3, +即第二章标记已存。样本至此结束。 diff --git a/frontend/e2e/helpers/api.ts b/frontend/e2e/helpers/api.ts index 884d40e..b2ceb30 100644 --- a/frontend/e2e/helpers/api.ts +++ b/frontend/e2e/helpers/api.ts @@ -4,6 +4,7 @@ import { request, type APIRequestContext } from "@playwright/test"; export const BASE = process.env.E2E_BASE_URL ?? "http://localhost:5173"; export const FIXTURE_CBZ = fileURLToPath(new URL("../fixtures/e2e-sample.cbz", import.meta.url)); +export const FIXTURE_TXT = fileURLToPath(new URL("../fixtures/e2e-sample-txt.txt", import.meta.url)); /** UI 登录与 API fixture 共用同一管理员凭据:E2E_ADMIN_* 优先,回落 deploy/.env 注入的 ADMIN_*。 */ export function creds(): { user: string; pass: string } { @@ -45,14 +46,19 @@ async function findBook(ctx: APIRequestContext, q: string): Promise<{ id: number } /** 幂等:书已存在直接返回 id;否则上传 fixture → 触发扫描 → 轮询等书出现(扫描是 fire-and-forget)。 */ -export async function ensureSampleBook(ctx: APIRequestContext, libName: string): Promise { - const title = "e2e-sample"; +export async function ensureBook( + ctx: APIRequestContext, + libName: string, + fileName: string, + absPath: string, +): Promise { + const title = fileName.replace(/\.[a-z]+$/i, ""); const existing = await findBook(ctx, title); if (existing) return existing.id; const libId = await ensureLibrary(ctx, libName); const up = await ctx.post(`/api/libraries/${libId}/upload`, { multipart: { - file: { name: "e2e-sample.cbz", mimeType: "application/zip", buffer: readFileSync(FIXTURE_CBZ) }, + file: { name: fileName, mimeType: "application/octet-stream", buffer: readFileSync(absPath) }, }, }); if (!up.ok()) throw new Error(`上传失败:HTTP ${up.status()} ${await up.text()}`); @@ -66,3 +72,8 @@ export async function ensureSampleBook(ctx: APIRequestContext, libName: string): await new Promise((r) => setTimeout(r, 1000)); } } + +/** CBZ 样本书便捷封装(auth-shelf 沿用)。 */ +export async function ensureSampleBook(ctx: APIRequestContext, libName: string): Promise { + return ensureBook(ctx, libName, "e2e-sample.cbz", FIXTURE_CBZ); +} diff --git a/frontend/e2e/text-search.spec.ts b/frontend/e2e/text-search.spec.ts new file mode 100644 index 0000000..da7eea4 --- /dev/null +++ b/frontend/e2e/text-search.spec.ts @@ -0,0 +1,55 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; +import { adminApi, creds, ensureBook, FIXTURE_TXT } from "./helpers/api"; +import { scanA11y } from "./helpers/axe"; + +let admin: APIRequestContext; +let bookId: number; + +test.beforeAll(async () => { + admin = await adminApi(); + // 文件名避免与 CBZ 样本书同名:fixture 用 e2e-sample-txt.txt(title=e2e-sample-txt) + bookId = await ensureBook(admin, "e2e", "e2e-sample-txt.txt", FIXTURE_TXT); +}); + +test.afterAll(async () => { + await admin?.dispose(); +}); + +test("登录 → txt 阅读 → 全书搜索 → 跳转", async ({ page }) => { + const { user, pass } = creds(); + + await page.goto("/login"); + await page.fill("#login-user", user); + await page.fill("#login-pass", pass); + await page.getByRole("button", { name: "登录", exact: true }).click(); + await expect(page).toHaveURL(/\/$/); + + const card = page.locator(".group > button", { hasText: "e2e-sample-txt" }); + await expect(card).toBeVisible(); + await card.click(); + await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`)); + + // 导航抽屉 → 搜索 tab + await page.getByRole("button", { name: "导航", exact: true }).click(); + const nav = page.locator("#reader-nav"); + await expect(nav).toBeVisible(); + await nav.getByRole("tab", { name: "搜索" }).click(); + await nav.getByRole("textbox", { name: "搜索全书" }).fill("zebra-xc42"); + // 大小写不敏感:第一章 2 处 + 第二章 1 处 = 3 行 + await expect(nav.getByRole("listitem")).toHaveCount(3, { timeout: 10_000 }); + expect(await nav.locator("mark").count()).toBe(3); // 高亮 + + // 点第二条 → 跳到第一章/第二章(第二条命中)的章,章计数 2/3,抽屉关闭 + await nav.getByRole("button").filter({ hasText: "第二章" }).first().click(); + await expect(page.getByText("2/3")).toBeVisible({ timeout: 10_000 }); + await expect(nav).not.toBeVisible(); + + await scanA11y(page, "reader-chrome"); + + // 退出 + await page.getByRole("link", { name: "书架" }).click(); + await expect(page).toHaveURL(/\/$/); + await page.getByRole("button", { name: new RegExp(`账户 ${user}`) }).click(); + await page.getByText("退出登录").click(); + await expect(page).toHaveURL(/\/login/); +});