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>
325 lines
19 KiB
TypeScript
325 lines
19 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
||
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('/');
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective');
|
||
await expect(page.locator('.leitmotiv li')).toHaveText(['contexte et cadrage', 'processus et modalités', 'boîte à outils']);
|
||
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
|
||
for (const item of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) {
|
||
await expect(nav.locator('.tool', { hasText: item })).toBeVisible();
|
||
}
|
||
await expect(nav.locator('.tool.off')).toHaveCount(3);
|
||
await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toBeVisible();
|
||
// The four pillars live on the page of a group, no longer here.
|
||
await expect(page.locator('.pillars')).toHaveCount(0);
|
||
await expect(page.getByText('Monnaie libre', { exact: true })).toHaveCount(0);
|
||
});
|
||
|
||
test('C2 — l’accueil avec session : le choix du groupe, ceux de la même clé ; pas l’Atelier du canal', async ({ page }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto('/');
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective');
|
||
await expect(page.locator('a.root')).toContainText('Monnaie libre');
|
||
for (const g of ['Monnaie libre', 'Axiom', 'Librodrome', 'Groupe local Vallée Drôme Crestois']) {
|
||
await expect(page.getByText(g, { exact: true })).toBeVisible();
|
||
}
|
||
await expect(page.getByText('Atelier du canal')).toHaveCount(0);
|
||
await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toHaveCount(0);
|
||
// The old address and « Mes groupes » lead here.
|
||
await page.goto('/groupes');
|
||
await expect(page).toHaveURL((u) => u.pathname === '/');
|
||
await page.goto('/g/monnaie-libre');
|
||
await page.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Mes groupes' }).click();
|
||
await expect(page).toHaveURL((u) => u.pathname === '/');
|
||
});
|
||
|
||
test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a minima', async ({ page }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto(DOC);
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification");
|
||
const units = page.locator('section.text article.unit');
|
||
await expect(units).toHaveCount(3);
|
||
await expect(units.nth(0).locator('h2')).toHaveText("Raison d'être");
|
||
await expect(units.nth(1).locator('h2')).toHaveText('Unicité du compte');
|
||
await expect(units.nth(2).locator('h2')).toHaveText('Réciprocité');
|
||
await expect(page.getByText('seed a minima · 0 vote')).toHaveCount(3);
|
||
await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible();
|
||
// One line per article: E3 and E7 hold their two variants inside.
|
||
await expect(page.locator('ul.deep button.row')).toHaveCount(30);
|
||
});
|
||
|
||
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();
|
||
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(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 }) => {
|
||
const yvv = await person(browser, 'Yvv');
|
||
const chiara = await person(browser, 'Chiara');
|
||
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('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 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 }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto(TEST_DOC);
|
||
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.
|
||
await body.fill((await body.inputValue()).replaceAll("'", '’'));
|
||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||
await expect(page.getByText('Coquille corrigée, sans toucher aux votes.')).toBeVisible();
|
||
await expect(unicity).toContainText('Je m’engage sur l’honneur');
|
||
// A change of meaning is turned away.
|
||
await panel.getByText('Corriger une coquille').click();
|
||
await body.fill('Tout autre chose, qui change le sens de cet engagement.');
|
||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||
await expect(panel.getByText('Ce changement touche au sens : propose-le comme modification.')).toBeVisible();
|
||
});
|
||
|
||
test('C7 — l’assistant propose de découper deux décisions', async ({ page }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto(TEST_DOC);
|
||
await page.getByRole('button', { name: 'Proposer un nouvel article' }).click();
|
||
const panel = page.locator('aside.panel');
|
||
await panel.getByLabel('Titre').fill('Joignabilité et vérification');
|
||
await panel.getByLabel('Texte').fill(
|
||
"Je m'engage à rester joignable par mes certifieurs.\n\nJe m'engage à vérifier la clé publique avec la personne.",
|
||
);
|
||
await expect(panel.getByText('Ce texte semble porter 2 décisions.', { exact: false })).toBeVisible();
|
||
await panel.getByRole('button', { name: 'Découper en 2 brouillons' }).click();
|
||
await expect(page.getByText('Découpé en 2 brouillons.')).toBeVisible();
|
||
await expect(page.getByRole('heading', { name: 'Tes brouillons' })).toBeVisible();
|
||
});
|
||
|
||
test('C8 — multilingue : le texte et l’interface en anglais et en espagnol', async ({ page }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto(DOC);
|
||
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Certification Commitment Act');
|
||
await expect(page.getByText('Open proposals', { exact: false }).first()).toBeVisible();
|
||
await page.getByRole('radiogroup', { name: 'Text language' }).getByRole('radio', { name: 'ES' }).click();
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Acta de compromiso de Certificación');
|
||
});
|
||
|
||
test('C9 — les aperçus : bien cotées, et tout', async ({ page }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto(DOC);
|
||
await page.getByRole('radio', { name: 'Si tout passe' }).click();
|
||
await expect(page.getByText("Aperçu : le texte tel qu'il serait si toutes les propositions", { exact: false })).toBeVisible();
|
||
await expect(page.locator('section.text article.unit')).toHaveCount(33);
|
||
await page.getByRole('radio', { name: 'Si les bien cotées passent' }).click();
|
||
await expect(page.locator('section.text article.unit')).toHaveCount(3);
|
||
});
|
||
|
||
test('C10 — trustinWallet : la gestion des accès est réservée', async ({ browser }) => {
|
||
const chiara = await person(browser, 'Chiara');
|
||
await chiara.goto('/acces');
|
||
await expect(chiara.getByText('La gestion des accès est réservée aux administrateurs de trustinWallet.')).toBeVisible();
|
||
const yvv = await person(browser, 'Yvv', true);
|
||
await yvv.goto('/acces');
|
||
await expect(yvv.getByRole('heading', { name: 'trustinWallet — accès' })).toBeVisible();
|
||
await yvv.getByLabel('Pour qui').fill('Invitée');
|
||
await yvv.getByRole('button', { name: "Créer le lien d'invitation" }).click();
|
||
await expect(yvv.locator('.link code')).toContainText('/invitation#');
|
||
});
|
||
|
||
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'] },
|
||
headers: { 'x-requested-with': 'libredecision' },
|
||
});
|
||
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 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('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 }) => {
|
||
const yvv = await person(browser, 'Yvv', true);
|
||
await yvv.goto('/acces');
|
||
const team = yvv.locator('section', { has: yvv.getByRole('heading', { name: "Liens d'équipe" }) });
|
||
await team.getByLabel('Pour quelle équipe').fill('Équipe de test');
|
||
await team.getByLabel("Groupe d'essai").check();
|
||
await team.getByRole('button', { name: "Créer le lien d'équipe" }).click();
|
||
const link = (await team.locator('.link code').textContent()) ?? '';
|
||
expect(link).toContain('/equipe#');
|
||
for (const name of ['Luna', 'Paul']) {
|
||
const ctx = await browser.newContext({ locale: 'fr-FR' });
|
||
const page = await ctx.newPage();
|
||
await page.goto(`/equipe#${link.split('#')[1]}`);
|
||
await expect(page.getByText('« Équipe de test » t’ouvre', { exact: false })).toBeVisible();
|
||
await page.getByLabel('Ton nom').fill(name);
|
||
await page.getByRole('button', { name: 'Rejoindre' }).click();
|
||
await expect(page).toHaveURL((u) => u.pathname === '/');
|
||
await ctx.close();
|
||
}
|
||
// A shared browser: the person signed in steps aside, the next one gives a name.
|
||
const shared = await person(browser, 'Zoé');
|
||
await shared.goto(`/equipe#${link.split('#')[1]}`);
|
||
await expect(shared.getByText('Ta session est ouverte sous le nom Zoé', { exact: false })).toBeVisible();
|
||
await shared.getByRole('button', { name: "Ce n'est pas moi : me déconnecter" }).click();
|
||
await shared.getByLabel('Ton nom').fill('luna');
|
||
await shared.getByRole('button', { name: 'Rejoindre' }).click();
|
||
await expect(shared.getByRole('alert')).toContainText('Ce nom est déjà pris');
|
||
await yvv.reload();
|
||
await expect(team.getByText('2 / 30 personnes', { exact: false })).toBeVisible();
|
||
await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2);
|
||
});
|
||
|
||
test('C18 — la Raison d’être ouvre le texte en vigueur, en trois langues', async ({ page }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto(DOC);
|
||
const first = page.locator('section.text article.unit').first();
|
||
await expect(first.locator('h2')).toHaveText("Raison d'être");
|
||
// The first sentence in italics, then two paragraphs.
|
||
const paragraphs = first.locator('.prose p');
|
||
await expect(paragraphs).toHaveCount(3);
|
||
await expect(paragraphs.nth(0).locator('em')).toContainText("Derrière une clé il n'y a qu'un seul être humain vivant");
|
||
await expect(paragraphs.nth(1)).toContainText("C'est le seul et unique propos de la certification");
|
||
await expect(paragraphs.nth(2)).toContainText('la première monnaie libre');
|
||
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
|
||
await expect(first.locator('h2')).toHaveText('Purpose');
|
||
await page.getByRole('radiogroup', { name: 'Text language' }).getByRole('radio', { name: 'ES' }).click();
|
||
await expect(first.locator('h2')).toHaveText('Razón de ser');
|
||
});
|
||
|
||
test('C19 — un article, une ligne : ses versions se déplient', async ({ page }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto(DOC);
|
||
const e3 = page.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' });
|
||
await expect(e3).toHaveCount(1);
|
||
await expect(e3).toContainText('2 versions');
|
||
await expect(e3).toHaveAttribute('aria-expanded', 'false');
|
||
// Unfolded with the keyboard: its two versions, each with its label.
|
||
await e3.focus();
|
||
await page.keyboard.press('Enter');
|
||
await expect(e3).toHaveAttribute('aria-expanded', 'true');
|
||
const versions = page.locator(`#${await e3.getAttribute('aria-controls')}`).getByRole('button');
|
||
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 unfolds its vote below it, as any proposal.
|
||
await versions.nth(1).click();
|
||
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);
|
||
});
|
||
|
||
test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto(DOC);
|
||
// From the head of the document: the scale of the group, at its usual turnout.
|
||
await page.getByRole('button', { name: 'Réglages de la formule' }).click();
|
||
await expect(page).toHaveURL(/onglet=reglages/);
|
||
const panel = page.locator('aside.panel');
|
||
await expect(panel.getByRole('heading', { name: 'Réglages de la formule' })).toBeVisible();
|
||
await expect(panel.getByText(/W — électorat : 5\s745/)).toBeVisible();
|
||
await expect(panel.getByText('C — minimum de voix pour : 50')).toBeVisible();
|
||
// No vote yet: the reference, twice C.
|
||
await expect(panel.getByText(/100 votants sur 5\s745 \(1,74 %\), deux fois C : la participation de référence/)).toBeVisible();
|
||
const rows = panel.locator('table tbody tr');
|
||
await expect(rows).toHaveCount(5);
|
||
await expect(rows.locator('th')).toHaveText([/1\s*très basse/, /2\s*basse/, /3\s*moyenne/, /4\s*haute/, /5\s*très haute/]);
|
||
await expect(rows.locator('td:nth-child(2)')).toHaveText(['50 %', '50 %', '55 %', '60 %', '66 %']);
|
||
await expect(rows.locator('td:nth-child(4)')).toHaveText(['60 %', '67 %', '75 %', '83 %', '90 %']);
|
||
// At 100 voters: C plus each level's share of the 50 votes beyond C.
|
||
const required = panel.locator('table tbody td.required');
|
||
await expect(required).toHaveText(['80', '84', '88', '92', '95']);
|
||
await expect(panel.getByText('celles exigées à la participation habituelle, 100 votants')).toBeVisible();
|
||
await page.keyboard.press('Escape');
|
||
|
||
// From the vote of an article: Unicité du compte is at 4 / 5, its row is marked.
|
||
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(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');
|
||
await expect(required).toHaveText(['50', '50', '50', '50', '50']);
|
||
await expect(panel.getByText('celles exigées à la participation de cette version, 0 votant.')).toBeVisible();
|
||
await page.keyboard.press('Escape');
|
||
|
||
// The five levels to choose from, in the test group; the change is voted at the article's level.
|
||
await page.goto(TEST_DOC);
|
||
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
|
||
await e2.hover();
|
||
await e2.getByRole('button', { name: 'Le vote' }).click();
|
||
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(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 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();
|
||
});
|