Files
decision/web/e2e/modules.spec.ts
T
YvvandClaude Opus 5.5 8528a269fc Lot U3 : l'interface de l'Acte d'engagement (Yvv, 03/11) — avant le texte, hors vote, « Info ouverture doc de réf. » (OpeningInfo, lib/opening.ts) : le paragraphe d'Yvv mot pour mot à la place de doc.empty.seeds (sa première phrase tant qu'aucun article n'est en vigueur, puis « entrent en vigueur » où « y » n'avait plus rien à reprendre), le cran d'ouverture des Propositions d'amorce, du titre, de l'ordre de chaque section et des autres articles (avant tout changement voté) et le quorum de chaque cran, lus dans l'API ; une séparation (un filet de la couleur de la clé de voûte, jamais une bordure) ; plus les titres « Introduction » et « Engagements fondamentaux » dans le texte. Les informations de F1, F1.1, F1.2, N1, N1.1 et O1 là où elles servent : vote.permanent, vote.rival, vote.others.rule, formula.Tprime, formula.round, formula.B.wot, formula.G, settings.share.note, settings.usual.quorum, settings.gradient, order.edit.effect ; les termes définis de T3c (« à la participation habituelle », « quorum de l'article », « part exigée », « repose sur moins de N propositions votées depuis un an »), plus de « comme d'habitude ». « Proposition d'amorce » (Starter proposal, Propuesta de arranque). Le mot d'Yvv pour qui n'est pas invité, à l'accueil sans session (à la place du bouton Ğ1 tant que la connexion Ğ1 n'est pas active) et sur « Se connecter » (signin.g1.pending sans redite). Réordonner : la ligne sous le pointeur lue dans la mise en page (offsetTop), pas là où animate:flip la montre — une poignée tirée sur plusieurs lignes ne revient plus en arrière ; le pointeur capturé par la liste. API : seedFeatured sur chaque version (seul changement sous crates/). C1, C3, C20, C27, C35, C38 suivent les mots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:28:41 +01:00

239 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, unfold, 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); 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). Lot U (Yvv, 2026-11-02): the order is one line at
// the end of its section, never the list of its articles again; « Réordonner » puts handles
// on them in place (C35); every version of a module is voted at its place, never among the
// proposals. 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';
/** 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, the quorum being 1, one voice may suffice (ADR 0013). Returns
* the number of voices for it. The second one votes at the module, by its address.
*/
async function secondVoice(page: Page, browser: Browser, proposalId: string, fold: 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}?vote=${proposalId}`);
await yvv.getByRole('region', { name: fold }).getByRole('button', { name: 'Pour', exact: true }).click();
await expect(yvv.getByText(news)).toBeVisible();
return 2;
}
test('C25 — l’ordre des articles : une ligne en fin de section, réordonnée sur place, votée là, en vigueur', async ({ page, browser }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
await unfold(page);
// Each section ends with its order, in one line: five sections (formule, inertie and
// ordonnancement are out of the document, ADR 0014).
const modules = page.getByRole('group', { name: /^Ordre des articles · / });
await expect(modules).toHaveCount(5);
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).toContainText('Ordre de la section');
await expect(order).toContainText('inertie 2 / 5 · basse');
await expect(order.getByText('seed a minima · 0 vote')).toBeVisible();
// Never the list of its articles again: it is read in its vote, or while reordering.
await expect(order.getByText(K1)).toHaveCount(0);
await order.scrollIntoViewIfNeeded();
await shot(page, 'c25-1-ordre-lecture');
// Its vote unfolds under its line, like an article's, the list whole.
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 »' });
// Its inertia, said once, in its own place (lot U2).
await expect(fondVote.getByRole('heading', { name: 'Inertie · 2 / 5 · basse' })).toBeVisible();
await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Responsabilité civique', 'Réciprocité']);
await fond.getByRole('button', { name: 'Le vote' }).click();
// Another order: « Réordonner », the article, then its new place — in place, no panel.
await order.hover();
await order.getByRole('button', { name: 'Réordonner' }).click();
await expect(page.locator('aside.panel')).toHaveCount(0);
const list = order.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 = order.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(order.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 order.getByRole('button', { name: `Descendre « ${K2} »` }).click();
await order.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 at the line, the list it proposes in sight; never a line among the proposals.
const fold = order.locator('section.fold');
await expect(fold).toHaveAttribute('aria-label', 'Le vote de « Ordre des articles · Conclusion »');
await expect(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([K2, K1]);
await expect(order).toContainText('1 ordre proposé');
await expect(page.locator('ul.deep button.row .title', { hasText: /^Ordre des articles · / })).toHaveCount(0);
// The order in force is read from it, among its other versions (folded until asked).
await fold.getByRole('button', { name: 'Autres versions (1)' }).click();
await expect(fold.getByRole('region', { name: 'Autres versions' })).toContainText('Ordre en vigueur');
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, 'Le vote de « 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 panel = page.locator('aside.panel');
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();
// In force now: its vote stays unfolded under its line, the list whole, in the new order.
await expect(order.locator('section.fold')).toContainText(`${voices} pour · 0 contre`);
await expect(order.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText([K2, K1]);
await order.scrollIntoViewIfNeeded();
await shot(page, 'c25-3b-ordre-en-vigueur');
await expect(order).toContainText(/adopté le/);
await unfold(page);
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');
// A module, named for what it is (in French here; the language changes below).
await expect(head.getByRole('group', { name: 'Titre du document' })).toBeVisible();
const bar = head.locator('.title-unit');
await expect(bar).toContainText('Titre du document · seed a minima · 0 vote');
await expect(bar).toContainText('inertie 4 / 5 · haute');
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');
await expect(panel.getByRole('heading', { name: 'Proposer un autre titre' })).toBeVisible();
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();
// Its vote unfolds under the title, where it is read; never a line among the proposals.
const fold = bar.locator('section.fold');
await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${next} »`);
await expect(bar).toContainText('1 titre proposé');
await expect(page.locator('ul.deep').getByText(next)).toHaveCount(0);
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, `Le vote de « ${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.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).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 : celle que le cran demande au-delà du quorum de l'article, à la participation habituelle.")).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, puis se lit à sa ligne', 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. Reordering on a
// phone: C35.
await page.goto(TEST_DOC);
await expect(page.locator('.title-unit .links')).toHaveCSS('opacity', '1');
await shot(page, 'c26-4-titre-mobile');
await ctx.close();
});