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>
239 lines
14 KiB
TypeScript
239 lines
14 KiB
TypeScript
import { devices, expect, test, type Browser, type Page } from '@playwright/test';
|
||
import { ALICE, DOC, person, shot, signIn, TEST_DOC, unfold, wallet } from './helpers';
|
||
|
||
// Lot D — the modules (Yvv, 2026-10-30; ADR 0009): at the end of each section, the
|
||
// order of its articles (inertia 2); at the head of the document, its title (inertia 4).
|
||
// Both voted like an article: unfolded, in the basket, on the Ğ1 chain in Monnaie libre
|
||
// (simulated here: e2e/chain-mock.mjs). Lot U (Yvv, 2026-11-02): the order is one line at
|
||
// the end of its section, never the list of its articles again; « Réordonner » puts handles
|
||
// on them in place (C35); every version of a module is voted at its place, never among the
|
||
// proposals. The server is shared with the specs that follow: the test group keeps its
|
||
// articles and their votes; its conclusion is reordered and its title changes.
|
||
|
||
const K1 = 'Règles abrégées de la toile de confiance';
|
||
const K2 = 'Monnaie G1';
|
||
|
||
/** Whether a version of a module of the test group is in force now. */
|
||
async function inForce(page: Page, proposalId: string): Promise<boolean> {
|
||
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 [d.modules.title, ...d.modules.orders].some((u) => u?.inForce?.id === proposalId);
|
||
}
|
||
|
||
/**
|
||
* The voices that bring a module version of the test group into force: hers, then a
|
||
* second one if hers was not enough. Every dev sign-in joins the test group: from 16
|
||
* members B^W vanishes and, the quorum being 1, one voice may suffice (ADR 0013). Returns
|
||
* the number of voices for it. The second one votes at the module, by its address.
|
||
*/
|
||
async function secondVoice(page: Page, browser: Browser, proposalId: string, fold: string, news: string): Promise<number> {
|
||
if (await inForce(page, proposalId)) {
|
||
await expect(page.getByText(news)).toBeVisible();
|
||
return 1;
|
||
}
|
||
const yvv = await person(browser, 'Yvv');
|
||
await yvv.goto(`${TEST_DOC}?vote=${proposalId}`);
|
||
await yvv.getByRole('region', { name: fold }).getByRole('button', { name: 'Pour', exact: true }).click();
|
||
await expect(yvv.getByText(news)).toBeVisible();
|
||
return 2;
|
||
}
|
||
|
||
test('C25 — l’ordre des articles : une ligne en fin de section, réordonnée sur place, votée là, en vigueur', async ({ page, browser }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto(TEST_DOC);
|
||
await unfold(page);
|
||
// Each section ends with its order, in one line: five sections (formule, inertie and
|
||
// ordonnancement are out of the document, ADR 0014).
|
||
const modules = page.getByRole('group', { name: /^Ordre des articles · / });
|
||
await expect(modules).toHaveCount(5);
|
||
await expect(page.getByRole('group', { name: 'Ordre des articles · Ordonnancement' })).toHaveCount(0);
|
||
const order = page.getByRole('group', { name: 'Ordre des articles · Conclusion' });
|
||
await expect(order).toContainText('Ordre de la section');
|
||
await expect(order).toContainText('inertie 2 / 5 · basse');
|
||
await expect(order.getByText('seed a minima · 0 vote')).toBeVisible();
|
||
// Never the list of its articles again: it is read in its vote, or while reordering.
|
||
await expect(order.getByText(K1)).toHaveCount(0);
|
||
await order.scrollIntoViewIfNeeded();
|
||
await shot(page, 'c25-1-ordre-lecture');
|
||
|
||
// Its vote unfolds under its line, like an article's, the list whole.
|
||
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
|
||
await fond.hover();
|
||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||
const fondVote = page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' });
|
||
// Its inertia, said once, in its own place (lot U2).
|
||
await expect(fondVote.getByRole('heading', { name: 'Inertie · 2 / 5 · basse' })).toBeVisible();
|
||
await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Responsabilité civique', 'Réciprocité']);
|
||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||
|
||
// Another order: « Réordonner », the article, then its new place — in place, no panel.
|
||
await order.hover();
|
||
await order.getByRole('button', { name: 'Réordonner' }).click();
|
||
await expect(page.locator('aside.panel')).toHaveCount(0);
|
||
const list = order.getByRole('list', { name: 'Nouvel ordre des articles' });
|
||
const items = list.locator('button.item');
|
||
const names = list.locator('button.item .name');
|
||
await expect(names).toHaveText([K1, K2]);
|
||
const submit = order.getByRole('button', { name: 'Proposer cet ordre' });
|
||
await expect(submit).toBeDisabled();
|
||
await items.nth(1).click();
|
||
await expect(items.nth(1)).toHaveAttribute('aria-pressed', 'true');
|
||
await items.nth(0).click();
|
||
await expect(names).toHaveText([K2, K1]);
|
||
await expect(order.getByText(`« ${K2} » en place 1 sur 2.`)).toBeAttached();
|
||
await expect(submit).toBeEnabled();
|
||
// With the keyboard: the moved article keeps the focus, the arrows move it.
|
||
await expect(items.nth(0)).toBeFocused();
|
||
await page.keyboard.press('ArrowDown');
|
||
await expect(names).toHaveText([K1, K2]);
|
||
await expect(submit).toBeDisabled();
|
||
await page.keyboard.press('ArrowUp');
|
||
await expect(names).toHaveText([K2, K1]);
|
||
// Or its arrows.
|
||
await order.getByRole('button', { name: `Descendre « ${K2} »` }).click();
|
||
await order.getByRole('button', { name: `Monter « ${K2} »` }).click();
|
||
await expect(names).toHaveText([K2, K1]);
|
||
await shot(page, 'c25-2-ordre-propose');
|
||
await submit.click();
|
||
await expect(page.getByText('Ordre proposé au vote.')).toBeVisible();
|
||
|
||
// Its vote unfolds at the line, the list it proposes in sight; never a line among the proposals.
|
||
const fold = order.locator('section.fold');
|
||
await expect(fold).toHaveAttribute('aria-label', 'Le vote de « Ordre des articles · Conclusion »');
|
||
await expect(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([K2, K1]);
|
||
await expect(order).toContainText('1 ordre proposé');
|
||
await expect(page.locator('ul.deep button.row .title', { hasText: /^Ordre des articles · / })).toHaveCount(0);
|
||
// The order in force is read from it, among its other versions (folded until asked).
|
||
await fold.getByRole('button', { name: 'Autres versions (1)' }).click();
|
||
await expect(fold.getByRole('region', { name: 'Autres versions' })).toContainText('Ordre en vigueur');
|
||
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
|
||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
||
await shot(page, 'c25-3-ordre-vote');
|
||
// Into force (her voice, or a second one): the section reads in its new order.
|
||
const voices = await secondVoice(page, browser, pid, 'Le vote de « Ordre des articles · Conclusion »', 'Le nouvel ordre de « Conclusion » est entré en vigueur.');
|
||
|
||
// In the basket too: at the end of its section.
|
||
await page.getByRole('button', { name: /^Voter en lot/ }).click();
|
||
const panel = page.locator('aside.panel');
|
||
const basket = panel.getByRole('region', { name: 'Conclusion' });
|
||
await expect(basket.locator('li.slot').last()).toContainText('Ordre des articles · Conclusion');
|
||
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
||
await page.reload();
|
||
// In force now: its vote stays unfolded under its line, the list whole, in the new order.
|
||
await expect(order.locator('section.fold')).toContainText(`${voices} pour · 0 contre`);
|
||
await expect(order.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText([K2, K1]);
|
||
await order.scrollIntoViewIfNeeded();
|
||
await shot(page, 'c25-3b-ordre-en-vigueur');
|
||
await expect(order).toContainText(/adopté le/);
|
||
await unfold(page);
|
||
const rows = await page.locator('ul.deep button.row .title').allTextContents();
|
||
expect(rows.indexOf(K2)).toBeLessThan(rows.indexOf(K1));
|
||
});
|
||
|
||
test('C26 — le titre du document : proposé et voté en tête, ses traductions à revoir', async ({ page, browser }) => {
|
||
await signIn(page, 'Chiara');
|
||
await page.goto(TEST_DOC);
|
||
const head = page.locator('header.head');
|
||
// A module, named for what it is (in French here; the language changes below).
|
||
await expect(head.getByRole('group', { name: 'Titre du document' })).toBeVisible();
|
||
const bar = head.locator('.title-unit');
|
||
await expect(bar).toContainText('Titre du document · seed a minima · 0 vote');
|
||
await expect(bar).toContainText('inertie 4 / 5 · haute');
|
||
await head.hover();
|
||
await bar.getByRole('button', { name: 'Proposer un autre titre' }).click();
|
||
await expect(page).toHaveURL(/onglet=proposer/);
|
||
const panel = page.locator('aside.panel');
|
||
await expect(panel.getByRole('heading', { name: 'Proposer un autre titre' })).toBeVisible();
|
||
const input = panel.getByRole('textbox', { name: 'Nouveau titre' });
|
||
await expect(input).toHaveValue("Acte d'engagement Certification");
|
||
const submit = panel.getByRole('button', { name: 'Proposer un autre titre' });
|
||
await expect(submit).toBeDisabled();
|
||
await expect(panel.getByText("Le titre du document se vote à l'inertie 4 / 5 · haute.", { exact: false })).toBeVisible();
|
||
const next = "Acte d'engagement de la certification";
|
||
await input.fill(next);
|
||
await shot(page, 'c26-1-titre-propose');
|
||
await submit.click();
|
||
await expect(page.getByText('Titre proposé au vote.')).toBeVisible();
|
||
// Its vote unfolds under the title, where it is read; never a line among the proposals.
|
||
const fold = bar.locator('section.fold');
|
||
await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${next} »`);
|
||
await expect(bar).toContainText('1 titre proposé');
|
||
await expect(page.locator('ul.deep').getByText(next)).toHaveCount(0);
|
||
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
|
||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
||
const voices = await secondVoice(page, browser, pid, `Le vote de « ${next} »`, `Le titre « ${next} » est entré en vigueur.`);
|
||
|
||
// The document changes title; its translations stay editorial, to review.
|
||
await page.reload();
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(next);
|
||
await expect(bar).toContainText(/adopté le/);
|
||
await expect(bar.locator('.tag')).toHaveCount(0);
|
||
// In force now: its vote stays unfolded, under the title.
|
||
const vote = bar.locator('section.fold');
|
||
await expect(bar.getByRole('button', { name: 'Le vote' })).toHaveAttribute('aria-expanded', 'true');
|
||
await expect(vote.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
|
||
await expect(vote.getByText(`${voices} pour · 0 contre`)).toBeVisible();
|
||
await shot(page, 'c26-2-titre-vote');
|
||
await bar.getByRole('button', { name: 'Le vote' }).click();
|
||
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
|
||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Certification Commitment Act');
|
||
await expect(bar.locator('.tag')).toHaveText('translation needs review');
|
||
await shot(page, 'c26-3-titre-traduction-a-revoir');
|
||
});
|
||
|
||
test('C27 — la participation ramenée dans ses bornes : la mesure, puis ce que le groupe demande', async ({ page }) => {
|
||
await signIn(page, 'Chiara');
|
||
// A group of four whose median is 2,5 voters, brought back to half of it by the scale.
|
||
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
|
||
const res = await route.fetch();
|
||
const body = await res.json();
|
||
body.doc.electorate = 4;
|
||
body.doc.participation = { ...body.doc.participation, source: 'median', median: 2.5, sample: 12, bounded: true, voters: 2, ratio: 0.5, bounds: [0.05, 0.5] };
|
||
await route.fulfill({ response: res, json: body });
|
||
});
|
||
await page.goto(TEST_DOC);
|
||
await page.getByRole('button', { name: 'Réglages de la formule' }).click();
|
||
const panel = page.locator('aside.panel');
|
||
// The measure as it is: 2,5 of 4 is 62,5 %, never 3 of 4 rounded first.
|
||
await expect(panel.getByText(/^3 votants sur 4 \(62,5\s%\) : la médiane des 12 propositions votées depuis un an\.$/)).toBeVisible();
|
||
// Then what the group asks, the group as subject.
|
||
await expect(panel.getByText(/^Ramenée entre 5\s% et 50\s% de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient \(50\s%\)\.$/)).toBeVisible();
|
||
await expect(panel.getByText("Part exigée : la part de « pour » demandée, à 2 votants comme d'habitude.")).toBeVisible();
|
||
await shot(page, 'c27-participation-bornee');
|
||
});
|
||
|
||
test('C25 — sur la chaîne Ğ1 et sur un téléphone : l’ordre se vote dans le panier, puis se lit à sa ligne', async ({ browser }) => {
|
||
// The chain is waited for: the server reads the indexer every 10 seconds.
|
||
test.setTimeout(90_000);
|
||
const ctx = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' });
|
||
const page = await ctx.newPage();
|
||
await signIn(page, 'Chiara');
|
||
await wallet(page, [ALICE]);
|
||
await page.goto(DOC);
|
||
// The basket of Monnaie libre: the order of the fundamental commitments, alone, in one transaction.
|
||
await page.getByRole('button', { name: /^Voter en lot/ }).click();
|
||
const panel = page.locator('aside.panel');
|
||
await panel.getByRole('group', { name: 'Ton choix pour « Ordre des articles · Engagements fondamentaux »' }).getByRole('button', { name: 'Pour' }).click();
|
||
await panel.getByRole('button', { name: 'Signer et envoyer (1)' }).click();
|
||
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
|
||
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
|
||
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
||
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
|
||
await expect(fond.getByText('seed a minima · 1 vote')).toBeVisible();
|
||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||
await expect(page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' }).locator('.voted')).toHaveText(
|
||
'Ton vote : pour · inscrit sur la chaîne Ğ1',
|
||
);
|
||
await fond.scrollIntoViewIfNeeded();
|
||
await shot(page, 'c25-4-ordre-mobile');
|
||
// The title on a phone: its gestures in sight, without a pointer to hover with. Reordering on a
|
||
// phone: C35.
|
||
await page.goto(TEST_DOC);
|
||
await expect(page.locator('.title-unit .links')).toHaveCSS('opacity', '1');
|
||
await shot(page, 'c26-4-titre-mobile');
|
||
await ctx.close();
|
||
});
|