Files
decision/web/e2e/vote.spec.ts
T
YvvandClaude Opus 5.5 924a40d790 v3 : C22 ne suppose plus un petit groupe d'essai
Chaque connexion de développement rejoint tous les groupes : B2 et F en ajoutent
quatre chacun, l'essai passe de 13 à 17 membres. Dès 16, B^W passe sous la
précision du calcul et, C valant 1, la voix seule de Chiara fait entrer sa
version de « Connaissance suffisante » en vigueur. Conforme à l'ADR 0002 (essai :
C = 1) ; l'ADR 0007 le dit maintenant. Boucle : 202 tests Rust, svelte-check 0,
49 vitest, e2e 31 / 31.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:09:35 +01:00

219 lines
14 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 } from '@playwright/test';
import { ALICE, chain, CHARLIE, DOC, person, shot, signed, signIn, TEST_DOC, VOTE_ACCOUNT, wallet } from './helpers';
// Lot B — the vote (Yvv, 2026-10-30): unfolded under the article, grouped in a
// basket, and on the Ğ1 chain where the electorate is the web of trust. The chain
// here is simulated (e2e/chain-mock.mjs): never a real transaction.
test('C21 — le vote déplié sous l’article : ton vote, les totaux, le seuil', async ({ page, browser }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
const toggle = e1.getByRole('button', { name: 'Le vote' });
await toggle.click();
const fold = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
await expect(fold).toBeVisible();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(toggle).toHaveAttribute('aria-controls', (await fold.getAttribute('id')) ?? '');
// How far the threshold is: the water, the totals, the votes asked and those missing.
await expect(fold.getByRole('img', { name: /Niveau : \d+ % du seuil/ })).toBeVisible();
await expect(fold.getByText(/^Seuil : \d+ voix pour$/)).toBeVisible();
await expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible();
await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(fold.getByText('0 pour · 0 contre')).toBeVisible();
// Her vote, at once.
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
await expect(fold.locator('.voted')).toHaveText('Ton vote : pour · enregistré');
await expect(fold.getByText('1 pour · 0 contre')).toBeVisible();
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(fold.getByText('Ton vote ne se montre à personne : seuls les totaux sont visibles.')).toBeVisible();
await shot(page, '1-vote-deplie');
// Someone else sees the totals, never who voted what.
const yvv = await person(browser, 'Yvv');
await yvv.goto(page.url());
const theirs = yvv.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
await expect(theirs.getByText('1 pour · 0 contre')).toBeVisible();
await expect(theirs.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(theirs.getByText('Chiara')).toHaveCount(0);
// Folded back, then her vote withdrawn: the group is left as found.
await toggle.click();
await expect(fold).toHaveCount(0);
await toggle.click();
await fold.getByRole('button', { name: 'Retirer mon vote' }).click();
await expect(fold.getByText('0 pour · 0 contre')).toBeVisible();
});
test('C22 — le vote groupé : une section en un geste, et l’article à deux versions se choisit', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
// From the list of proposals: « Voter cette section », on the technical commitments.
await page.locator('ul.deep li.section', { hasText: 'Engagements techniques' }).getByRole('button', { name: 'Voter cette section' }).click();
await expect(page).toHaveURL(/onglet=panier/);
await expect(page).toHaveURL(/section=technique/);
const panel = page.locator('aside.panel');
const tech = panel.getByRole('region', { name: 'Engagements techniques' });
await tech.getByRole('group', { name: 'Engagements techniques' }).getByRole('button', { name: 'Tout pour' }).click();
// E3 has two versions: the grouped gesture leaves it, and says so.
const e3 = tech.locator('li.slot', { hasText: 'Connaissance suffisante' });
await expect(e3.getByText('Plusieurs versions : choisis la tienne.')).toBeVisible();
await expect(e3.getByRole('button', { name: 'Pour', pressed: true })).toHaveCount(0);
await expect(panel.getByRole('status')).toContainText('plusieurs versions');
const filled = await tech.getByRole('button', { name: 'Pour', pressed: true }).count();
expect(filled).toBeGreaterThan(3);
// Her version of E3, chosen by hand.
await e3.getByRole('group', { name: /variante 1\.0\.0/ }).getByRole('button', { name: 'Pour' }).click();
const n = filled + 1;
await expect(panel.getByRole('button', { name: `Voter (${n})` })).toBeEnabled();
await shot(page, '2-panier-bureau');
// The basket stays for the tab: a reload keeps it.
await page.reload();
await expect(page.getByRole('button', { name: `Voter en lot · ${n}` })).toBeVisible();
// One gesture, one write.
const writes: string[] = [];
page.on('request', (r) => {
if (r.method() === 'POST' && r.url().includes('/api/')) writes.push(new URL(r.url()).pathname);
});
await panel.getByRole('button', { name: `Voter (${n})` }).click();
await expect(page.getByText(`${n} votes enregistrés.`)).toBeVisible();
expect(writes.filter((u) => u.endsWith('/votes'))).toHaveLength(1);
expect(writes.filter((u) => u.endsWith('/vote'))).toHaveLength(0);
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
// Each counted: her version of E3 has its voice, the other none. Here every
// dev sign-in joins the test group: from 16 members B^W vanishes and, C being 1,
// her voice alone may put her version in force (ADR 0007).
await expect(e3.getByRole('group', { name: /variante 1\.0\.0|en vigueur/ }).locator('..')).toContainText('1 pour · 0 contre · ton vote : pour');
await expect(e3.getByRole('group', { name: /variante 0\.4\.0-draft/ }).locator('..')).toContainText('0 pour · 0 contre');
});
test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur ; le dernier fait foi', async ({ page, browser }) => {
// The chain is waited for: the server reads the indexer every 10 seconds.
test.setTimeout(90_000);
await signIn(page, 'Chiara');
await wallet(page, [ALICE]);
await page.goto(DOC);
const { control } = await chain(page);
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
await shot(page, '0-entete-voter-en-lot');
// The basket: two articles in force, for, in one transaction.
await page.getByRole('button', { name: /^Voter en lot/ }).click();
const panel = page.locator('aside.panel');
for (const title of ['Unicité du compte', 'Réciprocité']) {
await panel.getByRole('group', { name: `Ton choix pour « ${title} »` }).getByRole('button', { name: 'Pour' }).click();
}
await expect(panel.getByText('Une seule transaction de 0,1 Ğ1, signée par ton portefeuille, pour tous tes choix.')).toBeVisible();
await panel.getByRole('button', { name: 'Signer et envoyer (2)' }).click();
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(dialog.getByText('2 votes, en une transaction : 0,1 Ğ1 vers le compte des votes, avec ton bulletin scellé.')).toBeVisible();
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
await expect(dialog.getByText("Compté pour l'identité Ğ1 « Alice ».")).toBeVisible();
// One signature: the amount to the vote account and the sealed ballot — nothing of the vote in clear.
const tx = await signed(page);
expect(tx).toHaveLength(1);
expect(tx[0]).toMatchObject({ address: ALICE.address, dest: VOTE_ACCOUNT, amount: '10' });
expect(tx[0]?.remark).toMatch(/^ld1:[0-9a-f]{64}$/);
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
// Under the article: her vote, from the chain, and the totals.
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
const fold1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
await expect(fold1.locator('.voted')).toHaveText('Ton vote : pour · inscrit sur la chaîne Ğ1');
await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible();
// The last one stands, in the order of the chain: a « contre » included first, but seen late
// by the indexer, never overrides the withdrawal included after it.
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
await e2.hover();
await e2.getByRole('button', { name: 'Le vote' }).click();
const fold2 = page.getByRole('region', { name: 'Le vote de « Réciprocité »' });
await control('hide');
await fold2.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(dialog.getByText('La chaîne Ğ1 confirme ton vote', { exact: false })).toBeVisible();
await shot(page, '4-chaine-attente');
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold2.getByText('En attente de la chaîne Ğ1 : contre.')).toBeVisible();
await control('show');
await fold2.getByRole('button', { name: 'Retirer mon vote' }).click();
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold2.getByText('0 pour · 0 contre')).toBeVisible();
await control('release');
await expect(fold2.getByText('En attente de la chaîne Ğ1', { exact: false })).toHaveCount(0, { timeout: 30_000 });
await expect(fold2.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(fold2.getByText('0 pour · 0 contre')).toBeVisible();
// One vote unfolded at a time (an accordion): back to the first, untouched.
await expect(fold1).toHaveCount(0);
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible();
await expect(fold2).toHaveCount(0);
// Without a wallet in the browser: Ğecko, a link and a QR code; the page waits for the chain.
const other = await person(browser, 'Maëlle');
await other.goto(DOC);
const raison = other.locator('section.text article.unit').first();
await raison.hover();
await raison.getByRole('button', { name: 'Le vote' }).click();
await raison.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
const sheet = other.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(sheet.getByText("Aucun portefeuille n'est branché à ce navigateur", { exact: false })).toBeVisible();
await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click();
await expect(sheet.getByRole('img', { name: 'Code à scanner avec Ğecko' })).toBeVisible();
const link = (await sheet.getByRole('link', { name: 'Ouvrir dans Ğecko' }).getAttribute('href')) ?? '';
expect(link).toMatch(new RegExp(`^g1://${VOTE_ACCOUNT}\\?amount=0\\.1&comment=ld1:[0-9a-f]{64}$`));
await shot(other, '5-gecko-qr');
// Charlie pays it from Ğecko: the simulated node includes it, the page sees it counted.
const { node } = await chain(other);
const remark = link.split('comment=')[1] ?? '';
const fields = { address: CHARLIE.address, dest: VOTE_ACCOUNT, amount: '10', remark, signature: '0x01' };
await other.request.post(node, {
data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [`0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`] },
});
await expect(sheet.getByText("Compté pour l'identité Ğ1 « Charlie ».")).toBeVisible({ timeout: 20_000 });
});
test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; le groupe d’essai vote', async ({ page }) => {
await signIn(page, 'Chiara');
// The e2e server has its vote account: the page is shown what a server without one answers
// (the server itself: `without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_votes`).
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
const res = await route.fetch();
const body = await res.json();
if (body.vote?.way === 'chain') {
body.vote = { ...body.vote, open: false, reason: 'chain_off', chain: null };
body.canVote = false;
}
await route.fulfill({ response: res, json: body });
});
await page.goto(DOC);
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Voter cette section' })).toHaveCount(0);
const first = page.locator('section.text article.unit').first();
await first.hover();
await first.getByRole('button', { name: 'Le vote' }).click();
const fold = first.locator('section.fold');
await expect(fold.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer.", { exact: false })).toBeVisible();
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// The totals and the threshold stay in sight.
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded();
await shot(page, '6-vote-ferme');
// The basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ».
await page.goto(`${DOC}?onglet=panier`);
const basket = page.locator('aside.panel');
await expect(basket.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert", { exact: false })).toBeVisible();
await expect(basket.getByText("Tes votes comptent dès l'envoi", { exact: false })).toHaveCount(0);
await expect(basket.getByRole('button', { name: /^Signer et envoyer|^Voter \(/ })).toBeDisabled();
// The test group still votes, directly.
await page.goto(TEST_DOC);
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
const unit = page.locator('section.text article.unit').first();
await unit.hover();
await unit.getByRole('button', { name: 'Le vote' }).click();
await expect(unit.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
});