- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
5.5 KiB
TypeScript
169 lines
5.5 KiB
TypeScript
/**
|
|
* electionResult — simple plurality (BLUEPRINT-V2.md Δ28, « Modalités » #4).
|
|
*
|
|
* Contract under test: the caller passes the LAST ACTIVE votes only
|
|
* (supersedesVoteId chains resolved by the store) — one vote per voter.
|
|
* Blank vote = no choicePersonId: counts for participation, never for
|
|
* designation. The engine NEVER breaks a tie.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { Vote } from '../../app/types/domain'
|
|
import { electionResult } from '../../app/engine/threshold'
|
|
|
|
let seq = 0
|
|
|
|
/** Build a minimal last-active Vote; omit choicePersonId for a blank. */
|
|
function vote(choicePersonId?: string): Vote {
|
|
seq++
|
|
return {
|
|
id: `vote-${seq}`,
|
|
collectiveId: 'col-1',
|
|
createdAt: '2026-08-11T00:00:00Z',
|
|
updatedAt: '2026-08-11T00:00:00Z',
|
|
sessionId: 'session-1',
|
|
voterId: `voter-${seq}`,
|
|
choicePersonId,
|
|
}
|
|
}
|
|
|
|
/** n votes for the same person (or n blanks when personId is undefined). */
|
|
function votes(n: number, personId?: string): Vote[] {
|
|
return Array.from({ length: n }, () => vote(personId))
|
|
}
|
|
|
|
describe('electionResult — clear plurality', () => {
|
|
it('elects the most designated person; blanks count in participation only', () => {
|
|
const ballot = [...votes(3, 'alice'), ...votes(2, 'bob'), ...votes(1)]
|
|
const result = electionResult(ballot, { electionMinParticipants: 5 })
|
|
|
|
expect(result).toEqual({
|
|
outcome: 'elected',
|
|
winnerId: 'alice',
|
|
counts: { alice: 3, bob: 2 },
|
|
blanks: 1,
|
|
participants: 6,
|
|
})
|
|
})
|
|
|
|
it('a tie below the top does not prevent election', () => {
|
|
const ballot = [...votes(3, 'alice'), ...votes(2, 'bob'), ...votes(2, 'carol')]
|
|
const result = electionResult(ballot, {})
|
|
|
|
expect(result.outcome).toBe('elected')
|
|
if (result.outcome === 'elected') {
|
|
expect(result.winnerId).toBe('alice')
|
|
expect(result.counts).toEqual({ alice: 3, bob: 2, carol: 2 })
|
|
}
|
|
})
|
|
|
|
it('works without any quorum configured (electionMinParticipants undefined)', () => {
|
|
const result = electionResult([vote('alice')], {})
|
|
expect(result.outcome).toBe('elected')
|
|
})
|
|
})
|
|
|
|
describe('electionResult — tie: the engine NEVER breaks it', () => {
|
|
it('two-way tie at the top => tie with sorted exAequoIds', () => {
|
|
const ballot = [...votes(2, 'bob'), ...votes(2, 'alice'), ...votes(1, 'carol')]
|
|
const result = electionResult(ballot, { electionMinParticipants: 3 })
|
|
|
|
expect(result).toEqual({
|
|
outcome: 'tie',
|
|
exAequoIds: ['alice', 'bob'], // sorted — deterministic order, not a tie-break
|
|
counts: { alice: 2, bob: 2, carol: 1 },
|
|
blanks: 0,
|
|
participants: 5,
|
|
})
|
|
})
|
|
|
|
it('three-way tie => all three ex aequo, sorted regardless of arrival order', () => {
|
|
const ballot = [...votes(2, 'carol'), ...votes(2, 'alice'), ...votes(2, 'bob'), ...votes(1)]
|
|
const result = electionResult(ballot, { electionMinParticipants: 4 })
|
|
|
|
expect(result.outcome).toBe('tie')
|
|
if (result.outcome === 'tie') {
|
|
expect(result.exAequoIds).toEqual(['alice', 'bob', 'carol'])
|
|
expect(result.blanks).toBe(1)
|
|
expect(result.participants).toBe(7)
|
|
}
|
|
})
|
|
|
|
it('never designates a winner among equals (no random, no first-come)', () => {
|
|
// Same ballot tallied twice must yield the exact same tie.
|
|
const ballot = [...votes(1, 'bob'), ...votes(1, 'alice')]
|
|
const first = electionResult(ballot, {})
|
|
const second = electionResult(ballot, {})
|
|
expect(first).toEqual(second)
|
|
expect(first.outcome).toBe('tie')
|
|
})
|
|
})
|
|
|
|
describe('electionResult — rejections', () => {
|
|
it('quorum not reached => rejected with reason quorum and required', () => {
|
|
const ballot = [...votes(2, 'alice'), ...votes(1)]
|
|
const result = electionResult(ballot, { electionMinParticipants: 5 })
|
|
|
|
expect(result).toEqual({
|
|
outcome: 'rejected',
|
|
reason: 'quorum',
|
|
participants: 3,
|
|
required: 5,
|
|
})
|
|
})
|
|
|
|
it('quorum reached but all blanks => rejected with reason no-designation', () => {
|
|
const ballot = votes(4)
|
|
const result = electionResult(ballot, { electionMinParticipants: 3 })
|
|
|
|
expect(result).toEqual({
|
|
outcome: 'rejected',
|
|
reason: 'no-designation',
|
|
participants: 4,
|
|
required: 3,
|
|
})
|
|
})
|
|
|
|
it('zero votes without quorum => rejected no-designation (required 0)', () => {
|
|
const result = electionResult([], {})
|
|
expect(result).toEqual({
|
|
outcome: 'rejected',
|
|
reason: 'no-designation',
|
|
participants: 0,
|
|
required: 0,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('electionResult — blanks and participation', () => {
|
|
it('quorum reached THANKS to blanks: blanks count for participation', () => {
|
|
// 3 designations alone would miss the quorum of 5; 2 blanks complete it.
|
|
const ballot = [...votes(3, 'alice'), ...votes(2)]
|
|
const result = electionResult(ballot, { electionMinParticipants: 5 })
|
|
|
|
expect(result).toEqual({
|
|
outcome: 'elected',
|
|
winnerId: 'alice',
|
|
counts: { alice: 3 },
|
|
blanks: 2,
|
|
participants: 5,
|
|
})
|
|
})
|
|
|
|
it('exact quorum boundary: participants === required passes', () => {
|
|
const ballot = [...votes(1, 'alice'), ...votes(1)]
|
|
const result = electionResult(ballot, { electionMinParticipants: 2 })
|
|
expect(result.outcome).toBe('elected')
|
|
})
|
|
|
|
it('blanks never appear in counts', () => {
|
|
const ballot = [...votes(2, 'alice'), ...votes(3)]
|
|
const result = electionResult(ballot, {})
|
|
if (result.outcome === 'elected') {
|
|
expect(Object.keys(result.counts)).toEqual(['alice'])
|
|
expect(result.blanks).toBe(3)
|
|
} else {
|
|
throw new Error(`expected elected, got ${result.outcome}`)
|
|
}
|
|
})
|
|
})
|