Fusion du lot F : objections redit / contredit, coquilles validées par leur auteur, messagerie, délai de 3 jours

Conflits avec B2 : docs.rs garde shown_tally / shown_evaluation et votable ;
proposals.rs sert les totaux montrés et « votable » ; CONTROLES réunit C24
(« faire une objection », le panier par son adresse), C28–C33 et les deux passages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 05:57:21 +01:00
co-authored by Claude Opus 5.5
55 changed files with 5435 additions and 456 deletions
+43
View File
@@ -81,6 +81,49 @@ export async function shot(page: Page, name: string) {
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
}
const CSRF = { 'x-requested-with': 'libredecision' };
/** The document of the test group, as the server gives it. */
export async function testDoc(page: Page): Promise<{
id: string;
units: { id: string; code: string; inForce: { id: string; title: string } | null; proposals: { id: string; title: string }[] }[];
}> {
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 { id: d.doc.id, units: d.units };
}
/** A new article proposed by the person of `page`, submitted to the vote; returns its id. */
export async function propose(page: Page, title: string, body: string): Promise<string> {
const doc = await testDoc(page);
const r = await page.request.post(`/api/docs/${doc.id}/proposals`, {
data: { kind: 'text', unitId: null, title, body, submit: true },
headers: CSRF,
});
if (!r.ok()) throw new Error(`propose ${r.status()} ${await r.text()}`);
return (await r.json()).id;
}
/** Moves the server's clock forward (dev login only) and passes the deadlines at once. */
export async function advanceClock(page: Page, seconds: number): Promise<{ lifted: number; applied: number; stale: number }> {
const r = await page.request.post('/api/dev/clock', { data: { seconds }, headers: CSRF });
if (!r.ok()) throw new Error(`clock ${r.status()}`);
return r.json();
}
/** Screenshots on a desktop and on a phone, when `LD_SHOTS` names a directory (never in the loop). */
export async function shots(page: Page, name: string) {
if (!process.env.LD_SHOTS) return;
const size = page.viewportSize();
await page.waitForTimeout(400);
await shot(page, `${name}-bureau`);
await page.setViewportSize({ width: 412, height: 915 });
await page.waitForTimeout(200);
await shot(page, `${name}-mobile`);
if (size) await page.setViewportSize(size);
await page.waitForTimeout(200);
}
/** The control door of the simulated chain, found where the page finds its node. */
export async function chain(page: Page, docPath = DOC) {
const [, , group] = docPath.split('/');
+223
View File
@@ -0,0 +1,223 @@
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');
});
+4 -2
View File
@@ -90,13 +90,15 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr
});
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
await signIn(page, 'Chiara');
// A seed text has no author: an administrator's fix applies at once (someone
// else's waits for an administrator, or for 3 days: C31).
await signIn(page, 'Yvv', true);
await page.goto(TEST_DOC);
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await unicity.hover();
await unicity.getByRole('button', { name: 'Le vote' }).click();
// What is not the vote lives in the panel, tab « Détails ».
await unicity.getByRole('button', { name: 'Coquilles, traductions, signalements, historique' }).click();
await unicity.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Corriger une coquille').click();