forked from yvv/decision
Lot U3 : les tests d'abord, rouges sur 590ccd7 — e2e C39 à C43 (5 / 5 rouges : le chapitre « Info ouverture doc de réf. » absent, « Proposition seed », le mot pour qui n'est pas invité absent, une poignée tirée sur trois lignes s'arrête en place 2, les informations de fonctionnement absentes) ; vitest opening.test.ts (lib/opening absent) et rules.test.ts (25 rouges : les mots d'Yvv, « Proposition d'amorce », le terme défini « participation habituelle », chaque phrase de règle épinglée avec le test qui la prouve) ; Rust the_view_keeps_which_versions_the_seed_put_forward_once_they_enter_into_force (seedFeatured absent de la vue)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import { 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');
|
||||
});
|
||||
Reference in New Issue
Block a user