v3 : le vote déplié sous l'article, le panier, la signature par le portefeuille Ğ1

- Le vote se déplie sous l'article, façon accordéon, l'URL pour état (?vote=) :
  niveau d'eau, totaux, seuil et voix manquantes, ton vote et où il en est
  (enregistré, inscrit sur la chaîne, en attente, à reconfirmer, non compté),
  inertie et formule. Le reste d'une version (coquilles, traductions,
  signalements, historique) passe dans l'onglet « Détails » du panneau.
- Le panier : article par article, « tout pour / tout contre » par section ou
  pour tout le document, jamais pour un article à plusieurs versions ; un seul
  geste (une écriture, ou une transaction).
- La signature : le portefeuille de la page (injectedWeb3), polkadot-api chargé
  au moment de signer ; sans portefeuille, Ğecko par lien et QR ; la page
  attend la confirmation. Un bulletin que le portefeuille n'a pas signé est
  lâché aussitôt (DELETE /api/ballots/{id}).
- Pastilles d'ambiance : flèches dans le groupe, anneau de focus pointillé à
  la couleur d'accent de la page (3:1 au moins), distinct du choix.
- e2e : chaîne simulée (indexeur et nœud), faux portefeuille ; C21–C24, et
  C4, C5, C6, C11, C17, C19, C20, M1 mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 22:47:26 +01:00
