Files
dodo/frontend/e2e/mobile-ui.spec.ts
T
bboysoul 4d5d1f46e0
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m40s
fix: compact mobile bottom navigation
2026-09-18 08:58:41 +08:00

242 lines
12 KiB
TypeScript

import type { Page } from '@playwright/test'
import { expect, test } from './fixtures'
function bottomTab(page: Page, name: string) {
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
}
async function openTaskComposer(page: Page) {
await page.getByRole('button', { name: '添加任务' }).click()
return page.getByRole('dialog', { name: /添加(?:今天)?任务/ })
}
test('bottom navigation uses the paper baseline geometry', async ({ page }) => {
await page.goto('/')
const metrics = await page.locator('.bottom').evaluate(element => {
const nav = element as HTMLElement
const rect = nav.getBoundingClientRect()
const buttons = [...nav.querySelectorAll<HTMLElement>('button')]
const active = nav.querySelector<HTMLElement>('button.active')!
const activeStyle = getComputedStyle(active)
const marker = getComputedStyle(active, '::before')
const fab = document.querySelector<HTMLElement>('.unified-fab')!
const fabRect = fab.getBoundingClientRect()
return {
viewportWidth: innerWidth,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
nav: { left: rect.left, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height },
buttonWidths: buttons.map(button => button.getBoundingClientRect().width),
buttonHeights: buttons.map(button => button.getBoundingClientRect().height),
background: getComputedStyle(nav).backgroundColor,
borderTopWidth: getComputedStyle(nav).borderTopWidth,
borderRadius: getComputedStyle(nav).borderRadius,
boxShadow: getComputedStyle(nav).boxShadow,
activeBackground: activeStyle.backgroundColor,
activeColor: activeStyle.color,
marker: { height: marker.height, background: marker.backgroundColor },
fabGap: rect.top - fabRect.bottom,
}
})
expect(metrics.documentOverflow).toBe(0)
expect(metrics.nav).toEqual({ left: 0, right: metrics.viewportWidth, bottom: page.viewportSize()!.height, width: metrics.viewportWidth, height: 56 })
expect(Math.max(...metrics.buttonWidths) - Math.min(...metrics.buttonWidths)).toBeLessThanOrEqual(1)
expect(metrics.buttonHeights.every(height => height >= 44)).toBeTruthy()
expect(metrics.background).toBe('rgb(255, 253, 248)')
expect(metrics.borderTopWidth).toBe('1px')
expect(metrics.borderRadius).toBe('0px')
expect(metrics.boxShadow).toBe('none')
expect(metrics.activeBackground).toBe('rgba(0, 0, 0, 0)')
expect(metrics.activeColor).toBe('rgb(241, 90, 41)')
expect(metrics.marker).toEqual({ height: '3px', background: 'rgb(241, 90, 41)' })
expect(metrics.fabGap).toBeGreaterThanOrEqual(12)
const fab = page.locator('.unified-fab')
const fabBox = await fab.boundingBox()
expect(fabBox).not.toBeNull()
await page.mouse.move(fabBox!.x + fabBox!.width / 2, fabBox!.y + fabBox!.height / 2)
await page.mouse.down()
await page.mouse.move(fabBox!.x + fabBox!.width / 2, page.viewportSize()!.height - 1, { steps: 8 })
await page.mouse.up()
const draggedGap = await page.evaluate(() => {
const nav = document.querySelector<HTMLElement>('.bottom')!.getBoundingClientRect()
const button = document.querySelector<HTMLElement>('.unified-fab')!.getBoundingClientRect()
return nav.top - button.bottom
})
expect(draggedGap).toBeGreaterThanOrEqual(12)
})
test('bottom navigation keeps its safe-area gap after dragging', async ({ page }) => {
await page.goto('/')
const nav = page.locator('.bottom')
await expect(nav).toBeVisible()
await page.locator('html').evaluate(element => element.style.setProperty('--safe-area-bottom', '34px', 'important'))
await expect.poll(() => nav.evaluate(element => getComputedStyle(element).height)).toBe('90px')
const navBox = await nav.boundingBox()
expect(navBox).not.toBeNull()
expect(navBox!.height).toBe(90)
const fab = page.locator('.unified-fab')
const fabBox = await fab.boundingBox()
expect(fabBox).not.toBeNull()
await page.mouse.move(fabBox!.x + fabBox!.width / 2, fabBox!.y + fabBox!.height / 2)
await page.mouse.down()
await page.mouse.move(fabBox!.x + fabBox!.width / 2, page.viewportSize()!.height - 1, { steps: 8 })
await page.mouse.up()
const gap = await page.evaluate(() => {
const navigation = document.querySelector<HTMLElement>('.bottom')!.getBoundingClientRect()
const button = document.querySelector<HTMLElement>('.unified-fab')!.getBoundingClientRect()
return navigation.top - button.bottom
})
expect(gap).toBeGreaterThanOrEqual(12)
})
test('all bottom destinations expose one active page and desktop layout stays unchanged', async ({ page }) => {
await page.goto('/')
const navigation = page.getByRole('navigation', { name: '主要导航' })
for (const label of ['今天', '习惯', '倒数日', '设置']) {
await bottomTab(page, label).click()
await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1)
await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page')
}
await bottomTab(page, '今天').click()
await page.setViewportSize({ width: 1440, height: 900 })
const desktop = await page.locator('.shell').evaluate(element => {
const shell = getComputedStyle(element)
const navigation = document.querySelector<HTMLElement>('.bottom')!
const navStyle = getComputedStyle(navigation)
const fab = document.querySelector<HTMLElement>('.unified-fab')!
const fabStyle = getComputedStyle(fab)
const fabRect = fab.getBoundingClientRect()
return {
shellDisplay: shell.display,
shellColumns: shell.gridTemplateColumns,
navDisplay: navStyle.display,
fab: { position: fabStyle.position, width: fabRect.width, height: fabRect.height },
}
})
expect(desktop.shellDisplay).toBe('grid')
expect(desktop.shellColumns.split(' ').length).toBe(3)
expect(desktop.navDisplay).toBe('none')
expect(desktop.fab).toEqual({ position: 'fixed', width: 56, height: 56 })
const desktopFab = page.locator('.unified-fab')
const desktopFabBox = await desktopFab.boundingBox()
expect(desktopFabBox).not.toBeNull()
await page.mouse.move(desktopFabBox!.x + desktopFabBox!.width / 2, desktopFabBox!.y + desktopFabBox!.height / 2)
await page.mouse.down()
await page.mouse.move(desktopFabBox!.x + desktopFabBox!.width / 2, 899, { steps: 8 })
await page.mouse.up()
const desktopBottomGap = await desktopFab.evaluate(element => innerHeight - element.getBoundingClientRect().bottom)
expect(desktopBottomGap).toBe(84)
})
test('settings are continuous, fit viewport, and controls are touch sized', async ({ page }) => {
await page.goto('/')
await bottomTab(page, '设置').click()
await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page')
const groups = page.locator('.settings-group')
await expect(groups).toHaveCount(4)
const layout = await page.locator('.settings-sections').evaluate(element => {
const groups = [...element.querySelectorAll<HTMLElement>(':scope > .settings-group')]
return {
bodyOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
gaps: groups.slice(1).map((group, index) => group.getBoundingClientRect().top - groups[index].getBoundingClientRect().bottom),
}
})
expect(layout.bodyOverflow).toBe(0)
expect(layout.gaps.every(gap => gap >= 0 && gap <= 20)).toBeTruthy()
const sessionButtons = page.getByRole('button', { name: /撤销会话|撤销其他会话/ })
for (const target of await page.locator('.settings-row button, .settings-row .file-button, .settings-row select').all()) {
const box = await target.boundingBox()
expect(box).not.toBeNull()
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
}
for (const target of await sessionButtons.all()) {
const box = await target.boundingBox()
expect(box).not.toBeNull()
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
}
})
test('task, habit, countdown, memo, action and confirmation overlays share the modal contract', async ({ page }, testInfo) => {
await page.goto('/')
const assertModal = async (dialog: ReturnType<Page['getByRole']>) => {
await expect(dialog).toBeVisible()
await expect(page.locator('#app')).toHaveAttribute('inert', '')
const metrics = await dialog.evaluate(element => {
const panel = element as HTMLElement
const header = panel.querySelector<HTMLElement>('.app-sheet__header, header')
const footer = panel.querySelector<HTMLElement>('.app-sheet__footer, footer')
return {
horizontalOverflow: panel.scrollWidth - panel.clientWidth,
headerVisible: !header || header.getBoundingClientRect().top >= 0,
footerVisible: !footer || footer.getBoundingClientRect().bottom <= innerHeight + 1,
}
})
expect(metrics).toEqual({ horizontalOverflow: 0, headerVisible: true, footerVisible: true })
}
await openTaskComposer(page)
await assertModal(page.getByRole('dialog', { name: /添加(?:今天)?任务/ }))
await page.keyboard.press('Escape')
await bottomTab(page, '习惯').click()
await page.getByRole('button', { name: '添加习惯' }).click()
await assertModal(page.getByRole('dialog', { name: '添加习惯' }))
await page.keyboard.press('Escape')
await bottomTab(page, '倒数日').click()
await page.getByRole('button', { name: '添加倒数日' }).click()
await assertModal(page.getByRole('dialog', { name: '新建倒数日' }))
await page.keyboard.press('Escape')
await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click()
await page.locator('.sidebar').getByRole('button', { name: '备忘录', exact: true }).click()
await page.getByRole('button', { name: '添加备忘录' }).click()
await assertModal(page.getByRole('dialog', { name: '备忘录详情' }))
await page.getByLabel('备忘录标题').fill(`未保存 ${testInfo.project.name}`)
await page.getByRole('button', { name: '关闭备忘录' }).click()
const confirmation = page.getByRole('dialog', { name: '放弃未保存的更改?' })
await assertModal(confirmation)
await confirmation.getByRole('button', { name: '取消' }).click()
await expect(page.getByRole('dialog', { name: '备忘录详情' })).toBeVisible()
await expect(page.getByLabel('备忘录标题')).toBeFocused()
})
test('AppSheet traps focus, Escape closes, and scrim owns outside hit testing', async ({ page }) => {
await page.goto('/')
const opener = page.getByRole('button', { name: '添加任务' })
await opener.focus()
const dialog = await openTaskComposer(page)
await expect(dialog).toBeVisible()
await expect(page.getByLabel('任务名称')).toBeFocused()
const background = page.locator('#app')
await expect(background).toHaveAttribute('aria-hidden', 'true')
await expect(background).toHaveAttribute('inert', '')
const geometry = await page.locator('.app-overlay').evaluate(element => {
const rect = element.getBoundingClientRect()
const hit = document.elementFromPoint(2, 2)
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, hitIsScrim: hit === element }
})
expect(geometry).toEqual({ x: 0, y: 0, width: page.viewportSize()!.width, height: page.viewportSize()!.height, hitIsScrim: true })
const focusable = dialog.locator('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])').filter({ visible: true })
const first = focusable.first()
const last = focusable.last()
await first.focus()
await page.keyboard.press('Shift+Tab')
await expect(last).toBeFocused()
await page.keyboard.press('Tab')
await expect(first).toBeFocused()
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
await expect(opener).toBeFocused()
await openTaskComposer(page)
await page.mouse.click(2, 2)
await expect(page.getByRole('dialog', { name: /添加(?:今天)?任务/ })).toBeHidden()
})