import { devices, expect, test, type Page } from '@playwright/test'; import { deepRow, DOC, person, propose, seedsInForce, shot, signIn, TEST_DOC, testDoc, unfold } from './helpers'; // Lot U — the reader (Yvv, 2026-11-02: « la répétition de "ordonnancement" est nulle. Il // faudrait que ce soit en fin de section, qui ajoute des "poignées" aux modules de la section. // Et permette de voter. » ; « combien il faut de oui pour passer et combien de non pour // maintenir l'opposition »). Run after modules.spec (C25 reads the order of the fundamental // commitments as the seed gives it): here it is reordered, voted, and may enter into force. test.beforeAll(async ({ browser }) => seedsInForce(browser)); const EF = 'Engagements fondamentaux'; const E1 = 'Unicité du compte'; const E2 = 'Réciprocité'; const ORDER = `Ordre des articles · ${EF}`; const CSRF = { 'x-requested-with': 'libredecision' }; /** Whether a version of a module of the test group is in force now. */ async function moduleInForce(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); } test('C35 — réordonner une section : les poignées sur ses articles, au glisser, au clavier, sans glisser, puis le vote', async ({ page, browser }) => { await signIn(page, 'Chiara'); await page.goto(TEST_DOC); // The order closes its section in one line: its name, its weight, its state — never the list again. const line = page.getByRole('group', { name: ORDER }); await expect(line).toBeVisible(); await expect(line).toContainText('Ordre de la section'); await expect(line).toContainText('inertie 2 / 5 · basse'); await expect(line.getByText(E1)).toHaveCount(0); // Nowhere a block that repeats the articles of a section, and no order among the proposals. await expect(page.locator('section.text').getByText('Ordre des articles ·', { exact: false })).toHaveCount(0); await expect(page.locator('ul.deep').getByText('Ordre des articles', { exact: false })).toHaveCount(0); await line.scrollIntoViewIfNeeded(); await shot(page, 'c35-1-ligne-ordre'); // « Réordonner »: the articles of the section fold to their titles, a handle before each, in place. await line.getByRole('button', { name: 'Réordonner' }).click(); await expect(page.locator('aside.panel')).toHaveCount(0); const list = page.locator('section.text').getByRole('list', { name: 'Nouvel ordre des articles' }); const names = list.locator('button.item .name'); await expect(names).toHaveText([EF, E1, E2]); // The articles in force of the section are in the list now, not twice; the one only proposed says so. await expect(page.locator('section.text article.unit', { hasText: E1 })).toHaveCount(0); await expect(list.locator('li', { hasText: EF })).toContainText('proposé'); await expect(list.locator('.grip')).toHaveCount(3); const submit = page.getByRole('button', { name: 'Proposer cet ordre' }); await expect(submit).toBeDisabled(); await shot(page, 'c35-2-poignees'); // By dragging its handle: Réciprocité onto the place of Unicité du compte. await list.locator('li', { hasText: E2 }).locator('.grip').dragTo(list.locator('li', { hasText: E1 })); await expect(names).toHaveText([EF, E2, E1]); await expect(page.getByText(`« ${E2} » en place 2 sur 3.`)).toBeAttached(); await expect(submit).toBeEnabled(); // With the keyboard: the article keeps the focus, the arrows move it. const items = list.locator('button.item'); await items.nth(1).focus(); await page.keyboard.press('ArrowUp'); await expect(names).toHaveText([E2, EF, E1]); await expect(items.nth(0)).toBeFocused(); await page.keyboard.press('ArrowDown'); await expect(names).toHaveText([EF, E2, E1]); // Without dragging: its arrows, or an article then its new place. await list.getByRole('button', { name: `Descendre « ${E2} »` }).click(); await expect(names).toHaveText([EF, E1, E2]); await expect(submit).toBeDisabled(); await items.nth(2).click(); await expect(items.nth(2)).toHaveAttribute('aria-pressed', 'true'); await items.nth(1).click(); await expect(names).toHaveText([EF, E2, E1]); // « Annuler » gives the section back as it read. await page.getByRole('button', { name: 'Annuler' }).click(); await expect(list).toHaveCount(0); await expect(page.locator('section.text article.unit', { hasText: E1 })).toHaveCount(1); await line.getByRole('button', { name: 'Réordonner' }).click(); await list.getByRole('button', { name: `Monter « ${E2} »` }).click(); await expect(names).toHaveText([EF, E2, E1]); // Then the vote: the order goes to the vote, and its vote unfolds at once, in the same place. await submit.click(); await expect(page.getByText('Ordre proposé au vote.')).toBeVisible(); const fold = line.locator('section.fold'); await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${ORDER} »`); await expect(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([EF, E2, E1]); await expect(line).toContainText('1 ordre proposé'); 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, 'c35-3-ordre-vote'); // Into force, by her voice or a second one: the section reads in its new order. if (!(await moduleInForce(page, pid))) { const yvv = await person(browser, 'Yvv'); const r = await yvv.request.post(`/api/proposals/${pid}/vote`, { data: { choice: 'for' }, headers: CSRF }); expect(r.ok()).toBe(true); await yvv.context().close(); } expect(await moduleInForce(page, pid)).toBe(true); await page.goto(TEST_DOC); const titles = await page.locator('section.text article.unit h2').allTextContents(); expect(titles.indexOf(E2)).toBeGreaterThan(-1); expect(titles.indexOf(E2)).toBeLessThan(titles.indexOf(E1)); await expect(line).toContainText(/adopté le/); }); test('C35 — sur un téléphone : réordonner une section où rien n’est en vigueur, la liste tient, les cibles se touchent', async ({ browser }) => { const ctx = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' }); const page = await ctx.newPage(); await signIn(page, 'Chiara'); await page.goto(DOC); // Nothing of the technical commitments is in the text: their order closes their section in the list. await unfold(page); const line = page.getByRole('group', { name: 'Ordre des articles · Engagements techniques' }); await line.getByRole('button', { name: 'Réordonner' }).click(); const list = page.getByRole('list', { name: 'Nouvel ordre des articles' }); const items = list.locator('button.item'); await expect(items).toHaveCount(11); await items.nth(3).click(); await items.nth(0).click(); await expect(page.getByRole('button', { name: 'Proposer cet ordre' })).toBeEnabled(); const holds = await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1); expect(holds).toBe(true); for (const target of [items.nth(0), list.locator('.grip').first(), list.getByRole('button', { name: /^Monter/ }).nth(1)]) { const box = await target.boundingBox(); expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); } await list.scrollIntoViewIfNeeded(); await shot(page, 'c35-4-poignees-mobile'); await page.getByRole('button', { name: 'Annuler' }).click(); await ctx.close(); }); test('C36 — combien de oui pour passer, combien de non pour bloquer : sous l’article déplié, et en bref dans la liste', async ({ page }) => { await signIn(page, 'Chiara'); // Before any vote (Monnaie libre): the Raison d'être, a seed proposal at level 2, quorum 12. await page.goto(DOC); const i1 = page.locator('section.text article.unit', { hasText: "Raison d'être" }).first(); await i1.hover(); await i1.getByRole('button', { name: 'Le vote' }).click(); const fold = page.getByRole('region', { name: "Le vote de « Raison d'être »" }); const need = fold.getByText('9 oui sur 12 votants pour passer', { exact: true }); await expect(need).toBeVisible(); // The quorum in a tooltip, never as a title. await expect(need).toHaveAttribute('title', 'Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ».'); // In the list, short: at level 3, quorum 50. await unfold(page); await expect(deepRow(page, 'Vérification personnelle de la clé')).toContainText('39 oui sur 50'); // With votes (the evaluation as the server gives it, ADR 0013): 9 « pour », 4 « contre » at // level 2, quorum 12, do not pass — 11 « pour » would; 4 « contre » hold the 9 in check. const title = 'Vérification personnelle de la clé'; const doc = await testDoc(page); const target = doc.units.find((u) => u.proposals.some((p) => p.title === title)); if (!target) throw new Error(`no « ${title} »`); let shape: 'held' | 'quorum' = 'held'; await page.route(/\/api\/docs\/[^/?]+$/, async (route) => { const res = await route.fetch(); const body = await res.json(); for (const u of body.units) { for (const p of u.proposals) { if (p.title !== title || p.status !== 'open') continue; p.tally = shape === 'held' ? { for: 9, against: 4 } : { for: 3, against: 0 }; p.evaluation = { ...p.evaluation, cast: shape === 'held' ? 13 : 3, quorum: 12, required: shape === 'held' ? 10 : 9, reached: false, missing: shape === 'held' ? 2 : 6, yesNeeded: shape === 'held' ? 11 : 9, noNeeded: shape === 'held' ? 4 : 0, }; } } await route.fulfill({ response: res, json: body }); }); await page.goto(TEST_DOC); await unfold(page); const row = deepRow(page, title); await expect(row).toContainText('passe à 11 oui · bloquée à 4 non'); await row.click(); const theirs = page.getByRole('region', { name: `Le vote de « ${title} »` }); await expect(theirs.getByText('11 oui pour passer · 4 non pour bloquer', { exact: true })).toBeVisible(); await expect(theirs.getByText('Il manque 2 voix pour.')).toBeVisible(); await shot(page, 'c36-1-oui-non'); // Below the quorum, the voters missing hold it alone: no « non » is needed. shape = 'quorum'; await page.reload(); await expect(theirs.getByText('9 oui pour passer · le quorum la bloque', { exact: true })).toBeVisible(); await expect(deepRow(page, title)).toContainText('passe à 9 oui'); await expect(deepRow(page, title)).not.toContainText('bloquée'); }); test('C37 — le quorum d’un article : son auteur le fixe avant le premier vote, revient au défaut, et lit les refus', async ({ page, browser }) => { await signIn(page, 'Chiara'); const title = 'Quorum choisi par son autrice'; const id = await propose(page, title, 'Je fixe le quorum de cet article avant que quiconque vote.'); await page.goto(`${TEST_DOC}?vote=${id}`); const fold = page.getByRole('region', { name: `Le vote de « ${title} »` }); const quorum = fold.getByRole('region', { name: /^Quorum/ }); await expect(quorum).toBeVisible(); // The default of its level, recalled; the test group asks 1. await expect(quorum.getByText('Par défaut, celui de son cran : 1 votant.')).toBeVisible(); await expect(quorum.getByRole('button', { name: /^Revenir au défaut/ })).toHaveCount(0); const field = quorum.getByRole('spinbutton', { name: "Quorum de l'article" }); await expect(field).toHaveValue('1'); await field.fill('3'); await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click(); await expect(page.getByText('Quorum fixé : 3 votants.')).toBeVisible(); await expect(fold.getByText(/oui sur 3 votants pour passer$/)).toBeVisible(); await shot(page, 'c37-1-quorum'); // Back to the default. await quorum.getByRole('button', { name: 'Revenir au défaut (1)' }).click(); await expect(page.getByText('Quorum revenu au défaut : 1 votant.')).toBeVisible(); await expect(fold.getByText(/oui sur 1 votant pour passer$/)).toBeVisible(); // Out of bounds: refused (400), said in her words. await field.fill('100000'); await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click(); await expect(quorum.getByRole('alert')).toHaveText(/^Ce quorum sort des bornes/); // Someone else never sees it. const yvv = await person(browser, 'Yvv'); await yvv.goto(`${TEST_DOC}?vote=${id}`); await expect(yvv.getByRole('region', { name: `Le vote de « ${title} »` })).toBeVisible(); await expect(yvv.getByRole('region', { name: /^Quorum/ })).toHaveCount(0); // A vote arrives while her page is open: refused (409), said in her words; then it is gone. const r = await yvv.request.post(`/api/proposals/${id}/vote`, { data: { choice: 'against' }, headers: CSRF }); expect(r.ok()).toBe(true); await field.fill('2'); await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click(); await expect(quorum.getByRole('alert')).toHaveText("Quelqu'un a déjà voté sur cet article : son quorum ne change plus."); await page.reload(); await expect(fold).toBeVisible(); await expect(fold.getByRole('region', { name: /^Quorum/ })).toHaveCount(0); await yvv.context().close(); });