refactor: unify task and habit list rows
ci / gitleaks (push) Successful in 10s
ci / docker (push) Successful in 3m45s

This commit is contained in:
2026-09-18 06:25:13 +08:00
parent e4e1d96b30
commit a4c1ac5978
8 changed files with 219 additions and 108 deletions
+10 -5
View File
@@ -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)
+34 -11
View File
@@ -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)
+77 -4
View File
@@ -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 })