import { expect, test, type Page } from '@playwright/test'; import { advanceClock, person, propose, seedsInForce, shots, TEST_DOC, testDoc } from './helpers'; // Lot F (Yvv, 2026-10-30, ADR 0010): an objection « redit » or « contredit » // holds back the later of two versions until it is lifted; the author answers, // the objector lifts or reiterates from the box « Messages », and with no answer // within 3 days it is lifted. A typo fix to someone else's text waits for its // author (an administrator for a seed text) or for 3 days. Each control has its // own people and its own proposals, in the test group. const DAY = 86_400; // An objection names an article in force, a typo of C31 a text in force: the seed proposals // I1, E1 and E2 (ADR 0011) are voted into force in the test group first. test.beforeAll(async ({ browser }) => seedsInForce(browser)); /** The menu's « Messages », with its number of unread messages. */ async function openBox(page: Page, unread: number) { const nav = page.getByRole('navigation', { name: 'Boîte à outils' }); const link = nav.getByRole('link', { name: `Messages, ${unread} ${unread > 1 ? 'non lus' : 'non lu'}` }); await expect(link).toBeVisible(); await expect(link.locator('.unread')).toHaveText(String(unread)); await link.click(); await expect(page.getByRole('heading', { level: 1, name: 'Messages' })).toBeVisible(); } /** The details of a version, in the panel. */ async function details(page: Page, id: string) { await page.goto(`${TEST_DOC}?onglet=details&version=${id}`); const panel = page.locator('aside.panel'); // One thing at a time in the panel (lot U), named at its head. await expect(panel.getByRole('heading', { name: 'Détails', exact: true })).toBeVisible(); return panel; } /** From the details of a version: an objection against an article in force. */ async function raise(page: Page, id: string, kind: 'Redit' | 'Contredit', target: string, reason: string) { const panel = await details(page, id); await panel.getByText('Faire une objection').click(); await panel.locator(`.kinds label:has(input[value="${kind === 'Redit' ? 'redit' : 'contredit'}"])`).click(); await expect(panel.getByRole('radio', { name: new RegExp(`^${kind}`) })).toBeChecked(); await panel.getByLabel('Quel article ?').selectOption({ label: `En vigueur — ${target}` }); await panel.getByLabel(kind === 'Redit' ? 'En quoi ces deux textes disent la même chose' : 'En quoi ces deux textes se contredisent').fill(reason); await panel.getByRole('button', { name: "Envoyer l'objection" }).click(); await expect(page.getByText("Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions.")).toBeVisible(); return panel; } test('C28 — « redit » : plus de « pour » sur la dernière arrivée (contre ou retrait), l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => { const aude = await person(browser, 'Aude'); const oscar = await person(browser, 'Oscar'); const vera = await person(browser, 'Vera'); const title = 'Une seule clé par personne'; const id = await propose(aude, title, "Je m'engage à n'avoir qu'un seul compte membre : une seule clé certifiante par être humain."); // Oscar: it repeats « Unicité du compte ». const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.'); await expect(panel.locator('.objection')).toContainText(`« ${title} » redit « Unicité du compte »`); await expect(panel.getByText('personne ne peut plus voter pour cette version')).toBeVisible(); await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible(); await shots(oscar, 'f1-objection-vue-par-qui-objecte'); // Vera, who votes (Yvv, 2026-11-01: « Retirer ou "contre" »): held back, said so; // « Contre » and the withdrawal, never « Pour »; the list marks it. await vera.goto(`${TEST_DOC}?vote=${id}`); const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` }); await expect( fold.getByText("Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là."), ).toBeVisible(); await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible(); const choice = fold.getByRole('group', { name: 'Ton vote', exact: true }); await expect(choice.getByRole('button', { name: 'Contre', exact: true })).toBeVisible(); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('retenue par une objection'); await fold.scrollIntoViewIfNeeded(); await shots(vera, 'f2-objection-vue-par-qui-vote'); // « Contre », then the withdrawal: the version can only lose votes; « Pour » never comes back. await choice.getByRole('button', { name: 'Contre', exact: true }).click(); await expect(vera.getByText('Vote enregistré.')).toBeVisible(); await expect(fold.getByText('Ton vote : contre')).toBeVisible(); await expect(fold.locator('.tally')).toHaveText('0 pour · 1 contre'); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); await choice.getByRole('button', { name: 'Retirer mon vote' }).click(); await expect(vera.getByText('Vote retiré.')).toBeVisible(); await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible(); await expect(fold.locator('.tally')).toHaveText('0 pour · 0 contre'); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); // In the basket: « Contre » or the withdrawal, never « Pour »; « Tout pour » skips it // and says so, « Tout contre » takes it. await vera.goto(`${TEST_DOC}?onglet=panier`); const basket = vera.locator('aside.panel'); await expect(basket.getByRole('heading', { name: 'Panier de vote' })).toBeVisible(); const pick = basket.getByRole('group', { name: `Ton choix pour « ${title} »` }); await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toBeVisible(); await expect(pick.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); const heldRow = basket.locator('.version.held').filter({ has: vera.getByRole('group', { name: `Ton choix pour « ${title} »` }) }); await expect(heldRow).toContainText('une objection la retient : contre ou retrait, pas de « pour »'); const whole = basket.getByRole('group', { name: 'Tout le document', exact: true }); await whole.getByRole('button', { name: 'Tout pour' }).click(); await expect(basket.getByText(/^Tu as tout mis pour, sauf \d+ versions? qu'une objection retient : sur elles?, tu peux voter contre ou retirer ton vote\.$/)).toBeVisible(); await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toHaveAttribute('aria-pressed', 'false'); await shots(vera, 'g1-panier-tout-pour-la-saute'); await whole.getByRole('button', { name: 'Tout contre' }).click(); await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toHaveAttribute('aria-pressed', 'true'); await heldRow.scrollIntoViewIfNeeded(); await shots(vera, 'g2-panier-tout-contre-la-prend'); await whole.getByRole('button', { name: 'Vider' }).click(); await expect(basket.getByText("Rien dans le panier pour l'instant.")).toBeVisible(); // Aude: a message, and the way to answer — modify the article repeated. await aude.goto(TEST_DOC); await openBox(aude, 1); const raised = aude.locator('li.message', { hasText: 'Oscar fait une objection' }); await expect(raised).toContainText(`ta version « ${title} » redit « Unicité du compte »`); await expect(raised).toContainText("Pour répondre, modifie l'article visé."); await shots(aude, 'f3-messages-auteur'); await raised.getByRole('button', { name: "Modifier l'article visé" }).click(); await expect(aude).toHaveURL(/onglet=proposer/); await expect(aude).toHaveURL(/objection=/); const sheet = aude.locator('aside.panel'); await expect(sheet.getByText("Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.")).toBeVisible(); const body = sheet.getByLabel('Texte', { exact: true }); await expect(body).toHaveValue(/Je m.engage sur l.honneur/); await body.fill(`${await body.inputValue()}\n\nUne seule clé certifiante par être humain, et un seul compte membre.`); await shots(aude, 'f4-reponse-auteur'); await sheet.getByRole('button', { name: 'Soumettre au vote' }).click(); await expect(aude.getByText('Proposition soumise au vote.')).toBeVisible(); // Oscar: the answer, to lift or reiterate, with its deadline. await oscar.goto(TEST_DOC); await openBox(oscar, 1); const answered = oscar.locator('li.message', { hasText: 'a reçu une réponse' }); await expect(answered).toContainText(`Ton objection sur « ${title} » a reçu une réponse.`); await expect(answered.getByText(/^échéance : /)).toBeVisible(); await expect(answered.getByRole('button', { name: "Réitérer l'objection" })).toBeVisible(); await shots(oscar, 'f5-messages-qui-objecte'); await answered.getByRole('button', { name: "Lever l'objection" }).click(); await expect(oscar.getByText('Objection levée.')).toBeVisible(); // Dealt with: no gesture any more, and read. await expect(answered.getByText('objection levée', { exact: true })).toBeVisible(); await expect(answered.locator('.btn')).toHaveCount(0); await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible(); // Vera: it is voted on again, « Pour » too. await vera.goto(`${TEST_DOC}?vote=${id}`); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0); }); test('C29 — « contredit » : l’auteur reformule, sans réponse de la personne qui objecte l’objection est levée à l’échéance', async ({ browser }) => { const aude = await person(browser, 'Aude'); const oscar = await person(browser, 'Oscar'); const vera = await person(browser, 'Vera'); const title = 'Certification à sens unique'; const id = await propose(aude, title, "Je certifie une personne sans attendre qu'elle me certifie à son tour."); await raise(oscar, id, 'Contredit', 'Réciprocité', 'Réciprocité demande que la certification aille dans les deux sens.'); // Aude, from the vote under the article: reword it. await aude.goto(`${TEST_DOC}?vote=${id}`); const fold = aude.getByRole('region', { name: `Le vote de « ${title} »` }); await expect(fold.getByText('Elle contredit « Réciprocité ».')).toBeVisible(); await expect(fold.getByText("Pour qu'elle se vote à nouveau, reformule-la.", { exact: false })).toBeVisible(); await fold.scrollIntoViewIfNeeded(); await shots(aude, 'f8-objection-vue-par-l-auteur'); await fold.getByRole('button', { name: 'Reformuler' }).click(); await expect(aude).toHaveURL(/modifier=1/); const panel = aude.locator('aside.panel'); const body = panel.getByLabel('Texte', { exact: true }); await expect(body).toBeVisible(); await body.fill("Je certifie une personne et j'attends qu'elle me certifie à son tour, comme le veut la réciprocité."); await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); await expect(aude.getByText('Nouvelle version enregistrée : les votants devront reconfirmer.')).toBeVisible(); await expect(fold.getByText(/^Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le /)).toBeVisible(); // Oscar says nothing; three days pass on the server's clock. const swept = await advanceClock(oscar, 3 * DAY + 60); expect(swept.lifted).toBeGreaterThanOrEqual(1); // Vera: votable again; the objection says why it was lifted. const panelV = await details(vera, id); await expect(panelV.locator('.objection')).toContainText('levée : 3 jours sans suite'); await vera.goto(`${TEST_DOC}?vote=${id}`); await expect(vera.getByRole('region', { name: `Le vote de « ${title} »` }).getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); // Oscar's message: dealt with, by the deadline. await oscar.goto('/messages'); const answered = oscar.locator('li.message', { hasText: `Ton objection sur « ${title} »` }); await expect(answered.getByText('objection levée : 3 jours sans suite')).toBeVisible(); await expect(answered.locator('.btn')).toHaveCount(0); }); test('C30 — la coquille d’un autre sur une proposition : en attente, puis validée par l’auteur depuis ses messages', async ({ browser }) => { const aude = await person(browser, 'Aude'); const vera = await person(browser, 'Vera'); const title = 'Clé unique et vivante'; const id = await propose(aude, title, "Je m'engage à n'utiliser qu'une seule clé certifiante, tenue par moi seul."); // Vera fixes the apostrophes: it waits for Aude, the text stays as it was. const panel = await details(vera, id); await panel.getByText('Corriger une coquille').click(); const body = panel.getByLabel('Texte', { exact: true }); await body.fill((await body.inputValue()).replaceAll("'", '’')); await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); await expect(vera.getByText(/^Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le /)).toBeVisible(); const fix = panel.locator('li.fix'); await expect(fix).toContainText('proposée par Vera'); await expect(fix).toContainText("en attente : s'applique d'elle-même le"); await expect(fix.locator('ins')).toHaveCount(3); await expect(panel.locator('.prose').first()).toContainText("Je m'engage à n'utiliser"); await shots(vera, 'f6-coquille-en-attente'); // Aude validates it from her box. await aude.goto(TEST_DOC); await openBox(aude, 1); const msg = aude.locator('li.message', { hasText: `Vera propose une correction de coquille dans ta version « ${title} ».` }); await expect(msg.locator('ins')).toHaveCount(3); await expect(msg.getByText(/^échéance : /)).toBeVisible(); await msg.getByRole('button', { name: 'Valider' }).click(); await expect(aude.getByText('Correction appliquée.')).toBeVisible(); await expect(msg.getByText('correction appliquée', { exact: true })).toBeVisible(); await expect(msg.locator('.btn')).toHaveCount(0); // The text carries the fix; its history says who wrote it and who let it in. const after = await details(vera, id); await expect(after.locator('.prose').first()).toContainText('Je m’engage à n’utiliser'); await expect(after.locator('.history')).toContainText('coquille corrigée'); await expect(after.locator('.history')).toContainText('par Vera'); await expect(after.locator('.history')).toContainText('validée par Aude'); await expect(after.locator('li.fix')).toContainText('appliquée · par Aude'); }); test('C31 — une coquille sur un texte en vigueur attend sa validation, puis s’applique', async ({ browser }) => { // An administrator opens the group first: the message goes to them (a seed text has no author). const ada = await person(browser, 'Ada', true); await ada.goto(TEST_DOC); const vera = await person(browser, 'Vera'); await vera.goto(TEST_DOC); const purpose = vera.locator('section.text article.unit', { hasText: "Raison d'être" }); await purpose.hover(); await purpose.getByRole('button', { name: 'Le vote' }).click(); await purpose.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click(); const panel = vera.locator('aside.panel'); await panel.getByText('Corriger une coquille').click(); const body = panel.getByLabel('Texte', { exact: true }); await body.fill((await body.inputValue()).replace("C'est l'unique", 'C’est l’unique')); await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); await expect(vera.getByText(/^Ta correction attend un administrateur, ce texte venant du seed/)).toBeVisible(); // In force, only a validated fix changes the text. await expect(purpose).toContainText("C'est l'unique enjeu"); await expect(panel.locator('li.fix')).toContainText("en attente : s'applique d'elle-même le"); await ada.reload(); await openBox(ada, 1); const msg = ada.locator('li.message', { hasText: "Vera propose une correction de coquille dans « Raison d'être », un texte du seed" }); await shots(ada, 'f7-messages-administrateur'); await msg.getByRole('button', { name: 'Valider' }).click(); await expect(ada.getByText('Correction appliquée.')).toBeVisible(); await vera.reload(); await expect(purpose).toContainText('C’est l’unique enjeu'); const doc = await testDoc(vera); const unit = doc.units.find((u) => u.inForce?.title === "Raison d'être"); const after = await details(vera, unit?.inForce?.id ?? ''); await expect(after.locator('.history')).toContainText('validée par Ada'); });