refactor: unify task and habit list rows
This commit is contained in:
@@ -32,8 +32,8 @@ async function expectTaskRowGeometry(row: Locator) {
|
||||
directChildrenInside,
|
||||
}
|
||||
})
|
||||
expect(metrics.height).toBeGreaterThanOrEqual(64)
|
||||
expect(metrics.height).toBeLessThanOrEqual(68)
|
||||
expect(metrics.height).toBeGreaterThanOrEqual(57)
|
||||
expect(metrics.height).toBeLessThanOrEqual(59)
|
||||
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth)
|
||||
expect(metrics.tailInside).toBe(true)
|
||||
expect(metrics.directChildrenInside).toBe(true)
|
||||
@@ -70,9 +70,14 @@ test('task rows keep approved rhythm without clipping across desktop and mobile'
|
||||
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')}`
|
||||
const shanghaiDate = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(new Date())
|
||||
const date = new Date(`${shanghaiDate}T12:00:00+08:00`)
|
||||
date.setUTCDate(date.getUTCDate() + offset)
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
await create(ordinaryTitle)
|
||||
await create(completedTitle)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { expect, test } from './fixtures'
|
||||
import { allowExpectedError, expect, test } from './fixtures'
|
||||
|
||||
async function csrf(request: APIRequestContext) {
|
||||
const state = await request.storageState()
|
||||
@@ -61,16 +61,19 @@ async function box(locator: Locator) {
|
||||
}
|
||||
|
||||
test('task rows use the body for detail and Trash keeps distinct actions', async ({ page, request, baseURL }, testInfo) => {
|
||||
const suffix = testInfo.project.name
|
||||
const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const bootstrap = await request.get('/api/v1/bootstrap')
|
||||
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||
expect(inbox).toBeTruthy()
|
||||
const todayTitle = `验收今天超长任务标题-${suffix}-用于确认正文获得更多实际可用宽度`
|
||||
const inboxTitle = `验收清单任务-${suffix}`
|
||||
const trashTitle = `验收回收站任务-${suffix}`
|
||||
const restoreTitle = `验收恢复任务-${suffix}`
|
||||
const purgeTitle = `验收永久删除任务-${suffix}`
|
||||
await createTask(request, baseURL!, inboxTitle, inbox.id)
|
||||
const trash = await createTask(request, baseURL!, trashTitle, inbox.id)
|
||||
expect((await mutate(request, baseURL!, `/api/v1/tasks/${trash.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
|
||||
const restoreCandidate = await createTask(request, baseURL!, restoreTitle, inbox.id)
|
||||
const purgeCandidate = await createTask(request, baseURL!, purgeTitle, inbox.id)
|
||||
expect((await mutate(request, baseURL!, `/api/v1/tasks/${restoreCandidate.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
|
||||
expect((await mutate(request, baseURL!, `/api/v1/tasks/${purgeCandidate.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: '添加任务' }).click()
|
||||
@@ -98,10 +101,30 @@ test('task rows use the body for detail and Trash keeps distinct actions', async
|
||||
await page.getByRole('button', { name: '关闭详情' }).click()
|
||||
|
||||
await openSidebarView(page, '回收站')
|
||||
const deletedRow = await taskRow(page, trashTitle)
|
||||
await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible()
|
||||
await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible()
|
||||
expect(await deletedRow.locator('.task-detail-trigger').count()).toBe(0)
|
||||
const restoreRow = await taskRow(page, restoreTitle)
|
||||
const purgeRow = await taskRow(page, purgeTitle)
|
||||
for (const deletedRow of [restoreRow, purgeRow]) {
|
||||
await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible()
|
||||
await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible()
|
||||
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('role')
|
||||
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('tabindex')
|
||||
expect(await deletedRow.locator('.task-detail-trigger, .task-check').count()).toBe(0)
|
||||
}
|
||||
await restoreRow.getByRole('button', { name: '恢复' }).click()
|
||||
await expect(restoreRow).toHaveCount(0)
|
||||
await purgeRow.getByRole('button', { name: '永久删除' }).click()
|
||||
const purgeDialog = page.getByRole('dialog', { name: `永久删除“${purgeTitle}”?` })
|
||||
await expect(purgeDialog).toBeVisible()
|
||||
// The UI can abort the completed 204 request while the confirmation overlay closes.
|
||||
allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`)
|
||||
await purgeDialog.getByRole('button', { name: '确认', exact: true }).click()
|
||||
await expect(purgeRow).toHaveCount(0)
|
||||
await page.reload()
|
||||
await expect(page.locator('.task-row').filter({ hasText: restoreTitle })).toHaveCount(0)
|
||||
await expect(page.locator('.task-row').filter({ hasText: purgeTitle })).toHaveCount(0)
|
||||
await openSidebarView(page, '收集箱')
|
||||
await expect(await taskRow(page, restoreTitle)).toHaveCount(1)
|
||||
await expect(page.locator('.task-row').filter({ hasText: purgeTitle })).toHaveCount(0)
|
||||
await expectNoHorizontalOverflow(page, 'tasks-trash', testInfo)
|
||||
})
|
||||
|
||||
@@ -131,8 +154,8 @@ test('Settings removes intro/empty danger and places mode-specific restore risk
|
||||
})
|
||||
|
||||
test('Habits and Countdowns use reduced headers, compact rows, and continuous archive styling', async ({ page, request, baseURL }, testInfo) => {
|
||||
const suffix = testInfo.project.name
|
||||
const day = new Date().toLocaleDateString('sv-SE')
|
||||
const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const day = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date())
|
||||
const focus = await createCountdown(request, baseURL!, `置顶倒数-${suffix}`, day, true)
|
||||
await createCountdown(request, baseURL!, `普通倒数-${suffix}`, day)
|
||||
const archived = await createCountdown(request, baseURL!, `归档倒数-${suffix}`, day)
|
||||
|
||||
@@ -10,6 +10,24 @@ async function mutate(request: APIRequestContext, baseURL: string, path: string,
|
||||
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<string, unknown> = {}) {
|
||||
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<string, number> = { 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 })
|
||||
}
|
||||
@@ -39,8 +57,55 @@ async function expectNotClipped(locator: Locator) {
|
||||
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
|
||||
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)
|
||||
@@ -60,7 +125,7 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq
|
||||
})
|
||||
expect(memoResponse.ok(), await memoResponse.text()).toBeTruthy()
|
||||
|
||||
const day = new Date().toLocaleDateString('sv-SE')
|
||||
const day = shanghaiToday().day
|
||||
const countdownTitle = `紧凑倒数-${suffix}`
|
||||
const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', {
|
||||
method: 'POST',
|
||||
@@ -78,8 +143,16 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq
|
||||
await searchInput.fill(`搜索保留-${suffix}`)
|
||||
const taskRow = page.locator('.task-row').filter({ hasText: taskTitle })
|
||||
await expect(taskRow).toHaveCount(1)
|
||||
await expectHeightInRange(taskRow, 64, 68)
|
||||
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()
|
||||
@@ -96,7 +169,7 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq
|
||||
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 expectHeightInRange(habitRow, 57, 59)
|
||||
await expectNotClipped(habitRow)
|
||||
await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click()
|
||||
const habitDetail = page.getByRole('dialog', { name: habitName })
|
||||
|
||||
Reference in New Issue
Block a user