import { describe, expect, it } from 'vitest'; import { admits, clearScope, counts, fill, heldIn, kept, lines, sections, votableIds, waiting } from '../../src/lib/basket'; import type { Choice, Proposal, Unit } from '../../src/lib/types'; function prop(id: string, unitId: string, over: Partial = {}): 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, votable: true, objections: 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 modules are voted like articles: the title first, each order at the end of its section', () => { // ADR 0009: the title of the document, the order of each section; an order of one article is left out. const title = { ...unit('T', null, prop('t', 'T', { kind: 'title', status: 'in_force' }), [prop('t2', 'T', { kind: 'title' })]), kind: 'title' as const }; const order = (id: string, section: string, moot = false) => ({ ...unit(id, section, prop(`${id}-v`, id, { kind: 'order', status: 'in_force' }), []), kind: 'order' as const, moot, }); const s = sections(DOC, { title, orders: [order('O-intro', 'introduction', true), order('O-fond', 'fondamental'), order('O-tech', 'technique')] }); expect(s.map((x) => x.key)).toEqual([null, 'introduction', 'fondamental', 'technique']); expect(s[0]?.slots.map((x) => x.unit.id)).toEqual(['T']); expect(s[0]?.slots[0]?.versions.map((p) => p.id)).toEqual(['t', 't2']); expect(s[1]?.slots.map((x) => x.unit.id)).toEqual(['I1']); expect(s[2]?.slots.map((x) => x.unit.id)).toEqual(['E1', 'E2', 'O-fond']); expect(s[3]?.slots.map((x) => x.unit.id)).toEqual(['E3', 'E4', 'O-tech']); // « Tout pour » a section fills its order too (one version), never the title (two). expect(fill(s[2]?.slots ?? [], 'for', {})).toEqual({ e2: 'for', 'O-fond-v': 'for' }); expect(waiting(s.flatMap((x) => x.slots), {}).map((x) => x.unit.id)).toEqual(['T', 'E1', 'E3']); }); it('the lines sent: a withdrawal is a null choice; a version gone from the document is left out', () => { const known = votableIds(sections([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 }); }); it('a version held back by an objection is shown apart: against or a withdrawal, never for', () => { // Yvv, 2026-11-01: « Retirer ou "contre" ». 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, apart from the versions in competition. 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 version to choose. expect(waiting(all, {})).toEqual([]); // « Tout pour » skips the held versions (and says so: `heldIn`); « Tout contre » takes them. expect(fill(all, 'for', {})).toEqual({ e5: 'for' }); expect(heldIn(all).map((p) => p.id)).toEqual(['e5r', 'e6']); expect(fill(all, 'against', {})).toEqual({ e5: 'against', e5r: 'against', e6: 'against' }); // What may be chosen: anything on a free version; against or withdrawing on a held one. const known = votableIds(s); expect(['for', 'against', 'none'].map((c) => admits(known, 'e5', c as Choice))).toEqual([true, true, true]); expect(['for', 'against', 'none'].map((c) => admits(known, 'e5r', c as Choice))).toEqual([false, true, true]); expect(admits(known, 'gone', 'against')).toBe(false); // A « for » kept from before the objection is neither kept nor sent; against and withdrawing are. expect(kept({ e5: 'for', e5r: 'for', e6: 'against', gone: 'none' }, known)).toEqual({ e5: 'for', e6: 'against' }); expect(lines({ e5: 'for', e5r: 'for', e6: 'none' }, known)).toEqual([ { proposalId: 'e5', choice: 'for' }, { proposalId: 'e6', choice: null }, ]); expect(lines({ e5r: 'against' }, known)).toEqual([{ proposalId: 'e5r', choice: 'against' }]); expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' }); }); });