co-authored by Claude Opus 5.5
parent 3541e1b1a6
commit b260596cfd
36 changed files with 4353 additions and 234 deletions
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { clearScope, counts, fill, lines, sections, waiting } from '../../src/lib/basket';
import type { Proposal, Unit } from '../../src/lib/types';
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
return {
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: {},
...over,
};
}
function unit(id: string, section: string | null, inForce: Proposal | null, proposals: Proposal[]): Unit {
return { id, code: id, section, position: 0, inertia: 3, inertiaChanges: [], inForce, proposals };
}
// The certification act in small: a text in force, a seed with a rival and a removal, two
// open articles, one with two variants, a change of inertia.
const I1 = unit('I1', 'introduction', prop('i1', 'I1', { status: 'in_force' }), []);
const E1 = unit('E1', 'fondamental', prop('e1', 'E1', { status: 'in_force' }), [
prop('e1r', 'E1'),
prop('e1x', 'E1', { kind: 'remove' }),
prop('e1k', 'E1', { kind: 'inertia', inertiaTarget: 5 }),
]);
const E2 = unit('E2', 'fondamental', prop('e2', 'E2', { status: 'in_force' }), [prop('e2k', 'E2', { kind: 'inertia', inertiaTarget: 5 })]);
const E3 = unit('E3', 'technique', null, [prop('e3a', 'E3', { label: '1.0.0' }), prop('e3b', 'E3', { label: '0.4.0' })]);
const E4 = unit('E4', 'technique', null, [prop('e4', 'E4'), prop('e4d', 'E4', { status: 'draft' })]);
const EMPTY = unit('E9', 'technique', null, []);
const DOC = [I1, E1, E2, E3, E4, EMPTY];
describe('the basket of a vote', () => {
it('sections in the order of the document; an article holds its versions in competition', () => {
const s = sections(DOC);
expect(s.map((x) => x.key)).toEqual(['introduction', 'fondamental', 'technique']);
const [, fond, tech] = s;
// The text in force, its rival and its removal compete; a change of inertia is apart.
expect(fond?.slots[0]?.versions.map((p) => p.id)).toEqual(['e1', 'e1r', 'e1x']);
expect(fond?.slots[0]?.inertia.map((p) => p.id)).toEqual(['e1k']);
expect(fond?.slots[1]?.versions.map((p) => p.id)).toEqual(['e2']);
// Drafts are nobody's to vote; an empty slot is left out.
expect(tech?.slots.map((x) => x.unit.id)).toEqual(['E3', 'E4']);
expect(tech?.slots[1]?.versions.map((p) => p.id)).toEqual(['e4']);
});
it('a grouped gesture fills the articles of a single version, never one with several', () => {
const all = sections(DOC).flatMap((x) => x.slots);
const picks = fill(all, 'for', {});
expect(picks).toEqual({ i1: 'for', e2: 'for', e4: 'for' });
// Not the change of inertia of E2, not E1 (three versions), not E3 (two variants).
expect(waiting(all, picks).map((x) => x.unit.id)).toEqual(['E1', 'E3']);
// Choosing a version settles the article.
const chosen = { ...picks, e3b: 'against' as const };
expect(waiting(all, chosen).map((x) => x.unit.id)).toEqual(['E1']);
// A section only: the others stay as they were.
const tech = sections(DOC)[2]?.slots ?? [];
expect(fill(tech, 'against', { i1: 'for', e3a: 'for' })).toEqual({ i1: 'for', e3a: 'for', e4: 'against' });
});
it('emptying a section leaves the rest', () => {
const fond = sections(DOC)[1]?.slots ?? [];
expect(clearScope(fond, { i1: 'for', e2: 'for', e1k: 'against', e4: 'none' })).toEqual({ i1: 'for', e4: 'none' });
});
it('the lines sent: a withdrawal is a null choice; a version gone from the document is left out', () => {
const known = new Set(['i1', 'e2', 'e4']);
expect(lines({ i1: 'for', e2: 'none', e4: 'against', gone: 'for' }, known)).toEqual([
{ proposalId: 'i1', choice: 'for' },
{ proposalId: 'e2', choice: null },
{ proposalId: 'e4', choice: 'against' },
]);
expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 });
});
});
+2
View File
@@ -11,6 +11,8 @@ import { fr } from '../../src/lib/i18n/fr';
export const FORBIDDEN_UI_TERMS = [
'déléguer', 'délégation', 'délégué', 'dette', 'retard', 'en souffrance', 'corpus', 'snapshot',
'verdict', 'triage', 'oligarchie', 'peuple adulte',
// The Ğ1 chain and its wallets, in plain words (lot B): never the jargon.
'blockchain', 'extrinsic', 'extrinsèque',
];
const fold = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
+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, translations: {},
votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
...over,
};
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { amountText, geckoLink } from '../../src/lib/wallet/gecko';
import { failure, injectedNames, testExtrinsic } from '../../src/lib/wallet/injected';
const ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
const COMMENT = `ld1:${'ab'.repeat(32)}`;
describe('Ğecko', () => {
it('the link and the QR code carry the account, the amount in Ğ1 and the sealed comment', () => {
expect(geckoLink(ACCOUNT, 10, COMMENT)).toBe(`g1://${ACCOUNT}?amount=0.1&comment=${COMMENT}`);
// Ğ1nkgo keeps [A-Za-z0-9 .;:!?()-_@&<>%]: the comment passes untouched.
expect(/^[A-Za-z0-9 .;:!?()\-_@&<>%]+$/.test(COMMENT)).toBe(true);
});
it('amounts in cents read as Ğ1', () => {
expect([amountText(10), amountText(100), amountText(125), amountText(1)]).toEqual(['0.1', '1', '1.25', '0.01']);
});
});
describe('wallets of the page', () => {
it('every extension offering the standard interface is found, the one for Ğ1 first', () => {
const win = { injectedWeb3: { 'polkadot-js': { enable: async () => ({}) }, 'duniter-connect': { enable: async () => ({}) }, broken: {} } };
expect(injectedNames(win)).toEqual(['duniter-connect', 'polkadot-js']);
expect(injectedNames({})).toEqual([]);
});
it('what a wallet answers is told in words', () => {
expect(failure(new Error('Cancelled'))).toBe('cancelled');
expect(failure(new Error('Rejected by user'))).toBe('cancelled');
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');
});
it('in tests, the fake node receives what was signed, readable', () => {
const hex = testExtrinsic({ address: '5Grw', dest: ACCOUNT, amount: '10', remark: COMMENT, signature: '0x01' });
expect(hex.startsWith('0x')).toBe(true);
const back = JSON.parse(new TextDecoder().decode(Uint8Array.from((hex.slice(2).match(/../g) ?? []).map((b) => parseInt(b, 16)))));
expect(back).toEqual({ address: '5Grw', dest: ACCOUNT, amount: '10', remark: COMMENT, signature: '0x01' });
});
});