/* 顶栏搜索(`search` 岛,ADR-0031):下拉栏里有什么、键盘怎么走、提交之后去哪儿。 * * 补全(`/api/suggest`)与搜索记录(`/api/search-history`)在这里各给一份桩,好数请求、好看写进去的 * 是什么;作品与详情沿用 `item-fixture.ts`。下拉栏的排版(两栏、页签、封面格)由 `design-people.test.ts` 量。 */ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import type { Browser, Page } from 'playwright-core'; import { launch, unmanagedScrollers, VIEWPORTS, withPageDiagnostics } from './harness.ts'; import { openItemPage, type ItemVisit } from './item-fixture.ts'; const DESKTOP = VIEWPORTS.find((viewport) => !viewport.mobile)!; const MENU = '#searchMenu'; const OPTION = `${MENU} [data-search-option]`; /** 「七海」命中一位女优和一枚标签;别的词一条都没有。 */ const SUGGEST = { q: '七海', groups: [ { kind: 'performer', label: '女优', total: 1, items: [ { value: '七海ひな', n: 12, matched: '', entity_id: 90_001, has_image: false, rep: null, agency: '', works: [] }, ] }, { kind: 'tag', label: '标签', total: 1, items: [{ value: '七海の夏', n: 3, matched: '' }] }, ], }; interface Search extends ItemVisit { suggest: URL[]; /** 桩里存着的搜索记录,最近的在前。 */ history: string[]; posts: { query?: string; operation?: string }[]; } const fold = (value: string) => value.normalize('NFKC').trim().toLocaleLowerCase(); async function openSearch(browser: Browser, { limit = 10, history = [] as string[], viewport = DESKTOP } = {}): Promise { const opened = await openItemPage(browser, '/', viewport, { settings: { searchHistoryLimit: limit }, ready: '#grid [data-media-card]', }); const { page } = opened; const suggest: URL[] = []; const stored = [...history]; const posts: Search['posts'] = []; await page.route(/\/api\/suggest\?/, (route) => { const url = new URL(route.request().url()); suggest.push(url); const q = url.searchParams.get('q') || ''; return route.fulfill({ json: q === SUGGEST.q ? SUGGEST : { q, groups: [] } }); }); await page.route(/\/api\/search-history/, (route) => { const request = route.request(); if (request.method() === 'POST') { const body = request.postDataJSON() as Search['posts'][number]; posts.push(body); const query = body.query || ''; const kept = stored.filter((value) => fold(value) !== fold(query)); stored.splice(0, stored.length, ...(body.operation === 'remove' ? kept : [query, ...kept])); return route.fulfill({ json: { ok: true } }); } const want = Number(new URL(request.url()).searchParams.get('limit')) || 10; return route.fulfill({ json: { items: stored.slice(0, want) } }); }); await page.locator(`${MENU} > .peach-react`).waitFor({ state: 'attached', timeout: 15_000 }); return { ...opened, suggest, history: stored, posts }; } const values = (page: Page) => page.locator(OPTION).evaluateAll((rows) => rows.map((row) => row.getAttribute('data-search-value'))); const active = (page: Page) => page.locator(OPTION).evaluateAll((rows) => rows.flatMap((row) => (row.hasAttribute('data-active') ? [row.getAttribute('data-search-value')] : []))); const query = (page: Page) => new URL(page.url()).searchParams.get('q'); async function typeQuery(opened: Search, text = SUGGEST.q) { const { page } = opened; await page.locator('#q').click(); await page.keyboard.type(text); await withPageDiagnostics(page, () => `搜索建议(请求 ${opened.suggest.map(url => url.search).join('、')})`, () => page.locator(`${OPTION}[data-search-value="七海の夏"]`).waitFor({ timeout: 5_000 })); } describe('顶栏搜索', () => { let browser: Browser; before(async () => { browser = await launch(); }); after(async () => { await browser?.close(); }); it('等待失败保留原异常以及输入、焦点和菜单现场,成功时返回原结果', async () => { const context = await browser.newContext(); try { const page = await context.newPage(); await page.setContent('
测试建议
'); await page.locator('#q').focus(); const failure = new Error('原始等待失败'); await assert.rejects(withPageDiagnostics(page, '诊断夹具', async () => { throw failure }), error => { assert.equal((error as Error).cause, failure); assert.match((error as Error).message, /原始等待失败/); assert.match((error as Error).message, /取证词/); assert.match((error as Error).message, /测试建议/); assert.match((error as Error).message, /focus/); return true; }); assert.equal(await withPageDiagnostics(page, '成功夹具', async () => 42), 42); } finally { await context.close(); } }); for (const viewport of VIEWPORTS) { it(`${viewport.name} 搜索长列表使用覆盖式滑块并可拖动,关闭重开保留一套轨道`, async () => { const opened = await openSearch(browser, { viewport, limit: 30, history: Array.from({ length: 30 }, (_, index) => `搜索记录 ${index + 1}`) }); const { page } = opened; try { if (viewport.mobile) await page.locator('#searchBtn').click(); await page.locator('#q').click(); await page.waitForFunction(() => { const menu = document.querySelector('#searchMenu')!; return menu.dataset.overlayScrollbar === 'true' && menu.querySelectorAll('[data-remove-history]').length === 30 && !menu.querySelector('[data-search-placeholder],[aria-busy="true"]') && menu.scrollHeight - menu.clientHeight > 100; }); const track = page.locator(`${MENU} > [data-overlay-track="y"]`); await track.waitFor({ state: 'visible' }); assert.equal(await page.locator(MENU).evaluate(node => getComputedStyle(node).scrollbarWidth), 'none'); const start = (await track.boundingBox())!; await page.locator(MENU).evaluate(node => { node.scrollTop = 100 }); await page.waitForFunction(() => { const menu = document.querySelector('#searchMenu')!; const track = menu.querySelector(':scope > .ov-y')!; return Math.abs(menu.scrollTop - 100) <= 1 && !!track.style.transform && Math.abs(new DOMMatrixReadOnly(track.style.transform).m42 - menu.scrollTop) <= 1; }); const moved = (await track.boundingBox())!; assert.ok(Math.abs(start.y - moved.y) <= 1, '轨道应保持在下拉框可视边缘'); const thumb = (await track.locator('.ovthumb').boundingBox())!; await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2); await page.mouse.down(); await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2 + 40, { steps: 5 }); await page.mouse.up(); assert.ok(await page.locator(MENU).evaluate(node => node.scrollTop > 100)); const beforeWheel = await page.locator(MENU).evaluate(node => node.scrollTop); const menuBox = (await page.locator(MENU).boundingBox())!; await page.mouse.move(menuBox.x + menuBox.width / 2, menuBox.y + menuBox.height / 2); await page.mouse.wheel(0, 140); await page.waitForFunction(previous => document.querySelector('#searchMenu')!.scrollTop > previous, beforeWheel); await page.keyboard.press('Escape'); await page.locator(MENU).waitFor({ state: 'hidden' }); await page.locator('#q').click(); await track.waitFor({ state: 'visible' }); assert.equal(await track.count(), 1); assert.deepEqual(await unmanagedScrollers(page), []); assert.deepEqual(opened.problems, []); } catch (error) { const state = await page.locator(MENU).evaluate(menu => ({ history: menu.querySelectorAll('[data-remove-history]').length, pending: menu.querySelectorAll('[data-search-placeholder],[aria-busy="true"]').length, scrollTop: menu.scrollTop, range: menu.scrollHeight - menu.clientHeight, transform: menu.querySelector(':scope > .ov-y')?.style.transform, })).catch(() => '未取得'); throw new Error(`${String(error)};搜索滚动状态:${JSON.stringify(state)}`, { cause: error }); } finally { await opened.close(); } }); it(`${viewport.name} 收起输入法保留搜索建议,焦点移出搜索区或点击页面关闭`, { timeout: 60_000 }, async () => { const opened = await openSearch(browser, { viewport, history: ['最近搜索'] }); try { const { page } = opened; if (viewport.mobile) await page.locator('#searchBtn').click(); await typeQuery(opened); await page.locator('#q').evaluate(field => field.blur()); await page.waitForTimeout(220); assert.equal(await page.locator(MENU).isVisible(), true, '输入法收起后建议仍可选择'); assert.equal(await page.locator('#q').inputValue(), SUGGEST.q); await page.locator('#grid [data-media-title]').first().focus(); await page.waitForTimeout(220); assert.equal(await page.locator(MENU).isVisible(), false, '键盘焦点移出搜索区关闭建议'); await page.locator('#q').focus(); await page.locator(MENU).waitFor({ state: 'visible' }); await page.locator('#q').evaluate(field => field.blur()); await page.waitForTimeout(220); await page.locator('#grid [data-media-title]').first().dispatchEvent('pointerdown'); await page.locator(MENU).waitFor({ state: 'hidden' }); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it(`${viewport.name} 空搜索显示作品封面、作者头像和新作订阅入口`, { timeout: 60_000 }, async () => { const opened = await openSearch(browser, { viewport, history: ['最近搜索'] }); try { const { page } = opened; // 推荐栏一个请求取齐:作品那份用演示库的真结果,其余几份换成能覆盖每一组的夹具。 await page.route(/\/api\/search\/recommendations/, async route => { const bundle = await (await route.fetch()).json(); await route.fulfill({ json: { ...bundle, jav: { items: [{ id: 11, asset_id: 11, code: 'ABW-101', title: 'JAV 作品', is_jav: true, has_cover: true, performer_refs: [{ id: 90001, name: '七海ひな', has_image: true }] }] }, follow: { sources: [{ id: 1, author_key: 'name:作者', label: '作者', ref: '作者', provider: 'pixiv', official_avatar_url: '/poster?id=11' }], groups: [{ primary: { id: 901, source_id: 1, title: '关注作品', thumb_url: '/poster?id=11', media_kind: 'video', status: 'saved' }, variants: [{ id: 902, source_id: 1, title: '图片作品', thumb_url: '/poster?id=12', media_kind: 'image', status: 'seen', media_items: [{ index: 2, media_kind: 'image', thumb_url: '/poster?id=13' }] }], duplicates: [] }], }, // 作者头像读名册那一行自己的地址(官方优先、归档兜底)。 authors: { items: [{ k: '作者', key: 'name:作者', entity_id: 90002, avatar: '/poster?id=11', avatar_fallback: '' }] }, feeds: { items: [{ id: 91, code: 'ABW-102', title: '订阅新作', has_cover: true }] }, } }); }); // 推荐在页面空闲时就预取好了,那时夹具还没挂上;挂好之后重开一次页面,预取走夹具。 await page.reload({ waitUntil: 'load' }); await page.locator(`${MENU} > .peach-react`).waitFor({ state: 'attached', timeout: 15_000 }); if (viewport.mobile) await page.locator('#searchBtn').click(); await page.locator('#q').click(); await page.locator(`${MENU} [data-search-recommendations]`).waitFor(); // 推荐没到之前栏里先是按最终版式摆的占位,等占位都换成真行再数。 await page.waitForFunction(() => !document.querySelector('#searchMenu [data-search-placeholder]'), undefined, { timeout: 10_000 }); assert.equal(await page.locator(`${MENU} [data-kind="author"] [data-search-face] img`).count(), 1); for (const kind of ['asset', 'jav', 'post', 'photo', 'release']) { const cover = page.locator(`${MENU} [data-kind="${kind}"] [data-search-pic] img`).first(); assert.ok(await cover.count(), `${kind} 没有图片`); const box = await cover.boundingBox(); assert.ok(box && box.width >= 80, `${kind} 没有以封面展示`); } const overflow = await page.locator(MENU).evaluate(el => ({ width: el.clientWidth, scrollWidth: el.scrollWidth, right: el.getBoundingClientRect().right, viewport: innerWidth, })); assert.ok(overflow.scrollWidth <= overflow.width + 1); assert.ok(overflow.right <= overflow.viewport + 1); assert.equal(await page.locator(`${MENU} [data-kind="photo"] h3 a`).getAttribute('href'), '/follow?media=images'); assert.ok((await page.locator(`${MENU} [data-kind="photo"] [data-search-pic] img`).getAttribute('src'))?.includes('id=13')); await page.screenshot({ path: `../build/agent-verification/browser/search-recommendations-${viewport.name}.png` }); const subscription = page.locator(`${MENU} [data-kind="performer"] h3 a`); await subscription.focus(); await page.waitForTimeout(180); assert.equal(await page.locator(MENU).isVisible(), true, '菜单内键盘焦点关闭了推荐'); await subscription.press('Enter'); await page.waitForURL('**/subscriptions?tab=add'); assert.ok(!new URL(page.url()).searchParams.has('q')); } finally { await opened.close(); } }); } for (const viewport of VIEWPORTS) { it(`单类视频预览可滚动,完整结果入口可由键盘提交(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await openSearch(browser, { viewport }); try { const { page } = opened; await page.route(/\/api\/suggest\?/, (route) => { const url = new URL(route.request().url()); const limit = Math.min(53, Number(url.searchParams.get('limit')) || 5); const items = Array.from({ length: limit }, (_, i) => ({ value: `ABF-${i + 1}`, id: i + 1, code: `ABF-${i + 1}`, title: '演示作品', card: { id: 11, code: `ABF-${i + 1}`, is_jav: true, has_cover: true }, })); return route.fulfill({ json: { q: 'abf', groups: [{ kind: 'asset', label: '视频', total: 53, items }] } }); }); if (viewport.mobile) await page.locator('#searchBtn').click(); await page.locator('#q').fill('abf'); await page.locator(`${MENU} [data-search-option="work"]`).first().waitFor({ state: 'visible' }); assert.equal(await page.locator(`${MENU} [data-search-option="work"]`).count(), 20); const geometry = await page.locator(MENU).evaluate((el) => ({ overflow: el.scrollHeight > el.clientHeight, bottom: el.getBoundingClientRect().bottom, viewport: innerHeight, pageScroll: scrollY, })); assert.ok(geometry.overflow, '搜索面板没有可滚动内容'); assert.ok(geometry.bottom <= geometry.viewport, '搜索面板超出视口'); await page.locator(MENU).hover(); await page.mouse.wheel(0, 400); await page.waitForFunction(() => document.querySelector('#searchMenu')!.scrollTop > 0); assert.equal(await page.evaluate(() => scrollY), geometry.pageScroll, '滚动搜索面板带动了背景页面'); for (let i = 0; i < 21; i++) await page.keyboard.press('ArrowDown'); const more = page.locator(`${MENU} [data-search-option="more"]`); assert.equal(await more.getAttribute('data-active'), ''); assert.equal(await more.textContent(), '查看全部 53 个视频'); await page.keyboard.press('Enter'); await page.waitForFunction(() => new URLSearchParams(location.search).get('q') === 'abf'); await page.locator(MENU).waitFor({ state: 'hidden' }); } finally { await opened.close(); } }); } it('连敲几个字只问一次补全;下拉栏照后端给的组次序摆', { timeout: 60_000 }, async () => { const opened = await openSearch(browser); try { const { page, suggest } = opened; await typeQuery(opened); assert.deepEqual(suggest.map((url) => url.searchParams.get('q')), [SUGGEST.q], '连敲两个字发了不止一次补全'); assert.deepEqual(await page.locator(`${MENU} [data-search-group] > h3`).allTextContents(), ['女优', '标签']); assert.deepEqual(await values(page), ['七海ひな', '七海の夏']); assert.equal(query(opened.page), null, '只是敲字,不该提交'); } finally { await opened.close(); } }); it('软键盘的回车键标成「搜索」', { timeout: 60_000 }, async () => { const opened = await openSearch(browser); try { assert.equal(await opened.page.locator('#q').getAttribute('enterkeyhint'), 'search'); } finally { await opened.close(); } }); it('上下键环绕着选;回车按选中的那一项搜,记进搜索记录,下拉栏收起', { timeout: 60_000 }, async () => { const opened = await openSearch(browser); try { const { page, posts } = opened; await typeQuery(opened); assert.deepEqual(await active(page), [], '刚画出来就有选中'); for (const [key, want] of [['ArrowDown', '七海ひな'], ['ArrowDown', '七海の夏'], ['ArrowDown', '七海ひな'], ['ArrowUp', '七海の夏']] as const) { await page.keyboard.press(key); assert.deepEqual(await active(page), [want], `${key} 之后选中的不对`); } assert.equal(await page.locator('#q').inputValue(), SUGGEST.q, '上下键不该改输入框里的字'); await page.keyboard.press('Enter'); await page.waitForFunction(() => new URLSearchParams(location.search).get('q') === '七海の夏'); await page.locator(MENU).waitFor({ state: 'hidden', timeout: 5_000 }); assert.equal(await page.locator('#q').inputValue(), '七海の夏'); assert.deepEqual(posts, [{ query: '七海の夏' }]); assert.notEqual(await page.evaluate(() => document.activeElement?.id), 'q', '提交之后焦点还留在输入框里'); } finally { await opened.close(); } }); it('搜索结果页的标题写搜索词;点侧栏「首页」回到不带词的首页', { timeout: 60_000 }, async () => { const opened = await openSearch(browser); try { const { page } = opened; const title = page.locator('#catalogPageHeading [data-page-title]'); await typeQuery(opened); await page.keyboard.press('Enter'); await page.waitForFunction(() => new URLSearchParams(location.search).has('q'), undefined, { timeout: 5_000 }); const searched = `搜索结果:「${query(page)!}」`; await page.waitForFunction(text => document.querySelector('#catalogPageHeading [data-page-title]')?.textContent === text, searched, { timeout: 5_000 }); await page.locator('[data-nav][aria-label="首页"]').first().click(); await page.waitForFunction(() => !new URLSearchParams(location.search).has('q'), undefined, { timeout: 5_000 }); assert.equal(await title.textContent(), '首页'); assert.equal(await page.locator('#q').inputValue(), '', '回到首页后搜索框里还留着词'); } finally { await opened.close(); } }); it('回车选中一位女优:打开她的资料页,不绕一趟搜索', { timeout: 60_000 }, async () => { const opened = await openSearch(browser); try { const { page, posts } = opened; await typeQuery(opened); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await page.waitForURL('**/performers/90001'); assert.equal(query(page), null); assert.deepEqual(posts, [{ query: '七海ひな' }], '记进搜索记录的应当是这个名字'); } finally { await opened.close(); } }); it('Escape 先只关下拉栏、字留着;再按一次才清空输入框', { timeout: 60_000 }, async () => { const opened = await openSearch(browser); try { const { page } = opened; await typeQuery(opened); await page.keyboard.press('Escape'); await page.locator(MENU).waitFor({ state: 'hidden', timeout: 5_000 }); assert.equal(await page.locator('#q').inputValue(), SUGGEST.q, '第一下 Escape 把字也清了'); assert.equal(await page.evaluate(() => document.activeElement?.id), 'q', '第一下 Escape 把焦点带走了'); await page.keyboard.press('Escape'); assert.equal(await page.locator('#q').inputValue(), '', '第二下 Escape 没有清空'); assert.equal(query(page), null, 'Escape 不该提交'); } finally { await opened.close(); } }); it('组字期间的回车与方向键是给输入法的:不提交、不挪选中、不关下拉栏', { timeout: 60_000 }, async () => { const opened = await openSearch(browser); try { const { page, posts } = opened; await typeQuery(opened); await page.locator('#q').evaluate((input) => { input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true, data: '' })); for (const key of ['ArrowDown', 'Enter']) { input.dispatchEvent(new KeyboardEvent('keydown', { key, isComposing: true, bubbles: true, cancelable: true })); } }); await page.waitForTimeout(300); assert.equal(query(page), null, '组字期间的回车提交了搜索'); assert.deepEqual(posts, []); assert.deepEqual(await active(page), [], '组字期间的方向键挪了选中'); assert.equal(await page.locator(MENU).isVisible(), true, '组字期间的回车把下拉栏收了'); await page.locator('#q').evaluate((input) => { input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '' })); }); await page.keyboard.press('Enter'); await page.waitForFunction(() => new URLSearchParams(location.search).get('q') === '七海'); assert.deepEqual(posts, [{ query: '七海' }]); } finally { await opened.close(); } }); it('搜索记录照设置的条数摆;删一条只摘掉这一条,下拉栏和焦点都不走;新搜的词排到最前', { timeout: 60_000 }, async () => { const opened = await openSearch(browser, { limit: 3, history: ['甲片', '乙片', '丙片', '丁片'] }); try { const { page, posts, history } = opened; const recent = () => page.locator(`${MENU} [data-search-group]`, { hasText: '搜索记录' }) .locator('[data-search-option]').evaluateAll((rows) => rows.map((row) => row.getAttribute('data-search-value'))); await page.locator('#q').click(); await page.locator(`${OPTION}[data-search-value="丙片"]`).waitFor({ timeout: 5_000 }); assert.deepEqual(await recent(), ['甲片', '乙片', '丙片'], '搜索记录没有照设置只摆三条'); await page.locator(`${MENU} [data-remove-history="乙片"]`).click(); await page.waitForFunction(() => !document.querySelector('#searchMenu [data-search-value="乙片"]')); assert.deepEqual(await recent(), ['甲片', '丙片']); assert.deepEqual(posts, [{ operation: 'remove', query: '乙片' }]); await page.waitForTimeout(300); assert.equal(await page.locator(MENU).isVisible(), true, '删一条记录把下拉栏收了'); assert.equal(await page.evaluate(() => document.activeElement?.id), 'q', '删一条记录把焦点抢走了'); await page.keyboard.type('戊片'); await page.keyboard.press('Enter'); await page.waitForFunction(() => new URLSearchParams(location.search).get('q') === '戊片'); assert.deepEqual(posts.at(-1), { query: '戊片' }); assert.deepEqual(history.slice(0, 3), ['戊片', '甲片', '丙片']); await page.locator('#q').evaluate((input: HTMLInputElement) => { input.value = ''; }); await page.locator('#q').click(); await page.locator(`${OPTION}[data-search-value="戊片"]`).waitFor({ timeout: 5_000 }); assert.deepEqual(await recent(), ['戊片', '甲片', '丙片']); } finally { await opened.close(); } }); });