import { expect, test, type Locator } from '@playwright/test'; import { DOC, expectWelcome, propose, seedsInForce, shot, signIn, TEST_DOC, testDoc } from './helpers'; /** WCAG contrast ratio of two CSS colours `rgb(r, g, b)`. */ function contrast(a: string, b: string): number { const lum = (c: string) => { const [r, g, bl] = (c.match(/[\d.]+/g) ?? []).slice(0, 3).map((x) => { const v = Number(x) / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (bl ?? 0); }; const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); return ((hi ?? 0) + 0.05) / ((lo ?? 0) + 0.05); } /** The colour the ring of a swatch is drawn on: the first opaque background around it. */ async function backdrop(el: Locator): Promise { return el.evaluate((node) => { for (let n: Element | null = node.parentElement; n; n = n.parentElement) { const bg = getComputedStyle(n).backgroundColor; if (bg && bg !== 'transparent' && !/rgba\(.*,\s*0\)$/.test(bg)) return bg; } return getComputedStyle(document.body).backgroundColor; }); } test('M1 — mobile : logo dans la barre, l’accueil sous la voûte, menu en tiroir, vote déplié, panier, réglages lisibles, pastilles au clavier', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto('/'); const brand = page.locator('header.topbar a.brand'); await expect(brand).toHaveAccessibleName('libreDecision'); await expect(brand.locator('svg')).toBeVisible(); await expect(page.locator('a.root')).toContainText('Monnaie libre'); // The welcome on this phone, then on a narrow one (360): the arch still shelters the three points. await expectWelcome(page); const size = page.viewportSize(); await page.setViewportSize({ width: 360, height: 780 }); await expectWelcome(page); if (size) await page.setViewportSize(size); await page.goto(DOC); await page.getByRole('button', { name: 'Menu' }).click(); const drawer = page.getByRole('dialog', { name: 'Boîte à outils' }); await expect(drawer.locator('.tool', { hasText: 'Protocoles' })).toBeVisible(); await expect(drawer.locator('.tool', { hasText: 'Docs de référence' }).locator('.count')).toHaveText('1'); const moods = drawer.getByRole('radiogroup', { name: 'Ambiance' }); await expect(moods.getByRole('radio')).toHaveCount(4); // The swatches, by keyboard: one stop for Tab, the arrows move and choose; the focus ring // reads at 3:1 or more on the page whatever the swatch, and is not the mark of the chosen one. await expect(moods.locator('[tabindex="0"]')).toHaveCount(1); const start = (await page.locator('html').getAttribute('data-mood')) ?? ''; await moods.getByRole('radio', { checked: true }).focus(); for (let i = 0; i < 4; i++) { await page.keyboard.press('ArrowRight'); const now = moods.getByRole('radio', { checked: true }); await expect(now).toBeFocused(); await expect(moods.locator('[tabindex="0"]')).toHaveCount(1); const ring = await now.evaluate((el) => { const cs = getComputedStyle(el); return { style: cs.outlineStyle, color: cs.outlineColor, width: parseFloat(cs.outlineWidth) }; }); expect(ring.style).toBe('dashed'); expect(ring.width).toBeGreaterThanOrEqual(2); const mood = await page.locator('html').getAttribute('data-mood'); expect(contrast(ring.color, await backdrop(moods)), `${mood}: ${ring.color}`).toBeGreaterThanOrEqual(3); if (i === 0) await shot(page, '7-pastilles-focus-mobile'); } await expect(page.locator('html')).toHaveAttribute('data-mood', start); await page.keyboard.press('ArrowLeft'); await expect(page.locator('html')).not.toHaveAttribute('data-mood', start); await page.keyboard.press('ArrowRight'); await expect(page.locator('html')).toHaveAttribute('data-mood', start); // The chosen one, without the focus: its own mark, a ring of ink, never the dashed one. const chosen = moods.getByRole('radio', { checked: true }); await chosen.evaluate((el) => (el as HTMLElement).blur()); const mark = await chosen.evaluate((el) => ({ outline: getComputedStyle(el).outlineStyle, shadow: getComputedStyle(el).boxShadow })); expect(mark.outline).toBe('none'); expect(mark.shadow).toContain('0px 0px 0px 2px'); await drawer.getByRole('button', { name: 'Fermer' }).first().click(); // The vote unfolds under the article, on a phone too (a seed proposal, ADR 0011: nothing is // in force at the start, the seed proposals open the text — lot U). const first = page.locator('section.text article.unit').first(); const toggle = first.getByRole('button', { name: 'Le vote' }); // A finger's target, and nothing to hover with: the gestures are in sight. expect((await toggle.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44); await toggle.click(); const fold = page.locator('section.text section.fold'); // Level 2, the quorum 12: « sur 12 votants » before any vote; the desktop controls may have voted. await expect(fold.locator('.need')).toHaveText(/^9 oui (sur 12 votants pour passer|pour passer · le quorum la bloque)$/); // The settings of the formula read on a phone: the table of the five levels holds in the sheet. await fold.getByRole('button', { name: 'Réglages de la formule' }).click(); const panel = page.locator('aside.panel'); await expect(panel.getByRole('heading', { name: 'Réglages de la formule' })).toBeInViewport(); await expect(panel.locator('table tbody tr')).toHaveCount(5); const fits = await panel.locator('.scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1); expect(fits).toBe(true); await panel.getByRole('button', { name: 'Fermer le panneau' }).click(); // The basket holds on a phone: its send button stays in sight, nothing overflows. await page.getByRole('button', { name: /^Voter en lot/ }).click(); await expect(panel.getByRole('heading', { name: 'Panier de vote' })).toBeVisible(); await panel.getByRole('group', { name: 'Ton choix pour « Unicité du compte »' }).getByRole('button', { name: 'Pour' }).click(); await panel.getByRole('group', { name: 'Ton choix pour « Réciprocité »' }).getByRole('button', { name: 'Contre' }).click(); const send = panel.getByRole('button', { name: 'Signer et envoyer (2)' }); await expect(send).toBeInViewport(); const holds = await panel.locator('.body').evaluate((el) => el.scrollWidth <= el.clientWidth + 1); expect(holds).toBe(true); await shot(page, '3-panier-mobile'); // Taken back out of the basket: nothing was sent. await panel.getByRole('group', { name: 'Tout le document' }).getByRole('button', { name: 'Vider' }).click(); await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible(); }); /** The height of a target, in CSS pixels. */ async function height(target: Locator): Promise { return (await target.boundingBox())?.height ?? 0; } /** Every gesture in sight under `root` — buttons, links, summaries — under 44 px high. */ async function small(root: Locator): Promise { return root.evaluate((el) => [...el.querySelectorAll('button, a[href], summary, [role="radio"]')] .filter((b) => b.checkVisibility({ visibilityProperty: true }) && b.getClientRects().length > 0) .map((b) => ({ what: (b.textContent || b.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim(), h: b.getBoundingClientRect().height })) .filter((x) => x.h < 44) .map((x) => `${x.what} (${Math.round(x.h)} px)`), ); } test('M2 — téléphone : les gestes du lecteur font au moins 44 px, les trois lectures se lisent en entier', async ({ page, browser }) => { await seedsInForce(browser); await signIn(page, 'Chiara'); // In the test group: a rival of the Raison d'être (in force), so that its vote names its // « Autres versions », and an article of hers, whose quorum she sets before any vote. const doc = await testDoc(page); const i1 = doc.units.find((u) => u.code === 'I1'); const r = await page.request.post(`/api/docs/${doc.id}/proposals`, { data: { kind: 'text', unitId: i1?.id, title: "Raison d'être", body: 'Une clé, une personne vivante : la certification ne garantit rien d’autre.', submit: true }, headers: { 'x-requested-with': 'libredecision' }, }); expect(r.ok()).toBe(true); const mine = await propose(page, 'Cibles au doigt', 'Je propose cet article pour régler son quorum au doigt.'); await page.goto(TEST_DOC); // The three readings, each whole on the screen: « Si tout passe » is never cut (lot U2). const views = page.getByRole('radiogroup', { name: 'Lire' }); const frame = await views.boundingBox(); expect.soft(await views.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); for (const reading of await views.getByRole('radio').all()) { const b = await reading.boundingBox(); expect.soft(b && frame && b.x >= frame.x - 1 && b.x + b.width <= frame.x + frame.width + 1, await reading.textContent()).toBe(true); } await expect.soft(views.getByRole('radio', { name: 'Si tout passe' })).toBeInViewport({ ratio: 1 }); await shot(page, 'm2-1-lectures'); // « Tout déplier », then the vote of the Raison d'être: every gesture a finger's target. expect.soft(await height(page.getByRole('button', { name: 'Tout déplier' }))).toBeGreaterThanOrEqual(44); const first = page.locator('section.text article.unit').first(); await first.getByRole('button', { name: 'Le vote' }).click(); const fold = first.locator('section.fold'); const others = fold.getByRole('button', { name: 'Autres versions (1)' }); expect.soft(await height(others)).toBeGreaterThanOrEqual(44); await others.click(); await fold.getByText('La formule du seuil').click(); expect.soft(await small(fold)).toEqual([]); await shot(page, 'm2-2-vote-deplie'); // Her own article: « Fixer ce quorum » and the rest of its vote. await page.goto(`${TEST_DOC}?vote=${mine}`); const hers = page.getByRole('region', { name: 'Le vote de « Cibles au doigt »' }); const set = hers.getByRole('button', { name: 'Fixer ce quorum' }); expect.soft(await height(set)).toBeGreaterThanOrEqual(44); await hers.getByText("Proposer un changement d'inertie").click(); expect.soft(await small(hers)).toEqual([]); await set.scrollIntoViewIfNeeded(); await shot(page, 'm2-3-quorum'); });