v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal

- 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>
This commit is contained in:
Yvv
2026-08-11 09:22:19 +02:00
co-authored by Claude Fable 5
parent 53d8752e40
commit f707b5f15d
18 changed files with 10324 additions and 181 deletions
+226
View File
@@ -0,0 +1,226 @@
/**
* 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 lappelant)', () => {
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 dune 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 lidentifiant', () => {
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 lobjet est utilisable
})
it('consent dun 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 lhé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 — lappelant 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()
})
})