diff --git a/frontend/e2e/mobile-ui.spec.ts b/frontend/e2e/mobile-ui.spec.ts index f2ed2fa..03bc418 100644 --- a/frontend/e2e/mobile-ui.spec.ts +++ b/frontend/e2e/mobile-ui.spec.ts @@ -15,7 +15,7 @@ test('settings are continuous, fit viewport, and controls are touch sized', asyn await bottomTab(page, '设置').click() await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page') const groups = page.locator('.settings-group') - await expect(groups).toHaveCount(5) + await expect(groups).toHaveCount(4) const layout = await page.locator('.settings-sections').evaluate(element => { const groups = [...element.querySelectorAll(':scope > .settings-group')] return { diff --git a/frontend/e2e/ui-reduction-acceptance.spec.ts b/frontend/e2e/ui-reduction-acceptance.spec.ts new file mode 100644 index 0000000..0635c85 --- /dev/null +++ b/frontend/e2e/ui-reduction-acceptance.spec.ts @@ -0,0 +1,231 @@ +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.getByRole('button', { name: /展开菜单|收起菜单/ }).click() + await page.locator('.sidebar').getByRole('button', { name, exact: true }).click() +} + +async function expectNoHorizontalOverflow(page: Page, label: string, testInfo: { project: { name: string } }) { + const metrics = await page.evaluate(() => ({ + viewport: { width: innerWidth, height: innerHeight }, + document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth }, + body: { clientWidth: document.body.clientWidth, scrollWidth: document.body.scrollWidth }, + })) + console.log(`[qa-metrics][${testInfo.project.name}][${label}] ${JSON.stringify(metrics)}`) + expect(metrics.document.scrollWidth).toBe(metrics.document.clientWidth) + expect(metrics.body.scrollWidth).toBeLessThanOrEqual(metrics.body.clientWidth) +} + +async function taskRow(page: Page, title: string) { + const row = page.locator('.task-row').filter({ has: page.locator('.task-main strong', { hasText: title }) }) + await expect(row).toHaveCount(1) + return row +} + +async function createTask(request: APIRequestContext, baseURL: string, title: string, listId: string, dueAt?: string) { + const response = await mutate(request, baseURL, '/api/v1/tasks', { + method: 'POST', + data: { title, list_id: listId, ...(dueAt ? { due_at: dueAt, due_has_time: false } : {}) }, + }) + expect(response.ok(), await response.text()).toBeTruthy() + return response.json() as Promise<{ id: string }> +} + +async function createCountdown(request: APIRequestContext, baseURL: string, title: string, eventDate: string, pinned = false) { + const response = await mutate(request, baseURL, '/api/v1/countdowns', { + method: 'POST', + data: { title, event_date: eventDate, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false, pinned }, + }) + expect(response.ok(), await response.text()).toBeTruthy() + return response.json() as Promise<{ id: string }> +} + +async function box(locator: Locator) { + const value = await locator.boundingBox() + expect(value).not.toBeNull() + return value! +} + +test('task rows use the body for detail and Trash keeps distinct actions', async ({ page, request, baseURL }, testInfo) => { + const suffix = testInfo.project.name + 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}` + 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() + + await page.goto('/') + await page.getByRole('button', { name: '添加任务' }).click() + await page.getByLabel('任务名称').fill(todayTitle) + await page.getByRole('button', { name: '添加任务', exact: true }).click() + const todayRow = await taskRow(page, todayTitle) + expect(await todayRow.locator('.task-detail-trigger').count()).toBe(0) + const todayGeometry = await todayRow.evaluate(element => { + const row = element.getBoundingClientRect() + const main = element.querySelector('.task-main')!.getBoundingClientRect() + return { row: { x: row.x, width: row.width, right: row.right }, main: { x: main.x, width: main.width, right: main.right }, scrollWidth: (element as HTMLElement).scrollWidth, clientWidth: (element as HTMLElement).clientWidth } + }) + console.log(`[qa-metrics][${suffix}][today-task] ${JSON.stringify(todayGeometry)}`) + expect(todayGeometry.main.width).toBeGreaterThan(200) + expect(todayGeometry.scrollWidth).toBeLessThanOrEqual(todayGeometry.clientWidth) + await todayRow.locator('.task-main').click() + await expect(page.getByRole('dialog', { name: '任务详情' })).toBeVisible() + await page.getByRole('button', { name: '关闭详情' }).click() + + await openSidebarView(page, '收集箱') + const inboxRow = await taskRow(page, inboxTitle) + expect(await inboxRow.locator('.task-detail-trigger').count()).toBe(0) + await inboxRow.locator('.task-main').click() + await expect(page.getByRole('dialog', { name: '任务详情' })).toBeVisible() + 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) + await expectNoHorizontalOverflow(page, 'tasks-trash', testInfo) +}) + +test('Settings removes intro/empty danger and places mode-specific restore risk copy correctly', async ({ page }, testInfo) => { + await page.goto('/') + await bottomTab(page, '设置').click() + expect(await page.locator('.view-intro').count()).toBe(0) + await expect(page.locator('.settings-group')).toHaveCount(4) + expect(await page.locator('.settings-danger').count()).toBe(0) + + const input = page.locator('input[type=file]') + await input.setInputFiles({ name: 'acceptance.zip', mimeType: 'application/zip', buffer: Buffer.from('zip-placeholder') }) + await page.getByLabel('恢复方式').selectOption('replace') + const warning = page.locator('.restore-replace-warning') + await expect(warning).toBeVisible() + const restoreRow = page.getByText('恢复方式', { exact: true }).locator('..').locator('..') + const zipGeometry = { row: await box(restoreRow), warning: await box(warning) } + console.log(`[qa-metrics][${testInfo.project.name}][settings-zip] ${JSON.stringify(zipGeometry)}`) + expect(Math.abs(zipGeometry.warning.y - zipGeometry.row.y)).toBeLessThan(zipGeometry.row.height) + + await input.setInputFiles({ name: 'legacy.csv', mimeType: 'text/csv', buffer: Buffer.from('\ufefftitle\nlegacy') }) + await expect(page.getByLabel('恢复方式')).toHaveValue('merge') + await expect(page.getByLabel('恢复方式')).toBeDisabled() + await expect(page.locator('.restore-replace-warning')).toHaveCount(0) + await expect(page.locator('.backup-preflight.legacy')).not.toContainText(/替换现有数据|替换并恢复/) + await expectNoHorizontalOverflow(page, 'settings', testInfo) +}) + +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 focus = await createCountdown(request, baseURL!, `置顶倒数-${suffix}`, day, true) + await createCountdown(request, baseURL!, `普通倒数-${suffix}`, day) + const archived = await createCountdown(request, baseURL!, `归档倒数-${suffix}`, day) + expect((await mutate(request, baseURL!, `/api/v1/countdowns/${archived.id}`, { method: 'DELETE' })).ok()).toBeTruthy() + expect(focus.id).toBeTruthy() + + await page.goto('/') + await bottomTab(page, '习惯').click() + expect(await page.locator('.view-intro').count()).toBe(0) + await expectNoHorizontalOverflow(page, 'habits', testInfo) + + await bottomTab(page, '倒数日').click() + await expect(page.locator('.countdown-summary')).toContainText(/\d+ 个重要日子/) + expect(await page.locator('.countdown-hero').count()).toBe(0) + const activeRow = page.locator('.countdown-row').filter({ hasText: `普通倒数-${suffix}` }) + await expect(activeRow).toHaveCount(1) + expect(await activeRow.locator('.countdown-main > small').count()).toBe(1) + expect(await activeRow.locator('.countdown-badges, .pinned-icon').count()).toBe(0) + const rowText = await activeRow.innerText() + expect(rowText).not.toMatch(/农历|不重复|每周|每月|每年/) + + const toggle = page.getByRole('button', { name: /已归档(1)/ }) + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await expect(toggle).toHaveAttribute('aria-controls', 'archived-countdowns') + const toggleBox = await box(toggle) + expect(toggleBox.height).toBeGreaterThanOrEqual(44) + await toggle.click() + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + const archive = page.locator('#archived-countdowns') + const archiveMetrics = await archive.evaluate(element => { + const style = getComputedStyle(element) + const articles = [...element.querySelectorAll('article')].map(article => { + const itemStyle = getComputedStyle(article) + return { borderRadius: itemStyle.borderRadius, boxShadow: itemStyle.boxShadow, borderTop: itemStyle.borderTopWidth, borderBottom: itemStyle.borderBottomWidth } + }) + const buttons = [...element.querySelectorAll('button')].map(button => ({ text: button.innerText, ...button.getBoundingClientRect().toJSON() })) + return { container: { borderRadius: style.borderRadius, boxShadow: style.boxShadow }, articles, buttons } + }) + console.log(`[qa-metrics][${testInfo.project.name}][countdown-archive] ${JSON.stringify(archiveMetrics)}`) + expect(archiveMetrics.container.boxShadow).toBe('none') + expect(archiveMetrics.articles.every(item => item.borderRadius === '0px' && item.boxShadow === 'none')).toBeTruthy() + expect(archiveMetrics.buttons.every(item => item.width >= 44 && item.height >= 44)).toBeTruthy() + await expectNoHorizontalOverflow(page, 'countdowns', testInfo) +}) + +test('Memo mobile search is one-row, focus-safe, persistent when collapsed, and desktop-wide', async ({ page }, testInfo) => { + await page.goto('/') + await openSidebarView(page, '备忘录') + const toolbar = page.locator('.memo-toolbar') + const scope = page.locator('.memo-scope') + const toggle = page.getByRole('button', { name: '展开搜索备忘录' }) + const collapsedGeometry = { toolbar: await box(toolbar), scope: await box(scope), toggle: await box(toggle) } + console.log(`[qa-metrics][${testInfo.project.name}][memo-mobile-collapsed] ${JSON.stringify(collapsedGeometry)}`) + expect(collapsedGeometry.toggle.width).toBeGreaterThanOrEqual(44) + expect(collapsedGeometry.toggle.height).toBeGreaterThanOrEqual(44) + expect(Math.abs(collapsedGeometry.scope.y - collapsedGeometry.toggle.y)).toBeLessThanOrEqual(4) + expect(collapsedGeometry.toolbar.height).toBeLessThanOrEqual(52) + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await toggle.click() + const input = page.getByRole('textbox', { name: '搜索备忘录' }) + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await expect(input).toBeFocused() + await input.fill(`保留查询-${testInfo.project.name}`) + const openPanel = page.locator('#memo-search-panel') + const openMetrics = await openPanel.evaluate(element => { + const rect = element.getBoundingClientRect() + return { x: rect.x, right: rect.right, width: rect.width, scrollWidth: (element as HTMLElement).scrollWidth, clientWidth: (element as HTMLElement).clientWidth } + }) + console.log(`[qa-metrics][${testInfo.project.name}][memo-mobile-open] ${JSON.stringify(openMetrics)}`) + expect(openMetrics.x).toBeGreaterThanOrEqual(0) + expect(openMetrics.right).toBeLessThanOrEqual(page.viewportSize()!.width + 1) + expect(openMetrics.scrollWidth).toBeLessThanOrEqual(openMetrics.clientWidth) + + await toggle.click() + await expect(openPanel).toBeHidden() + await expect(toggle).toBeFocused() + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await toggle.click() + await expect(input).toHaveValue(`保留查询-${testInfo.project.name}`) + await expect(input).toBeFocused() + await expectNoHorizontalOverflow(page, 'memo-mobile', testInfo) + + await page.setViewportSize({ width: 1440, height: 900 }) + await expect(openPanel).toBeVisible() + const desktopMetrics = await openPanel.evaluate(element => { + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + return { viewport: { width: innerWidth, height: innerHeight }, x: rect.x, y: rect.y, width: rect.width, display: style.display, hidden: (element as HTMLElement).hidden, documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth } + }) + console.log(`[qa-metrics][${testInfo.project.name}][memo-desktop] ${JSON.stringify(desktopMetrics)}`) + expect(desktopMetrics.hidden).toBeFalsy() + expect(desktopMetrics.width).toBeGreaterThanOrEqual(320) + expect(desktopMetrics.documentOverflow).toBe(0) + await expect(toggle).toBeHidden() +}) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2c2c5a9..c83f76f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1523,7 +1523,7 @@ onUnmounted(() => {
{{node.task.title}}{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}{{['','低','中','高'][node.task.priority]}}
- +
已隐藏已完成任务
diff --git a/frontend/src/CountdownPanel.test.ts b/frontend/src/CountdownPanel.test.ts index 9f0a101..e351117 100644 --- a/frontend/src/CountdownPanel.test.ts +++ b/frontend/src/CountdownPanel.test.ts @@ -5,6 +5,7 @@ import CountdownPanel from './CountdownPanel.vue' import { invalidateCountdownCache } from './lib/mvp-utils' const source = readFileSync('src/CountdownPanel.vue', 'utf8') +const css = readFileSync('src/style.css', 'utf8') const cleanups: Array<() => void> = [] const countdown = { id:'c1', title:'发布日', event_date:'2026-09-20', display_date:'2026-09-20', kind:'countdown', repeat_rule:'none', icon:'', pinned:false, archived_at:null, days:4, calendar_mode:'solar', lunar_year:null, lunar_month:null, lunar_day:null, ignore_year:false, lunar_text:null, updated_at:'v1' } const countdownB = { ...countdown, id:'c2', title:'旅行日', event_date:'2026-09-24', display_date:'2026-09-24', days:8 } @@ -108,6 +109,23 @@ describe('countdown modal accessibility', () => { expect(source).not.toContain('

{{group.title}}

') }) + it('keeps active rows compact and archive controls accessible', () => { + expect(source).not.toContain('class="countdown-hero"') + expect(source).toContain('class="countdown-summary"') + expect(source).not.toContain('