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 ordinaryRow = page.locator('.task-row').filter({ hasText: ordinaryTitle }) const completedRow = page.locator('.task-row').filter({ hasText: completedTitle }) const rowState = async (row: Locator) => row.evaluate((element: HTMLElement) => { const style = getComputedStyle(element) const marker = getComputedStyle(element, '::before') const title = getComputedStyle(element.querySelector('.task-main strong') as HTMLElement) return { background: style.backgroundColor, markerWidth: marker.width, markerBackground: marker.backgroundColor, titleColor: title.color, titleDecoration: title.textDecorationLine, } }) expect((await rowState(ordinaryRow)).background).toBe('rgba(0, 0, 0, 0)') const hasFinePointer = await page.evaluate(() => matchMedia('(hover:hover) and (pointer:fine)').matches) if (hasFinePointer) { await ordinaryRow.hover() await expect(ordinaryRow).toHaveCSS('background-color', 'rgb(255, 248, 238)') } await ordinaryRow.locator('.task-main').click() await expect(ordinaryRow).toHaveCSS('background-color', 'rgb(255, 244, 229)') const selectedState = await rowState(ordinaryRow) expect(selectedState.markerWidth).toBe('2px') expect(selectedState.markerBackground).toBe('rgb(241, 90, 41)') const ordinaryBox = await ordinaryRow.boundingBox() expect(ordinaryBox).toBeTruthy() await page.mouse.move(ordinaryBox!.x + ordinaryBox!.width / 2, ordinaryBox!.y + ordinaryBox!.height / 2) await page.mouse.down() expect((await rowState(ordinaryRow)).background).toBe('rgb(255, 244, 229)') await page.mouse.up() await ordinaryRow.evaluate(element => element.classList.add('done', 'overdue-task')) expect((await rowState(ordinaryRow)).background).toBe('rgb(255, 244, 229)') await ordinaryRow.evaluate(element => { element.classList.remove('selected', 'done', 'overdue-task') element.classList.add('task-row--trash') }) await ordinaryRow.hover() const trashState = await rowState(ordinaryRow) expect(trashState.background).toBe('rgba(0, 0, 0, 0)') expect(trashState.markerBackground).toBe('rgba(0, 0, 0, 0)') await completedRow.getByRole('button', { name: `完成${completedTitle}` }).click() await expect(completedRow).toHaveClass(/done/) await expect(completedRow).toHaveCSS('background-color', 'rgb(251, 247, 240)') await expect(completedRow.locator('.task-main strong')).toHaveCSS('color', 'rgb(112, 104, 95)') const completedState = await rowState(completedRow) expect(completedState.titleDecoration).toContain('line-through') 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) } })