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) { if ((page.viewportSize()?.width ?? 0) <= 930) { 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('/') if ((page.viewportSize()?.width ?? 0) <= 930) await bottomTab(page, '习惯').click() else await page.locator('.sidebar').getByRole('button', { name: '习惯', exact: true }).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).toHaveCSS('background-color', 'rgb(251, 247, 240)') await expect(booleanRow.getByRole('button', { name: `减少${booleanName}一次` })).toHaveAttribute('aria-pressed', 'true') await booleanRow.locator('.habit-main').click() await expect(booleanRow).toHaveClass(/selected/) await expect(booleanRow).toHaveCSS('background-color', 'rgb(255, 244, 229)') await expect(page.getByRole('dialog', { name: booleanName })).toBeVisible() await page.getByRole('button', { name: '关闭习惯详情' }).click() await expect(booleanRow).not.toHaveClass(/selected/) 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 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, '收集箱') await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0) 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) const taskDetail = page.getByRole('dialog', { name: '任务详情' }) await expect(taskDetail.getByRole('button', { name: '保存更改' })).toBeEnabled() await page.keyboard.press('Escape') await expect(taskDetail).toBeHidden() await bottomTab(page, '习惯').click() const mobileHabitGeometry = await page.evaluate(() => { const heading = document.querySelector('.habit-section-heading')!.getBoundingClientRect() const list = document.querySelector('.habit-list')!.getBoundingClientRect() return { headingWidth: heading.width, listWidth: list.width, headingLeft: heading.left, listLeft: list.left } }) expect(mobileHabitGeometry.headingWidth).toBeCloseTo(mobileHabitGeometry.listWidth, 0) expect(mobileHabitGeometry.headingLeft).toBeCloseTo(mobileHabitGeometry.listLeft, 0) 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 page.setViewportSize({ width: 1440, height: 900 }) const desktopHabitGeometry = await page.evaluate(() => { const view = document.querySelector('main.list-main > .mvp-view')!.getBoundingClientRect() const heading = document.querySelector('.habit-section-heading')!.getBoundingClientRect() const list = document.querySelector('.habit-list')!.getBoundingClientRect() return { viewWidth: view.width, headingWidth: heading.width, listWidth: list.width, headingLeft: heading.left, listLeft: list.left } }) expect(desktopHabitGeometry.viewWidth).toBeGreaterThan(630) expect(desktopHabitGeometry.headingWidth).toBeCloseTo(desktopHabitGeometry.listWidth, 0) expect(desktopHabitGeometry.headingLeft).toBeCloseTo(desktopHabitGeometry.listLeft, 0) await page.setViewportSize({ width: testInfo.project.name === 'mobile-390' ? 390 : 375, height: testInfo.project.name === 'mobile-390' ? 844 : 667 }) 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: /刷新|搜索/ })).toHaveCount(0) })