|
| 1 | +import { randomBytes } from 'node:crypto'; |
| 2 | +import { expect, test, type Locator, type Page, type TestInfo } from '@playwright/test'; |
| 3 | +import { openPlaces } from './places.ts'; |
| 4 | + |
| 5 | +// #384: the duplicates page against the design guide. A group is a list sheet (surface tone, `--border` hairline, rows |
| 6 | +// split by `--border`, never the teal `--line`), its size and dates are figures in the mono face, each copy's checkbox |
| 7 | +// sits in a 44 px label on the phone, and the "Where to look" legend starts at the column's edge. Dark theme, where |
| 8 | +// `--line` is the bright teal. |
| 9 | +const tag = (info: TestInfo) => `${info.project.name}-${info.repeatEachIndex}-${info.retry}`; |
| 10 | +const mobile = (info: TestInfo) => info.project.name.startsWith('mobile'); |
| 11 | +const style = (l: Locator, prop: string) => l.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), prop); |
| 12 | +const MONO = /IBM Plex Mono/; |
| 13 | + |
| 14 | +async function headers(page: Page) { |
| 15 | + const me = await (await page.request.get('/api/v1/me')).json() as { csrfToken: string }; |
| 16 | + return { 'x-csrf-token': me.csrfToken, origin: new URL(page.url()).origin }; |
| 17 | +} |
| 18 | +async function putTheme(page: Page, theme: 'dark' | 'system') { |
| 19 | + const r = await page.request.put('/api/v1/settings', { data: { locale: null, theme }, headers: await headers(page) }); |
| 20 | + expect(r.status(), await r.text()).toBe(200); |
| 21 | +} |
| 22 | +async function put(page: Page, path: string, data: Buffer) { |
| 23 | + const r = await page.request.put(`/api/v1/sources/home/content?path=${encodeURIComponent(path)}&onCollision=fail`, { |
| 24 | + headers: { ...await headers(page), 'content-type': 'application/octet-stream' }, data }); |
| 25 | + expect(r.status()).toBe(201); |
| 26 | +} |
| 27 | +/** A CSS colour expression as the page computes it in the current theme. */ |
| 28 | +async function resolved(page: Page, css: string): Promise<string> { |
| 29 | + return page.evaluate((value) => { |
| 30 | + const probe = document.createElement('div'); |
| 31 | + probe.style.color = value; |
| 32 | + document.body.append(probe); |
| 33 | + const out = getComputedStyle(probe).color; |
| 34 | + probe.remove(); |
| 35 | + return out; |
| 36 | + }, css); |
| 37 | +} |
| 38 | + |
| 39 | +test.afterEach(async ({ page }) => { await putTheme(page, 'system'); }); |
| 40 | + |
| 41 | +test('#384 duplicates: groups are list sheets, figures in mono, 44 px checkbox labels on the phone', async ({ page }, info) => { |
| 42 | + await page.goto('/'); |
| 43 | + await expect(page.getByRole('navigation', { name: /Pfad|Path/ })).toBeVisible(); |
| 44 | + await putTheme(page, 'dark'); |
| 45 | + // Under one parent that sorts last, so the home's root keeps other tests' rows in place. |
| 46 | + const dir = `zz-duplicates-384/dups-${tag(info)}`; |
| 47 | + const h = await headers(page); |
| 48 | + expect([201, 409]).toContain((await page.request.post('/api/v1/sources/home/mkdir', { headers: h, data: { path: '/zz-duplicates-384' } })).status()); |
| 49 | + expect((await page.request.post('/api/v1/sources/home/mkdir', { headers: h, data: { path: `/${dir}` } })).status()).toBe(201); |
| 50 | + const same = randomBytes(900); |
| 51 | + const first = `a-${tag(info)}.bin`; |
| 52 | + const second = `b-${tag(info)}.bin`; |
| 53 | + await put(page, `/${dir}/${first}`, same); |
| 54 | + await put(page, `/${dir}/${second}`, same); |
| 55 | + |
| 56 | + await page.reload(); |
| 57 | + const nav = await openPlaces(page); |
| 58 | + await nav.getByRole('link', { name: /^\s*(Doppelte Dateien|Duplicates)\s*$/ }).click(); |
| 59 | + await expect(page.getByRole('heading', { name: /^(Doppelte Dateien|Duplicates)$/, level: 1 })).toBeVisible(); |
| 60 | + |
| 61 | + // The legend starts where the checkboxes below it start. |
| 62 | + const sources = page.locator('.duplicates-sources'); |
| 63 | + const legendLeft = await sources.locator('legend').evaluate((el) => el.getBoundingClientRect().left); |
| 64 | + const fieldsetLeft = await sources.evaluate((el) => el.getBoundingClientRect().left); |
| 65 | + expect(legendLeft).toBeCloseTo(fieldsetLeft, 0); |
| 66 | + |
| 67 | + await page.getByRole('button', { name: /^(Doppelte Dateien suchen|Find duplicates|Erneut suchen|Search again)$/ }).click(); |
| 68 | + const group = page.locator('.duplicate-group', { has: page.getByText(second, { exact: true }) }); |
| 69 | + await expect(group).toBeVisible({ timeout: 30_000 }); |
| 70 | + await expect(group.locator('.duplicate-copy')).toHaveCount(2); |
| 71 | + |
| 72 | + // A list sheet: surface tone and the warm hairline, rows split by it too; the teal `--line` is not a sheet edge. |
| 73 | + const line = await resolved(page, 'var(--line)'); |
| 74 | + const border = await resolved(page, 'var(--border)'); |
| 75 | + expect(border).not.toBe(line); |
| 76 | + expect(await style(group, 'border-top-color')).toBe(border); |
| 77 | + expect(await style(group, 'background-color')).toBe(await resolved(page, 'var(--surface)')); |
| 78 | + expect(await style(group.locator('.duplicate-copy').nth(1), 'border-top-color')).toBe(border); |
| 79 | + |
| 80 | + // Figures in the mono face, inside their sentences. |
| 81 | + const sizeFigure = group.getByRole('heading').locator('.value-mono'); |
| 82 | + await expect(sizeFigure).toHaveText(/900/); |
| 83 | + expect(await style(sizeFigure, 'font-family')).toMatch(MONO); |
| 84 | + expect(await style(group.getByRole('heading'), 'font-family')).not.toMatch(MONO); |
| 85 | + const dateFigure = group.locator('.duplicate-copy').first().locator('.origin .value-mono'); |
| 86 | + await expect(dateFigure).toHaveText(/\d/); |
| 87 | + expect(await style(dateFigure, 'font-family')).toMatch(MONO); |
| 88 | + |
| 89 | + // Each checkbox sits in a label; on the phone that label is a 44 px target. |
| 90 | + const checkbox = group.getByRole('checkbox', { name: new RegExp(second.replace(/\./g, '\\.')) }); |
| 91 | + const label = checkbox.locator('xpath=ancestor::label[1]'); |
| 92 | + await expect(label).toHaveClass(/check-target/); |
| 93 | + if (mobile(info)) { |
| 94 | + const b = (await label.boundingBox())!; |
| 95 | + expect(b.height).toBeGreaterThanOrEqual(44); |
| 96 | + expect(b.width).toBeGreaterThanOrEqual(44); |
| 97 | + } |
| 98 | + // The label hands a tap to its checkbox. |
| 99 | + await expect(checkbox).not.toBeChecked(); |
| 100 | + await label.click(); |
| 101 | + await expect(checkbox).toBeChecked(); |
| 102 | +}); |
0 commit comments