import type { Locator, Page } from '@playwright/test' import { allowExpectedError, expect, test } from './fixtures' async function bottomTab(page: Page, name: string) { return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) } function taskRow(page: Page, title: string) { return page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) }) } async function assertInsideViewport(locator: Locator, page: Page) { const box = await locator.boundingBox() const viewport = page.viewportSize() expect(box).not.toBeNull() expect(viewport).not.toBeNull() expect(box!.x).toBeGreaterThanOrEqual(0) expect(box!.y).toBeGreaterThanOrEqual(0) expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1) expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1) } test('Today plain-list composition has no overlap or horizontal overflow', async ({ page }) => { await page.goto('/') await expect(await bottomTab(page, '今天')).toHaveAttribute('aria-current', 'page') const context = page.locator('.today-context') const environment = page.locator('.today-environment') const title = page.locator('.today-page-title') const remaining = page.locator('.today-remaining') const filter = page.locator('.today-inline-filter') const firstSection = page.locator('.today-section-toggle').first() for (const locator of [context, environment, title, remaining, filter, firstSection]) { await expect(locator).toBeVisible() } const geometry = await page.locator('main').evaluate((main) => { const selectors = ['.today-environment', '.today-heading', '.today-section-toggle'] const rects = selectors.map(selector => main.querySelector(selector)!.getBoundingClientRect()) const context = main.querySelector('.today-context')! const contextRect = context.getBoundingClientRect() return { mainScrollWidth: main.scrollWidth, mainClientWidth: main.clientWidth, contextScrollWidth: context.scrollWidth, contextClientWidth: context.clientWidth, contextLeft: contextRect.left, contextRight: contextRect.right, rects: rects.map(rect => ({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height })), } }) expect(geometry.mainScrollWidth).toBeLessThanOrEqual(geometry.mainClientWidth) expect(geometry.contextScrollWidth).toBeLessThanOrEqual(geometry.contextClientWidth) for (const rect of geometry.rects) { expect(rect.width).toBeGreaterThan(0) expect(rect.height).toBeGreaterThan(0) expect(rect.left).toBeGreaterThanOrEqual(geometry.contextLeft - 1) expect(rect.right).toBeLessThanOrEqual(geometry.contextRight + 1) } for (let index = 0; index < geometry.rects.length - 1; index += 1) { expect(geometry.rects[index].bottom).toBeLessThanOrEqual(geometry.rects[index + 1].top + 1) } }) test('Today task persists through detail, completion and reopen', async ({ page }, testInfo) => { const title = `E2E 今日任务 ${testInfo.project.name}` await page.goto('/') await expect(await bottomTab(page, '今天')).toHaveAttribute('aria-current', 'page') await page.getByRole('button', { name: '添加任务' }).click() await page.getByLabel('任务名称').fill(title) await page.getByRole('button', { name: '添加任务', exact: true }).click() const row = taskRow(page, title) await expect(row).toHaveCount(1) const rowMain = row.locator('.task-main') await rowMain.click() const detail = page.getByRole('dialog', { name: '任务详情' }) await expect(detail).toBeVisible() await assertInsideViewport(detail, page) await page.getByRole('button', { name: '关闭详情' }).click() await expect(rowMain).toBeFocused() await row.getByRole('button', { name: `完成${title}` }).click() await expect(row).toHaveClass(/done/) await page.reload() const persisted = taskRow(page, title) await expect(persisted).toHaveCount(1) await expect(persisted.getByRole('button', { name: `重新打开${title}` })).toBeVisible() await persisted.getByRole('button', { name: `重新打开${title}` }).click() await expect(persisted.getByRole('button', { name: `完成${title}` })).toBeVisible() }) test('numeric habit records history, edits target, and continues', async ({ page }, testInfo) => { const name = `E2E 数量习惯 ${testInfo.project.name}` await page.goto('/') await (await bottomTab(page, '习惯')).click() await expect(await bottomTab(page, '习惯')).toHaveAttribute('aria-current', 'page') await page.getByRole('button', { name: '添加习惯' }).click() await page.getByLabel('新习惯名称').fill(name) await page.getByLabel('习惯类型').selectOption('numeric') await page.getByLabel('目标值').fill('2') await page.getByRole('button', { name: '添加习惯', exact: true }).click() const row = page.locator('.habit-row').filter({ hasText: name }) await expect(row).toHaveCount(1) const check = row.getByRole('button', { name: `完成${name}一次` }) await check.click() await expect(row).toContainText('1 / 2') await check.click() await expect(row).toContainText('2 / 2') await row.getByRole('button', { name: `查看习惯详情:${name}` }).click() const detail = page.getByRole('dialog', { name: name }) await expect(detail.getByRole('heading', { name: '历史记录' })).toBeVisible() await expect(detail.locator('.habit-history__row')).toContainText('2 / 2') await detail.getByRole('button', { name: '编辑习惯' }).click() await page.getByLabel('目标值').fill('3') await page.getByRole('button', { name: '保存修改' }).click() await expect(row).toContainText('2 / 3') await row.getByRole('button', { name: `完成${name}一次` }).click() await expect(row).toContainText('3 / 3') await page.reload() const persistedRow = page.locator('.habit-row').filter({ hasText: name }) await expect(persistedRow).toContainText('3 / 3') await persistedRow.getByRole('button', { name: `查看习惯详情:${name}` }).click() const persistedDetail = page.getByRole('dialog', { name }) await expect(persistedDetail.locator('.habit-history__row')).toContainText('3 / 3') }) test('countdown archive and restore keeps one entity after refresh', async ({ page }, testInfo) => { const title = `E2E 倒数日 ${testInfo.project.name}` await page.goto('/') await (await bottomTab(page, '倒数日')).click() await page.getByRole('button', { name: '添加倒数日' }).click() await page.getByLabel('倒数日名称').fill(title) await page.getByRole('button', { name: '保存', exact: true }).click() const item = page.getByRole('button').filter({ hasText: title }) await expect(item).toHaveCount(1) await item.click() const detail = page.getByRole('dialog', { name: title }) await expect(detail).toBeVisible() // The UI intentionally aborts its DELETE fetch after the 204 response while closing the detail sheet. allowExpectedError(page, 'requestfailed: DELETE http://127.0.0.1:5173/api/v1/countdowns/') await Promise.all([ page.waitForResponse(response => response.url().includes(`/api/v1/countdowns/`) && response.request().method() === 'DELETE' && response.status() === 204), detail.getByRole('button', { name: '归档' }).click(), ]) await page.getByRole('button', { name: /已归档(1)/ }).click() const archived = page.locator('.archived-countdowns article').filter({ hasText: title }) await expect(archived).toHaveCount(1) await archived.getByRole('button', { name: '恢复' }).click() await page.reload() await expect(page.getByText(title, { exact: true })).toHaveCount(1) })