Files
decision/web/e2e/modules.spec.ts
T
YvvandClaude Opus 5.5 17addf8d9f Lot S, phase 2 : le quorum dans l'interface (fr, en, es), e2e 35 / 35
- types.ts : evaluation.quorum / yesNeeded / noNeeded, levels[].quorum, l'unité
  quorum / quorumDefault / canSetQuorum, doc.quorums (optionnels) ; publication
  « suspended » (ADR 0011) ;
- Réglages de la formule : la colonne « Quorum » des cinq crans et sa note, la
  participation de référence « deux fois 50, le quorum de référence » ; plus de
  ligne C ; sur téléphone, les six colonnes tiennent dans la feuille ;
- La formule du seuil : T' = max(T, Q), R = B^W + ratio(T')·T', la ligne Q (le
  quorum de la version) au lieu de C ;
- textes : deep.note, formula.reading, settings.* parlent du quorum, plus de C
  (test vu rouge puis vert, tests/unit/inertia.test.ts) ;
- e2e : Seuil 9 (cran 2) et 39 (cran 3) dans Monnaie libre, les quorums 12 · 12 ·
  50 · 50 · 50, voix pour à 100 votants 60 · 67 · 75 · 83 · 90, à 0 votant
  8 · 9 · 39 · 43 · 46.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:15:18 +01:00

