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 { 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 { 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(); });