Files
decision/web/e2e/objections.spec.ts
T

267 lines
17 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 { 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 le seul", 'C’est le seul'));
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 le seul et unique propos");
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 le seul et unique propos');
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');
});