- engine/ : parametric (médiane basse, cristallisation, impact linear-share, bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome), settings (resolveSettings + replis), triage (R-U→R6, phrases françaises), impact (concernés calculés), électionResult (blanc, quorum, égalité sans départage machine) — 296 tests vitest verts - moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits), tokens routes/états, socle borderless, print A4, tampon 井 - data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée - Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test - backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer) - test anti-lexique (marqueur ld-v2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
227 lines
8.1 KiB
TypeScript
227 lines
8.1 KiB
TypeScript
/**
|
||
* resolveSettings — the Pact IS the settings store (Δ10).
|
||
* Defaults, voted overrides, specified fallbacks, corrupted-bundle resilience.
|
||
*/
|
||
import { describe, expect, it } from 'vitest'
|
||
import { SETTINGS_DEFAULTS, hasConsentProtocol, resolveSettings } from '../../app/engine/settings'
|
||
import type { Clause, ClauseVersion, Json } from '../../app/types/domain'
|
||
|
||
const T0 = '2026-08-01T00:00:00.000Z'
|
||
const T1 = '2026-08-05T00:00:00.000Z'
|
||
|
||
let seq = 0
|
||
|
||
function makeClause(settingKey: string, overrides: Partial<Clause> = {}): Clause {
|
||
seq += 1
|
||
return {
|
||
id: `clause-${seq}`,
|
||
collectiveId: 'col-1',
|
||
createdAt: T0,
|
||
updatedAt: T0,
|
||
docId: 'doc-pact',
|
||
section: 'Réglages',
|
||
position: seq,
|
||
code: `S${seq}`,
|
||
title: settingKey,
|
||
inertia: 'standard',
|
||
settingKey,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function makeVersion(
|
||
clauseId: string,
|
||
settingValue: Json,
|
||
overrides: Partial<ClauseVersion> = {},
|
||
): ClauseVersion {
|
||
seq += 1
|
||
return {
|
||
id: `version-${seq}`,
|
||
collectiveId: 'col-1',
|
||
createdAt: T0,
|
||
updatedAt: T0,
|
||
clauseId,
|
||
decisionId: 'dec-founding',
|
||
versionLabel: 'v1',
|
||
content: 'clause adoptée',
|
||
settingValue,
|
||
status: 'current',
|
||
adoptedAt: T0,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
/** Build a Pact from [settingKey, settingValue] pairs and resolve it. */
|
||
function resolveFrom(entries: [string, Json][]) {
|
||
const clauses = entries.map(([key]) => makeClause(key))
|
||
const versions = clauses.map((clause, index) => makeVersion(clause.id, entries[index]![1]))
|
||
return resolveSettings(clauses, versions)
|
||
}
|
||
|
||
describe('resolveSettings — les défauts complets', () => {
|
||
it('sans aucune clause, tous les seuils de triage prennent leur défaut', () => {
|
||
const settings = resolveSettings([], [])
|
||
expect(settings.triage).toEqual({
|
||
smallGroupMax: 5,
|
||
collectiveMin: 50,
|
||
consentMax: 7,
|
||
objectionWindowHours: 48,
|
||
adviceWindowHours: 72,
|
||
framingDays: 14,
|
||
concernEscalateRatio: 0.5,
|
||
recurrenceThreshold: 3,
|
||
reviewDelayDays: 90,
|
||
requireEffects: 'binding',
|
||
})
|
||
expect(settings.triage).toEqual(SETTINGS_DEFAULTS)
|
||
})
|
||
|
||
it('sans aucune clause, les protocoles optionnels restent indéfinis (repli → consent chez l’appelant)', () => {
|
||
const settings = resolveSettings([], [])
|
||
expect(settings.protocolByRange.nuanced).toBeUndefined()
|
||
expect(settings.protocolByRange.large).toBeUndefined()
|
||
expect(settings.protocolByRange.parametric).toBeUndefined()
|
||
expect(settings.protocolByRange.election).toBeUndefined()
|
||
expect(settings.protocolByRange.clauseByInertia).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('resolveSettings — une clause adoptée change une valeur', () => {
|
||
it('la version courante d’une clause remplace le défaut', () => {
|
||
const settings = resolveFrom([['triage.smallGroupMax', 8]])
|
||
expect(settings.triage.smallGroupMax).toBe(8)
|
||
expect(settings.triage.collectiveMin).toBe(50) // les autres clés gardent leur défaut
|
||
})
|
||
|
||
it('une version seulement proposée ne change rien', () => {
|
||
const clause = makeClause('triage.framingDays')
|
||
const version = makeVersion(clause.id, 21, { status: 'proposed', adoptedAt: undefined })
|
||
expect(resolveSettings([clause], [version]).triage.framingDays).toBe(14)
|
||
})
|
||
|
||
it('une version remplacée ne change rien — seule la courante compte', () => {
|
||
const clause = makeClause('triage.consentMax')
|
||
const superseded = makeVersion(clause.id, 3, { status: 'superseded' })
|
||
const current = makeVersion(clause.id, 9, { adoptedAt: T1 })
|
||
expect(resolveSettings([clause], [superseded, current]).triage.consentMax).toBe(9)
|
||
})
|
||
|
||
it('deux versions courantes (anomalie) — la plus récemment adoptée gagne', () => {
|
||
const clause = makeClause('triage.objectionWindowHours')
|
||
const older = makeVersion(clause.id, 24, { adoptedAt: T0 })
|
||
const newer = makeVersion(clause.id, 96, { adoptedAt: T1 })
|
||
expect(resolveSettings([clause], [older, newer]).triage.objectionWindowHours).toBe(96)
|
||
})
|
||
|
||
it('une clause archivée est ignorée', () => {
|
||
const clause = makeClause('triage.smallGroupMax', { archivedAt: T1 })
|
||
const version = makeVersion(clause.id, 12)
|
||
expect(resolveSettings([clause], [version]).triage.smallGroupMax).toBe(5)
|
||
})
|
||
})
|
||
|
||
describe('resolveSettings — requireEffects résolu', () => {
|
||
it('« none » voté est appliqué', () => {
|
||
expect(resolveFrom([['triage.requireEffects', 'none']]).triage.requireEffects).toBe('none')
|
||
})
|
||
|
||
it('« structural » voté est appliqué', () => {
|
||
expect(resolveFrom([['triage.requireEffects', 'structural']]).triage.requireEffects).toBe(
|
||
'structural',
|
||
)
|
||
})
|
||
|
||
it('une valeur inconnue retombe sur « binding »', () => {
|
||
expect(resolveFrom([['triage.requireEffects', 'everything']]).triage.requireEffects).toBe(
|
||
'binding',
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('resolveSettings — le protocole de consentement', () => {
|
||
it('la clause protocols.consent résout l’identifiant', () => {
|
||
const settings = resolveFrom([['protocols.consent', 'proto-consent-1']])
|
||
expect(settings.protocolByRange.consent).toBe('proto-consent-1')
|
||
expect(hasConsentProtocol(settings)).toBe(true)
|
||
})
|
||
|
||
it('consent introuvable (bundle corrompu) — objet complet, consent vide, jamais de crash', () => {
|
||
expect(() => resolveSettings([], [])).not.toThrow()
|
||
const settings = resolveSettings([], [])
|
||
expect(settings.protocolByRange.consent).toBe('')
|
||
expect(hasConsentProtocol(settings)).toBe(false)
|
||
expect(settings.triage.smallGroupMax).toBe(5) // le reste de l’objet est utilisable
|
||
})
|
||
|
||
it('consent d’un mauvais type compte comme introuvable', () => {
|
||
const settings = resolveFrom([['protocols.consent', 42]])
|
||
expect(settings.protocolByRange.consent).toBe('')
|
||
expect(hasConsentProtocol(settings)).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('resolveSettings — les protocoles optionnels et l’héritage Ğ1', () => {
|
||
it('nuanced, large, parametric et election se résolvent chacun', () => {
|
||
const settings = resolveFrom([
|
||
['protocols.consent', 'p-consent'],
|
||
['protocols.nuanced', 'p-nuanced'],
|
||
['protocols.large', 'p-large'],
|
||
['protocols.parametric', 'p-parametric'],
|
||
['protocols.election', 'p-election'],
|
||
])
|
||
expect(settings.protocolByRange.nuanced).toBe('p-nuanced')
|
||
expect(settings.protocolByRange.large).toBe('p-large')
|
||
expect(settings.protocolByRange.parametric).toBe('p-parametric')
|
||
expect(settings.protocolByRange.election).toBe('p-election')
|
||
})
|
||
|
||
it('clauseByInertia complet (4 presets) est exposé', () => {
|
||
const settings = resolveFrom([
|
||
['protocols.clauseByInertia.low', 'p-low'],
|
||
['protocols.clauseByInertia.standard', 'p-standard'],
|
||
['protocols.clauseByInertia.high', 'p-high'],
|
||
['protocols.clauseByInertia.max', 'p-max'],
|
||
])
|
||
expect(settings.protocolByRange.clauseByInertia).toEqual({
|
||
low: 'p-low',
|
||
standard: 'p-standard',
|
||
high: 'p-high',
|
||
max: 'p-max',
|
||
})
|
||
})
|
||
|
||
it('clauseByInertia partiel reste indéfini — l’appelant retombe sur consent', () => {
|
||
const settings = resolveFrom([
|
||
['protocols.clauseByInertia.low', 'p-low'],
|
||
['protocols.clauseByInertia.standard', 'p-standard'],
|
||
['protocols.clauseByInertia.high', 'p-high'],
|
||
])
|
||
expect(settings.protocolByRange.clauseByInertia).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('resolveSettings — les valeurs invalides retombent sur les défauts, en silence', () => {
|
||
it('un mauvais type sur une clé numérique est ignoré', () => {
|
||
expect(resolveFrom([['triage.smallGroupMax', 'huit']]).triage.smallGroupMax).toBe(5)
|
||
})
|
||
|
||
it('un nombre négatif est ignoré', () => {
|
||
expect(resolveFrom([['triage.framingDays', -3]]).triage.framingDays).toBe(14)
|
||
})
|
||
|
||
it('un ratio négatif est ignoré', () => {
|
||
expect(resolveFrom([['triage.concernEscalateRatio', -0.5]]).triage.concernEscalateRatio).toBe(
|
||
0.5,
|
||
)
|
||
})
|
||
|
||
it('null est ignoré', () => {
|
||
expect(resolveFrom([['triage.reviewDelayDays', null]]).triage.reviewDelayDays).toBe(90)
|
||
})
|
||
|
||
it('un identifiant de protocole vide compte comme non résolu', () => {
|
||
const settings = resolveFrom([['protocols.nuanced', ' ']])
|
||
expect(settings.protocolByRange.nuanced).toBeUndefined()
|
||
})
|
||
})
|