242 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 { devices, expect, test, type Browser, type Page } from '@playwright/test';
import { ALICE, DOC, person, shot, signIn, TEST_DOC, wallet } from './helpers';
// Lot D — the modules (Yvv, 2026-10-30; ADR 0009): at the end of each section, the
// order of its articles (inertia 2), proposed with a list one clicks; at the head of
// the document, its title (inertia 4). Both voted like an article: unfolded, in the
// basket, on the Ğ1 chain in Monnaie libre (simulated here: e2e/chain-mock.mjs).
// The server is shared with the specs that follow: the test group keeps its articles
// and their votes; its conclusion is reordered and its title changes.
const K1 = 'Règles abrégées de la toile de confiance';
const K2 = 'Monnaie G1';
/** A vote unfolded in the list of proposals: « Pour », then the news. */
async function voteFor(page: Page, row: string) {
await page.locator('ul.deep button.row', { hasText: row }).click();
await page.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
}
/** Whether a version of a module of the test group is in force now. */
async function inForce(page: Page, proposalId: string): Promise<boolean> {
const g = await (await page.request.get('/api/groups/essai')).json();
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
return [d.modules.title, ...d.modules.orders].some((u) => u?.inForce?.id === proposalId);
}
/**
* The voices that bring a module version of the test group into force: hers, then a
* second one if hers was not enough. Every dev sign-in joins the test group: from 16
* members B^W vanishes and, C being 1, one voice may suffice (ADR 0007). Returns the
* number of voices for it.
*/
async function secondVoice(page: Page, browser: Browser, proposalId: string, row: string, news: string): Promise<number> {
if (await inForce(page, proposalId)) {
await expect(page.getByText(news)).toBeVisible();
return 1;
}
const yvv = await person(browser, 'Yvv');
await yvv.goto(TEST_DOC);
await voteFor(yvv, row);
await expect(yvv.getByText(news)).toBeVisible();
return 2;
}
test('C25 — l’ordre des articles : en fin de section, proposé en cliquant, au clavier, voté, en vigueur', async ({ page, browser }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
// Each section ends with its order; a section of one article has nothing to decide.
const modules = page.locator('section.text [role="group"]');
await expect(modules).toHaveCount(7);
await expect(page.getByRole('group', { name: 'Ordre des articles · Ordonnancement' })).toHaveCount(0);
const order = page.getByRole('group', { name: 'Ordre des articles · Conclusion' });
await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K1}`, `2 ${K2}`]);
await expect(order).toContainText('2 articles · inertie 2 / 5 · basse');
await expect(order.getByText('seed a minima · 0 vote')).toBeVisible();
await order.scrollIntoViewIfNeeded();
await shot(page, 'c25-1-ordre-lecture');
// Its vote unfolds under it, like an article's, the list whole (the mark's real number of
// votes is seen in Monnaie libre, below: here one voice may confirm a seed).
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
await fond.hover();
await fond.getByRole('button', { name: 'Le vote' }).click();
const fondVote = page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' });
await expect(fondVote.getByText('inertie 2 / 5 · basse', { exact: true })).toBeVisible();
await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Réciprocité']);
await fond.getByRole('button', { name: 'Le vote' }).click();
// Another order: the article, then its new place.
await order.hover();
await order.getByRole('button', { name: 'Proposer un autre ordre' }).click();
await expect(page).toHaveURL(/onglet=proposer/);
const panel = page.locator('aside.panel');
const list = panel.getByRole('list', { name: 'Nouvel ordre des articles' });
const items = list.locator('button.item');
const names = list.locator('button.item .name');
await expect(names).toHaveText([K1, K2]);
const submit = panel.getByRole('button', { name: 'Proposer cet ordre' });
await expect(submit).toBeDisabled();
await items.nth(1).click();
await expect(items.nth(1)).toHaveAttribute('aria-pressed', 'true');
await items.nth(0).click();
await expect(names).toHaveText([K2, K1]);
await expect(panel.getByText(`« ${K2} » en place 1 sur 2.`)).toBeAttached();
await expect(submit).toBeEnabled();
// With the keyboard: the moved article keeps the focus, the arrows move it.
await expect(items.nth(0)).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(names).toHaveText([K1, K2]);
await expect(submit).toBeDisabled();
await page.keyboard.press('ArrowUp');
await expect(names).toHaveText([K2, K1]);
// Or its arrows.
await panel.getByRole('button', { name: `Descendre « ${K2} »` }).click();
await panel.getByRole('button', { name: `Monter « ${K2} »` }).click();
await expect(names).toHaveText([K2, K1]);
await shot(page, 'c25-2-ordre-propose');
await submit.click();
await expect(page.getByText('Ordre proposé au vote.')).toBeVisible();
// Its vote unfolds in the list of proposals, the list it proposes in sight.
const fold = page.locator('ul.deep section.fold');
await expect(fold).toHaveAttribute('aria-label', 'Le vote de « Ordre des articles · Conclusion »');
await expect(fold.locator('ol.order li')).toHaveText([K2, K1]);
await expect(page.locator('ul.deep li.section', { hasText: 'Conclusion' })).toBeVisible();
await expect(page.locator('ul.deep button.row', { hasText: 'Ordre des articles · Conclusion' })).toContainText(`${K2} · ${K1}`);
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
await expect(page.getByText('Vote enregistré.')).toBeVisible();
await shot(page, 'c25-3-ordre-vote');
// Into force (her voice, or a second one): the section reads in its new order.
const voices = await secondVoice(page, browser, pid, 'Ordre des articles · Conclusion', 'Le nouvel ordre de « Conclusion » est entré en vigueur.');
// In the basket too: at the end of its section.
await page.getByRole('button', { name: /^Voter en lot/ }).click();
const basket = panel.getByRole('region', { name: 'Conclusion' });
await expect(basket.locator('li.slot').last()).toContainText('Ordre des articles · Conclusion');
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
await page.reload();
await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K2}`, `2 ${K1}`]);
// In force now: its vote stays unfolded, under the module, the list whole.
await expect(order.locator('section.fold')).toContainText(`${voices} pour · 0 contre`);
await order.scrollIntoViewIfNeeded();
await shot(page, 'c25-3b-ordre-en-vigueur');
await expect(order).toContainText(/adopté le/);
const rows = await page.locator('ul.deep button.row .title').allTextContents();
expect(rows.indexOf(K2)).toBeLessThan(rows.indexOf(K1));
});
test('C26 — le titre du document : proposé et voté en tête, ses traductions à revoir', async ({ page, browser }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
const head = page.locator('header.head');
const bar = head.locator('.title-unit');
await expect(bar).toContainText('Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote');
await head.hover();
await bar.getByRole('button', { name: 'Proposer un autre titre' }).click();
await expect(page).toHaveURL(/onglet=proposer/);
const panel = page.locator('aside.panel');
const input = panel.getByRole('textbox', { name: 'Nouveau titre' });
await expect(input).toHaveValue("Acte d'engagement Certification");
const submit = panel.getByRole('button', { name: 'Proposer un autre titre' });
await expect(submit).toBeDisabled();
await expect(panel.getByText("Le titre du document se vote à l'inertie 4 / 5 · haute.", { exact: false })).toBeVisible();
const next = "Acte d'engagement de la certification";
await input.fill(next);
await shot(page, 'c26-1-titre-propose');
await submit.click();
await expect(page.getByText('Titre proposé au vote.')).toBeVisible();
const fold = page.locator('ul.deep section.fold');
await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${next} »`);
await expect(page.locator('ul.deep button.row', { hasText: next })).toContainText('Titre du document');
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
await expect(page.getByText('Vote enregistré.')).toBeVisible();
const voices = await secondVoice(page, browser, pid, next, `Le titre « ${next} » est entré en vigueur.`);
// The document changes title; its translations stay editorial, to review.
await page.reload();
await expect(page.getByRole('heading', { level: 1 })).toHaveText(next);
await expect(bar).toContainText(/adopté le/);
await expect(bar.locator('.tag')).toHaveCount(0);
// In force now: its vote stays unfolded, under the title.
const vote = bar.locator('section.fold');
await expect(bar.getByRole('button', { name: 'Le vote' })).toHaveAttribute('aria-expanded', 'true');
await expect(vote.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
await expect(vote.getByText(`${voices} pour · 0 contre`)).toBeVisible();
await shot(page, 'c26-2-titre-vote');
await bar.getByRole('button', { name: 'Le vote' }).click();
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(bar.locator('.tag')).toHaveText('translation needs review');
await shot(page, 'c26-3-titre-traduction-a-revoir');
});
test('C27 — la participation ramenée dans ses bornes : la mesure, puis ce que le groupe demande', async ({ page }) => {
await signIn(page, 'Chiara');
// A group of four whose median is 2,5 voters, brought back to half of it by the scale.
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
const res = await route.fetch();
const body = await res.json();
body.doc.electorate = 4;
body.doc.participation = { ...body.doc.participation, source: 'median', median: 2.5, sample: 12, bounded: true, voters: 2, ratio: 0.5, bounds: [0.05, 0.5] };
await route.fulfill({ response: res, json: body });
});
await page.goto(TEST_DOC);
await page.getByRole('button', { name: 'Réglages de la formule' }).click();
const panel = page.locator('aside.panel');
// The measure as it is: 2,5 of 4 is 62,5 %, never 3 of 4 rounded first.
await expect(panel.getByText(/^3 votants sur 4 \(62,5\s%\) : la médiane des 12 propositions votées depuis un an\.$/)).toBeVisible();
// Then what the group asks, the group as subject.
await expect(panel.getByText(/^Ramenée entre 5\s% et 50\s% de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient \(50\s%\)\.$/)).toBeVisible();
await expect(panel.getByText("Part exigée : la part de « pour » demandée, à 2 votants comme d'habitude.")).toBeVisible();
await shot(page, 'c27-participation-bornee');
});
test('C25 — sur la chaîne Ğ1 et sur un téléphone : l’ordre se vote dans le panier, la liste tient', async ({ browser }) => {
// The chain is waited for: the server reads the indexer every 10 seconds.
test.setTimeout(90_000);
const ctx = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' });
const page = await ctx.newPage();
await signIn(page, 'Chiara');
await wallet(page, [ALICE]);
await page.goto(DOC);
// The basket of Monnaie libre: the order of the fundamental commitments, alone, in one transaction.
await page.getByRole('button', { name: /^Voter en lot/ }).click();
const panel = page.locator('aside.panel');
await panel.getByRole('group', { name: 'Ton choix pour « Ordre des articles · Engagements fondamentaux »' }).getByRole('button', { name: 'Pour' }).click();
await panel.getByRole('button', { name: 'Signer et envoyer (1)' }).click();
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
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 panel.getByRole('button', { name: 'Fermer le panneau' }).click();
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
await expect(fond.getByText('seed a minima · 1 vote')).toBeVisible();
await fond.getByRole('button', { name: 'Le vote' }).click();
await expect(page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' }).locator('.voted')).toHaveText(
'Ton vote : pour · inscrit sur la chaîne Ğ1',
);
await fond.scrollIntoViewIfNeeded();
await shot(page, 'c25-4-ordre-mobile');
// The title on a phone: its gestures in sight, without a pointer to hover with.
await page.goto(TEST_DOC);
await expect(page.locator('.title-unit .links')).toHaveCSS('opacity', '1');
await shot(page, 'c26-4-titre-mobile');
// The list one clicks, on a phone: nothing overflows, the buttons are big enough to touch.
const tech = page.getByRole('group', { name: 'Ordre des articles · Engagements techniques' });
await tech.getByRole('button', { name: 'Proposer un autre ordre' }).click();
const items = panel.getByRole('list', { name: 'Nouvel ordre des articles' }).locator('button.item');
await expect(items).toHaveCount(11);
await items.nth(3).click();
await items.nth(0).click();
await expect(panel.getByRole('button', { name: 'Proposer cet ordre' })).toBeEnabled();
const holds = await panel.locator('.body').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
expect(holds).toBe(true);
const box = await items.nth(0).boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
await shot(page, 'c25-5-ordre-propose-mobile');
await page.getByRole('heading', { level: 1 }).scrollIntoViewIfNeeded();
await ctx.close();
});