/** * 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 { 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 { 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() }) })