Files
decision/web/e2e/mobile.spec.ts
T

180 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> {
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<number> {
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<string[]> {
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');
});