Files
dodo/frontend/e2e/habit-detail-paper-flow.spec.ts
T
bboysoul 0baedbc99e
ci / gitleaks (push) Successful in 9s
ci / docker (push) Successful in 3m46s
feat: redesign habit detail paper flow
2026-09-18 21:10:50 +08:00

156 lines
8.9 KiB
TypeScript

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<APIRequestContext['fetch']>[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<string, unknown> = {}) {
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 numericOpener = page.locator('.habit-row').filter({ hasText: numericName }).getByRole('button', { name: `查看习惯详情:${numericName}` })
await numericOpener.click()
let dialog = page.getByRole('dialog', { name: numericName })
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<void>(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 mask = page.locator('.app-sheet-mask').filter({ has: dialog })
await expect(mask).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()
await mask.click({ position: { x: 2, y: 2 }, force: true })
await expect(dialog).toBeVisible()
await page.keyboard.press('Escape')
await expect(dialog).toBeVisible()
releaseRestore()
await expect(dialog).toBeHidden()
await expect(archiveToggle).toBeFocused()
})