diff --git a/frontend/e2e/mobile-journeys.spec.ts b/frontend/e2e/mobile-journeys.spec.ts index f37ce88..d677b58 100644 --- a/frontend/e2e/mobile-journeys.spec.ts +++ b/frontend/e2e/mobile-journeys.spec.ts @@ -20,42 +20,47 @@ async function assertInsideViewport(locator: Locator, page: Page) { expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1) } -test('Today paper composition has no overlap or horizontal overflow', async ({ page }) => { +test('Today plain-list composition has no overlap or horizontal overflow', async ({ page }) => { await page.goto('/') await expect(await bottomTab(page, '今天')).toHaveAttribute('aria-current', 'page') - const board = page.locator('.today-board') - const environment = page.locator('.today-environment') - const tracks = board.locator('.today-track') - await expect(board).toBeVisible() - await expect(environment).toBeVisible() - await expect(tracks).toHaveCount(2) - const geometry = await board.evaluate((node) => { - const boardRect = node.getBoundingClientRect() - const environmentRect = node.querySelector('.today-environment')!.getBoundingClientRect() - const trackRects = [...node.querySelectorAll('.today-track')].map(item => item.getBoundingClientRect()) + const context = page.locator('.today-context') + const environment = page.locator('.today-environment') + const title = page.locator('.today-page-title') + const remaining = page.locator('.today-remaining') + const filter = page.locator('.today-inline-filter') + const firstSection = page.locator('.today-section-toggle').first() + + for (const locator of [context, environment, title, remaining, filter, firstSection]) { + await expect(locator).toBeVisible() + } + + const geometry = await page.locator('main').evaluate((main) => { + const selectors = ['.today-environment', '.today-page-title', '.today-remaining', '.today-inline-filter', '.today-section-toggle'] + const rects = selectors.map(selector => main.querySelector(selector)!.getBoundingClientRect()) + const context = main.querySelector('.today-context')! + const contextRect = context.getBoundingClientRect() return { - boardRight: boardRect.right, - boardBottom: boardRect.bottom, - boardScrollWidth: node.scrollWidth, - boardClientWidth: node.clientWidth, - environmentBottom: environmentRect.bottom, - trackTops: trackRects.map(rect => rect.top), - trackRights: trackRects.map(rect => rect.right), - trackBottoms: trackRects.map(rect => rect.bottom), + mainScrollWidth: main.scrollWidth, + mainClientWidth: main.clientWidth, + contextScrollWidth: context.scrollWidth, + contextClientWidth: context.clientWidth, + contextLeft: contextRect.left, + contextRight: contextRect.right, + rects: rects.map(rect => ({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height })), } }) - expect(geometry.boardScrollWidth).toBeLessThanOrEqual(geometry.boardClientWidth) - expect(Math.min(...geometry.trackTops)).toBeGreaterThanOrEqual(geometry.environmentBottom - 1) - expect(Math.max(...geometry.trackRights)).toBeLessThanOrEqual(geometry.boardRight + 1) - expect(Math.max(...geometry.trackBottoms)).toBeLessThanOrEqual(geometry.boardBottom + 1) - for (const track of await tracks.all()) { - const head = track.locator('.today-track-head') - const labels = head.locator('strong, span') - await expect(labels).toHaveCount(2) - const boxes = await labels.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect())) - expect(Math.abs(boxes[0].top - boxes[1].top)).toBeLessThan(4) + expect(geometry.mainScrollWidth).toBeLessThanOrEqual(geometry.mainClientWidth) + expect(geometry.contextScrollWidth).toBeLessThanOrEqual(geometry.contextClientWidth) + for (const rect of geometry.rects) { + expect(rect.width).toBeGreaterThan(0) + expect(rect.height).toBeGreaterThan(0) + expect(rect.left).toBeGreaterThanOrEqual(geometry.contextLeft - 1) + expect(rect.right).toBeLessThanOrEqual(geometry.contextRight + 1) + } + for (let index = 0; index < geometry.rects.length - 1; index += 1) { + expect(geometry.rects[index].bottom).toBeLessThanOrEqual(geometry.rects[index + 1].top + 1) } }) test('Today task persists through detail, completion and reopen', async ({ page }, testInfo) => { diff --git a/frontend/e2e/today-plain-list.spec.ts b/frontend/e2e/today-plain-list.spec.ts new file mode 100644 index 0000000..6808ea1 --- /dev/null +++ b/frontend/e2e/today-plain-list.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from './fixtures' + +const widths = [721, 720, 390, 375] + +test('Today plain-list layout matches the approved responsive geometry', async ({ page }) => { + await page.goto('/') + for (const width of widths) { + await page.setViewportSize({ width, height: width <= 390 ? 844 : 900 }) + await expect(page.locator('.today-environment')).toBeVisible() + await expect(page.getByRole('button', { name: '刷新当前页面' })).toBeVisible() + await expect(page.getByRole('switch', { name: '显示已完成' })).toHaveCount(1) + await expect(page.locator('.today-environment__gold')).toContainText('Au99.99') + const metrics = await page.evaluate(() => { + const rect = (selector: string) => document.querySelector(selector)!.getBoundingClientRect() + const environment = document.querySelector('.today-environment')! + const calendar = document.querySelector('.today-environment__calendar')! + const weather = document.querySelector('.today-environment__weather')! + const gold = document.querySelector('.today-environment__gold')! + const title = document.querySelector('.today-page-title')! + const remaining = document.querySelector('.today-remaining')! + const filter = document.querySelector('.today-inline-filter')! + const firstSection = document.querySelector('.today-section-toggle')! + const main = document.querySelector('main.today-main')! + const content = rect('.today-context') + const orderedElements = [environment, title, remaining, filter, firstSection] + const ordered = orderedElements.map((element) => { + const box = element.getBoundingClientRect() + return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, width: box.width, height: box.height } + }) + const tolerance = 1 + const orderedPairs = ordered.slice(0, -1).map((current, index) => ({ + previousBottom: current.bottom, + nextTop: ordered[index + 1].top, + separated: current.bottom <= ordered[index + 1].top + tolerance, + })) + const positiveGeometry = ordered.every(box => box.width > 0 && box.height > 0) + const insideContent = ordered.every(box => box.left >= content.left - tolerance && box.right <= content.right + tolerance) + const visible = (element: HTMLElement) => { + const style = getComputedStyle(element) + return style.visibility !== 'hidden' && style.display !== 'none' && element.getBoundingClientRect().width > 0 + } + const rows = [...environment.children] + .filter((node): node is HTMLElement => node instanceof HTMLElement && visible(node) && !node.classList.contains('sr-only')) + .map(node => Math.round(node.getBoundingClientRect().top)) + const uniqueRows = [...new Set(rows)] + const collisions = [calendar, weather, gold].some((a, index, items) => items.slice(index + 1).some(b => { + const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect() + return ar.left < br.right && ar.right > br.left && ar.top < br.bottom && ar.bottom > br.top + })) + const secondary = [...environment.querySelectorAll('small')].map(node => ({ scrollWidth: node.scrollWidth, clientWidth: node.clientWidth, visible: visible(node) })) + return { + viewport: innerWidth, + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + mainOverflow: main.scrollWidth - main.clientWidth, + content: { left: content.left, right: content.right, width: content.width }, + environment: { ...environment.getBoundingClientRect().toJSON(), scrollWidth: environment.scrollWidth, clientWidth: environment.clientWidth }, + directions: { calendar: getComputedStyle(calendar).flexDirection, weather: getComputedStyle(weather).flexDirection, gold: getComputedStyle(gold).flexDirection }, + uniqueRows, + collisions, + secondary, + ordered, + orderedPairs, + positiveGeometry, + insideContent, + weatherText: weather.innerText, + goldText: gold.innerText, + goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label'), + } + }) + console.log(`[today-geometry][${width}] ${JSON.stringify(metrics)}`) + expect(metrics.documentOverflow).toBe(0) + expect(metrics.mainOverflow).toBeLessThanOrEqual(0) + expect(metrics.environment.scrollWidth).toBeLessThanOrEqual(metrics.environment.clientWidth) + expect(metrics.collisions).toBeFalsy() + expect(metrics.positiveGeometry).toBeTruthy() + expect(metrics.insideContent).toBeTruthy() + expect(metrics.orderedPairs.every(pair => pair.separated)).toBeTruthy() + expect(metrics.goldText).toContain('Au99.99') + expect(metrics.goldLabel).toMatch(/市场日期 \d{4}-\d{2}-\d{2}/) + expect(metrics.secondary.every(line => line.visible && line.scrollWidth <= line.clientWidth)).toBeTruthy() + if (width === 721) { + expect(metrics.uniqueRows).toHaveLength(1) + expect(metrics.environment.height).toBeLessThan(80) + } else { + expect(metrics.uniqueRows).toHaveLength(2) + expect(metrics.directions.calendar).toBe('row') + expect(metrics.directions.weather).toBe('column') + expect(metrics.directions.gold).toBe('column') + } + if (width >= 721) { + expect(metrics.content.width).toBeLessThanOrEqual(721) + expect(Math.abs(metrics.content.left - (width - metrics.content.width) / 2)).toBeLessThanOrEqual(2) + } + } +}) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 28d8c31..5318c69 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -321,12 +321,8 @@ const activeName = computed(() => { if (activeView.value === 'settings') return '设置与数据' return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱' }) -const todayHabitProgress = computed(() => `${todayHabitCompleted.value} / ${todayHabitTotal.value}`) const todayTaskRemaining = computed(() => Math.max(0, todayTaskTotal.value - todayTaskCompleted.value)) const todayHabitRemaining = computed(() => Math.max(0, todayHabitTotal.value - todayHabitCompleted.value)) -const progressWidth = (completed: number, total: number) => `${total > 0 ? Math.min(100, Math.round(completed / total * 100)) : 0}%` -const todayTaskProgressPercent = computed(() => progressWidth(todayTaskCompleted.value, todayTaskTotal.value)) -const todayHabitProgressPercent = computed(() => progressWidth(todayHabitCompleted.value, todayHabitTotal.value)) function persistTodaySectionCollapse() { writeTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY, todaySectionCollapse.value) } @@ -334,17 +330,6 @@ function toggleTodaySection(section: keyof TodaySectionCollapse) { todaySectionCollapse.value[section] = !todaySectionCollapse.value[section] persistTodaySectionCollapse() } -async function navigateTodaySection(section: 'tasks' | 'habits') { - if (todaySectionCollapse.value[section]) { - todaySectionCollapse.value[section] = false - persistTodaySectionCollapse() - } - await nextTick() - await new Promise((resolve) => requestAnimationFrame(() => resolve())) - const heading = document.getElementById(`today-${section}-heading`) - heading?.focus({ preventScroll: true }) - document.getElementById(`today-${section}`)?.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'start' }) -} function updateTodayHabitSummary(value: { total: number; completed: number }) { todayHabitTotal.value = value.total todayHabitCompleted.value = value.completed @@ -1519,12 +1504,12 @@ onUnmounted(() => { -
+
-

{{ activeName }}

+

{{ activeName }}

- +
@@ -1539,30 +1524,22 @@ onUnmounted(() => {