import type { APIRequestContext, Locator, 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 } }) } async function expectTaskRowGeometry(row: Locator) { const metrics = await row.evaluate((element: HTMLElement) => { const controls = [...element.querySelectorAll('button')].map(control => { const rect = control.getBoundingClientRect() return { width: rect.width, height: rect.height } }) const tail = element.querySelector('.task-tail') const tailRect = tail?.getBoundingClientRect() const rowRect = element.getBoundingClientRect() const directChildrenInside = [...element.children].every(child => { const rect = (child as HTMLElement).getBoundingClientRect() return rect.left >= rowRect.left && rect.right <= rowRect.right && rect.top >= rowRect.top && rect.bottom <= rowRect.bottom }) return { height: rowRect.height, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, controls, tailInside: !tailRect || (tailRect.left >= rowRect.left && tailRect.right <= rowRect.right), directChildrenInside, } }) expect(metrics.height).toBeGreaterThanOrEqual(57) expect(metrics.height).toBeLessThanOrEqual(59) expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth) expect(metrics.tailInside).toBe(true) expect(metrics.directChildrenInside).toBe(true) for (const control of metrics.controls) { expect(control.width).toBeGreaterThanOrEqual(44) expect(control.height).toBeGreaterThanOrEqual(44) } } async function openInbox(page: Page) { if ((await page.viewportSize())!.width <= 930) { await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click() } await page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }).click() } test('task rows keep approved rhythm without clipping across desktop and mobile', async ({ page, request, baseURL }) => { const suffix = `${test.info().project.name}-${Date.now()}` const bootstrap = await request.get('/api/v1/bootstrap') expect(bootstrap.ok()).toBeTruthy() const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) const create = async (title: string, extra: Record = {}) => { const response = await mutate(request, baseURL!, '/api/v1/tasks', { method: 'POST', data: { title, list_id: inbox.id, ...extra }, }) expect(response.ok(), await response.text()).toBeTruthy() return response.json() as Promise<{ id: string }> } const ordinaryTitle = `普通任务-${suffix}` const completedTitle = `完成任务-${suffix}` const longTitle = `超长任务-${suffix}-` + '这是一段用于确认标题省略且不撑高任务行的连续文字'.repeat(5) const dueTitle = `含截止日期-${suffix}` const parentTitle = `含子任务-${suffix}` const localDate = (offset: number) => { const shanghaiDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()) const date = new Date(`${shanghaiDate}T12:00:00+08:00`) date.setUTCDate(date.getUTCDate() + offset) return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).format(date) } await create(ordinaryTitle) await create(completedTitle) await create(longTitle) await create(dueTitle, { due_at: `${localDate(1)}T23:59:00`, due_has_time: true }) const parent = await create(parentTitle) await create(`子任务-${suffix}`, { parent_id: parent.id }) await page.setViewportSize({ width: 1440, height: 900 }) await page.goto('/') await openInbox(page) const titles = [ordinaryTitle, completedTitle, longTitle, dueTitle, parentTitle] for (const title of titles) { const row = page.locator('.task-row').filter({ hasText: title }) await expect(row).toHaveCount(1) await expectTaskRowGeometry(row) } const completedRow = page.locator('.task-row').filter({ hasText: completedTitle }) await completedRow.getByRole('button', { name: `完成${completedTitle}` }).click() await expect(completedRow).toHaveClass(/done/) await expectTaskRowGeometry(completedRow) await expect(page.locator('.task-row').filter({ hasText: dueTitle }).locator('.task-tail')).toBeVisible() await expect(page.locator('.task-row').filter({ hasText: parentTitle })).toContainText('0/1') for (const viewport of [{ width: 390, height: 844 }, { width: 375, height: 667 }]) { await page.setViewportSize(viewport) for (const title of titles) { await expectTaskRowGeometry(page.locator('.task-row').filter({ hasText: title })) } const pageMetrics = await page.evaluate(() => ({ clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth })) expect(pageMetrics.scrollWidth).toBeLessThanOrEqual(pageMetrics.clientWidth) } })