forked from yvv/decision
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>
224 lines
14 KiB
TypeScript
224 lines
14 KiB
TypeScript
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');
|
||
});
|