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:
Yvv
2026-11-03 22:28:32 +01:00
co-authored by Claude Opus 5.5
parent 590ccd7267
commit 9d478d668a
4 changed files with 573 additions and 0 deletions
+130
View File
@@ -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> = {}): 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<Unit> = {}, versions: Partial<Proposal>[] = [{}]): 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<Proposal>).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<DocView, 'units' | 'modules'> {
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();
});
});
+141
View File
@@ -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/);
}
});
});