Files
decision/web/e2e/ordre-quorum.spec.ts
T

294 lines
17 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 { devices, expect, test, type Page } from '@playwright/test';
import { deepRow, DOC, person, propose, seedsInForce, shot, signIn, TEST_DOC, testDoc, unfold } from './helpers';
// Lot U — the reader (Yvv, 2026-11-02: « la répétition de "ordonnancement" est nulle. Il
// faudrait que ce soit en fin de section, qui ajoute des "poignées" aux modules de la section.
// Et permette de voter. » ; « combien il faut de oui pour passer et combien de non pour
// maintenir l'opposition »). Run after modules.spec (C25 reads the order of the fundamental
// commitments as the seed gives it): here it is reordered, voted, and may enter into force.
test.beforeAll(async ({ browser }) => seedsInForce(browser));
const EF = 'Engagements fondamentaux';
const E1 = 'Unicité du compte';
const E2 = 'Réciprocité';
const ORDER = `Ordre des articles · ${EF}`;
const CSRF = { 'x-requested-with': 'libredecision' };
/** Whether a version of a module of the test group is in force now. */
async function moduleInForce(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);
}
test('C35 — réordonner une section : les poignées sur ses articles, au glisser, au clavier, sans glisser, puis le vote', async ({ page, browser }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
// The order closes its section in one line: its name, its weight, its state — never the list again.
const line = page.getByRole('group', { name: ORDER });
await expect(line).toBeVisible();
await expect(line).toContainText('Ordre de la section');
await expect(line).toContainText('inertie 2 / 5 · basse');
await expect(line.getByText(E1)).toHaveCount(0);
// Nowhere a block that repeats the articles of a section, and no order among the proposals.
await expect(page.locator('section.text').getByText('Ordre des articles ·', { exact: false })).toHaveCount(0);
await expect(page.locator('ul.deep').getByText(/^Ordre des articles · /)).toHaveCount(0);
await line.scrollIntoViewIfNeeded();
await shot(page, 'c35-1-ligne-ordre');
// « Réordonner »: the articles of the section fold to their titles, a handle before each, in place.
await line.getByRole('button', { name: 'Réordonner' }).click();
await expect(page.locator('aside.panel')).toHaveCount(0);
const list = page.locator('section.text').getByRole('list', { name: 'Nouvel ordre des articles' });
const names = list.locator('button.item .name');
await expect(names).toHaveText([EF, E1, E2]);
// The articles in force of the section are in the list now, not twice; the one only proposed says so.
await expect(page.locator('section.text article.unit', { hasText: E1 })).toHaveCount(0);
await expect(list.locator('li', { hasText: EF })).toContainText('proposé');
await expect(list.locator('.grip')).toHaveCount(3);
const submit = page.getByRole('button', { name: 'Proposer cet ordre' });
await expect(submit).toBeDisabled();
// Off, and it says why (UX-lecteur.md: « le bouton reste éteint et le dit » — lot U2).
const why = "C'est l'ordre en vigueur : déplace au moins un article.";
await expect(page.getByText(why)).toBeVisible();
await expect(submit).toHaveAccessibleDescription(why);
await shot(page, 'c35-2-poignees');
// By dragging its handle: Réciprocité onto the place of Unicité du compte.
await list.locator('li', { hasText: E2 }).locator('.grip').dragTo(list.locator('li', { hasText: E1 }));
await expect(names).toHaveText([EF, E2, E1]);
await expect(page.getByText(`« ${E2} » en place 2 sur 3.`)).toBeAttached();
await expect(submit).toBeEnabled();
await expect(page.getByText(why)).toHaveCount(0);
// With the keyboard: the article keeps the focus, the arrows move it.
const items = list.locator('button.item');
await items.nth(1).focus();
await page.keyboard.press('ArrowUp');
await expect(names).toHaveText([E2, EF, E1]);
await expect(items.nth(0)).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(names).toHaveText([EF, E2, E1]);
// Without dragging: its arrows, or an article then its new place.
await list.getByRole('button', { name: `Descendre « ${E2} »` }).click();
await expect(names).toHaveText([EF, E1, E2]);
await expect(submit).toBeDisabled();
await expect(page.getByText(why)).toBeVisible();
await items.nth(2).click();
await expect(items.nth(2)).toHaveAttribute('aria-pressed', 'true');
await items.nth(1).click();
await expect(names).toHaveText([EF, E2, E1]);
// « Annuler » gives the section back as it read.
await page.getByRole('button', { name: 'Annuler' }).click();
await expect(list).toHaveCount(0);
await expect(page.locator('section.text article.unit', { hasText: E1 })).toHaveCount(1);
await line.getByRole('button', { name: 'Réordonner' }).click();
await list.getByRole('button', { name: `Monter « ${E2} »` }).click();
await expect(names).toHaveText([EF, E2, E1]);
// Then the vote: the order goes to the vote, and its vote unfolds at once, in the same place.
await submit.click();
await expect(page.getByText('Ordre proposé au vote.')).toBeVisible();
const fold = line.locator('section.fold');
await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${ORDER} »`);
await expect(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([EF, E2, E1]);
await expect(line).toContainText('1 ordre proposé');
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, 'c35-3-ordre-vote');
// Into force, by her voice or a second one: the section reads in its new order.
if (!(await moduleInForce(page, pid))) {
const yvv = await person(browser, 'Yvv');
const r = await yvv.request.post(`/api/proposals/${pid}/vote`, { data: { choice: 'for' }, headers: CSRF });
expect(r.ok()).toBe(true);
await yvv.context().close();
}
expect(await moduleInForce(page, pid)).toBe(true);
await page.goto(TEST_DOC);
await expect(page.locator('section.text article.unit', { hasText: E2 })).toBeVisible();
const titles = await page.locator('section.text article.unit h2').allTextContents();
expect(titles.indexOf(E2)).toBeGreaterThan(-1);
expect(titles.indexOf(E2)).toBeLessThan(titles.indexOf(E1));
await expect(line).toContainText(/adopté le/);
});
test('C35 — sur un téléphone : réordonner une section où rien n’est en vigueur, la liste tient, les cibles se touchent', async ({ browser }) => {
const ctx = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' });
const page = await ctx.newPage();
await signIn(page, 'Chiara');
await page.goto(DOC);
// Nothing of the technical commitments is in the text: their order closes their section in the list.
await unfold(page);
const line = page.getByRole('group', { name: 'Ordre des articles · Engagements techniques' });
await line.getByRole('button', { name: 'Réordonner' }).click();
const list = page.getByRole('list', { name: 'Nouvel ordre des articles' });
const items = list.locator('button.item');
await expect(items).toHaveCount(11);
await items.nth(3).click();
await items.nth(0).click();
await expect(page.getByRole('button', { name: 'Proposer cet ordre' })).toBeEnabled();
const holds = await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1);
expect(holds).toBe(true);
for (const target of [items.nth(0), list.locator('.grip').first(), list.getByRole('button', { name: /^Monter/ }).nth(1)]) {
const box = await target.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
await list.scrollIntoViewIfNeeded();
await shot(page, 'c35-4-poignees-mobile');
await page.getByRole('button', { name: 'Annuler' }).click();
await ctx.close();
});
test('C36 — combien de oui pour passer, combien de non pour bloquer : sous l’article déplié, et en bref dans la liste', async ({ page }) => {
await signIn(page, 'Chiara');
// Before any vote (Monnaie libre): the Raison d'être, a seed proposal at level 2, quorum 12.
await page.goto(DOC);
const i1 = page.locator('section.text article.unit', { hasText: "Raison d'être" }).first();
await i1.hover();
await i1.getByRole('button', { name: 'Le vote' }).click();
const fold = page.getByRole('region', { name: "Le vote de « Raison d'être »" });
const need = fold.getByText('9 oui sur 12 votants pour passer', { exact: true });
await expect(need).toBeVisible();
// The quorum in a tooltip, never as a title.
await expect(need).toHaveAttribute('title', 'Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ».');
// Said once: the line of the seed proposal, right above its vote, does not say it again (lot U2).
await expect(i1.locator('footer')).not.toContainText(/oui (sur|pour)/);
await expect(i1.locator('footer')).not.toContainText(/\d+ pour · \d+ contre/);
// In the list, short: at level 3, quorum 50.
await unfold(page);
await expect(deepRow(page, 'Vérification personnelle de la clé')).toContainText('39 oui sur 50');
// With votes (the evaluation as the server gives it, ADR 0013): 9 « pour », 4 « contre » at
// level 2, quorum 12, do not pass — 11 « pour » would; 4 « contre » hold the 9 in check.
const title = 'Vérification personnelle de la clé';
const doc = await testDoc(page);
const target = doc.units.find((u) => u.proposals.some((p) => p.title === title));
if (!target) throw new Error(`no « ${title} »`);
let shape: 'held' | 'quorum' = 'held';
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
const res = await route.fetch();
const body = await res.json();
for (const u of body.units) {
for (const p of u.proposals) {
if (p.title !== title || p.status !== 'open') continue;
p.tally = shape === 'held' ? { for: 9, against: 4 } : { for: 3, against: 0 };
p.evaluation = {
...p.evaluation,
cast: shape === 'held' ? 13 : 3,
quorum: 12,
required: shape === 'held' ? 10 : 9,
reached: false,
missing: shape === 'held' ? 2 : 6,
yesNeeded: shape === 'held' ? 11 : 9,
noNeeded: shape === 'held' ? 4 : 0,
};
}
}
await route.fulfill({ response: res, json: body });
});
await page.goto(TEST_DOC);
await unfold(page);
const row = deepRow(page, title);
await expect(row).toContainText('passe à 11 oui · bloquée à 4 non');
await row.click();
const theirs = page.getByRole('region', { name: `Le vote de « ${title} »` });
await expect(theirs.getByText('11 oui pour passer · 4 non pour bloquer', { exact: true })).toBeVisible();
await expect(theirs.getByText('Il manque 2 voix pour.')).toBeVisible();
await shot(page, 'c36-1-oui-non');
// Below the quorum, the voters missing hold it alone: no « non » is needed.
shape = 'quorum';
await page.reload();
await expect(theirs.getByText('9 oui pour passer · le quorum la bloque', { exact: true })).toBeVisible();
await expect(deepRow(page, title)).toContainText('passe à 9 oui');
await expect(deepRow(page, title)).not.toContainText('bloquée');
});
test('C37 — le quorum d’un article : son auteur le fixe avant le premier vote, revient au défaut, et lit les refus', async ({ page, browser }) => {
await signIn(page, 'Chiara');
const title = 'Quorum choisi par son autrice';
const id = await propose(page, title, 'Je fixe le quorum de cet article avant que quiconque vote.');
await page.goto(`${TEST_DOC}?vote=${id}`);
const fold = page.getByRole('region', { name: `Le vote de « ${title} »` });
const quorum = fold.getByRole('region', { name: /^Quorum/ });
await expect(quorum).toBeVisible();
// The default of its level, recalled; the test group asks 1.
await expect(quorum.getByText('Par défaut, celui de son cran : 1 votant.')).toBeVisible();
await expect(quorum.getByRole('button', { name: /^Revenir au défaut/ })).toHaveCount(0);
const field = quorum.getByRole('spinbutton', { name: "Quorum de l'article" });
await expect(field).toHaveValue('1');
await field.fill('3');
await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click();
await expect(page.getByText('Quorum fixé : 3 votants.')).toBeVisible();
await expect(fold.getByText(/oui sur 3 votants pour passer$/)).toBeVisible();
await shot(page, 'c37-1-quorum');
// Back to the default.
await quorum.getByRole('button', { name: 'Revenir au défaut (1)' }).click();
await expect(page.getByText('Quorum revenu au défaut : 1 votant.')).toBeVisible();
await expect(fold.getByText(/oui sur 1 votant pour passer$/)).toBeVisible();
// Out of bounds: refused (400), said in her words.
await field.fill('100000');
await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click();
await expect(quorum.getByRole('alert')).toHaveText(/^Ce quorum sort des bornes/);
// Someone else never sees it.
const yvv = await person(browser, 'Yvv');
await yvv.goto(`${TEST_DOC}?vote=${id}`);
await expect(yvv.getByRole('region', { name: `Le vote de « ${title} »` })).toBeVisible();
await expect(yvv.getByRole('region', { name: /^Quorum/ })).toHaveCount(0);
// A vote arrives while her page is open: refused (409), said in her words; then it is gone.
const r = await yvv.request.post(`/api/proposals/${id}/vote`, { data: { choice: 'against' }, headers: CSRF });
expect(r.ok()).toBe(true);
await field.fill('2');
await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click();
// « ne change pas », not « plus »: a vote withdrawn here leaves no trace (lot T2).
await expect(quorum.getByRole('alert')).toHaveText("Quelqu'un a déjà voté sur cet article : son quorum ne change pas.");
await page.reload();
await expect(fold).toBeVisible();
await expect(fold.getByRole('region', { name: /^Quorum/ })).toHaveCount(0);
await yvv.context().close();
});
test('C38 — l’en-tête compte ce que montre la liste ; un changement d’inertie d’un ordre ou du titre se trouve à sa ligne', async ({ page }) => {
await signIn(page, 'Chiara');
/** The versions « Propositions en cours » shows: one per line, or those inside a line of several. */
const listed = () =>
page.locator('ul.deep li.article').evaluateAll((lis) => lis.reduce((n, li) => n + Math.max(1, li.querySelectorAll('ul.versions > li').length), 0));
// Monnaie libre, nothing in force: the seed proposals at the head, said apart; the count of the
// proposals in progress is what the list below shows (lot U2: the orders are not counted there).
await page.goto(DOC);
await unfold(page);
const facts = page.locator('.facts');
await expect.soft(facts).toContainText('0 article en vigueur · 3 Propositions seed · ');
await expect.soft(facts).toContainText(new RegExp(`· ${await listed()} propositions en cours`));
// A change of inertia on the order of a section and on the title (test group): each is found at
// the line of its module, where its vote unfolds — never a line of « Propositions en cours ».
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();
const modules: { id: string; inertia: number }[] = [d.modules.orders.find((o: { section: string }) => o.section === 'conclusion'), d.modules.title];
const made: string[] = [];
for (const unit of modules) {
const r = await page.request.post(`/api/units/${unit.id}/inertia`, { data: { target: unit.inertia === 5 ? 4 : unit.inertia + 1 }, headers: CSRF });
expect(r.ok()).toBe(true);
made.push((await r.json()).id);
}
await page.goto(TEST_DOC);
const title = page.getByRole('group', { name: 'Titre du document' });
const titleChange = title.getByRole('button', { name: "1 changement d'inertie proposé" });
await expect(titleChange).toBeVisible();
await titleChange.click();
await expect(title.getByRole('region', { name: /^Le vote de « inertie vers \d \/ 5 · / })).toBeVisible();
await shot(page, 'c38-1-titre-inertie');
await unfold(page);
await expect.soft(facts).toContainText(new RegExp(`· ${await listed()} propositions en cours`));
await expect(page.locator('ul.deep').getByText(/inertie vers/)).toHaveCount(0);
const order = page.getByRole('group', { name: 'Ordre des articles · Conclusion' });
const orderChange = order.getByRole('button', { name: "1 changement d'inertie proposé" });
await expect(orderChange).toBeVisible();
await orderChange.click();
await expect(order.getByRole('region', { name: /^Le vote de « inertie vers \d \/ 5 · / })).toBeVisible();
await order.scrollIntoViewIfNeeded();
await shot(page, 'c38-2-ordre-inertie');
// Withdrawn: the group is left as found.
for (const id of made) expect((await page.request.post(`/api/proposals/${id}/withdraw`, { headers: CSRF })).ok()).toBe(true);
});