forked from yvv/decision
ADR 0014 « Les informations de fonctionnement ne se votent pas » : ce qui sort
(F1, F1.1, F1.2, F1.3, N1, N1.1, O1 et l'ordre de leurs sections), comment (rien
d'effacé, units.retired_at), la garde et ses refus, aucun vote futur (404 / 400),
ce que dira l'interface (U3), la file de publication, le retour arrière (prouvé
avec l'image de de1b7eb), les limites. textes-amorce.md : chaque texte avant /
après en fr, en, es, E12, ce qui sort, les choix à confirmer par Yvv.
e2e (aucun changement de web/src) : C3, C9 (27 articles, cinq sections, quatre
Propositions seed), C17 (33 modules à voter), C18 et C31 (le texte d'I1 de
T4), C25 (cinq lignes d'ordre, E12 dans l'ordre de « fondamental »), C35 (E12
entre E1 et E2 ; le glisser d'une place), C38 (4 Propositions seed).
CONTROLES.md suit ; CLAUDE.md : article_amend.rs, retire.rs, amorce.rs,
fixtures/seed-m8, le seed de 27 articles, l'invariant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
267 lines
17 KiB
TypeScript
267 lines
17 KiB
TypeScript
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');
|
||
});
|