test(frontend): text search e2e with txt fixture (B2)
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
第一章 样本
|
||||||
|
|
||||||
|
这是第一章的正文,包含标记词 ZEBRA-XC42 用于搜索断言。
|
||||||
|
第一章的正文还有许多话,用来把两个章节的标题拉开足够的距离,
|
||||||
|
因为分章器会丢弃距上一个保留标题不足两百字的行,避免把正文里的提及
|
||||||
|
当成目录条目。下面是凑篇幅的段落:书的特点是按章阅读,每章之间用
|
||||||
|
明显的编号标题分隔;扫描器把文件落盘后由解析器分卷与分章;阅读器按
|
||||||
|
章渲染,长文的滚动与恢复都按章节与章内偏移来记录。ZEBRA-XC42 是
|
||||||
|
搜索断言的标记词,本段只出现一次。再写几行凑足两百字以上:风起云
|
||||||
|
收,纸上得来终觉浅,绝知此事要躬行。如此五章的信息足够编造一份适合
|
||||||
|
端到端测试用的文本样本,昭然有效,勿改内容。
|
||||||
|
|
||||||
|
第二章 落雨
|
||||||
|
|
||||||
|
雨点敲窗。第二章也有 zebra-xc42 的小写变体,用于验证大小写不敏感的
|
||||||
|
命中;这一章的正文同样要足以拉开与第一章标题的距离,避免被分章器
|
||||||
|
当成正文里的目录行丢弃。雨水落在窗上,本章的内容较第一章多几度斜
|
||||||
|
面:先有标记词的小写变体,再有凑篇幅的句子。为了保持两百字的间距,
|
||||||
|
本章正文需要再写几行;夜读时开着夜色主题,正文底色与文字色由阅读
|
||||||
|
主题映射而来;行距与边距档位随时可调,搜索结果由防抖输入驱动,命中
|
||||||
|
列表以章节 + 前后文片段呈现,点击即可跳到命中章的大致位置。落雨行
|
||||||
|
至结尾,此章同样只包含一次标记词,合计两处命中。
|
||||||
|
|
||||||
|
第三章 风止
|
||||||
|
|
||||||
|
wind 结尾章,无标记词。风止一章同样凑足两百字,让分章器认全三章。
|
||||||
|
结尾章的正文不包含任何搜索断言词,用于验证搜索结果不会多算。风过
|
||||||
|
无痕,桌上灯亮;夜读的读者从目录跳章时看到当前章的计数变为 2/3,
|
||||||
|
即第二章标记已存。样本至此结束。
|
||||||
@@ -4,6 +4,7 @@ import { request, type APIRequestContext } from "@playwright/test";
|
|||||||
|
|
||||||
export const BASE = process.env.E2E_BASE_URL ?? "http://localhost:5173";
|
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_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_*。 */
|
/** UI 登录与 API fixture 共用同一管理员凭据:E2E_ADMIN_* 优先,回落 deploy/.env 注入的 ADMIN_*。 */
|
||||||
export function creds(): { user: string; pass: string } {
|
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)。 */
|
/** 幂等:书已存在直接返回 id;否则上传 fixture → 触发扫描 → 轮询等书出现(扫描是 fire-and-forget)。 */
|
||||||
export async function ensureSampleBook(ctx: APIRequestContext, libName: string): Promise<number> {
|
export async function ensureBook(
|
||||||
const title = "e2e-sample";
|
ctx: APIRequestContext,
|
||||||
|
libName: string,
|
||||||
|
fileName: string,
|
||||||
|
absPath: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const title = fileName.replace(/\.[a-z]+$/i, "");
|
||||||
const existing = await findBook(ctx, title);
|
const existing = await findBook(ctx, title);
|
||||||
if (existing) return existing.id;
|
if (existing) return existing.id;
|
||||||
const libId = await ensureLibrary(ctx, libName);
|
const libId = await ensureLibrary(ctx, libName);
|
||||||
const up = await ctx.post(`/api/libraries/${libId}/upload`, {
|
const up = await ctx.post(`/api/libraries/${libId}/upload`, {
|
||||||
multipart: {
|
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()}`);
|
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));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** CBZ 样本书便捷封装(auth-shelf 沿用)。 */
|
||||||
|
export async function ensureSampleBook(ctx: APIRequestContext, libName: string): Promise<number> {
|
||||||
|
return ensureBook(ctx, libName, "e2e-sample.cbz", FIXTURE_CBZ);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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); // <mark> 高亮
|
||||||
|
|
||||||
|
// 点第二条 → 跳到第一章/第二章(第二条命中)的章,章计数 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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user