forked from yvv/decision
239 lines
13 KiB
TypeScript
239 lines
13 KiB
TypeScript
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();
|
||
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);
|
||
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 ».');
|
||
// 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();
|
||
});
|