Lot U : C35 à C37 écrits avant le code et vus rouges (poignées et vote, oui pour passer · non pour bloquer, quorum de l'auteur) ; leurs mots en fr, en, es ; needOf ; le vote simulé ne promet plus son effacement à l'ouverture de la chaîne

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 07:51:07 +01:00
co-authored by Claude Opus 5.5
parent af08fdaa8b
commit 70f7bb7247
7 changed files with 455 additions and 9 deletions
+11
View File
@@ -80,6 +80,17 @@ export function deepRow(page: Page, title: string) {
return page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) });
}
/**
* « Propositions en cours » shows its sections folded (lot U): « Tout déplier » opens them
* all, so that a line can be clicked.
*/
export async function unfold(page: Page) {
const all = page.getByRole('button', { name: 'Tout déplier' });
await expect(all).toBeVisible();
await all.click();
await expect(page.getByRole('button', { name: 'Tout replier' })).toBeVisible();
}
/** A screenshot for the visual check, when `LD_SHOTS` names a directory (never in the loop). */
export async function shot(page: Page, name: string) {
const dir = process.env.LD_SHOTS;
+237
View File
@@ -0,0 +1,237 @@
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', { exact: false })).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();
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();
// 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 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);
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 ».');
// 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();
await expect(quorum.getByRole('alert')).toHaveText("Quelqu'un a déjà voté sur cet article : son quorum ne change plus.");
await page.reload();
await expect(fold).toBeVisible();
await expect(fold.getByRole('region', { name: /^Quorum/ })).toHaveCount(0);
await yvv.context().close();
});