import type { APIRequestContext, Locator, Page } from '@playwright/test' import { expect, test } from './fixtures' 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, options: Parameters[1]) { return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } }) } async function createHabit(request: APIRequestContext, baseURL: string, name: string, extra: Record = {}) { const response = await mutate(request, baseURL, '/api/v1/habits', { method: 'POST', data: { name, kind: 'boolean', schedule_type: 'daily', ...extra }, }) expect(response.ok(), await response.text()).toBeTruthy() return response.json() as Promise<{ id: string }> } function shanghaiToday() { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', }).formatToParts(new Date()) const value = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)!.value const weekdays: Record = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 } return { day: `${value('year')}-${value('month')}-${value('day')}`, weekday: weekdays[value('weekday')] } } function bottomTab(page: Page, name: string) { return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) } async function openSidebarView(page: Page, name: string) { await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click() const target = page.locator('.sidebar').getByRole('button', { name, exact: true }) await expect(target).toBeInViewport() await target.click() } async function expectHeightInRange(locator: Locator, minimum: number, maximum: number) { const box = await locator.boundingBox() expect(box).not.toBeNull() expect(box!.height).toBeGreaterThanOrEqual(minimum) expect(box!.height).toBeLessThanOrEqual(maximum) } async function expectNotClipped(locator: Locator) { const metrics = await locator.evaluate((element: HTMLElement) => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, })) expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth) expect(metrics.scrollHeight).toBeLessThanOrEqual(metrics.clientHeight) } test('boolean habits complete in place while paused and unscheduled rows explain why they are read-only', async ({ page, request, baseURL }, testInfo) => { const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const today = shanghaiToday() const booleanName = `布尔习惯-${suffix}` const pausedName = `暂停习惯-${suffix}` const unscheduledName = `未安排习惯-${suffix}` await createHabit(request, baseURL!, booleanName) const paused = await createHabit(request, baseURL!, pausedName) await createHabit(request, baseURL!, unscheduledName, { schedule_type: 'weekly', weekdays: [(today.weekday + 1) % 7] }) const pause = await mutate(request, baseURL!, `/api/v1/habits/${paused.id}/pauses`, { method: 'POST', data: { start_date: today.day, end_date: today.day }, }) expect(pause.ok(), await pause.text()).toBeTruthy() await page.goto('/') await bottomTab(page, '习惯').click() const booleanRow = page.locator('.habit-row').filter({ hasText: booleanName }) const booleanCheck = booleanRow.getByRole('button', { name: `完成${booleanName}一次` }) const checkGeometry = await booleanCheck.evaluate((element) => { const control = element.getBoundingClientRect() const row = element.closest('.habit-row')!.getBoundingClientRect() return { visibleWidth: control.width - Math.max(row.left - control.left, 0) - Math.max(control.right - row.right, 0), height: control.height, } }) expect(checkGeometry.visibleWidth).toBeGreaterThanOrEqual(44) expect(checkGeometry.height).toBeGreaterThanOrEqual(44) await booleanCheck.click() await expect(booleanRow).toHaveClass(/done/) await expect(booleanRow.getByRole('button', { name: `减少${booleanName}一次` })).toHaveAttribute('aria-pressed', 'true') await page.reload() const persistedBooleanRow = page.locator('.habit-row').filter({ hasText: booleanName }) await expect(persistedBooleanRow).toHaveClass(/done/) await persistedBooleanRow.locator('.habit-main').focus() await persistedBooleanRow.locator('.habit-main').press('Enter') await expect(page.getByRole('dialog', { name: booleanName })).toBeVisible() await page.getByRole('button', { name: '关闭习惯详情' }).click() const pausedRow = page.locator('.habit-row').filter({ hasText: pausedName }) await expect(pausedRow).toContainText('今天已暂停') await expect(pausedRow.getByRole('button', { name: '今天已暂停' })).toBeDisabled() const unscheduledRow = page.locator('.habit-row').filter({ hasText: unscheduledName }) await expect(unscheduledRow).toContainText('今天未安排') await expect(unscheduledRow.getByRole('button', { name: '今天未安排' })).toBeDisabled() }) test('approved polish keeps search state, dense rows, title-only memos, and unique detail titles', async ({ page, request, baseURL }, testInfo) => { const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const bootstrapResponse = await request.get('/api/v1/bootstrap') expect(bootstrapResponse.ok()).toBeTruthy() const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) expect(inbox).toBeTruthy() const taskTitle = `搜索保留-${suffix}-这是用于检查长标题和右侧控件不会裁切的任务标题` const taskResponse = await mutate(request, baseURL!, '/api/v1/tasks', { method: 'POST', data: { title: taskTitle, list_id: inbox.id }, }) expect(taskResponse.ok(), await taskResponse.text()).toBeTruthy() const memoTitle = `Markdown 摘要-${suffix}` const memoResponse = await mutate(request, baseURL!, '/api/v1/memos', { method: 'POST', data: { title: memoTitle, content: '# 标题\n\n**重点** 与 [链接文字](https://example.com)\n\n- 列表项' }, }) expect(memoResponse.ok(), await memoResponse.text()).toBeTruthy() const day = shanghaiToday().day const countdownTitle = `紧凑倒数-${suffix}` const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', { method: 'POST', data: { title: countdownTitle, event_date: day, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false, pinned: true }, }) expect(countdownResponse.ok(), await countdownResponse.text()).toBeTruthy() await page.goto('/') await openSidebarView(page, '收集箱') const mobileGeometry = await page.evaluate(() => { const main = document.querySelector('main.list-main')!.getBoundingClientRect() const header = document.querySelector('.list-page-context')!.getBoundingClientRect() const filter = document.querySelector('.list-inline-filter')!.getBoundingClientRect() const search = document.querySelector('.list-search-reveal')!.getBoundingClientRect() const toggle = document.querySelector('.list-search-reveal .task-search-toggle')!.getBoundingClientRect() return { main: { left: main.left, right: main.right }, header: { left: header.left, right: header.right, bottom: header.bottom }, filter: { left: filter.left, bottom: filter.bottom }, search: { left: search.left, right: search.right, top: search.top }, toggle: { left: toggle.left, top: toggle.top } } }) expect(mobileGeometry.header.left - mobileGeometry.main.left).toBeCloseTo(29, 0) expect(mobileGeometry.main.right - mobileGeometry.header.right).toBeCloseTo(29, 0) expect(mobileGeometry.search.left).toBeGreaterThanOrEqual(mobileGeometry.header.left) expect(mobileGeometry.search.right).toBeLessThanOrEqual(mobileGeometry.header.right) expect(mobileGeometry.toggle.top).toBeGreaterThanOrEqual(mobileGeometry.header.bottom - 1) expect(mobileGeometry.toggle.left).toBeGreaterThanOrEqual(mobileGeometry.filter.left) const searchToggle = page.getByRole('button', { name: '展开搜索任务' }) await expect(searchToggle).toHaveAttribute('aria-expanded', 'false') await searchToggle.click() const searchInput = page.getByRole('textbox', { name: '搜索任务' }) await expect(searchInput).toBeFocused() await searchInput.fill(`搜索保留-${suffix}`) const taskRow = page.locator('.task-row').filter({ hasText: taskTitle }) await expect(taskRow).toHaveCount(1) await expectHeightInRange(taskRow, 57, 59) await expectNotClipped(taskRow) const transparent = 'rgba(0, 0, 0, 0)' await expect(taskRow).toHaveCSS('background-color', transparent) await taskRow.hover() await expect(taskRow).toHaveCSS('background-color', transparent) await taskRow.locator('.task-main').click() await expect(taskRow).toHaveClass(/selected/) await expect(taskRow).toHaveCSS('background-color', transparent) await page.keyboard.press('Escape') await searchInput.press('Escape') const collapsedToggle = page.getByRole('button', { name: '展开搜索任务' }) await expect(collapsedToggle).toBeFocused() await expect(collapsedToggle).toHaveAttribute('aria-expanded', 'false') await collapsedToggle.click() await expect(searchInput).toHaveValue(`搜索保留-${suffix}`) await bottomTab(page, '习惯').click() const habitName = `长标题习惯-${suffix}-检查进度与按钮不被裁切` await page.getByRole('button', { name: '添加习惯' }).click() await page.getByLabel('新习惯名称').fill(habitName) await page.getByLabel('习惯类型').selectOption('numeric') await page.getByLabel('目标值').fill('8') await page.getByRole('button', { name: '添加习惯', exact: true }).click() const habitRow = page.locator('.habit-row').filter({ hasText: habitName }) await expect(habitRow).toHaveCount(1) await expectHeightInRange(habitRow, 57, 59) await expectNotClipped(habitRow) await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click() const habitDetail = page.getByRole('dialog', { name: habitName }) await expect(habitDetail.getByRole('heading', { name: habitName, exact: true })).toHaveCount(1) await expect(habitDetail).not.toContainText('习惯详情') await habitDetail.getByRole('button', { name: '关闭习惯详情' }).click() await bottomTab(page, '倒数日').click() const focus = page.locator('.countdown-focus').filter({ hasText: countdownTitle }) await expect(focus).toHaveCount(1) await expectHeightInRange(focus, 136, 148) await expect(focus).not.toContainText(/置顶的重要日子|下一个重要日子|还有|已经过去/) await expectNotClipped(focus) await focus.click() const countdownDetail = page.getByRole('dialog', { name: countdownTitle }) await expect(countdownDetail.getByRole('heading', { name: countdownTitle, exact: true })).toHaveCount(1) await expect(countdownDetail).not.toContainText('重要日子详情') await countdownDetail.getByRole('button', { name: '关闭详情' }).click() await openSidebarView(page, '备忘录') const memoRow = page.locator('.memo-row').filter({ hasText: memoTitle }) await expect(memoRow).toHaveCount(1) await expectHeightInRange(memoRow, 72, 76) await expect(memoRow.locator('.memo-row__excerpt')).toHaveCount(0) await expect(memoRow).not.toContainText('标题 重点 与 链接文字 列表项') await expect(memoRow.getByText(memoTitle, { exact: true })).toHaveCount(1) await expect(memoRow.locator('time')).toHaveCount(1) await expectNotClipped(memoRow) const shortMemoTitle = `短正文-${suffix}` const shortMemoResponse = await mutate(request, baseURL!, '/api/v1/memos', { method: 'POST', data: { title: shortMemoTitle, content: 'BODY_ONLY_SHORT' }, }) expect(shortMemoResponse.ok(), await shortMemoResponse.text()).toBeTruthy() await page.reload() const shortMemoRow = page.locator('.memo-row').filter({ hasText: shortMemoTitle }) await expect(shortMemoRow).toHaveCount(1) await expectHeightInRange(shortMemoRow, 72, 76) await expect(shortMemoRow.locator('.memo-row__excerpt')).toHaveCount(0) await expect(shortMemoRow).not.toContainText('BODY_ONLY_SHORT') await page.setViewportSize({ width: 1440, height: 900 }) const desktopInbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }) await expect(desktopInbox).toBeInViewport() await desktopInbox.click() await expect(page.getByRole('button', { name: /展开搜索任务|收起搜索任务/ })).toBeHidden() const desktopSearch = page.getByRole('textbox', { name: '搜索任务' }) await expect(desktopSearch).toBeVisible() const desktopPanel = page.locator('.list-search-reveal') const desktopBox = await desktopPanel.boundingBox() expect(desktopBox).not.toBeNull() expect(desktopBox!.width).toBeGreaterThanOrEqual(320) const desktopHeader = page.locator('.list-page-context') const desktopList = page.locator('.task-list') const desktopGeometry = await Promise.all([desktopHeader, desktopPanel, desktopList].map(async locator => locator.boundingBox())) for (const box of desktopGeometry) expect(box).not.toBeNull() expect(Math.abs(desktopGeometry[0]!.x - desktopGeometry[1]!.x)).toBeLessThanOrEqual(1) expect(Math.abs(desktopGeometry[1]!.x - desktopGeometry[2]!.x)).toBeLessThanOrEqual(1) expect(desktopGeometry[0]!.width).toBeLessThanOrEqual(900) await desktopSearch.fill(`搜索保留-${suffix}`) const desktopTaskRow = page.locator('.task-row').filter({ hasText: taskTitle }) await expect(desktopTaskRow).toHaveCount(1) await expectNotClipped(desktopTaskRow) })