diff --git a/frontend/e2e/today-plain-list.spec.ts b/frontend/e2e/today-plain-list.spec.ts index 6808ea1..649573e 100644 --- a/frontend/e2e/today-plain-list.spec.ts +++ b/frontend/e2e/today-plain-list.spec.ts @@ -1,16 +1,65 @@ +import type { APIRequestContext } from '@playwright/test' import { expect, test } from './fixtures' const widths = [721, 720, 390, 375] -test('Today plain-list layout matches the approved responsive geometry', async ({ page }) => { +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.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 === 721 ? 630 : width - 58, + environmentHeight: width === 721 ? 55 : width === 375 ? 90 : 93, + }) + await expect(taskRowLocator).toHaveCount(1) + await expect(habitRowLocator).toHaveCount(1) await expect(page.locator('.today-environment')).toBeVisible() await expect(page.getByRole('button', { name: '刷新当前页面' })).toBeVisible() await expect(page.getByRole('switch', { name: '显示已完成' })).toHaveCount(1) await expect(page.locator('.today-environment__gold')).toContainText('Au99.99') - const metrics = await page.evaluate(() => { + 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')! @@ -22,6 +71,20 @@ test('Today plain-list layout matches the approved responsive geometry', async ( const firstSection = document.querySelector('.today-section-toggle')! 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, title, remaining, filter, firstSection] const ordered = orderedElements.map((element) => { const box = element.getBoundingClientRect() @@ -50,13 +113,16 @@ test('Today plain-list layout matches the approved responsive geometry', async ( 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 }, uniqueRows, collisions, + collisionRects: [calendar, weather, gold].map(element => element.getBoundingClientRect().toJSON()), secondary, ordered, orderedPairs, @@ -65,8 +131,32 @@ test('Today plain-list layout matches the approved responsive geometry', async ( weatherText: weather.innerText, goldText: gold.innerText, goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label'), + summaries, + styles: { + mainPaddingLeft: getComputedStyle(main).paddingLeft, + 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: getComputedStyle(fab).boxShadow, + fabIconWidth: fab.querySelector('svg')?.getBoundingClientRect().width, + }, } - }) + }, { taskId, habitId }) console.log(`[today-geometry][${width}] ${JSON.stringify(metrics)}`) expect(metrics.documentOverflow).toBe(0) expect(metrics.mainOverflow).toBeLessThanOrEqual(0) @@ -78,14 +168,43 @@ test('Today plain-list layout matches the approved responsive geometry', async ( 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') + expect(metrics.styles.fabShadow).toContain('8px 18px') + expect(metrics.styles.fabIconWidth).toBeCloseTo(30, 0) if (width === 721) { expect(metrics.uniqueRows).toHaveLength(1) - expect(metrics.environment.height).toBeLessThan(80) + expect(metrics.content.width).toBeCloseTo(630, 0) + expect(metrics.environment.height).toBeLessThanOrEqual(72) + expect(metrics.styles.titleFontSize).toBe('34px') } else { expect(metrics.uniqueRows).toHaveLength(2) 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 ? 90 : 93, 0) + } } if (width >= 721) { expect(metrics.content.width).toBeLessThanOrEqual(721) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5318c69..2f63726 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1432,7 +1432,7 @@ onUnmounted(() => { {{ error }} -
+