440 lines
24 KiB
TypeScript
440 lines
24 KiB
TypeScript
import type { Page } from '@playwright/test'
|
|
import { allowExpectedError, expect, test } from './fixtures'
|
|
|
|
function bottomTab(page: Page, name: string) {
|
|
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
|
}
|
|
|
|
async function openSettings(page: Page) {
|
|
const desktopSettings = page.getByRole('navigation', { name: '管理' }).getByRole('button', { name: '设置', exact: true })
|
|
const box = await desktopSettings.boundingBox()
|
|
if (box && box.x + box.width > 0 && box.y + box.height > 0 && box.x < (await page.viewportSize())!.width) await desktopSettings.click()
|
|
else {
|
|
await page.getByRole('button', { name: /^(展开|收起)菜单$/ }).click()
|
|
await desktopSettings.click()
|
|
}
|
|
}
|
|
|
|
async function openTaskComposer(page: Page) {
|
|
await page.getByRole('button', { name: '添加任务' }).click()
|
|
return page.getByRole('dialog', { name: /添加(?:今天)?任务/ })
|
|
}
|
|
|
|
test('desktop task composer stays compact, centered, and scrollable on short screens', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
await page.goto('/')
|
|
let dialog = await openTaskComposer(page)
|
|
await expect(dialog).toBeVisible()
|
|
|
|
const measure = () => dialog.evaluate(element => {
|
|
const panel = element as HTMLElement
|
|
const rect = panel.getBoundingClientRect()
|
|
const body = panel.querySelector<HTMLElement>('.app-sheet__body')!
|
|
const header = panel.querySelector<HTMLElement>('.app-sheet__header')!.getBoundingClientRect()
|
|
const footer = panel.querySelector<HTMLElement>('.app-sheet__footer')!.getBoundingClientRect()
|
|
return {
|
|
viewport: { width: innerWidth, height: innerHeight },
|
|
panel: { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height },
|
|
body: { clientHeight: body.clientHeight, scrollHeight: body.scrollHeight, overflowY: getComputedStyle(body).overflowY },
|
|
headerTop: header.top,
|
|
footerBottom: footer.bottom,
|
|
}
|
|
})
|
|
|
|
let metrics = await measure()
|
|
expect(metrics.panel.width).toBeCloseTo(560, 0)
|
|
expect(metrics.panel.left).toBeCloseTo((metrics.viewport.width - metrics.panel.width) / 2, 0)
|
|
expect(metrics.panel.top).toBeCloseTo((metrics.viewport.height - metrics.panel.height) / 2, 0)
|
|
expect(metrics.panel.height).toBeLessThanOrEqual(521)
|
|
|
|
await page.setViewportSize({ width: 1280, height: 633 })
|
|
metrics = await measure()
|
|
expect(metrics.panel.width).toBeCloseTo(560, 0)
|
|
expect(metrics.panel.left).toBeCloseTo((metrics.viewport.width - metrics.panel.width) / 2, 0)
|
|
expect(metrics.panel.top).toBeCloseTo((metrics.viewport.height - metrics.panel.height) / 2, 0)
|
|
expect(metrics.panel.height).toBeLessThanOrEqual(metrics.viewport.height * 0.82 + 1)
|
|
expect(metrics.body.overflowY).toBe('auto')
|
|
expect(metrics.body.scrollHeight).toBeGreaterThan(metrics.body.clientHeight)
|
|
expect(metrics.headerTop).toBeGreaterThanOrEqual(0)
|
|
expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.viewport.height + 1)
|
|
|
|
await page.keyboard.press('Escape')
|
|
await page.setViewportSize({ width: 930, height: 844 })
|
|
dialog = await openTaskComposer(page)
|
|
const mobile = await dialog.evaluate(element => {
|
|
const rect = element.getBoundingClientRect()
|
|
return { left: rect.left, right: rect.right, bottom: rect.bottom, width: rect.width, viewportWidth: innerWidth, viewportHeight: innerHeight }
|
|
})
|
|
expect(mobile.left).toBeCloseTo(0, 0)
|
|
expect(mobile.right).toBeCloseTo(mobile.viewportWidth, 0)
|
|
expect(mobile.width).toBeCloseTo(mobile.viewportWidth, 0)
|
|
expect(mobile.bottom).toBeCloseTo(mobile.viewportHeight, 0)
|
|
})
|
|
|
|
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.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: [] }))
|
|
await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [], sources: [] } }))
|
|
await page.goto('/')
|
|
const navigation = page.getByRole('navigation', { name: '主要导航' })
|
|
const mobile = (await page.viewportSize())!.width <= 930
|
|
if (mobile) {
|
|
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()
|
|
} else {
|
|
await expect(navigation).toBeHidden()
|
|
}
|
|
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('calendar week focus and sheets keep the approved responsive geometry', async ({ page }) => {
|
|
const now = new Date()
|
|
const startsAt = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9).toISOString()
|
|
const endsAt = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 10).toISOString()
|
|
await page.route('**/api/v1/calendar-subscriptions', route => route.fulfill({ json: [{ id:'calendar-source', name:'工作', url:'https://example.com/work.ics', color:'#f15a29', enabled:true, refreshed_at:null, last_error:null, stale:false }] }))
|
|
await page.route('**/api/v1/calendar-events?*', route => route.fulfill({ json: { events: [{ id:'calendar-event', title:'晨会', starts_at:startsAt, ends_at:endsAt, all_day:false, source_id:'calendar-source', source_name:'工作', color:'#f15a29', description:'同步本周事项', location:'会议室' }], sources: [] } }))
|
|
await page.goto('/')
|
|
const viewport = page.viewportSize()!
|
|
const mobile = viewport.width <= 930
|
|
if (mobile) await bottomTab(page, '日历订阅').click()
|
|
else await page.locator('.primary-nav').getByRole('button', { name: '日历订阅', exact: true }).click()
|
|
const strip = page.getByRole('group', { name: '选择日期' })
|
|
await expect(strip).toBeVisible()
|
|
await expect(strip.locator('.calendar-week-day')).toHaveCount(7)
|
|
await expect(page.locator('.calendar-heading h1')).toHaveCount(0)
|
|
const metrics = await strip.evaluate(element => ({
|
|
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
stripOverflow: element.scrollWidth > element.clientWidth,
|
|
buttons: [...element.querySelectorAll<HTMLElement>('.calendar-week-day')].map(button => ({ width: button.getBoundingClientRect().width, height: button.getBoundingClientRect().height })),
|
|
}))
|
|
expect(metrics.documentOverflow).toBe(0)
|
|
expect(metrics.buttons.every(button => button.width >= 44 && button.height >= 44)).toBeTruthy()
|
|
const selectedColors = await strip.locator('.calendar-week-day.is-selected').evaluate(element => {
|
|
const style = getComputedStyle(element)
|
|
return { background: style.backgroundColor, color: style.color }
|
|
})
|
|
expect(selectedColors).toEqual({ background: 'rgb(241, 90, 41)', color: 'rgb(255, 255, 255)' })
|
|
if (viewport.width <= 375) expect(metrics.stripOverflow).toBeTruthy()
|
|
|
|
const shell = page.locator('.shell')
|
|
const assertSheetGeometry = async (sheet: ReturnType<typeof page.locator>) => {
|
|
await expect(sheet).toBeVisible()
|
|
const box = await sheet.boundingBox()
|
|
expect(box).not.toBeNull()
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBe(0)
|
|
if (mobile) {
|
|
expect(box!.x).toBeCloseTo(0, 0)
|
|
expect(box!.width).toBeCloseTo(viewport.width, 0)
|
|
const innerOverflow = await sheet.evaluate(element => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, childrenInside: [...element.querySelectorAll<HTMLElement>('.app-sheet__header,.app-sheet__body,.app-sheet__footer')].every(child => child.getBoundingClientRect().right <= element.getBoundingClientRect().right + 1) }))
|
|
expect(innerOverflow.scrollWidth).toBeLessThanOrEqual(innerOverflow.clientWidth)
|
|
expect(innerOverflow.childrenInside).toBeTruthy()
|
|
} else {
|
|
await expect(shell).toHaveClass(/detail-open/)
|
|
expect(box!.width).toBeCloseTo(350, 0)
|
|
expect(box!.x + box!.width).toBeCloseTo(viewport.width, 0)
|
|
expect(box!.height).toBeCloseTo(viewport.height, 0)
|
|
}
|
|
}
|
|
|
|
await page.getByRole('button', { name: '管理日历源' }).click()
|
|
await page.getByRole('button', { name: '添加日历订阅' }).click()
|
|
const form = page.getByRole('dialog', { name: '添加日历源' })
|
|
await assertSheetGeometry(form)
|
|
await form.getByRole('button', { name: '关闭订阅表单' }).click()
|
|
await page.getByRole('button', { name: '关闭日历源' }).click()
|
|
|
|
await page.locator('[data-event-id="calendar-event"]').click()
|
|
const detail = page.getByRole('dialog', { name: '晨会' })
|
|
await assertSheetGeometry(detail)
|
|
})
|
|
|
|
test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => {
|
|
await page.goto('/')
|
|
await openSettings(page)
|
|
if ((await page.viewportSize())!.width <= 720) {
|
|
await expect(page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name: '设置', exact: true })).toHaveCount(0)
|
|
}
|
|
const groups = page.locator('.settings-group')
|
|
await expect(groups).toHaveCount(4)
|
|
const layout = await page.locator('.settings-sections').evaluate(element => {
|
|
const page = element as HTMLElement
|
|
const main = page.closest('main') as HTMLElement
|
|
const heading = page.querySelector<HTMLElement>('.settings-heading h1')!
|
|
const sectionHeaders = [...page.querySelectorAll<HTMLElement>('.settings-group > header')]
|
|
const rows = [...page.querySelectorAll<HTMLElement>('.settings-row')]
|
|
const menu = main.querySelector<HTMLElement>('.topbar > .icon')!.getBoundingClientRect()
|
|
const rect = page.getBoundingClientRect()
|
|
const mainRect = main.getBoundingClientRect()
|
|
return {
|
|
bodyOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
leftPadding: rect.left - mainRect.left,
|
|
rightPadding: mainRect.right - rect.right,
|
|
headingSize: getComputedStyle(heading).fontSize,
|
|
sectionHeights: sectionHeaders.map(header => header.getBoundingClientRect().height),
|
|
rowHeights: rows.map(row => row.getBoundingClientRect().height),
|
|
menuWidth: menu.width,
|
|
}
|
|
})
|
|
expect(layout.bodyOverflow).toBe(0)
|
|
const isMobileContract = (await page.viewportSize())!.width <= 720
|
|
if (isMobileContract) {
|
|
expect(layout.leftPadding).toBeCloseTo(29, 0)
|
|
expect(layout.rightPadding).toBeCloseTo(29, 0)
|
|
expect(layout.headingSize).toBe('24px')
|
|
} else {
|
|
const pageWidth = await page.locator('.settings-sections').evaluate(element => element.getBoundingClientRect().width)
|
|
expect(pageWidth).toBeLessThanOrEqual(900)
|
|
expect(layout.leftPadding).toBeCloseTo(layout.rightPadding, 0)
|
|
expect(layout.headingSize).toBe('34px')
|
|
}
|
|
expect(layout.sectionHeights.every(height => height === 44)).toBeTruthy()
|
|
expect(layout.rowHeights.every(height => height >= (isMobileContract ? 64 : 62))).toBeTruthy()
|
|
expect(layout.menuWidth).toBeGreaterThanOrEqual(44)
|
|
await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0)
|
|
|
|
const exportButton = page.getByRole('button', { name: '导出 ZIP' })
|
|
const fileInput = page.locator('input[type=file]')
|
|
await fileInput.setInputFiles({ name: 'settings-geometry.zip', mimeType: 'application/zip', buffer: Buffer.from('zip-placeholder') })
|
|
const preflightButton = page.getByRole('button', { name: '开始预检' })
|
|
const actionColors = await Promise.all([exportButton, preflightButton].map(control => control.evaluate(element => ({ background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color }))))
|
|
expect(actionColors[0]).toEqual({ background: 'rgb(241, 90, 41)', color: 'rgb(255, 255, 255)' })
|
|
expect(actionColors[1].background).not.toBe('rgb(241, 90, 41)')
|
|
|
|
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(isMobileContract ? 44 : 40)
|
|
}
|
|
|
|
const logout = page.getByRole('button', { name: '退出登录', exact: true })
|
|
await expect(logout).toBeVisible()
|
|
await expect(logout).toBeEnabled()
|
|
const logoutBox = await logout.boundingBox()
|
|
expect(logoutBox).not.toBeNull()
|
|
expect(logoutBox!.width).toBeGreaterThanOrEqual(88)
|
|
expect(logoutBox!.height).toBeGreaterThanOrEqual(isMobileContract ? 44 : 40)
|
|
allowExpectedError(page, 'requestfailed: POST http://127.0.0.1:5173/api/v1/auth/logout net::ERR_ABORTED')
|
|
await page.route('**/api/v1/auth/logout', route => route.fulfill({ status: 204 }))
|
|
await logout.click()
|
|
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
|
|
await expect(page.getByRole('heading', { name: '欢迎回来', exact: true })).toBeVisible()
|
|
await expect(page.getByText('登录后继续处理今天的事项。')).toBeVisible()
|
|
await expect(page.getByText('登录后继续你的清单')).toBeVisible()
|
|
const authGeometry = await page.locator('.auth-card').evaluate(element => {
|
|
const card = element as HTMLElement
|
|
const cardRect = card.getBoundingClientRect()
|
|
const inputRects = [...card.querySelectorAll<HTMLElement>('input')].map(input => input.getBoundingClientRect())
|
|
const buttonRect = card.querySelector<HTMLElement>('button.primary')!.getBoundingClientRect()
|
|
return {
|
|
viewportWidth: innerWidth,
|
|
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
card: { left: cardRect.left, right: cardRect.right, width: cardRect.width },
|
|
brand: {
|
|
fontSize: getComputedStyle(card.querySelector<HTMLElement>('.auth-brand .brand')!).fontSize,
|
|
logoWidth: card.querySelector<HTMLElement>('.auth-brand .brand-logo')!.getBoundingClientRect().width,
|
|
},
|
|
inputHeights: inputRects.map(rect => rect.height),
|
|
buttonHeight: buttonRect.height,
|
|
}
|
|
})
|
|
expect(authGeometry.documentOverflow).toBe(0)
|
|
const expectedCardWidth = Math.min(400, authGeometry.viewportWidth - (isMobileContract ? 36 : 48))
|
|
expect(authGeometry.card.width).toBeCloseTo(expectedCardWidth, 0)
|
|
expect(authGeometry.card.left).toBeCloseTo((authGeometry.viewportWidth - expectedCardWidth) / 2, 0)
|
|
expect(authGeometry.card.right).toBeCloseTo((authGeometry.viewportWidth + expectedCardWidth) / 2, 0)
|
|
expect(authGeometry.brand).toEqual({ fontSize: '28px', logoWidth: 34 })
|
|
expect(authGeometry.inputHeights.every(height => height >= 46)).toBeTruthy()
|
|
expect(authGeometry.buttonHeight).toBeGreaterThanOrEqual(46)
|
|
})
|
|
|
|
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()
|
|
})
|