diff --git a/frontend/e2e/task-row-rhythm.spec.ts b/frontend/e2e/task-row-rhythm.spec.ts new file mode 100644 index 0000000..8c2d5ea --- /dev/null +++ b/frontend/e2e/task-row-rhythm.spec.ts @@ -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[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('button')].map(control => { + const rect = control.getBoundingClientRect() + return { width: rect.width, height: rect.height } + }) + const tail = element.querySelector('.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 = {}) => { + 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) + } +}) diff --git a/frontend/e2e/visual-polish.spec.ts b/frontend/e2e/visual-polish.spec.ts new file mode 100644 index 0000000..1b981a3 --- /dev/null +++ b/frontend/e2e/visual-polish.spec.ts @@ -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[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) +}) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 82d2ed7..5439d6e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -76,6 +76,7 @@ let listHandlePending: { id: string; pointer: ListDragPointer } | undefined let suppressListClickId = '' const query = ref('') const searchInput = ref(null) +const taskSearchToggle = ref(null) const mobileSearchOpen = ref(false) const searchPullDistance = ref(0) let searchTouchStartY: number | null = null @@ -1344,6 +1345,21 @@ function openTaskSearch() { searchPullDistance.value = 0 void nextTick(() => searchInput.value?.focus()) } +function closeTaskSearch() { + if (!isMobileSearchLayout()) return + mobileSearchOpen.value = false + searchPullDistance.value = 0 + void nextTick(() => taskSearchToggle.value?.focus()) +} +function toggleTaskSearch() { + if (mobileSearchOpen.value) closeTaskSearch() + else openTaskSearch() +} +function handleTaskSearchKeydown(event: KeyboardEvent) { + if (event.key !== 'Escape' || !isMobileSearchLayout()) return + event.preventDefault() + closeTaskSearch() +} function startSearchPull(event: TouchEvent) { if (!taskSearchAvailable.value || !isMobileSearchLayout()) return const target = event.target as Element | null @@ -1509,9 +1525,10 @@ onUnmounted(() => { -
+
+ - +