From 9d478d668a53e9f805314035c73edcc832db5dd8 Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 3 Nov 2026 22:28:32 +0100 Subject: [PATCH] =?UTF-8?q?Lot=20U3=20:=20les=20tests=20d'abord,=20rouges?= =?UTF-8?q?=20sur=20590ccd7=20=E2=80=94=20e2e=20C39=20=C3=A0=20C43=20(5=20?= =?UTF-8?q?/=205=20rouges=20:=20le=20chapitre=20=C2=AB=20Info=20ouverture?= =?UTF-8?q?=20doc=20de=20r=C3=A9f.=20=C2=BB=20absent,=20=C2=AB=20Propositi?= =?UTF-8?q?on=20seed=20=C2=BB,=20le=20mot=20pour=20qui=20n'est=20pas=20inv?= =?UTF-8?q?it=C3=A9=20absent,=20une=20poign=C3=A9e=20tir=C3=A9e=20sur=20tr?= =?UTF-8?q?ois=20lignes=20s'arr=C3=AAte=20en=20place=202,=20les=20informat?= =?UTF-8?q?ions=20de=20fonctionnement=20absentes)=20;=20vitest=20opening.t?= =?UTF-8?q?est.ts=20(lib/opening=20absent)=20et=20rules.test.ts=20(25=20ro?= =?UTF-8?q?uges=20:=20les=20mots=20d'Yvv,=20=C2=AB=20Proposition=20d'amorc?= =?UTF-8?q?e=20=C2=BB,=20le=20terme=20d=C3=A9fini=20=C2=AB=20participation?= =?UTF-8?q?=20habituelle=20=C2=BB,=20chaque=20phrase=20de=20r=C3=A8gle=20?= =?UTF-8?q?=C3=A9pingl=C3=A9e=20avec=20le=20test=20qui=20la=20prouve)=20;?= =?UTF-8?q?=20Rust=20the=5Fview=5Fkeeps=5Fwhich=5Fversions=5Fthe=5Fseed=5F?= =?UTF-8?q?put=5Fforward=5Fonce=5Fthey=5Fenter=5Finto=5Fforce=20(seedFeatu?= =?UTF-8?q?red=20absent=20de=20la=20vue)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ld-server/tests/seed_proposals.rs | 45 ++++ web/e2e/ouverture.spec.ts | 257 +++++++++++++++++++++++ web/tests/unit/opening.test.ts | 130 ++++++++++++ web/tests/unit/rules.test.ts | 141 +++++++++++++ 4 files changed, 573 insertions(+) create mode 100644 web/e2e/ouverture.spec.ts create mode 100644 web/tests/unit/opening.test.ts create mode 100644 web/tests/unit/rules.test.ts diff --git a/crates/ld-server/tests/seed_proposals.rs b/crates/ld-server/tests/seed_proposals.rs index c873c34..e07a2ac 100644 --- a/crates/ld-server/tests/seed_proposals.rs +++ b/crates/ld-server/tests/seed_proposals.rs @@ -727,3 +727,48 @@ async fn a_seed_proposal_enters_by_the_vote_in_the_test_group() { .expect("adoption"); assert_eq!(electorate, 16); } + +/// Lot U3 (Yvv, 2026-11-03: « un petit chapitre qui explicite les réglages initiaux de l'auteur, +/// notamment Quorum et Inertie »): the chapter « Info ouverture doc de réf. » says at which level +/// the Propositions d'amorce opened, read in the data of the document. Once one has entered into +/// force it is no longer a seed proposal (`seedProposal`), but the view still says the seed put +/// it forward (`seedFeatured`): its level at the opening stays readable, and it is never counted +/// among the other articles. +#[tokio::test] +async fn the_view_keeps_which_versions_the_seed_put_forward_once_they_enter_into_force() { + let state = fresh().await; + let mut a = Client::of(&state); + a.login("Yvv", false).await; + for group in GROUPS { + let doc = a.doc(group).await; + let featured: Vec<&str> = doc["units"] + .as_array() + .expect("units") + .iter() + .filter(|u| { + std::iter::once(&u["inForce"]) + .chain(u["proposals"].as_array().expect("p").iter()) + .any(|v| v["seedFeatured"].as_bool() == Some(true)) + }) + .filter_map(|u| u["code"].as_str()) + .collect(); + assert_eq!(featured, ["I1", "E1", "E12", "E2"], "{group}"); + // Every version says it, true or false; the modules were never put forward. + for v in versions(&doc) { + let seeded = v["seedFeatured"].as_bool().unwrap_or_else(|| panic!("{group}: {v}")); + assert_eq!(seeded, v["seedProposal"].as_bool() == Some(true), "{group}: {}", v["title"]); + } + } + + // Into force in the test group, by one voice (quorum 1): no longer a seed proposal, still + // put forward by the seed, at the level it opened at. + let doc = a.doc("essai").await; + let i1 = unit(&doc, "I1")["proposals"][0]["id"].as_str().expect("i1").to_owned(); + assert_eq!(a.vote(&i1, "for").await["entered"][0]["code"], "I1"); + let doc = a.doc("essai").await; + let p = &unit(&doc, "I1")["inForce"]; + assert_eq!(p["id"], i1.as_str()); + assert_eq!((p["seedProposal"].as_bool(), p["seedFeatured"].as_bool()), (Some(false), Some(true))); + assert_eq!(unit(&doc, "I1")["inertia"], LOW); + assert!(unit(&doc, "I1")["inertiaChanges"].as_array().expect("changes").is_empty()); +} diff --git a/web/e2e/ouverture.spec.ts b/web/e2e/ouverture.spec.ts new file mode 100644 index 0000000..813b470 --- /dev/null +++ b/web/e2e/ouverture.spec.ts @@ -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'); +}); diff --git a/web/tests/unit/opening.test.ts b/web/tests/unit/opening.test.ts new file mode 100644 index 0000000..65d1119 --- /dev/null +++ b/web/tests/unit/opening.test.ts @@ -0,0 +1,130 @@ +// Lot U3 — « Info ouverture doc de réf. » (Yvv, 2026-11-03: « un petit chapitre qui explicite les +// réglages initiaux de l'auteur, notamment Quorum et Inertie »): what the chapter says is read in +// the data of the document, never written in. The level a module opened at is the one before any +// change voted (a change of the seed's settings, an amendment, is the author's); a Proposition +// d'amorce stays one once it has entered into force (`seedFeatured`). +import { describe, expect, it } from 'vitest'; +import { isAmorce, openedAt, openingLevels, quorumsOf } from '../../src/lib/opening'; +import type { DocView, Inertia, InertiaChange, Proposal, Unit } from '../../src/lib/types'; + +function version(over: Partial = {}): Proposal { + return { + id: 'p', + unitId: 'u', + kind: 'text', + inertiaTarget: null, + title: 'Un article', + body: '', + status: 'open', + origin: 'seed', + label: null, + author: null, + mine: false, + revision: 1, + createdAt: '2026-11-03T00:00:00Z', + updatedAt: '2026-11-03T00:00:00Z', + submittedAt: null, + enteredAt: null, + confirmedAt: null, + seedMinimal: false, + seedProposal: false, + seedFeatured: false, + tally: { for: 0, against: 0 }, + tallyPending: { for: 0, against: 0 }, + tallySimulated: { for: 0, against: 0 }, + evaluation: null, + votedAtInertia: 3, + blocked: false, + votable: true, + objections: 0, + myVote: null, + myVoteState: null, + myVoteStale: false, + myVoteChain: false, + myBallot: null, + translations: {}, + ...over, + }; +} + +let n = 0; +function unit(inertia: Inertia, over: Partial = {}, versions: Partial[] = [{}]): Unit { + n += 1; + return { + id: `u${n}`, + code: `X${n}`, + section: 'technique', + position: n, + inertia, + inertiaChanges: [], + inForce: null, + proposals: versions.map((v) => version({ id: `p${n}`, ...v })), + ...over, + }; +} + +const change = (from: Inertia, to: Inertia, cause: InertiaChange['cause']): InertiaChange => ({ from, to, cause, at: '2026-11-03T00:00:00Z' }); + +describe('the level a module opened at', () => { + it('is its level when nothing changed it', () => { + expect(openedAt(unit(3))).toBe(3); + }); + it('is the author’s: a change of the seed’s settings (an amendment) is part of the opening', () => { + expect(openedAt(unit(2, { inertiaChanges: [change(3, 2, 'amendment')] }))).toBe(2); + }); + it('is the one before the first change voted, whatever came after', () => { + expect(openedAt(unit(4, { inertiaChanges: [change(2, 4, 'vote')] }))).toBe(2); + expect(openedAt(unit(5, { inertiaChanges: [change(3, 2, 'amendment'), change(2, 4, 'vote'), change(4, 5, 'vote')] }))).toBe(2); + }); +}); + +describe('a Proposition d’amorce', () => { + it('is the article the seed put forward, open or in force since', () => { + expect(isAmorce(unit(2, {}, [{ seedProposal: true, seedFeatured: true }]))).toBe(true); + expect(isAmorce(unit(2, { inForce: version({ status: 'in_force', seedFeatured: true, enteredAt: '2026-11-03T00:00:00Z' }) }, []))).toBe(true); + expect(isAmorce(unit(3))).toBe(false); + }); + it('a server that does not say it yet makes no article an amorce', () => { + const old = version(); + delete (old as Partial).seedFeatured; + expect(isAmorce(unit(3, { proposals: [old] }))).toBe(false); + }); +}); + +describe('the levels at the opening, by kind of module', () => { + function view(units: Unit[], title: Unit | null, orders: Unit[]): Pick { + return { units, modules: { title, orders } }; + } + it('the amorces, the title, the order of each section, the other articles of the seed', () => { + const v = view( + [ + unit(2, {}, [{ seedProposal: true, seedFeatured: true }]), + unit(3, { inertiaChanges: [change(2, 3, 'vote')], inForce: version({ status: 'in_force', seedFeatured: true }) }, []), + unit(3), + unit(4, { inertiaChanges: [change(3, 4, 'vote')] }), + // A member's article is not a setting of the author. + unit(5, {}, [{ origin: 'member' }]), + ], + unit(4, { kind: 'title' }), + [unit(2, { kind: 'order', section: 'introduction' }), unit(3, { kind: 'order', section: 'technique', inertiaChanges: [change(2, 3, 'vote')] })], + ); + expect(openingLevels(v)).toEqual({ amorce: [2], title: [4], orders: [2], others: [3] }); + }); + it('several levels in one kind are all said, lowest first; a kind that is absent says none', () => { + const v = view([unit(4), unit(3), unit(4)], null, []); + expect(openingLevels(v)).toEqual({ amorce: [], title: [], orders: [], others: [3, 4] }); + }); +}); + +describe('the quorum of each level', () => { + it('said once when every level has the same', () => { + expect(quorumsOf([1, 1, 1, 1, 1])).toEqual({ same: 1, list: [1, 1, 1, 1, 1] }); + }); + it('listed from level 1 to level 5 otherwise', () => { + expect(quorumsOf([12, 12, 50, 50, 50])).toEqual({ same: null, list: [12, 12, 50, 50, 50] }); + }); + it('nothing when the server does not say them', () => { + expect(quorumsOf(undefined)).toBeNull(); + expect(quorumsOf([])).toBeNull(); + }); +}); diff --git a/web/tests/unit/rules.test.ts b/web/tests/unit/rules.test.ts new file mode 100644 index 0000000..46e5edb --- /dev/null +++ b/web/tests/unit/rules.test.ts @@ -0,0 +1,141 @@ +// Lot U3 (Yvv, 2026-11-03): the rules of operation leave the votes (ADR 0014) and the screen says +// them where they serve. Each phrase the screen says of a rule is pinned here, letter for letter, +// with the test of the engine that proves it — the closed list of lot T3c +// (docs/chantiers/acte-certification/t3c-liste-fermee.md) on the texts of M8, kept in +// crates/ld-server/tests/fixtures/seed-m8/. The inventory of where each lives: +// docs/chantiers/acte-certification/U3-informations.md. +import { describe, expect, it } from 'vitest'; +import { en } from '../../src/lib/i18n/en'; +import { es } from '../../src/lib/i18n/es'; +import { fr } from '../../src/lib/i18n/fr'; + +const CATALOGS = { fr, en, es }; + +describe('Yvv’s words, word for word', () => { + it('the chapter is « Info ouverture doc de réf. », and its paragraph replaces « Commence par les Propositions seed »', () => { + expect(fr['opening.title']).toBe('Info ouverture doc de réf.'); + expect(`${fr['opening.none']} ${fr['opening.amorce']}`).toBe( + "Aucun article n'est encore en vigueur. 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.", + ); + expect(Object.keys(fr)).not.toContain('doc.empty.seeds'); + }); + it('once an article is in force, the first sentence goes, and « y » says what it stood for', () => { + expect(fr['opening.amorce.after']).toBe(fr['opening.amorce'].replace("y entrent par le vote", 'entrent en vigueur par le vote')); + }); + it('the word for whoever is not invited yet (« @ chiara » read « à Chiara »), and its translations', () => { + expect(fr['signin.uninvited']).toBe( + "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.", + ); + expect(en['signin.uninvited']).toBe( + 'To join a decision group, for now ask Chiara or Yvv for an invitation link. This prototype is not yet wired to the chain.', + ); + expect(es['signin.uninvited']).toBe( + '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.', + ); + }); + it('said once: the Ğ1 line no longer says how to get in meanwhile', () => { + expect(fr['signin.g1.pending']).toBe("La connexion Ğ1 arrive : l'inscription de libreDecision auprès de connect.monnaie-libre.fr est en cours."); + for (const cat of Object.values(CATALOGS)) expect(cat['signin.g1.pending']).not.toMatch(/invitation|invitación/); + }); +}); + +describe('« Proposition d’amorce » (fr), « Starter proposal » (en), « Propuesta de arranque » (es)', () => { + it('names the seed proposals everywhere', () => { + expect([fr['doc.seedProposal'], fr['docs.seeds.one'], fr['docs.seeds.other']]).toEqual(["Proposition d'amorce", "{n} Proposition d'amorce", "{n} Propositions d'amorce"]); + expect([en['doc.seedProposal'], en['docs.seeds.one'], en['docs.seeds.other']]).toEqual(['Starter proposal', '{n} starter proposal', '{n} starter proposals']); + expect([es['doc.seedProposal'], es['docs.seeds.one'], es['docs.seeds.other']]).toEqual(['Propuesta de arranque', '{n} propuesta de arranque', '{n} propuestas de arranque']); + }); + it('never « seed » next to « proposition », « proposal », « propuesta »', () => { + for (const [lang, cat] of Object.entries(CATALOGS)) { + for (const [k, v] of Object.entries(cat)) { + expect(v, `${lang} ${k}`).not.toMatch(/propositions? seed|seed proposals?|propuestas? (seed|semilla)/i); + } + } + }); +}); + +describe('the rules the screen says, true for every W and every quorum (lots T3 to T3c)', () => { + it('the defined term « participation habituelle », never « comme d’habitude »', () => { + const asUsual = { fr: /comme d'habitude/, en: /as usual/, es: /como de costumbre/ }; + for (const [lang, cat] of Object.entries(CATALOGS)) { + for (const [k, v] of Object.entries(cat)) expect(v, `${lang} ${k}`).not.toMatch(asUsual[lang as keyof typeof asUsual]); + } + }); + + // Each phrase, and the test that proves it (crates/ld-server/tests/…). + const PROVEN: [keyof typeof fr, string, string][] = [ + // seed_quorum_texts.rs under_the_quorum_it_takes_as_many_for_as_if_the_quorum_had_voted (N1.1 2, F1.2 12); + // the_share_asked_falls_beyond_the_article_s_quorum_down_to_m (N1.1 3). + [ + 'formula.reading', + "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é. Au-delà, la part de « pour » demandée, la plus haute au quorum, baisse quand la participation monte ; quand tout le monde vote, la majorité M suffit.", + 'under_the_quorum_… · the_share_asked_falls_…', + ], + // the_formulas_of_the_texts_are_those_the_engine_computes (T' = max(T, Q), F1.1, N1.1 4). + ['formula.Tprime', "T′ — votants retenus : {n}, T ou Q s'il en manque", 'the_formulas_…'], + // ld-engine threshold::a_whole_number_of_votes_never_gains_one_by_rounding (« A genuine fraction still + // rounds up »: F1.1 « arrondi à l'entier au-dessus »). + ['formula.round', "Une version atteint son seuil quand ses voix pour atteignent R, arrondi à l'entier au-dessus.", 'ld-engine threshold'], + // g_gives_the_share_of_the_level_at_the_usual_turnout_beyond_the_article_s_quorum (F1.2 18). + ['formula.G', 'G — gradient : {n}, déduit de la participation habituelle du groupe', 'g_gives_…'], + // b_to_the_power_w_never_counts_in_the_web_of_trust (F1.2 16), said only when W is the web of trust. + ['formula.B.wot', "À la puissance W, B est si petit qu'il ne compte pas dans la toile de confiance.", 'b_to_the_power_w_…'], + // the_share_of_a_level_is_asked_at_the_usual_turnout_beyond_the_article_s_quorum (F1.2 20, N1.1 5). + ['settings.share.note', "Part exigée : celle que le cran demande au-delà du quorum de l'article, à la participation habituelle.", 'the_share_of_a_level_…'], + // the_share_asked_falls_… (N1.1 3), under_the_quorum_… (N1.1 2). + [ + 'settings.reading', + "Au-delà du quorum de l'article, plus il y a de votants, plus la part exigée baisse, jusqu'à la majorité M quand tout le monde vote. Sous le quorum de l'article, les votants qui manquent comptent comme « non », et il faut autant de « pour » que si ce quorum avait voté.", + 'the_share_asked_falls_… · under_the_quorum_…', + ], + // the_participation_habituelle_is_the_median_brought_back_within_its_bounds; tests/inertia.rs + // the_usual_participation_is_the_median_of_five_voted_proposals (N1.1 12: the year counts). + [ + 'settings.usual.reference.other', + "{n} votants sur {electorate} ({pct}), deux fois {q}, le quorum de référence : la participation de référence, tant qu'elle repose sur moins de {min} propositions votées depuis un an ({sample} pour l'instant).", + 'the_participation_habituelle_… · inertia.rs', + ], + // tests/quorum.rs in_monnaie_libre_a_quorum_holds_two_voices_and_a_ballot_counts_even_pending (N1.1 13). + ['settings.usual.quorum', "Elle ne dépend pas du quorum d'un article.", 'quorum.rs in_monnaie_libre_…'], + // g_gives_… (N1.1 15). + ['settings.gradient', "G se calcule pour que la part exigée, au-delà du quorum de l'article, soit exactement celle du cran à la participation habituelle.", 'g_gives_…'], + // tests/quorum.rs the_author_of_an_article_sets_its_quorum_before_the_first_vote; tests/ballots.rs + // a_vote_exists_for_every_state_of_the_buffer (F1.2 14, lot T2: « la trace d'aucun vote »). + ['quorum.explain', "Tu fixes le quorum de cet article tant qu'il ne garde la trace d'aucun vote. En dessous, les votants qui manquent comptent comme « non ».", 'quorum.rs · ballots.rs'], + [ + 'opening.quorum.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.", + 'quorum.rs · ballots.rs', + ], + // under_the_quorum_… (F1.2 12), each_level_has_its_quorum_and_every_evaluation_says_it (F1.2 13). + ['opening.quorum.rule', "Sous le quorum d'un article, les votants qui manquent comptent comme « non ».", 'under_the_quorum_… · quorum.rs'], + // ld-engine compose::a_rival_must_beat_the_version_in_force (F1.2 6); tests/api.rs + // a_rival_replaces_the_seed_and_the_seed_returns_among_proposals (F1.2 7). + [ + 'vote.rival', + '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.', + 'compose · api.rs', + ], + // ld-engine compose::rivals_on_one_unit_yield_one_winner (F1.2 8). + ['vote.others.rule', 'Si plusieurs versions atteignent leur seuil, celle qui a la marge la plus large entre en vigueur.', 'compose'], + // ld-engine ballot::the_last_one_stands_in_the_order_of_the_chain; tests/ballots.rs the same (F1.2 3, 4). + ['vote.permanent', 'Le vote reste ouvert, sans échéance : tu peux changer ou retirer ton vote à tout moment, le dernier fait foi.', 'ballot · ballots.rs'], + // tests/modules.rs an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_force (O1 4: + // « Nothing else moved: the other sections read as before »). + ['order.edit.effect', "S'il entre en vigueur, seuls les articles de cette section changent de place ; aucun texte ne change.", 'modules.rs'], + ]; + it.each(PROVEN)('%s', (key, text) => { + expect(fr[key]).toBe(text); + for (const cat of [en, es]) expect(cat[key], key).toBeTruthy(); + }); + it('the defined terms are carried in English and in Spanish too', () => { + for (const k of ['settings.share.note', 'settings.gradient', 'formula.G'] as const) { + expect(en[k], k).toMatch(/usual turnout/); + expect(es[k], k).toMatch(/participación habitual/); + } + for (const k of ['settings.share.note', 'settings.reading', 'settings.gradient', 'formula.reading', 'inertia.explain'] as const) { + expect(en[k], k).toMatch(/article's quorum/); + expect(es[k], k).toMatch(/quórum (Q )?del artículo/); + } + }); +});