import type { APIRequestContext, Page } from '@playwright/test' import { expect, test } from './fixtures' async function csrf(request: APIRequestContext) { const state = await request.storageState() return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? '' } async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters[1]) { return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } }) } function shanghaiDay(offset = 0) { const instant = new Date(Date.now() + offset * 86_400_000) const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(instant) const value = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)!.value return `${value('year')}-${value('month')}-${value('day')}` } async function createHabit(request: APIRequestContext, baseURL: string, name: string, kind: 'boolean' | 'numeric', target = 1, extra: Record = {}) { const response = await mutate(request, baseURL, '/api/v1/habits', { method: 'POST', data: { name, kind, target, max_value: kind === 'numeric' ? target * 2 : 1, schedule_type: 'daily', ...extra }, }) expect(response.ok(), await response.text()).toBeTruthy() return response.json() as Promise<{ id: string }> } async function openHabits(page: Page) { await page.goto('/') if ((page.viewportSize()?.width ?? 0) <= 930) { await page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name: '习惯', exact: true }).click() } else { await page.locator('.sidebar').getByRole('button', { name: '习惯', exact: true }).click() } } async function expectNoHorizontalOverflow(page: Page) { const widths = await page.evaluate(() => ({ client: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth })) expect(widths.scroll).toBeLessThanOrEqual(widths.client) } test('habit detail paper flow is responsive, ordered, scrollable, and preserves overlay contracts', async ({ page, request, baseURL }, testInfo) => { const nonce = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` const numericName = `纸页数字-${nonce}` const booleanName = `纸页布尔-${nonce}` const archivedName = `纸页归档-${nonce}` const numeric = await createHabit(request, baseURL!, numericName, 'numeric', 100) const boolean = await createHabit(request, baseURL!, booleanName, 'boolean') const archived = await createHabit(request, baseURL!, archivedName, 'numeric', 7, { start_date: shanghaiDay(-30) }) for (const [habit, value] of [[numeric, 68], [boolean, 1]] as const) { const response = await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${shanghaiDay()}`, { method: 'PUT', data: { value } }) expect(response.ok(), await response.text()).toBeTruthy() } for (let offset = 0; offset > -24; offset -= 1) { const response = await mutate(request, baseURL!, `/api/v1/habits/${archived.id}/logs/${shanghaiDay(offset)}`, { method: 'PUT', data: { value: Math.abs(offset % 8) } }) expect(response.ok(), await response.text()).toBeTruthy() } const archiveResponse = await mutate(request, baseURL!, `/api/v1/habits/${archived.id}`, { method: 'DELETE' }) expect(archiveResponse.ok(), await archiveResponse.text()).toBeTruthy() await openHabits(page) await expectNoHorizontalOverflow(page) const shell = page.locator('.shell') const main = page.locator('main') const mainBeforeDetail = await main.boundingBox() const numericOpener = page.locator('.habit-row').filter({ hasText: numericName }).getByRole('button', { name: `查看习惯详情:${numericName}` }) await numericOpener.click() let dialog = page.getByRole('dialog', { name: numericName }) await expect(shell).toHaveClass(/detail-open/) if ((page.viewportSize()?.width ?? 0) > 930) { const [mainAfterDetail, detailBox] = await Promise.all([main.boundingBox(), dialog.boundingBox()]) expect(mainBeforeDetail).not.toBeNull() expect(mainAfterDetail).not.toBeNull() expect(detailBox).not.toBeNull() expect(mainBeforeDetail!.width - mainAfterDetail!.width).toBeCloseTo(350, 0) expect(detailBox!.width).toBeCloseTo(350, 0) expect(detailBox!.x).toBeCloseTo(mainAfterDetail!.x + mainAfterDetail!.width, 0) expect(detailBox!.height).toBeCloseTo(page.viewportSize()!.height, 0) } await expect(dialog.locator('.habit-detail-hero')).toContainText('68 / 100') await expect(dialog.locator('.habit-detail-progress-row')).toContainText('68%') await expect(dialog.locator('.habit-detail-archive-note')).toHaveCount(0) if ((page.viewportSize()?.width ?? 0) <= 930) { const originalViewport = page.viewportSize()! await page.setViewportSize({ width: 720, height: 900 }) const archiveButton = dialog.getByRole('button', { name: '归档习惯' }) const archiveBox = await archiveButton.boundingBox() expect(archiveBox).not.toBeNull() expect(archiveBox!.width).toBeGreaterThanOrEqual(44) expect(archiveBox!.height).toBeGreaterThanOrEqual(44) await page.setViewportSize(originalViewport) } const activeOrder = await dialog.evaluate(element => { const selectors = ['.habit-detail-header', '.habit-detail-hero', '.habit-detail-progress-row', '.habit-detail-meta', '.habit-history', '.habit-detail-active-actions'] const nodes = selectors.map(selector => element.querySelector(selector)!) return nodes.slice(0, -1).every((node, index) => Boolean(node.compareDocumentPosition(nodes[index + 1]) & Node.DOCUMENT_POSITION_FOLLOWING)) }) expect(activeOrder).toBe(true) await dialog.getByRole('button', { name: '关闭习惯详情' }).click() await expect(numericOpener).toBeFocused() const booleanOpener = page.locator('.habit-row').filter({ hasText: booleanName }).getByRole('button', { name: `查看习惯详情:${booleanName}` }) await booleanOpener.click() dialog = page.getByRole('dialog', { name: booleanName }) await expect(dialog.locator('.habit-detail-hero')).toContainText('已完成') await dialog.getByRole('button', { name: '关闭习惯详情' }).click() const archiveToggle = page.locator('.habit-archive-toggle') await archiveToggle.click() const archivedOpener = page.getByRole('button', { name: new RegExp(archivedName) }) await archivedOpener.click() dialog = page.getByRole('dialog', { name: archivedName }) await expect(dialog).toHaveAttribute('aria-labelledby', 'habit-detail-title') await expect(dialog.locator('.habit-detail-archive-note')).toHaveText('此习惯已归档,历史记录仍完整保留。') await expect(dialog.locator('.habit-detail-hero')).toHaveCount(0) await expect(dialog.locator('.habit-detail-progress-row')).toHaveCount(0) await expect(dialog).not.toContainText('今日进度') const order = await dialog.evaluate(element => { const note = element.querySelector('.habit-detail-archive-note')! const header = element.querySelector('.habit-detail-header')! return Boolean(note.compareDocumentPosition(header) & Node.DOCUMENT_POSITION_FOLLOWING) }) expect(order).toBe(true) const body = dialog.locator('.habit-detail-body') const footer = dialog.locator('.habit-detail-archived-actions') await expect(footer).toBeInViewport() const before = await Promise.all([dialog.locator('.habit-detail-header').boundingBox(), footer.boundingBox()]) const scroll = await body.evaluate(element => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, top: element.scrollTop })) expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight) await body.evaluate(element => { element.scrollTop = element.scrollHeight }) await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeGreaterThan(0) const after = await Promise.all([dialog.locator('.habit-detail-header').boundingBox(), footer.boundingBox()]) expect(after[0]?.y).toBeCloseTo(before[0]!.y, 0) expect(after[1]?.y).toBeCloseTo(before[1]!.y, 0) await expectNoHorizontalOverflow(page) if ((page.viewportSize()?.width ?? 0) <= 930) { for (const button of [dialog.getByRole('button', { name: '关闭习惯详情' }), dialog.getByRole('button', { name: '永久删除' }), dialog.getByRole('button', { name: '恢复习惯' })]) { const box = await button.boundingBox() expect(box).not.toBeNull() expect(box!.width).toBeGreaterThanOrEqual(44) expect(box!.height).toBeGreaterThanOrEqual(44) } } let releaseRestore!: () => void const restoreGate = new Promise(resolve => { releaseRestore = resolve }) await page.route(`**/api/v1/habits/${archived.id}/restore`, async route => { await restoreGate; await route.continue() }) await dialog.getByRole('button', { name: '恢复习惯' }).click() const overlay = (page.viewportSize()?.width ?? 0) <= 930 ? page.locator('.app-sheet-mask').filter({ has: dialog }) : dialog await expect(overlay).toHaveAttribute('aria-busy', 'true') const closeButton = dialog.getByRole('button', { name: '关闭习惯详情' }) const deleteButton = dialog.getByRole('button', { name: '永久删除' }) const restoreButton = dialog.getByRole('button', { name: '恢复习惯' }) await expect(closeButton).toBeDisabled() await expect(deleteButton).toBeDisabled() await expect(restoreButton).toBeDisabled() await closeButton.click({ force: true }) await expect(dialog).toBeVisible() if ((page.viewportSize()?.width ?? 0) <= 930) await overlay.click({ position: { x: 2, y: 2 }, force: true }) await expect(dialog).toBeVisible() await page.keyboard.press('Escape') await expect(dialog).toBeVisible() // Desktop same-view navigation must force-close a busy detail even though user dismissal stays locked. if ((page.viewportSize()?.width ?? 0) > 930) { await page.locator('.sidebar').getByRole('button', { name: '习惯', exact: true }).click() await expect(dialog).toBeHidden() await expect(shell).not.toHaveClass(/detail-open/) } releaseRestore() await expect(dialog).toBeHidden() await expect(page.locator('.habit-row').filter({ hasText: archivedName })).toBeVisible() })