Files
decision/web/e2e/ouverture.spec.ts
T

332 lines
21 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 { DOC, seedsInForce, shot, signIn, TEST_DOC } from './helpers';
// Lot U3 (Yvv, 2026-11-03, vers 20 h 38 et avant 20 h 44): the rules of operation leave the votes
// (ADR 0014) and find their place in the screen. Before the text, a short chapter out of the vote,
// « Info ouverture doc de réf. », says the settings of the author at the opening, read in the data
// of the document; a separation; then the first article, with no title of section where the
// articles carry theirs. « Proposition d'amorce » for « Proposition seed ». A word for whoever is
// not invited yet. A handle dragged over several rows says where the article went.
test.beforeAll(async ({ browser }) => seedsInForce(browser));
const CSRF = { 'x-requested-with': 'libredecision' };
const TITLE = 'Info ouverture doc de réf.';
/** Yvv's paragraph, word for word (03/11, vers 20 h 38): it replaces « Commence par les Propositions seed ». */
const NONE = "Aucun article n'est encore en vigueur.";
const AMORCE =
"Les Propositions d'amorce y entrent par le vote, comme les propositions qui suivent, avec moins d'inertie pour que le document prenne vie plus facilement. Il est recommandé d'augmenter ensuite l'inertie, par le vote, pour diminuer la fréquence des changements.";
/** Once an article is in force, the first sentence goes; « y » has nothing to stand for, it says « en vigueur ». */
const AMORCE_AFTER =
"Les Propositions d'amorce entrent en vigueur par le vote, comme les propositions qui suivent, avec moins d'inertie pour que le document prenne vie plus facilement. Il est recommandé d'augmenter ensuite l'inertie, par le vote, pour diminuer la fréquence des changements.";
const AUTHOR =
"L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que l'article ne garde la trace d'aucun vote.";
/** Yvv, 03/11 avant 20 h 44 (« @ chiara » lu « à Chiara »). */
const UNINVITED =
"Pour rentrer dans un groupe de décisions, il faut pour l'instant demander un lien d'invitation à Chiara ou à Yvv. Ce prototype n'est pas encore câblé sur la chaîne.";
/** The top of the chapter, the separation and the first article of the text, in this order on the page. */
async function topOf(page: Page) {
return page.evaluate(() => {
const box = (el: Element | null) => el?.getBoundingClientRect() ?? null;
const chapter = box(document.querySelector('section.opening'));
const rule = box(document.querySelector('hr.opening-rule'));
const first = box(document.querySelector('section.text article.unit'));
return { chapter: chapter && { top: chapter.top, bottom: chapter.bottom }, rule: rule && { top: rule.top, bottom: rule.bottom }, first: first && { top: first.top } };
});
}
test('C39 — l’ouverture du document : « Info ouverture doc de réf. », hors vote, lu dans les données ; une séparation ; puis le texte, sans les titres Introduction et Engagements fondamentaux', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
// The chapter, named as Yvv named it, before the text; out of the vote: no gesture of a vote.
const chapter = page.getByRole('region', { name: TITLE });
await expect(chapter).toBeVisible();
await expect(chapter.getByRole('heading', { name: TITLE })).toBeVisible();
await expect(chapter.getByRole('button')).toHaveCount(0);
// Nothing in force: Yvv's paragraph, word for word, in place of the old line.
await expect(chapter.getByText(`${NONE} ${AMORCE}`)).toBeVisible();
await expect(page.getByText(/Commence par les Propositions/)).toHaveCount(0);
// The settings of the author at the opening: the inertia, then the quorum of each level.
await expect(chapter.getByRole('heading', { name: "Inertie à l'ouverture" })).toBeVisible();
await expect(chapter.locator('li')).toHaveText([
/^Propositions d'amorce\s*2 \/ 5 · basse$/,
/^Titre du document\s*4 \/ 5 · haute$/,
/^Ordre de chaque section\s*2 \/ 5 · basse$/,
/^Autres articles\s*3 \/ 5 · moyenne$/,
]);
await expect(chapter.getByRole('heading', { name: 'Quorum' })).toBeVisible();
await expect(chapter).toContainText('12 · 12 · 50 · 50 · 50 votants, du cran 1 au cran 5.');
await expect(chapter).toContainText("Sous le quorum d'un article, les votants qui manquent comptent comme « non ».");
await expect(chapter).toContainText(AUTHOR);
// Then a separation, drawn without a border, then the first article: in this order.
const rule = page.locator('hr.opening-rule');
await expect(rule).toHaveCount(1);
const at = await topOf(page);
expect(at.chapter && at.rule && at.first).toBeTruthy();
expect(at.rule!.top).toBeGreaterThanOrEqual(at.chapter!.bottom);
expect(at.first!.top).toBeGreaterThanOrEqual(at.rule!.bottom);
// The four moods: the rule is a soft colour of the mood, never a border.
for (const mood of ['pierre', 'calcaire', 'nuit', 'ardoise']) {
await page.evaluate((m) => (document.documentElement.dataset.mood = m), mood);
const look = await rule.evaluate((el) => {
const cs = getComputedStyle(el);
return { image: cs.backgroundImage, borders: [cs.borderTopWidth, cs.borderBottomWidth, cs.borderLeftWidth, cs.borderRightWidth], height: el.getBoundingClientRect().height };
});
expect(look.image, mood).toContain('gradient');
expect(look.borders, mood).toEqual(['0px', '0px', '0px', '0px']);
expect(look.height, mood).toBeGreaterThanOrEqual(1);
}
await page.evaluate(() => (document.documentElement.dataset.mood = 'pierre'));
// The titles « Introduction » and « Engagements fondamentaux » are not shown: the articles carry
// theirs (Yvv: « Ça embrouille. »). The order of the section stays votable, at its end.
const text = page.locator('section.text');
await expect(text.locator('.section-name')).toHaveCount(0);
await expect(text.getByText('Introduction', { exact: true })).toHaveCount(0);
await expect(text.getByText('Engagements fondamentaux', { exact: true })).toHaveCount(0);
const order = text.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
await expect(order).toBeVisible();
await expect(order.getByRole('button', { name: 'Réordonner' })).toBeAttached();
await shot(page, 'c39-1-ouverture');
// Read in the data of the document, never written in: other quorums, another level of the
// title, and the chapter follows.
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
const res = await route.fetch();
const body = await res.json();
body.doc.quorums = [3, 4, 5, 6, 7];
body.modules.title.inertia = 5;
await route.fulfill({ response: res, json: body });
});
await page.reload();
await expect(chapter).toContainText('3 · 4 · 5 · 6 · 7 votants, du cran 1 au cran 5.');
await expect(chapter.locator('li').nth(1)).toHaveText(/^Titre du document\s*5 \/ 5 · très haute$/);
await page.unroute(/\/api\/docs\/[^/?]+$/);
// The test group, its seed proposals voted into force (beforeAll): the first sentence goes,
// they keep the level they opened at, and the quorum is one voter at each level.
await page.goto(TEST_DOC);
await expect(chapter).toBeVisible();
await expect(chapter.getByText(NONE)).toHaveCount(0);
await expect(chapter.getByText(AMORCE_AFTER)).toBeVisible();
await expect(chapter.locator('li').first()).toHaveText(/^Propositions d'amorce\s*2 \/ 5 · basse$/);
await expect(chapter.locator('li').last()).toHaveText(/^Autres articles\s*3 \/ 5 · moyenne$/);
await expect(chapter).toContainText('1 votant à chaque cran.');
for (const name of await text.locator('.section-name').allTextContents()) {
expect(['Introduction', 'Engagements fondamentaux']).not.toContain(name.trim());
}
await expect(text.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' })).toBeVisible();
});
test('C40 — « Proposition d’amorce » à la place de « Proposition seed », en français, en anglais, en espagnol', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
const seeds = page.locator('section.text article.unit');
await expect(seeds.locator('.tag', { hasText: "Proposition d'amorce" })).toHaveCount(4);
await expect(page.locator('.facts')).toContainText("0 article en vigueur · 4 Propositions d'amorce · ");
await expect(page.locator('body')).not.toContainText(/propositions? seed/i);
await shot(page, 'c40-amorce');
const langs = page.getByRole('radiogroup', { name: 'Langue du texte' });
await langs.getByRole('radio', { name: 'EN' }).click();
await expect(seeds.locator('.tag', { hasText: 'Starter proposal' })).toHaveCount(4);
await expect(page.locator('.facts')).toContainText('4 starter proposals');
await expect(page.locator('body')).not.toContainText(/seed proposals?/i);
await page.getByRole('radiogroup', { name: 'Text language' }).getByRole('radio', { name: 'ES' }).click();
await expect(seeds.locator('.tag', { hasText: 'Propuesta de arranque' })).toHaveCount(4);
await expect(page.locator('.facts')).toContainText('4 propuestas de arranque');
await expect(page.locator('body')).not.toContainText(/propuestas? (seed|semilla)/i);
});
test('C41 — qui n’est pas encore invité : le mot d’Yvv à l’accueil et sur « Se connecter », sans redite', async ({ page }) => {
// The welcome without a session, the Ğ1 sign-in not active (the e2e server, as production):
// Yvv's word in place of a button that would lead nowhere.
await page.goto('/');
await expect(page.getByText(UNINVITED)).toBeVisible();
await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toHaveCount(0);
await expect(page.getByText("Tu as reçu un lien d'invitation ? Ouvre-le simplement : il te connecte.")).toBeVisible();
await shot(page, 'c41-1-accueil');
// « Se connecter »: said once, and the Ğ1 line no longer says how to get in meanwhile.
await page.goto('/connexion');
await expect(page.getByText(UNINVITED)).toHaveCount(1);
await expect(page.getByText(UNINVITED)).toBeVisible();
await expect(page.getByText("La connexion Ğ1 arrive : l'inscription de libreDecision auprès de connect.monnaie-libre.fr est en cours.", { exact: true })).toBeVisible();
await expect(page.getByText(/En attendant, on entre avec un lien d'invitation/)).toHaveCount(0);
await expect(page.getByText("Tu as reçu un lien d'invitation ? Ouvre-le simplement : il te connecte.")).toBeVisible();
await shot(page, 'c41-2-connexion');
// In English and in Spanish, the same word.
for (const [lang, words] of [
['en', 'To join a decision group, for now ask Chiara or Yvv for an invitation link. This prototype is not yet wired to the chain.'],
['es', 'Para entrar en un grupo de decisiones, por ahora hay que pedir un enlace de invitación a Chiara o a Yvv. Este prototipo aún no está conectado a la cadena.'],
] as const) {
await page.evaluate((l) => localStorage.setItem('ld:lang', l), lang);
await page.goto('/connexion');
await expect(page.getByText(words)).toBeVisible();
}
await page.evaluate(() => localStorage.setItem('ld:lang', 'fr'));
});
test('C42 — une poignée tirée sur plusieurs lignes : la nouvelle place est dite, le focus suit l’article', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
// The fundamental commitments, four articles, in the text of the test group (beforeAll).
const line = page.locator('section.text').getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
await line.hover();
await line.getByRole('button', { name: 'Réordonner' }).click();
const list = page.getByRole('list', { name: 'Nouvel ordre des articles' });
const names = list.locator('button.item .name');
await expect(names).toHaveCount(4);
const before = await names.allTextContents();
const last = before[3]!;
// The last handle taken, and carried over the three rows above it, step by step, as a hand does.
const grip = await list.locator('li').nth(3).locator('.grip').boundingBox();
const top = await list.locator('li').nth(0).boundingBox();
expect(grip && top).toBeTruthy();
await page.mouse.move(grip!.x + grip!.width / 2, grip!.y + grip!.height / 2);
await page.mouse.down();
await page.mouse.move(grip!.x + grip!.width / 2, top!.y + top!.height / 2, { steps: 24 });
await page.mouse.up();
await expect(names).toHaveText([last, ...before.slice(0, 3)]);
// Said, and the focus on the article moved: the arrows go on from there.
await expect(page.getByText(`« ${last} » en place 1 sur 4.`)).toBeAttached();
await expect(list.locator('button.item').nth(0)).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(names).toHaveText([before[0]!, last, before[1]!, before[2]!]);
await expect(page.getByText(`« ${last} » en place 2 sur 4.`)).toBeAttached();
await page.getByRole('button', { name: 'Annuler' }).click();
await expect(list).toHaveCount(0);
});
test('C43 — les informations de fonctionnement là où elles servent : le vote déplié, la formule, les réglages, l’ordre, le quorum', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
// The vote of a Proposition d'amorce: a permanent vote, the last gesture counts.
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 »" });
await expect(fold).toContainText('Le vote reste ouvert, sans échéance : tu peux changer ou retirer ton vote à tout moment, le dernier fait foi.');
// The formula: T′, R rounded up, G from the usual turnout, the quorum of the article.
await fold.getByText('La formule du seuil').click();
await expect(fold).toContainText("T′ — votants retenus : 12, T ou Q s'il en manque");
await expect(fold).toContainText('G — gradient');
await expect(fold).toContainText('déduit de la participation habituelle du groupe');
await expect(fold).toContainText("Une version atteint son seuil quand ses voix pour atteignent R, arrondi à l'entier au-dessus.");
await expect(fold).toContainText("Sous le quorum Q de l'article, les votants qui manquent comptent comme « non » : il faut autant de « pour » que si Q personnes avaient voté.");
await shot(page, 'c43-1-vote-formule');
// The settings: the defined terms, never « comme d'habitude ».
await fold.getByRole('button', { name: 'Réglages de la formule' }).click();
const panel = page.locator('aside.panel');
await expect(panel).toContainText("Part exigée : celle que le cran demande au-delà du quorum de l'article, à la participation habituelle.");
await expect(panel).toContainText("Elle ne dépend pas du quorum d'un article.");
await expect(panel).toContainText("G se calcule pour que la part exigée, au-delà du quorum de l'article, soit exactement celle du cran à la participation habituelle.");
await expect(panel).not.toContainText("d'habitude");
await shot(page, 'c43-2-reglages');
await page.keyboard.press('Escape');
// The test group: the order of a section, and the order in force with an order proposed beside it.
await page.goto(TEST_DOC);
const line = page.locator('section.text').getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
await line.hover();
await line.getByRole('button', { name: 'Réordonner' }).click();
await expect(line).toContainText("S'il entre en vigueur, seuls les articles de cette section changent de place ; aucun texte ne change.");
await page.getByRole('button', { name: 'Annuler' }).click();
// An order proposed beside the one in force (C35 leaves the old one back among the proposals; if
// not, one is proposed here): its vote says the margin it must beat.
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 fond = d.modules.orders.find((o: { section: string }) => o.section === 'fondamental');
if (!fond.proposals.some((p: { status: string; kind: string }) => p.status === 'open' && p.kind === 'order')) {
const ids: string[] = fond.members;
const r = await page.request.post(`/api/units/${fond.id}/order`, { data: { order: [...ids.slice(1), ids[0]] }, headers: CSRF });
expect(r.ok()).toBe(true);
await page.reload();
}
await line.getByRole('button', { name: /ordres? proposés?$/ }).click();
const rival = line.locator('section.fold');
await expect(rival).toContainText(
'Un texte est déjà en vigueur : pour le remplacer, cette version doit en plus atteindre une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.',
);
// The order in force: its other versions, and which one enters when several reach their threshold.
await line.hover();
await line.getByRole('button', { name: 'Le vote' }).click();
const inForce = line.locator('section.fold');
await inForce.getByRole('button', { name: /^Autres versions/ }).click();
await expect(inForce).toContainText('Si plusieurs versions atteignent leur seuil, celle qui a la marge la plus large entre en vigueur.');
await shot(page, 'c43-3-ordre-rival');
});
/**
* What of the table of the five levels is not seen whole in the settings panel: a header or a
* cell that leaves the table or the panel, a text that leaves its cell; the table, the panel or
* the page scrolled sideways to reach it. Empty when every column reads in full.
*/
async function hiddenInSettings(page: Page): Promise<string[]> {
return page.evaluate(() => {
const panel = document.querySelector('aside.panel');
const scroll = panel?.querySelector('.settings .scroll');
const table = scroll?.querySelector('table');
const body = panel?.querySelector('.body');
if (!panel || !scroll || !table || !body) return ['pas de tableau dans le panneau'];
const faults: string[] = [];
for (const [el, what] of [
[scroll, 'le tableau'],
[body, 'le panneau'],
[document.documentElement, 'la page'],
] as const) {
if (el.scrollWidth > el.clientWidth + 1) faults.push(`${what} défile de côté (${el.scrollWidth} > ${el.clientWidth})`);
}
const p = panel.getBoundingClientRect();
const s = scroll.getBoundingClientRect();
const left = Math.max(p.left, s.left, 0);
const right = Math.min(p.right, s.right, document.documentElement.clientWidth);
const cols = table.querySelectorAll('thead th').length;
if (cols !== 6) faults.push(`${cols} colonnes au lieu de 6`);
for (const cell of table.querySelectorAll('th, td')) {
const name = (cell.textContent ?? '').replace(/\s+/g, ' ').trim();
const c = cell.getBoundingClientRect();
const range = document.createRange();
range.selectNodeContents(cell);
const r = range.getBoundingClientRect();
if (c.left < left - 0.5 || c.right > right + 0.5)
faults.push(`« ${name} » : sa cellule sort du panneau (${Math.round(c.left)}–${Math.round(c.right)} hors de ${Math.round(left)}–${Math.round(right)})`);
if (r.left < c.left - 0.5 || r.right > c.right + 0.5) faults.push(`« ${name} » : son texte sort de sa cellule`);
}
return faults;
});
}
test('C44 — les réglages de la formule se lisent en entier : chaque colonne des cinq crans et chaque en-tête dans le panneau, sur bureau et sur téléphone', async ({ page, browser }) => {
// Lot U3b (03/11): on a desktop, the panel cut the table on the right, « Voix pour » out of sight.
await signIn(page, 'Chiara');
const g = await (await page.request.get('/api/groups/monnaie-libre')).json();
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
const e1 = d.units.find((u: { code: string }) => u.code === 'E1');
const version: string = e1.proposals.find((p: { seedProposal?: boolean }) => p.seedProposal).id;
const phone = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' });
const screens: { name: string; tag: string; page: Page; size?: { width: number; height: number } }[] = [
{ name: 'bureau 1360 × 900', tag: 'bureau-1360', page, size: { width: 1360, height: 900 } },
{ name: 'bureau 1024 × 768', tag: 'bureau-1024', page, size: { width: 1024, height: 768 } },
{ name: 'téléphone (Pixel 7)', tag: 'telephone', page: await phone.newPage() },
];
for (const screen of screens) {
const p = screen.page;
if (screen.size) await p.setViewportSize(screen.size);
if (p !== page) await signIn(p, 'Chiara');
// From the head of the document (the usual turnout), then from a version (« cet article » in its row).
for (const from of ['tete', 'version']) {
await p.goto(from === 'tete' ? `${DOC}?onglet=reglages` : `${DOC}?onglet=reglages&version=${version}`);
const panel = p.locator('aside.panel');
await expect(panel.getByRole('heading', { name: 'Réglages de la formule' })).toBeVisible();
await expect(panel.locator('table tbody tr')).toHaveCount(5);
if (from === 'version') await expect(panel.locator('tr.current th')).toContainText('cet article');
await expect(panel.locator('thead th').last()).toHaveText('Voix pour');
await panel.locator('table').scrollIntoViewIfNeeded();
// Soft: every screen is looked at, and the report names each one where the table is cut.
expect.soft(await hiddenInSettings(p), `${screen.name}, ${from === 'tete' ? 'en tête du document' : 'depuis une version'}`).toEqual([]);
await shot(p, `c44-${screen.tag}-${from}`);
}
}
await phone.close();
});