import type { APIRequestContext } from '@playwright/test' import { expect, test } from './fixtures' const widths = [1600, 1440, 721, 720, 390, 375] 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, data: Record) { return request.post(path, { data, headers: { 'x-csrf-token': await csrf(request), origin: baseURL } }) } test('Today plain-list layout matches the approved responsive geometry', async ({ page, request, baseURL }, testInfo) => { const runId = `${testInfo.project.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const taskTitle = `原型对齐今日任务-${runId}` const habitName = `原型对齐习惯-${runId}` const bootstrap = await request.get('/api/v1/bootstrap') expect(bootstrap.ok(), await bootstrap.text()).toBeTruthy() const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) expect(inbox).toBeTruthy() const today = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()) const taskResponse = await mutate(request, baseURL!, '/api/v1/tasks', { title: taskTitle, list_id: inbox.id, due_at: `${today}T23:59:00+08:00`, due_has_time: false }) expect(taskResponse.ok(), await taskResponse.text()).toBeTruthy() const taskId = (await taskResponse.json()).id as string const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { name: habitName, kind: 'numeric', target: 8, max_value: 8, schedule_type: 'daily' }) expect(habitResponse.ok(), await habitResponse.text()).toBeTruthy() const habitId = (await habitResponse.json()).id as string await page.route('**/api/v1/today/environment', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ date: { solar_date: '2026-09-19', weekday: '星期六', lunar: '八月初九' }, weather: { status: 'fresh', temperature_c: 26.4, text: '多云', observed_at: '2026-09-19T20:45:00+08:00' }, gold: { status: 'fresh', contract: 'Au99.99', price_cny_per_gram: 835.62, market_date: '2026-09-18' }, }), })) await page.goto('/') const taskRowLocator = page.locator('#today-tasks .task-row').filter({ hasText: taskTitle }) const habitRowLocator = page.locator('.today-habit-row').filter({ hasText: habitName }) await expect(taskRowLocator).toHaveCount(1) await expect(habitRowLocator).toHaveCount(1) for (const width of widths) { await page.setViewportSize({ width, height: width <= 390 ? 844 : 900 }) await expect.poll(async () => page.evaluate(() => { const content = document.querySelector('.today-context')?.getBoundingClientRect() const environment = document.querySelector('.today-environment')?.getBoundingClientRect() if (!content || !environment) return null return { media720: matchMedia('(max-width: 720.98px)').matches, contentWidth: Math.round(content.width), environmentHeight: Math.round(environment.height), } })).toEqual({ media720: width <= 720, contentWidth: width >= 1440 ? 1080 : width === 721 ? 630 : width - 58, environmentHeight: width >= 721 ? 55 : width === 375 ? 79 : 81, }) await expect(page.locator('.today-heading')).toHaveCount(1) await expect(page.locator('.today-inline-add')).toHaveCount(0) await expect(page.locator('.unified-fab')).toHaveCount(1) await expect(taskRowLocator).toHaveCount(1) await expect(habitRowLocator).toHaveCount(1) await expect(page.locator('.today-environment')).toBeVisible() await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0) await expect(page.getByRole('switch', { name: '显示已完成' })).toHaveCount(1) await expect(page.locator('.today-environment__gold')).toContainText('Au99.99') const metrics = await page.evaluate(({ taskId, habitId }) => { const rect = (selector: string) => document.querySelector(selector)!.getBoundingClientRect() const environment = document.querySelector('.today-environment')! const calendar = document.querySelector('.today-environment__calendar')! const weather = document.querySelector('.today-environment__weather')! const gold = document.querySelector('.today-environment__gold')! const title = document.querySelector('.today-page-title')! const remaining = document.querySelector('.today-remaining')! const filter = document.querySelector('.today-inline-filter')! const firstSection = document.querySelector('.today-section-toggle')! const heading = document.querySelector('.today-heading')! const main = document.querySelector('main.today-main')! const content = rect('.today-context') const sectionTitle = firstSection.querySelector('.today-section-title')! const sectionSummary = firstSection.querySelector('.today-section-summary')! const sectionIcon = firstSection.querySelector('.today-section-chevron')! const summaries = [...document.querySelectorAll('.today-section-summary')].map(summary => ({ text: summary.innerText.trim(), expected: summary.closest('.today-section-toggle')?.getAttribute('aria-controls') === 'today-habits' ? document.querySelectorAll('.today-habit-row').length : summary.closest('.today-section-toggle')?.getAttribute('aria-controls') === 'today-overdue' ? document.querySelectorAll('#today-overdue .task-row').length : document.querySelectorAll('#today-tasks .task-row').length, })) const taskRow = document.querySelector(`#today-tasks .task-row[data-task-id="${taskId}"]`)! const habitRow = document.querySelector(`.today-habit-row[data-habit-id="${habitId}"]`)! const fab = document.querySelector('.unified-fab') const orderedElements = [environment, heading, firstSection] const ordered = orderedElements.map((element) => { const box = element.getBoundingClientRect() return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, width: box.width, height: box.height } }) const tolerance = 1 const orderedPairs = ordered.slice(0, -1).map((current, index) => ({ previousBottom: current.bottom, nextTop: ordered[index + 1].top, separated: current.bottom <= ordered[index + 1].top + tolerance, })) const positiveGeometry = ordered.every(box => box.width > 0 && box.height > 0) const insideContent = ordered.every(box => box.left >= content.left - tolerance && box.right <= content.right + tolerance) const visible = (element: HTMLElement) => { const style = getComputedStyle(element) return style.visibility !== 'hidden' && style.display !== 'none' && element.getBoundingClientRect().width > 0 } const rows = [...environment.children] .filter((node): node is HTMLElement => node instanceof HTMLElement && visible(node) && !node.classList.contains('sr-only')) .map(node => Math.round(node.getBoundingClientRect().top)) const uniqueRows = [...new Set(rows)] const collisions = [calendar, weather, gold].some((a, index, items) => items.slice(index + 1).some(b => { const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect() return ar.left < br.right && ar.right > br.left && ar.top < br.bottom && ar.bottom > br.top })) const secondary = [...environment.querySelectorAll('small')].map(node => ({ scrollWidth: node.scrollWidth, clientWidth: node.clientWidth, visible: visible(node) })) return { viewport: innerWidth, media720: matchMedia('(max-width: 720.98px)').matches, documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, mainOverflow: main.scrollWidth - main.clientWidth, main: { ...main.getBoundingClientRect().toJSON(), clientWidth: main.clientWidth, paddingLeft: getComputedStyle(main).paddingLeft, paddingRight: getComputedStyle(main).paddingRight }, content: { left: content.left, right: content.right, width: content.width }, environment: { ...environment.getBoundingClientRect().toJSON(), scrollWidth: environment.scrollWidth, clientWidth: environment.clientWidth }, directions: { calendar: getComputedStyle(calendar).flexDirection, weather: getComputedStyle(weather).flexDirection, gold: getComputedStyle(gold).flexDirection }, itemWidths: [calendar, weather, gold].map((element) => element.getBoundingClientRect().width), itemHeights: [calendar, weather, gold].map((element) => element.getBoundingClientRect().height), uniqueRows, collisions, collisionRects: [calendar, weather, gold].map(element => element.getBoundingClientRect().toJSON()), secondary, ordered, orderedPairs, positiveGeometry, insideContent, weatherText: weather.innerText, goldText: gold.innerText, goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label') ?? '', summaries, styles: { mainPaddingLeft: getComputedStyle(main).paddingLeft, contextHeight: content.height, headingDisplay: getComputedStyle(heading).display, headingAlignItems: getComputedStyle(heading).alignItems, filterTop: filter.getBoundingClientRect().top, filterRight: filter.getBoundingClientRect().right, viewportHeight: innerHeight, titleFontSize: getComputedStyle(title).fontSize, titleFontWeight: getComputedStyle(title).fontWeight, filterWidth: filter.getBoundingClientRect().width, filterHeight: filter.getBoundingClientRect().height, filterVisualHeight: filter.querySelector('.completed-filter-pill__track')!.getBoundingClientRect().height, filterBackground: getComputedStyle(filter).backgroundColor, filterBorderRadius: getComputedStyle(filter).borderRadius, sectionBorderBottom: getComputedStyle(firstSection).borderBottomWidth, sectionTitleSize: getComputedStyle(sectionTitle).fontSize, sectionTitleWeight: getComputedStyle(sectionTitle).fontWeight, sectionSummaryText: sectionSummary.innerText, sectionIconText: sectionIcon.textContent, taskHeight: taskRow.getBoundingClientRect().height, taskBackground: getComputedStyle(taskRow).backgroundColor, taskBorderBottom: getComputedStyle(taskRow).borderBottomWidth, habitHeight: habitRow.getBoundingClientRect().height, habitBackground: getComputedStyle(habitRow).backgroundColor, habitBorderBottom: getComputedStyle(habitRow).borderBottomWidth, fabShadow: fab ? getComputedStyle(fab).boxShadow : '', fabIconWidth: fab?.querySelector('svg')?.getBoundingClientRect().width ?? 0, }, } }, { taskId, habitId }) console.log(`[today-geometry][${width}] ${JSON.stringify(metrics)}`) expect(metrics.documentOverflow).toBe(0) expect(metrics.mainOverflow).toBeLessThanOrEqual(0) expect(metrics.environment.scrollWidth).toBeLessThanOrEqual(metrics.environment.clientWidth) expect(metrics.collisions).toBeFalsy() expect(metrics.positiveGeometry).toBeTruthy() expect(metrics.insideContent).toBeTruthy() expect(metrics.orderedPairs.every(pair => pair.separated)).toBeTruthy() expect(metrics.goldText).toContain('Au99.99') expect(metrics.goldLabel).toMatch(/市场日期 \d{4}-\d{2}-\d{2}/) expect(metrics.secondary.every(line => line.visible && line.scrollWidth <= line.clientWidth)).toBeTruthy() expect(metrics.styles.titleFontWeight).toBe('700') expect(metrics.styles.sectionTitleSize).toBe('13px') expect(metrics.styles.sectionTitleWeight).toBe('700') expect(metrics.styles.sectionSummaryText).not.toContain('项') expect(metrics.summaries.every(summary => /^\d+$/.test(summary.text) && Number(summary.text) === summary.expected)).toBeTruthy() expect(metrics.styles.sectionIconText).not.toContain('项') expect(metrics.styles.sectionBorderBottom).toBe('1px') expect(metrics.styles.taskHeight).toBeCloseTo(58, 0) expect(metrics.styles.habitHeight).toBeCloseTo(58, 0) expect(metrics.styles.taskBackground).toBe('rgba(0, 0, 0, 0)') expect(metrics.styles.habitBackground).toBe('rgba(0, 0, 0, 0)') expect(metrics.styles.taskBorderBottom).toBe('1px') expect(metrics.styles.habitBorderBottom).toBe('1px') expect(metrics.styles.filterWidth).toBeLessThanOrEqual(90) expect(metrics.styles.filterHeight).toBeGreaterThanOrEqual(width <= 720 ? 44 : 36) expect(metrics.styles.filterVisualHeight).toBeLessThanOrEqual(36) expect(metrics.styles.filterBackground).toBe('rgba(0, 0, 0, 0)') expect(metrics.styles.filterBorderRadius).toBe('0px') const filter = page.getByRole('switch', { name: '显示已完成' }) await filter.hover() expect(await filter.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)') await filter.evaluate((element) => { element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) }) expect(await filter.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)') expect(await filter.evaluate(element => getComputedStyle(element).borderColor)).toBe('rgba(0, 0, 0, 0)') expect(await filter.evaluate(element => getComputedStyle(element.querySelector('.completed-filter-pill__track')!).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)') await filter.evaluate((element) => { element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })) element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerType: 'mouse' })) }) expect(metrics.styles.headingDisplay).toBe('grid') expect(metrics.styles.headingAlignItems).toBe('center') expect(metrics.styles.filterTop).toBeGreaterThanOrEqual(metrics.ordered[1].top) expect(metrics.styles.filterTop).toBeLessThanOrEqual(metrics.ordered[1].bottom - metrics.styles.filterHeight) expect(metrics.styles.filterRight).toBeCloseTo(metrics.content.right, 0) if (width <= 720) expect(metrics.styles.contextHeight).toBeLessThanOrEqual(210) expect(metrics.styles.fabShadow).toContain('8px 18px') expect(metrics.styles.fabIconWidth).toBeCloseTo(30, 0) if (width >= 721) { expect(metrics.uniqueRows).toHaveLength(1) expect(metrics.content.width).toBeCloseTo(width >= 1440 ? 1080 : 630, 0) expect(Math.max(...metrics.itemWidths) - Math.min(...metrics.itemWidths)).toBeLessThanOrEqual(1) expect(Math.max(...metrics.itemHeights) - Math.min(...metrics.itemHeights)).toBeLessThanOrEqual(1) expect(metrics.environment.height).toBeLessThanOrEqual(72) expect(metrics.styles.titleFontSize).toBe('34px') } else { expect(metrics.uniqueRows).toHaveLength(2) expect(metrics.collisionRects[0].width).toBeCloseTo(metrics.content.width, 0) expect(metrics.collisionRects[0].left).toBeCloseTo(metrics.content.left, 0) expect(metrics.collisionRects[0].right).toBeCloseTo(metrics.content.right, 0) expect(metrics.collisionRects[1].width).toBeCloseTo(metrics.collisionRects[2].width, 0) expect(metrics.collisionRects[1].left).toBeCloseTo(metrics.content.left, 0) expect(metrics.collisionRects[2].right).toBeCloseTo(metrics.content.right, 0) expect(metrics.collisionRects[1].right).toBeCloseTo(metrics.collisionRects[2].left, 0) expect(metrics.directions.calendar).toBe('row') expect(metrics.directions.weather).toBe('column') expect(metrics.directions.gold).toBe('column') if (width <= 720) { expect(metrics.content.width).toBeCloseTo(width - 58, 0) expect(metrics.content.left).toBeCloseTo(29, 0) expect(metrics.styles.mainPaddingLeft).toBe('29px') expect(metrics.styles.titleFontSize).toBe('24px') expect(metrics.environment.height).toBeCloseTo(width === 375 ? 79 : 81, 0) } } if (width >= 721) { expect(metrics.content.width).toBeLessThanOrEqual(width >= 1440 ? 1080 : 721) expect(Math.abs(metrics.content.left - (metrics.main.left + (metrics.main.width - metrics.content.width) / 2))).toBeLessThanOrEqual(2) } } })