forked from yvv/decision
- Le vote se déplie sous l'article, façon accordéon, l'URL pour état (?vote=) :
niveau d'eau, totaux, seuil et voix manquantes, ton vote et où il en est
(enregistré, inscrit sur la chaîne, en attente, à reconfirmer, non compté),
inertie et formule. Le reste d'une version (coquilles, traductions,
signalements, historique) passe dans l'onglet « Détails » du panneau.
- Le panier : article par article, « tout pour / tout contre » par section ou
pour tout le document, jamais pour un article à plusieurs versions ; un seul
geste (une écriture, ou une transaction).
- La signature : le portefeuille de la page (injectedWeb3), polkadot-api chargé
au moment de signer ; sans portefeuille, Ğecko par lien et QR ; la page
attend la confirmation. Un bulletin que le portefeuille n'a pas signé est
lâché aussitôt (DELETE /api/ballots/{id}).
- Pastilles d'ambiance : flèches dans le groupe, anneau de focus pointillé à
la couleur d'accent de la page (3:1 au moins), distinct du choix.
- e2e : chaîne simulée (indexeur et nœud), faux portefeuille ; C21–C24, et
C4, C5, C6, C11, C17, C19, C20, M1 mis à jour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
9.6 KiB
TypeScript
171 lines
9.6 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
||
import { chain, CHARLIE, DOC, person, signIn } from './helpers';
|
||
|
||
const ML = '/g/monnaie-libre';
|
||
const path = (p: string) => (u: URL) => u.pathname === p;
|
||
|
||
async function background(page: Page, selector: string) {
|
||
return page.locator(selector).evaluate((el) => getComputedStyle(el).backgroundColor);
|
||
}
|
||
|
||
test('C13 — logo « Clé de voûte » sans tampon, quatre ambiances, icônes', async ({ browser }) => {
|
||
// First visit: the device's light or dark setting picks pierre or nuit.
|
||
for (const [scheme, expected] of [['light', 'pierre'], ['dark', 'nuit']] as const) {
|
||
const ctx = await browser.newContext({ locale: 'fr-FR', colorScheme: scheme });
|
||
const page = await ctx.newPage();
|
||
await page.goto('/');
|
||
await expect(page.locator('html')).toHaveAttribute('data-mood', expected);
|
||
await ctx.close();
|
||
}
|
||
const ctx = await browser.newContext({ locale: 'fr-FR', colorScheme: 'light', viewport: { width: 1360, height: 900 } });
|
||
const page = await ctx.newPage();
|
||
await page.goto('/');
|
||
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
|
||
const brand = nav.locator('a.brand');
|
||
await expect(brand).toHaveAccessibleName('libreDecision');
|
||
await expect(brand.locator('svg')).toBeVisible();
|
||
await expect(page.locator('.stamp')).toHaveCount(0);
|
||
expect(await brand.locator('svg').evaluate((el) => getComputedStyle(el).transform)).toBe('none');
|
||
// The seal stays where it is.
|
||
await expect(page.locator('svg.seal')).toBeVisible();
|
||
const moods = nav.getByRole('radiogroup', { name: 'Ambiance' });
|
||
for (const [name, mood, cle] of [
|
||
['Calcaire', 'calcaire', '#a8661e'],
|
||
['Ardoise', 'ardoise', '#e0a65c'],
|
||
['Nuit', 'nuit', '#6ec1c6'],
|
||
['Pierre', 'pierre', '#16808c'],
|
||
]) {
|
||
await moods.getByRole('radio', { name }).click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-mood', mood);
|
||
await expect(moods.getByRole('radio', { name })).toHaveAttribute('aria-checked', 'true');
|
||
expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--cle').trim())).toBe(cle);
|
||
}
|
||
await moods.getByRole('radio', { name: 'Ardoise' }).click();
|
||
await page.reload();
|
||
await expect(page.locator('html')).toHaveAttribute('data-mood', 'ardoise');
|
||
// Favicon and icons.
|
||
for (const href of ['/favicon.svg', '/apple-touch-icon.png', '/icon-192.png', '/icon-512.png', '/manifest.webmanifest']) {
|
||
expect((await page.request.get(href)).status(), href).toBe(200);
|
||
}
|
||
await expect(page.locator('link[rel="icon"][type="image/svg+xml"]')).toHaveAttribute('href', /favicon\.svg$/);
|
||
await expect(page.locator('link[rel="apple-touch-icon"]')).toHaveAttribute('href', /apple-touch-icon\.png$/);
|
||
await ctx.close();
|
||
});
|
||
|
||
test('C14 — la page d’un groupe : les quatre piliers, puis « En cours »', async ({ page }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto(ML);
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Monnaie libre');
|
||
const pillars = page.locator('.pillars .pillar');
|
||
await expect(pillars).toHaveCount(4);
|
||
for (const name of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) {
|
||
await expect(pillars.filter({ hasText: name })).toHaveCount(1);
|
||
}
|
||
await expect(pillars.filter({ hasText: 'Docs de référence' })).toContainText('ouvert');
|
||
await expect(pillars.filter({ hasText: 'à venir' })).toHaveCount(3);
|
||
const current = page.locator('section', { has: page.getByRole('heading', { name: 'En cours' }) });
|
||
await current.getByRole('link', { name: /Acte d'engagement Certification/ }).click();
|
||
await expect(page).toHaveURL(path(DOC));
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification");
|
||
});
|
||
|
||
test('C15 — le menu : le nombre de docs, surligné seulement dedans, seul Docs réagit au survol', async ({ browser }) => {
|
||
const page = await person(browser, 'Lina');
|
||
// Nothing remembered yet: the tools lead home.
|
||
await page.goto('/');
|
||
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
|
||
const tool = (name: string) => nav.locator('.tool', { hasText: name });
|
||
await expect(tool('Docs de référence')).toHaveAttribute('href', '/');
|
||
await page.goto(ML);
|
||
await expect(tool('Docs de référence').locator('.count')).toHaveText('1');
|
||
await expect(tool('Docs de référence')).toHaveAccessibleName('Docs de référence, 1 doc en cours');
|
||
await expect(nav.locator('.tool[aria-current="page"]')).toHaveCount(0);
|
||
await expect(nav.locator('.tool.off')).toHaveCount(3);
|
||
// Hover: Docs reacts, a tool to come does not.
|
||
const docsBefore = await background(page, 'nav .tool.docs');
|
||
await tool('Docs de référence').hover();
|
||
await expect.poll(() => background(page, 'nav .tool.docs')).not.toBe(docsBefore);
|
||
const mandatesBefore = await background(page, 'nav .tool.mandats');
|
||
await tool('Mandats').hover();
|
||
await page.waitForTimeout(400);
|
||
expect(await background(page, 'nav .tool.mandats')).toBe(mandatesBefore);
|
||
// Inside a tool, it alone is highlighted.
|
||
await tool('Docs de référence').click();
|
||
await expect(page).toHaveURL(path(`${ML}/docs`));
|
||
await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Docs de référence/);
|
||
await page.goto(DOC);
|
||
await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Docs de référence/);
|
||
// A tool to come opens its page, which shows what is coming, dimmed.
|
||
await tool('Mandats').click();
|
||
await expect(page).toHaveURL(path(`${ML}/mandats`));
|
||
await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Mandats/);
|
||
// Outside a group, the tools aim at the last group opened.
|
||
await page.goto('/');
|
||
await expect(tool('Docs de référence')).toHaveAttribute('href', `${ML}/docs`);
|
||
await expect(tool('Protocoles')).toHaveAttribute('href', `${ML}/protocoles`);
|
||
await expect(nav.locator('.tool[aria-current="page"]')).toHaveCount(0);
|
||
});
|
||
|
||
test('C16 — Docs, Mandats, Protocoles : « En cours » et « À venir », éteints', async ({ browser }) => {
|
||
const page = await person(browser, 'Noé');
|
||
// Old addresses, before any group: home.
|
||
await page.goto('/mandats');
|
||
await expect(page).toHaveURL(path('/'));
|
||
await page.goto(`${ML}/docs`);
|
||
const section = (name: string) => page.locator('section', { has: page.getByRole('heading', { name, exact: true }) });
|
||
await expect(section('En cours').getByRole('link', { name: /Acte d'engagement Certification/ })).toBeVisible();
|
||
const soon = section('À venir');
|
||
await expect(soon.locator('li')).toHaveText(["Acte d'engagement forgerons"]);
|
||
await expect(soon.getByRole('link')).toHaveCount(0);
|
||
await page.goto(`${ML}/mandats`);
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Mandats');
|
||
await expect(section('À venir').locator('li')).toHaveText(['Administration forum Duniter', 'Administration forum Monnaie libre']);
|
||
await expect(section('À venir').getByRole('link')).toHaveCount(0);
|
||
await page.goto(`${ML}/protocoles`);
|
||
await expect(section('À venir').locator('li')).toHaveText(['Runtime upgrade — décision on-chain']);
|
||
// Old addresses lead to the tools of the current group.
|
||
for (const tool of ['decisions', 'mandats', 'protocoles']) {
|
||
await page.goto(`/${tool}`);
|
||
await expect(page).toHaveURL(path(`${ML}/${tool}`));
|
||
}
|
||
// In English and Spanish, what is coming follows.
|
||
await page.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('button', { name: 'ES' }).click();
|
||
await page.goto(`${ML}/protocoles`);
|
||
await expect(page.locator('section.soon li')).toHaveText(['Runtime upgrade — decisión on-chain']);
|
||
await page.getByRole('navigation', { name: 'Caja de herramientas' }).getByRole('button', { name: 'FR' }).click();
|
||
});
|
||
|
||
test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans ton vote', async ({ browser }) => {
|
||
const page = await person(browser, 'Maëlle');
|
||
await page.goto(`${ML}/decisions`);
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Décisions');
|
||
const rows = page.locator('ul.lines > li');
|
||
await expect(rows).toHaveCount(1);
|
||
await expect(rows.first()).toContainText("Acte d'engagement Certification");
|
||
await expect(rows.first()).toContainText('33 modules à voter');
|
||
await expect(rows.first()).toContainText('dont 33 sans ton vote');
|
||
// A vote on one version of E3 (it has a variant): one module fewer without her vote. « Contre »
|
||
// counts as much, and leaves the previews of C9 unchanged (the server is shared). In Monnaie
|
||
// libre the vote goes through the Ğ1 chain: the ballot, then what her wallet (Charlie's
|
||
// account) would send, on the simulated chain.
|
||
const { doc, node } = await chain(page);
|
||
const e3 = doc.units.find((u) => u.code === 'E3');
|
||
const r = await page.request.post(`/api/docs/${doc.doc.id}/ballots`, {
|
||
data: { lines: [{ proposalId: e3?.proposals[1]?.id, choice: 'against' }], author: CHARLIE.address },
|
||
headers: { 'x-requested-with': 'libredecision' },
|
||
});
|
||
expect(r.ok()).toBe(true);
|
||
const { ballot } = await r.json();
|
||
const fields = { address: CHARLIE.address, dest: ballot.account, amount: String(ballot.amount), remark: ballot.comment, signature: '0x01' };
|
||
const extrinsic = `0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`;
|
||
await page.request.post(node, { data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [extrinsic] } });
|
||
await expect
|
||
.poll(async () => (await (await page.request.get(`/api/ballots/${ballot.id}`)).json()).ballot.status, { timeout: 15_000 })
|
||
.toBe('confirmed');
|
||
await page.reload();
|
||
await expect(rows.first()).toContainText('33 modules à voter');
|
||
await expect(rows.first()).toContainText('dont 32 sans ton vote');
|
||
await rows.first().getByRole('link').click();
|
||
await expect(page).toHaveURL(path(DOC));
|
||
});
|