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 mobileTab = bottomTab(page, '设置') if (await mobileTab.isVisible()) { await mobileTab.click() return } 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('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('button')] const active = nav.querySelector('button.active')! const activeStyle = getComputedStyle(active) const marker = getComputedStyle(active, '::before') const fab = document.querySelector('.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('.bottom')!.getBoundingClientRect() const button = document.querySelector('.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('.bottom')!.getBoundingClientRect() const button = document.querySelector('.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('.bottom')! const navStyle = getComputedStyle(navigation) const fab = document.querySelector('.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 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(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 page = element as HTMLElement const main = page.closest('main') as HTMLElement const heading = page.querySelector('.settings-heading h1')! const sectionHeaders = [...page.querySelectorAll('.settings-group > header')] const rows = [...page.querySelectorAll('.settings-row')] const menu = main.querySelector('.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('input')].map(input => input.getBoundingClientRect()) const buttonRect = card.querySelector('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('.auth-brand .brand')!).fontSize, logoWidth: card.querySelector('.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) => { 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('.app-sheet__header, header') const footer = panel.querySelector('.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() })