Fusion de la tête du chantier (924a40d : lots B2 et F) dans le lot D

Les deux côtés gardés partout. Aux coutures :
- une objection (redit / contredit) porte sur deux articles, jamais sur un module ;
  un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du
  titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre
  de départ, ou 3 jours), et documents.title suit à l'application ;
- votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran
  et dans document.json ;
- « Modules à voter » ne compte plus une version retenue par une objection (test
  vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ;
- le panier : une fonction pour les articles et les modules, avec les versions
  retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut
  répondre à une objection ;
- migrations 0007 (modules) puis 0008 (objections) ;
- C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres).

Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55,
build, e2e 35 / 35 (C1–C33 et M1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 09:41:30 +01:00
co-authored by Claude Opus 5.5
82 changed files with 8285 additions and 677 deletions
+24 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { clearScope, counts, fill, lines, sections, waiting } from '../../src/lib/basket';
import { clearScope, counts, fill, lines, sections, votableIds, waiting } from '../../src/lib/basket';
import type { Proposal, Unit } from '../../src/lib/types';
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
@@ -7,7 +7,7 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3,
blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
...over,
};
}
@@ -92,4 +92,26 @@ describe('the basket of a vote', () => {
]);
expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 });
});
it('a version held back by an objection is shown apart: no grouped gesture fills it, nothing is sent for it', () => {
// E5 has the text in force and a rival held back; E6 is a new article, held back.
const e5 = unit('E5', 'technique', prop('e5', 'E5', { status: 'in_force' }), [
prop('e5r', 'E5', { blocked: true, votable: false }),
]);
const e6 = unit('E6', 'technique', null, [prop('e6', 'E6', { blocked: true, votable: false })]);
const s = sections([e5, e6]);
const [slotE5, slotE6] = s[0]?.slots ?? [];
expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']);
expect(slotE5?.held.map((p) => p.id)).toEqual(['e5r']);
// A held article stays in sight, with nothing to choose.
expect(slotE6?.versions).toEqual([]);
expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']);
const all = s.flatMap((x) => x.slots);
// The held rival does not compete now: the text in force is the only choice.
expect(fill(all, 'for', {})).toEqual({ e5: 'for' });
expect(waiting(all, {})).toEqual([]);
// A choice kept from before the objection is not sent.
expect(lines({ e5: 'for', e5r: 'for' }, votableIds(s))).toEqual([{ proposalId: 'e5', choice: 'for' }]);
expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' });
});
});
+29
View File
@@ -0,0 +1,29 @@
// What the end-to-end tests import is declared: a package found only as the
// dependency of another (as @noble/hashes under polkadot-api) may vanish with it.
import { readdirSync, readFileSync } from 'node:fs';
import { builtinModules } from 'node:module';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = join(__dirname, '../..');
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const declared = new Set([...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]);
/** `@scope/name/sub.js` → `@scope/name`; `name/sub` → `name`. */
const packageOf = (spec: string) => spec.split('/').slice(0, spec.startsWith('@') ? 2 : 1).join('/');
describe('the end-to-end tests', () => {
it('import only declared packages', () => {
const dir = join(root, 'e2e');
for (const file of readdirSync(dir).filter((f) => /\.(mjs|ts)$/.test(f))) {
const text = readFileSync(join(dir, file), 'utf8');
for (const [, spec] of text.matchAll(/^import[^'"]*['"]([^'"]+)['"]/gm)) {
if (!spec || spec.startsWith('.') || spec.startsWith('node:') || builtinModules.includes(spec)) continue;
expect(declared.has(packageOf(spec)), `${file}: ${spec}`).toBe(true);
}
}
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { condense } from '../../src/lib/diff';
import type { DiffPart } from '../../src/lib/types';
const text = (parts: DiffPart[]) => parts.map((p) => (p.op === 'same' ? p.text : `[${p.op === 'added' ? '+' : '-'}${p.text}]`)).join('');
describe('a typo fix, told around its change', () => {
it('keeps a few words on each side of a change, cut between words', () => {
const parts: DiffPart[] = [
{ op: 'same', text: "Raison d'être\nDerrière une clé il n'y a qu'un seul être humain vivant. C" },
{ op: 'removed', text: "'" },
{ op: 'added', text: '’' },
{ op: 'same', text: "est le seul et unique propos de la certification, qui donne à notre toile fiduciaire sa valeur." },
];
expect(text(condense(parts, 24))).toBe("… être humain vivant. C[-'][+’]est le seul et unique …");
});
it('two changes far apart: the middle is cut; close ones stay together', () => {
const far: DiffPart[] = [
{ op: 'same', text: 'Je m' },
{ op: 'removed', text: "'" },
{ op: 'added', text: '’' },
{ op: 'same', text: 'engage à vérifier la clé publique avec la personne, en sa présence, avant toute certification de son compte, et à n' },
{ op: 'removed', text: "'" },
{ op: 'added', text: '’' },
{ op: 'same', text: 'en rien faire sinon.' },
];
expect(text(condense(far, 20))).toBe("Je m[-'][+’]engage à vérifier la … son compte, et à n[-'][+’]en rien faire sinon.");
const near: DiffPart[] = [
{ op: 'same', text: 'Je m' },
{ op: 'removed', text: "'" },
{ op: 'added', text: '’' },
{ op: 'same', text: 'engage à n' },
{ op: 'removed', text: "'" },
{ op: 'added', text: '’' },
{ op: 'same', text: 'utiliser qu' },
];
expect(condense(near, 20)).toEqual(near);
});
it('nothing changed: the text as it is', () => {
const same: DiffPart[] = [{ op: 'same', text: 'Un texte.' }];
expect(condense(same)).toEqual(same);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { answerPanel, holding } from '../../src/lib/objections';
import type { Objection, VersionRef } from '../../src/lib/types';
const ref = (id: string, over: Partial<VersionRef> = {}): VersionRef => ({
id, title: id, status: 'open', unitId: `u-${id}`, code: id.toUpperCase(), author: null, authorId: null, ...over,
});
function objection(over: Partial<Objection> = {}): Objection {
return {
id: 'o1', kind: 'redit', reason: 'Ces deux textes disent la même chose.', objector: 'Yvv', objectorInvited: false,
mine: false, at: '', phase: 'author', cycle: 1, dueAt: null, answer: null, answerId: null, answeredAt: null,
lift: null, liftedAt: null, heldVersion: ref('k1', { author: 'Chiara', authorId: 'dev:chiara' }),
other: ref('e2', { status: 'in_force' }), iAnswer: true, canLift: false, canReiterate: false, held: true, ...over,
};
}
describe('answering an objection', () => {
it('a repetition: a proposal linked to it, on the article repeated, from its text', () => {
expect(answerPanel(objection(), 'dev:chiara')).toEqual({ tab: 'propose', unitId: 'u-e2', proposalId: 'e2', objectionId: 'o1' });
});
it('a contradiction: one rewords one’s own version in place', () => {
expect(answerPanel(objection({ kind: 'contredit' }), 'dev:chiara')).toEqual({ tab: 'details', unitId: 'u-k1', proposalId: 'k1', edit: true });
});
it('a contradiction on a seeded version: a linked proposal on its article', () => {
const o = objection({ kind: 'contredit', heldVersion: ref('k1') });
expect(answerPanel(o, 'dev:chiara')).toEqual({ tab: 'propose', unitId: 'u-k1', proposalId: 'k1', objectionId: 'o1' });
});
it('nothing held back, nothing to answer; a lifted objection holds nothing', () => {
expect(answerPanel(objection({ heldVersion: null }), 'dev:chiara')).toBeNull();
const list = [objection(), objection({ id: 'o2', phase: 'lifted' }), objection({ id: 'o3', held: false })];
expect(holding(list).map((o) => o.id)).toEqual(['o1']);
});
});
+73
View File
@@ -0,0 +1,73 @@
// The receipt of a vote on the Ğ1 chain, checked in the browser: the same
// canonical text and the same seal as the engine (crates/ld-engine/src/ballot.rs,
// whose vectors were computed apart with Python's hashlib.blake2b).
import { describe, expect, it } from 'vitest';
import { canonical, checkReceipt, seal } from '../../src/lib/receipt';
const SALT = '00112233445566778899aabbccddeeff';
const LINES = [
{ proposalId: 'p2', revision: 1, choice: 'against' as const },
{ proposalId: 'p1', revision: 3, choice: 'for' as const },
];
describe('the sealed ballot', () => {
it('reads as the engine writes it, the lines in the order of their versions', () => {
expect(canonical('d0c', null, LINES, SALT)).toBe(
'ld1\ndoc d0c\nauthor -\nline p1 3 for\nline p2 1 against\nsalt 00112233445566778899aabbccddeeff\n',
);
});
it('seals to the remark of the engine', () => {
const s = seal('d0c', null, LINES, SALT);
expect(s?.digest).toBe('20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340');
expect(s?.comment).toBe('ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340');
expect(s?.remarkHash).toBe('0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3');
const bound = seal('d0c', 'g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv', [{ proposalId: 'p1', revision: 3, choice: 'none' }], 'ffeeddccbbaa99887766554433221100');
expect(bound?.digest).toBe('6a6d8a90b5003ff3c4d050dbd36199e8bbf34c4cc11620618399226c9237c05e');
expect(bound?.remarkHash).toBe('0x846495fe62b9a5184a0d3090d4d26f621887c334890404169c00575ee74540e4');
});
it('what cannot be sealed', () => {
expect(seal('d0c', null, [], SALT)).toBeNull();
expect(seal('d0c', null, [LINES[0]!, { ...LINES[0]!, revision: 2 }], SALT)).toBeNull();
expect(seal('d 0c', null, LINES, SALT)).toBeNull();
expect(seal('d0c', null, LINES, SALT.toUpperCase())).toBeNull();
expect(seal('d0c', null, LINES, '0011')).toBeNull();
expect(seal('d0c', null, [{ proposalId: 'p1', revision: 1.5, choice: 'for' }], SALT)).toBeNull();
});
});
describe('a receipt', () => {
const receipt = {
format: 'ld1',
doc: 'd0c',
author: null,
lines: LINES.map((l) => ({ ...l, article: 'E1', title: 'Unicité du compte' })),
salt: SALT,
comment: 'ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340',
block: 3_400_000,
};
it('holds when its lines and its salt give its comment', () => {
expect(checkReceipt(JSON.stringify(receipt))).toEqual({
kind: 'ok',
comment: receipt.comment,
remarkHash: '0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3',
block: 3_400_000,
lines: receipt.lines,
});
});
it('does not hold once a choice is changed', () => {
const changed = { ...receipt, lines: [{ ...receipt.lines[0]!, choice: 'for' }, receipt.lines[1]!] };
const check = checkReceipt(JSON.stringify(changed));
expect(check.kind).toBe('mismatch');
if (check.kind === 'mismatch') expect(check.computed).not.toBe(receipt.comment);
});
it('what is not a receipt', () => {
for (const text of ['', 'nope', '[]', '{}', JSON.stringify({ ...receipt, format: 'ld2' }), JSON.stringify({ ...receipt, lines: 'x' })]) {
expect(checkReceipt(text).kind, text).toBe('unreadable');
}
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
votedAtInertia: 3, blocked: false, votable: true, objections: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
...over,
};
}
+24 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { InvalidTxError } from 'polkadot-api';
import { amountText, geckoLink } from '../../src/lib/wallet/gecko';
import { failure, injectedNames, testExtrinsic } from '../../src/lib/wallet/injected';
import { failure, injectedNames, mayLetGo, testExtrinsic } from '../../src/lib/wallet/injected';
const ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
const COMMENT = `ld1:${'ab'.repeat(32)}`;
@@ -30,6 +31,28 @@ describe('wallets of the page', () => {
expect(failure({ type: 'Invalid', value: { type: 'Payment' } })).toBe('funds');
expect(failure(new Error('Inability to pay some fees (e.g. account balance too low)'))).toBe('funds');
expect(failure(new Error('socket hang up'))).toBe('wallet');
// Refused by the pool of the node: it will never be included.
expect(failure({ type: 'Invalid', value: { type: 'Stale' } })).toBe('chain');
expect(failure({ code: 1010, message: 'Invalid Transaction' })).toBe('chain');
});
it('a refusal of the node, as polkadot-api raises it, is the chain saying no (relecture of 7ffa4fd)', () => {
// The real class: its message is indented JSON (`"type": "Invalid"`, with a space).
for (const reason of ['Stale', 'AncientBirthBlock', 'BadProof', 'ExhaustsResources', 'Future']) {
const e = new InvalidTxError({ type: 'Invalid', value: { type: reason } });
expect(failure(e), reason).toBe('chain');
expect(mayLetGo(failure(e), true), reason).toBe(true);
}
expect(failure(new InvalidTxError({ type: 'Invalid', value: { type: 'Payment' } }))).toBe('funds');
expect(failure(new InvalidTxError({ type: 'Unknown', value: { type: 'CannotLookup' } }))).toBe('chain');
});
it('a ballot is let go when nothing left the page, or when the chain said no — kept when it may still land', () => {
// Before the signature (the node down before sending, a refusal, the window closed): nothing left.
for (const f of ['node', 'wallet', 'cancelled', 'funds', 'account', 'chain'] as const) expect(mayLetGo(f, false), f).toBe(true);
// Signed and handed to the node: it may reach the chain, unless the chain refused it.
for (const f of ['funds', 'account', 'chain'] as const) expect(mayLetGo(f, true), f).toBe(true);
for (const f of ['node', 'wallet'] as const) expect(mayLetGo(f, true), f).toBe(false);
});
it('in tests, the fake node receives what was signed, readable', () => {