251 lines
16 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
})
|