Files
decision/web/e2e/objections.spec.ts
T
YvvandClaude Opus 5.5 83f8ca0981 v3 : interface des objections, des coquilles validées et de la messagerie ; contrôles C28–C31
Une version retenue par une objection le dit sous l'article, à chacun
selon son rôle : la personne qui vote voit qu'elle ne se vote plus (les
votes faits restent) ; l'auteur, quoi faire (« Modifier l'article visé »
pour une redite, « Reformuler » pour une contradiction) ; la personne qui
objecte, lever ou réitérer avant l'échéance. Le panier la montre sans
choix et ne l'envoie jamais.

« Détails » : faire une objection (redit / contredit), les objections et
où elles en sont, les coquilles proposées par d'autres avec leur diff
resserré, valider / refuser / revenir, l'historique dit qui a validé.
Une coquille se propose sur toute version ouverte ou en vigueur.

Boîte « Messages » dans le menu, avec le nombre de non-lus (pastille sur
le bouton du menu en mobile) : objet, texte, gestes, échéance ; un message
traité n'a plus de geste. Répondre à une objection lit son message.

Trilingue (fr, en, es). C6 suit la règle : la coquille d'un administrateur
sur un texte du seed passe tout de suite ; C28–C31 couvrent le reste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 04:21:20 +01:00

224 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 { expect, test, type Page } from '@playwright/test';
import { advanceClock, person, propose, 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;
/** 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');
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
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 : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.")).toBeVisible();
return panel;
}
test('C28 — « redit » : la dernière arrivée ne se vote plus, 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('cette version ne se vote plus')).toBeVisible();
await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible();
await shots(oscar, 'f1-objection-vue-par-qui-objecte');
// Vera, who votes: held back, said so, nothing to press; 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 : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.")).toBeVisible();
await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible();
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('non votable : objection');
await fold.scrollIntoViewIfNeeded();
await shots(vera, 'f2-objection-vue-par-qui-vote');
// 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.
await vera.reload();
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');
});