style: refine dense task views
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
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<APIRequestContext['fetch']>[1]) {
|
||||
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } })
|
||||
}
|
||||
|
||||
async function expectTaskRowGeometry(row: Locator) {
|
||||
const metrics = await row.evaluate((element: HTMLElement) => {
|
||||
const controls = [...element.querySelectorAll<HTMLElement>('button')].map(control => {
|
||||
const rect = control.getBoundingClientRect()
|
||||
return { width: rect.width, height: rect.height }
|
||||
})
|
||||
const tail = element.querySelector<HTMLElement>('.task-tail')
|
||||
const tailRect = tail?.getBoundingClientRect()
|
||||
const rowRect = element.getBoundingClientRect()
|
||||
const directChildrenInside = [...element.children].every(child => {
|
||||
const rect = (child as HTMLElement).getBoundingClientRect()
|
||||
return rect.left >= rowRect.left && rect.right <= rowRect.right && rect.top >= rowRect.top && rect.bottom <= rowRect.bottom
|
||||
})
|
||||
return {
|
||||
height: rowRect.height,
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
controls,
|
||||
tailInside: !tailRect || (tailRect.left >= rowRect.left && tailRect.right <= rowRect.right),
|
||||
directChildrenInside,
|
||||
}
|
||||
})
|
||||
expect(metrics.height).toBeGreaterThanOrEqual(64)
|
||||
expect(metrics.height).toBeLessThanOrEqual(68)
|
||||
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth)
|
||||
expect(metrics.tailInside).toBe(true)
|
||||
expect(metrics.directChildrenInside).toBe(true)
|
||||
for (const control of metrics.controls) {
|
||||
expect(control.width).toBeGreaterThanOrEqual(44)
|
||||
expect(control.height).toBeGreaterThanOrEqual(44)
|
||||
}
|
||||
}
|
||||
|
||||
async function openInbox(page: Page) {
|
||||
if ((await page.viewportSize())!.width <= 930) {
|
||||
await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click()
|
||||
}
|
||||
await page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }).click()
|
||||
}
|
||||
|
||||
test('task rows keep approved rhythm without clipping across desktop and mobile', async ({ page, request, baseURL }) => {
|
||||
const suffix = `${test.info().project.name}-${Date.now()}`
|
||||
const bootstrap = await request.get('/api/v1/bootstrap')
|
||||
expect(bootstrap.ok()).toBeTruthy()
|
||||
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||
|
||||
const create = async (title: string, extra: Record<string, unknown> = {}) => {
|
||||
const response = await mutate(request, baseURL!, '/api/v1/tasks', {
|
||||
method: 'POST', data: { title, list_id: inbox.id, ...extra },
|
||||
})
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
return response.json() as Promise<{ id: string }>
|
||||
}
|
||||
|
||||
const ordinaryTitle = `普通任务-${suffix}`
|
||||
const completedTitle = `完成任务-${suffix}`
|
||||
const longTitle = `超长任务-${suffix}-` + '这是一段用于确认标题省略且不撑高任务行的连续文字'.repeat(5)
|
||||
const dueTitle = `含截止日期-${suffix}`
|
||||
const parentTitle = `含子任务-${suffix}`
|
||||
const localDate = (offset: number) => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + offset)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
await create(ordinaryTitle)
|
||||
await create(completedTitle)
|
||||
await create(longTitle)
|
||||
await create(dueTitle, { due_at: `${localDate(1)}T23:59:00`, due_has_time: true })
|
||||
const parent = await create(parentTitle)
|
||||
await create(`子任务-${suffix}`, { parent_id: parent.id })
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await page.goto('/')
|
||||
await openInbox(page)
|
||||
|
||||
const titles = [ordinaryTitle, completedTitle, longTitle, dueTitle, parentTitle]
|
||||
for (const title of titles) {
|
||||
const row = page.locator('.task-row').filter({ hasText: title })
|
||||
await expect(row).toHaveCount(1)
|
||||
await expectTaskRowGeometry(row)
|
||||
}
|
||||
const completedRow = page.locator('.task-row').filter({ hasText: completedTitle })
|
||||
await completedRow.getByRole('button', { name: `完成${completedTitle}` }).click()
|
||||
await expect(completedRow).toHaveClass(/done/)
|
||||
await expectTaskRowGeometry(completedRow)
|
||||
await expect(page.locator('.task-row').filter({ hasText: dueTitle }).locator('.task-tail')).toBeVisible()
|
||||
await expect(page.locator('.task-row').filter({ hasText: parentTitle })).toContainText('0/1')
|
||||
|
||||
for (const viewport of [{ width: 390, height: 844 }, { width: 375, height: 667 }]) {
|
||||
await page.setViewportSize(viewport)
|
||||
for (const title of titles) {
|
||||
await expectTaskRowGeometry(page.locator('.task-row').filter({ hasText: title }))
|
||||
}
|
||||
const pageMetrics = await page.evaluate(() => ({ clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth }))
|
||||
expect(pageMetrics.scrollWidth).toBeLessThanOrEqual(pageMetrics.clientWidth)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
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<APIRequestContext['fetch']>[1]) {
|
||||
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } })
|
||||
}
|
||||
|
||||
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('approved polish keeps search state, dense rows, title-only memos, and unique detail titles', async ({ page, request, baseURL }, testInfo) => {
|
||||
const suffix = testInfo.project.name
|
||||
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 = new Date().toLocaleDateString('sv-SE')
|
||||
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 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, 64, 68)
|
||||
await expectNotClipped(taskRow)
|
||||
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, 84, 88)
|
||||
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('.search-reveal')
|
||||
const desktopBox = await desktopPanel.boundingBox()
|
||||
expect(desktopBox).not.toBeNull()
|
||||
expect(desktopBox!.width).toBeGreaterThanOrEqual(320)
|
||||
await desktopSearch.fill(`搜索保留-${suffix}`)
|
||||
const desktopTaskRow = page.locator('.task-row').filter({ hasText: taskTitle })
|
||||
await expect(desktopTaskRow).toHaveCount(1)
|
||||
await expectNotClipped(desktopTaskRow)
|
||||
})
|
||||
Reference in New Issue
Block a user