v3 : le vote déplié sous l'article, le panier, la signature par le portefeuille Ğ1

- 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>
This commit is contained in:
Yvv
2026-10-30 22:47:26 +01:00
co-authored by Claude Opus 5.5
parent 3541e1b1a6
commit b260596cfd
36 changed files with 4353 additions and 234 deletions
+56 -33
View File
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { DOC, person, signIn, TEST_DOC } from './helpers';
import { BOB, DOC, person, signIn, TEST_DOC, wallet } from './helpers';
test('C1 — l’accueil sans session : le titre, le leitmotiv en trois lignes, la connexion, le menu complet', async ({ page }) => {
await page.goto('/');
@@ -49,24 +49,29 @@ test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a
await expect(page.locator('ul.deep button.row')).toHaveCount(30);
});
test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', async ({ page }) => {
test('C4 — les actions sont contextuelles, le vote se déplie sous l’article', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
const first = page.locator('section.text article.unit').first();
await first.hover();
await first.getByRole('button', { name: 'Le vote' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible();
await panel.getByText('La formule du seuil').click();
await expect(panel.getByText(/W — électorat/)).toBeVisible();
const toggle = first.getByRole('button', { name: 'Le vote' });
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
const fold = first.locator('section.fold');
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(fold.getByText('Il manque 50 voix pour.')).toBeVisible();
await fold.getByText('La formule du seuil').click();
await expect(fold.getByText(/W — électorat/)).toBeVisible();
// The level of the article sets M and G: the Raison d'être is at 3 / 5.
await expect(panel.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
await expect(panel.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
// The panel lives in the URL.
await expect(page).toHaveURL(/onglet=vote/);
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(fold.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
await expect(fold.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
// No window opens: the vote unfolds in place, lives in the URL, and folds back.
await expect(page.locator('aside.panel')).toHaveCount(0);
await expect(page).toHaveURL(/vote=/);
await toggle.click();
await expect(fold).toHaveCount(0);
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(page).not.toHaveURL(/vote=/);
});
test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (groupe d’essai)', async ({ browser }) => {
@@ -75,12 +80,13 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr
for (const p of [yvv, chiara]) {
await p.goto(TEST_DOC);
await p.locator('ul.deep button.row', { hasText: 'Les deux garanties réciproques' }).click();
await p.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true }).click();
await p.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
await expect(p.getByText('Vote enregistré.')).toBeVisible();
}
await expect(chiara.getByText('« Les deux garanties réciproques » est entré en vigueur.')).toBeVisible();
await chiara.keyboard.press('Escape');
await expect(chiara.locator('section.text article.unit')).toHaveCount(4);
// In force now: its vote stays unfolded, under the text.
await expect(chiara.locator('section.text section.fold')).toContainText('2 pour · 0 contre');
});
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
@@ -89,7 +95,10 @@ test('C6 — une coquille se corrige sans vote', async ({ page }) => {
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await unicity.hover();
await unicity.getByRole('button', { name: 'Le vote' }).click();
// What is not the vote lives in the panel, tab « Détails ».
await unicity.getByRole('button', { name: 'Coquilles, traductions, signalements, historique' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Corriger une coquille').click();
const body = panel.getByLabel('Texte', { exact: true });
// Typographic apostrophes: the letters and the signs stay, only their style changes.
@@ -151,7 +160,7 @@ test('C10 — trustinWallet : la gestion des accès est réservée', async ({ br
await expect(yvv.locator('.link code')).toContainText('/invitation#');
});
test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de la toile votent', async ({ browser }) => {
test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de la toile votent, par la chaîne', async ({ browser }) => {
const yvv = await person(browser, 'Yvv', true);
const r = await yvv.request.post('/api/access/invitations', {
data: { label: 'Invitée', groups: ['monnaie-libre', 'essai'] },
@@ -160,17 +169,28 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de
const { link } = (await r.json()) as { link: string };
const ctx = await browser.newContext({ locale: 'fr-FR' });
const invited = await ctx.newPage();
// Her wallet holds Bob's account: an identity, not a member.
await wallet(invited, [BOB]);
await invited.goto(`/invitation#${link.split('#')[1]}`);
await expect(invited).toHaveURL((u) => u.pathname === '/');
await invited.goto(DOC);
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
const panel = invited.locator('aside.panel');
await expect(panel.getByText('Ici, seuls les membres de la toile de confiance Ğ1 votent', { exact: false })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// In the test group, the same person votes.
const fold = invited.locator('section.fold');
// An invitation gives no voice: the voice is that of the Ğ1 identity of the wallet that signs.
await expect(fold.getByText("Ta voix compte pour l'identité Ğ1 du portefeuille qui signe", { exact: false })).toBeVisible();
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
const dialog = invited.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(
dialog.getByText("Ton vote n'a pas été compté : le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1."),
).toBeVisible({ timeout: 20_000 });
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold.getByText("Ton dernier vote n'a pas été compté", { exact: false })).toBeVisible();
await expect(fold.getByText('0 pour · 0 contre')).toBeVisible();
// In the test group, the same person votes, directly.
await invited.goto(TEST_DOC);
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
await expect(invited.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
await expect(invited.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
await expect(invited.locator('section.fold').getByText('Ta voix compte', { exact: false })).toHaveCount(0);
});
test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre identité', async ({ browser }) => {
@@ -237,10 +257,11 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page
await expect(versions).toHaveCount(2);
await expect(versions.nth(0)).toContainText('variante 1.0.0');
await expect(versions.nth(1)).toContainText('variante 0.4.0-draft');
// A version opens its vote, as any proposal.
// A version unfolds its vote below it, as any proposal.
await versions.nth(1).click();
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(page).toHaveURL(/version=/);
await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('section.fold').getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(page).toHaveURL(/vote=/);
await e3.click();
await expect(e3).toHaveAttribute('aria-expanded', 'false');
await expect(versions).toHaveCount(0);
@@ -273,8 +294,8 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
await expect(panel.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
await panel.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(e1.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
await e1.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(panel.locator('tr.current')).toHaveCount(1);
await expect(panel.locator('tr.current th')).toContainText('4');
await expect(panel.locator('tr.current th')).toContainText('cet article');
@@ -287,15 +308,17 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
await e2.hover();
await e2.getByRole('button', { name: 'Le vote' }).click();
await panel.getByText("Proposer un changement d'inertie").click();
const levels = panel.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
await e2.getByText("Proposer un changement d'inertie").click();
const levels = e2.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
await expect(levels).toHaveCount(5);
await expect(levels.nth(3)).toBeDisabled();
await expect(levels.nth(3)).toContainText('actuelle');
await expect(panel.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
await expect(e2.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
await levels.nth(4).click();
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
await panel.getByRole('button', { name: 'Soumettre au vote' }).click();
await expect(panel.getByRole('heading', { name: 'inertie vers 5 / 5 · très haute' })).toBeVisible();
await expect(panel.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
await e2.getByRole('button', { name: 'Soumettre au vote' }).click();
// Its own vote unfolds, in the list of proposals.
const change = page.getByRole('region', { name: 'Le vote de « inertie vers 5 / 5 · très haute »' });
await expect(change).toBeVisible();
await expect(change.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
});