Files
dodo/frontend/e2e/today-plain-list.spec.ts
T
bboysoul 4973d56a64
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 7m17s
refactor: remove manual refresh and search controls
2026-09-19 21:15:23 +08:00

251 lines
16 KiB
TypeScript

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<string, unknown>) {
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<HTMLElement>('.today-context')?.getBoundingClientRect()
const environment = document.querySelector<HTMLElement>('.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<HTMLElement>(selector)!.getBoundingClientRect()
const environment = document.querySelector<HTMLElement>('.today-environment')!
const calendar = document.querySelector<HTMLElement>('.today-environment__calendar')!
const weather = document.querySelector<HTMLElement>('.today-environment__weather')!
const gold = document.querySelector<HTMLElement>('.today-environment__gold')!
const title = document.querySelector<HTMLElement>('.today-page-title')!
const remaining = document.querySelector<HTMLElement>('.today-remaining')!
const filter = document.querySelector<HTMLElement>('.today-inline-filter')!
const firstSection = document.querySelector<HTMLElement>('.today-section-toggle')!
const heading = document.querySelector<HTMLElement>('.today-heading')!
const main = document.querySelector<HTMLElement>('main.today-main')!
const content = rect('.today-context')
const sectionTitle = firstSection.querySelector<HTMLElement>('.today-section-title')!
const sectionSummary = firstSection.querySelector<HTMLElement>('.today-section-summary')!
const sectionIcon = firstSection.querySelector<HTMLElement>('.today-section-chevron')!
const summaries = [...document.querySelectorAll<HTMLElement>('.today-section-summary')].map(summary => ({
text: summary.innerText.trim(),
expected: summary.closest<HTMLButtonElement>('.today-section-toggle')?.getAttribute('aria-controls') === 'today-habits'
? document.querySelectorAll('.today-habit-row').length
: summary.closest<HTMLButtonElement>('.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<HTMLElement>(`#today-tasks .task-row[data-task-id="${taskId}"]`)!
const habitRow = document.querySelector<HTMLElement>(`.today-habit-row[data-habit-id="${habitId}"]`)!
const fab = document.querySelector<HTMLElement>('.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<HTMLElement>('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<HTMLElement>('.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')
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)
}
}
})