forked from yvv/decision
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:
File diff suppressed because it is too large
Load Diff
+284
-181
@@ -1,158 +1,196 @@
|
||||
/* ==========================================================================
|
||||
libreDecision — Mood / Ambiance System
|
||||
Palettes harmoniques variees, colores en lite, lumineux en dark.
|
||||
libreDecision v2 — Ambiances (champ lexical du puits 井)
|
||||
Source (light, eau claire) · Margelle (light, pierre chaude)
|
||||
Nappe (dark, eau profonde) · Minuit (dark, encre & lanterne)
|
||||
Borderless : profondeur par background + box-shadow, jamais de bordures.
|
||||
========================================================================== */
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Peps — Chaud, colore, tonique (Light)
|
||||
Palette: corail vif / ocre / indigo / vert franc
|
||||
Source — l'eau vive au grand jour (Light)
|
||||
-------------------------------------------------------------------------- */
|
||||
.mood-peps {
|
||||
--mood-bg: #faf8f5;
|
||||
--mood-surface: #ffffff;
|
||||
--mood-surface-hover: #fdf2ec;
|
||||
--mood-text: #1e1410;
|
||||
--mood-text-muted: #7a5e48;
|
||||
--mood-accent: #d44a10;
|
||||
--mood-accent-soft: rgba(212, 74, 16, 0.08);
|
||||
.mood-source {
|
||||
--mood-bg: #f0f5f6;
|
||||
--mood-surface: #fcfefe;
|
||||
--mood-surface-hover: #e6eff2;
|
||||
--mood-text: #102126;
|
||||
--mood-text-muted: #52707a;
|
||||
--mood-accent: #0f7fa8;
|
||||
--mood-accent-soft: rgba(15, 127, 168, 0.09);
|
||||
--mood-accent-text: #ffffff;
|
||||
--mood-border: #e8d8c8;
|
||||
--mood-secondary: #4a5ec0;
|
||||
--mood-tertiary: #c07820;
|
||||
--mood-success: #1a8c3e;
|
||||
--mood-warning: #c47d0a;
|
||||
--mood-error: #c23028;
|
||||
--mood-gradient: linear-gradient(145deg, #fdf8f0 0%, #ffffff 40%, #f0f4fb 100%);
|
||||
--mood-shadow: rgba(180, 80, 20, 0.07);
|
||||
--mood-input-bg: #ffffff;
|
||||
--mood-input-border: #d4c0aa;
|
||||
--mood-input-focus: #d44a10;
|
||||
--mood-secondary: #a8742c; /* laiton — le mandat, le pouvoir confié */
|
||||
--mood-tertiary: #2e8a72; /* eau végétale — l'avis, le dialogue */
|
||||
--mood-success: #1d8a4a;
|
||||
--mood-warning: #b07c14;
|
||||
--mood-error: #bc3d33;
|
||||
--mood-gradient: linear-gradient(150deg, #eaf3f5 0%, #fcfefe 45%, #eef2ee 100%);
|
||||
--mood-shadow: rgba(20, 90, 120, 0.08);
|
||||
--mood-input-bg: #fcfefe;
|
||||
--mood-input-border: #b8ccd2;
|
||||
--mood-input-focus: #0f7fa8;
|
||||
|
||||
--mood-status-prepa: #b8600e;
|
||||
--mood-status-prepa-bg: rgba(184, 96, 14, 0.12);
|
||||
--mood-status-vote: #4a5ec0;
|
||||
--mood-status-vote-bg: rgba(74, 94, 192, 0.10);
|
||||
--mood-status-vigueur: #1a8c3e;
|
||||
--mood-status-vigueur-bg: rgba(26, 140, 62, 0.10);
|
||||
--mood-status-clos: #6e5844;
|
||||
--mood-status-clos-bg: rgba(110, 88, 68, 0.08);
|
||||
--mood-status-prepa: #8a7040;
|
||||
--mood-status-prepa-bg: rgba(138, 112, 64, 0.10);
|
||||
--mood-status-fenetre: #b07c14;
|
||||
--mood-status-fenetre-bg: rgba(176, 124, 20, 0.11);
|
||||
--mood-status-vote: #2661b8;
|
||||
--mood-status-vote-bg: rgba(38, 97, 184, 0.10);
|
||||
--mood-status-fige: #58708c;
|
||||
--mood-status-fige-bg: rgba(88, 112, 140, 0.12);
|
||||
--mood-status-vigueur: #1d8a4a;
|
||||
--mood-status-vigueur-bg: rgba(29, 138, 74, 0.10);
|
||||
--mood-status-revoque: #a4553a;
|
||||
--mood-status-revoque-bg: rgba(164, 85, 58, 0.10);
|
||||
--mood-status-clos: #5f6f74;
|
||||
--mood-status-clos-bg: rgba(95, 111, 116, 0.09);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Zen — Vegetal lumineux, nature vivante (Light)
|
||||
Palette: vert franc / terre sienne / bleu canard / bois chaud
|
||||
Margelle — la pierre du puits, calcaire et bronze (Light)
|
||||
-------------------------------------------------------------------------- */
|
||||
.mood-zen {
|
||||
--mood-bg: #f4f6f0;
|
||||
--mood-surface: #fbfcf8;
|
||||
--mood-surface-hover: #eaf0e4;
|
||||
--mood-text: #1a2418;
|
||||
--mood-text-muted: #5a7050;
|
||||
--mood-accent: #2e8b48;
|
||||
--mood-accent-soft: rgba(46, 139, 72, 0.09);
|
||||
.mood-margelle {
|
||||
--mood-bg: #f6f3ec;
|
||||
--mood-surface: #fdfbf6;
|
||||
--mood-surface-hover: #efe9dc;
|
||||
--mood-text: #221c10;
|
||||
--mood-text-muted: #77684c;
|
||||
--mood-accent: #96682a;
|
||||
--mood-accent-soft: rgba(150, 104, 42, 0.10);
|
||||
--mood-accent-text: #ffffff;
|
||||
--mood-border: #b8d0a8;
|
||||
--mood-secondary: #8a6030;
|
||||
--mood-tertiary: #2a7888;
|
||||
--mood-success: #268040;
|
||||
--mood-warning: #a87818;
|
||||
--mood-error: #b83838;
|
||||
--mood-gradient: linear-gradient(145deg, #f0f5ea 0%, #fbfcf8 40%, #f4efe6 100%);
|
||||
--mood-shadow: rgba(40, 100, 50, 0.07);
|
||||
--mood-input-bg: #fbfcf8;
|
||||
--mood-input-border: #a8c898;
|
||||
--mood-input-focus: #2e8b48;
|
||||
--mood-secondary: #285e88; /* le ciel dans l'eau du seau */
|
||||
--mood-tertiary: #4f7a3c; /* mousse entre les pierres */
|
||||
--mood-success: #3d7e34;
|
||||
--mood-warning: #a8720e;
|
||||
--mood-error: #b23f30;
|
||||
--mood-gradient: linear-gradient(150deg, #f3eee2 0%, #fdfbf6 45%, #f0eee6 100%);
|
||||
--mood-shadow: rgba(120, 90, 40, 0.09);
|
||||
--mood-input-bg: #fdfbf6;
|
||||
--mood-input-border: #cfc2a6;
|
||||
--mood-input-focus: #96682a;
|
||||
|
||||
--mood-status-prepa: #a87818;
|
||||
--mood-status-prepa-bg: rgba(168, 120, 24, 0.11);
|
||||
--mood-status-vote: #2a7888;
|
||||
--mood-status-vote-bg: rgba(42, 120, 136, 0.10);
|
||||
--mood-status-vigueur: #268040;
|
||||
--mood-status-vigueur-bg: rgba(38, 128, 64, 0.10);
|
||||
--mood-status-clos: #607858;
|
||||
--mood-status-clos-bg: rgba(96, 120, 88, 0.08);
|
||||
--mood-status-prepa: #7c6a48;
|
||||
--mood-status-prepa-bg: rgba(124, 106, 72, 0.10);
|
||||
--mood-status-fenetre: #a8720e;
|
||||
--mood-status-fenetre-bg: rgba(168, 114, 14, 0.11);
|
||||
--mood-status-vote: #285e88;
|
||||
--mood-status-vote-bg: rgba(40, 94, 136, 0.10);
|
||||
--mood-status-fige: #6e6a58;
|
||||
--mood-status-fige-bg: rgba(110, 106, 88, 0.12);
|
||||
--mood-status-vigueur: #3d7e34;
|
||||
--mood-status-vigueur-bg: rgba(61, 126, 52, 0.10);
|
||||
--mood-status-revoque: #9c5236;
|
||||
--mood-status-revoque-bg: rgba(156, 82, 54, 0.10);
|
||||
--mood-status-clos: #6f6a5c;
|
||||
--mood-status-clos-bg: rgba(111, 106, 92, 0.09);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Chagrine — Nuit profonde, reflets varies (Dark)
|
||||
Palette: bleu lavande / ambre chaud / rose ancien / vert d'eau
|
||||
Nappe — l'eau profonde sous la ville (Dark)
|
||||
-------------------------------------------------------------------------- */
|
||||
.mood-chagrine {
|
||||
--mood-bg: #0e1018;
|
||||
--mood-surface: #151a28;
|
||||
--mood-surface-hover: #1c2640;
|
||||
--mood-text: #dce0ec;
|
||||
--mood-text-muted: #8898b8;
|
||||
--mood-accent: #6488d8;
|
||||
--mood-accent-soft: rgba(100, 136, 216, 0.12);
|
||||
--mood-accent-text: #ffffff;
|
||||
--mood-border: #222e48;
|
||||
--mood-secondary: #d0a040;
|
||||
--mood-tertiary: #c87090;
|
||||
--mood-success: #48c87a;
|
||||
--mood-warning: #d8a838;
|
||||
--mood-error: #d86060;
|
||||
--mood-gradient: linear-gradient(145deg, #0e1018 0%, #151a28 40%, #101420 100%);
|
||||
--mood-shadow: rgba(60, 100, 180, 0.12);
|
||||
--mood-input-bg: #151a28;
|
||||
--mood-input-border: #2a3858;
|
||||
--mood-input-focus: #6488d8;
|
||||
.mood-nappe {
|
||||
--mood-bg: #0a141b;
|
||||
--mood-surface: #101f2a;
|
||||
--mood-surface-hover: #16293a;
|
||||
--mood-text: #d6e3ea;
|
||||
--mood-text-muted: #7e98a8;
|
||||
--mood-accent: #3fa9cc;
|
||||
--mood-accent-soft: rgba(63, 169, 204, 0.13);
|
||||
--mood-accent-text: #06141c;
|
||||
--mood-secondary: #cfa14a; /* laiton à la lueur de la lampe */
|
||||
--mood-tertiary: #52b894;
|
||||
--mood-success: #46bf7c;
|
||||
--mood-warning: #d2a13c;
|
||||
--mood-error: #d86458;
|
||||
--mood-gradient: linear-gradient(150deg, #0a141b 0%, #101f2a 45%, #0d1a24 100%);
|
||||
--mood-shadow: rgba(30, 120, 160, 0.14);
|
||||
--mood-input-bg: #101f2a;
|
||||
--mood-input-border: #24425a;
|
||||
--mood-input-focus: #3fa9cc;
|
||||
|
||||
--mood-status-prepa: #d8a838;
|
||||
--mood-status-prepa-bg: rgba(216, 168, 56, 0.14);
|
||||
--mood-status-vote: #6488d8;
|
||||
--mood-status-vote-bg: rgba(100, 136, 216, 0.14);
|
||||
--mood-status-vigueur: #48c87a;
|
||||
--mood-status-vigueur-bg: rgba(72, 200, 122, 0.14);
|
||||
--mood-status-clos: #7888a8;
|
||||
--mood-status-clos-bg: rgba(120, 136, 168, 0.10);
|
||||
--mood-status-prepa: #a5987c;
|
||||
--mood-status-prepa-bg: rgba(165, 152, 124, 0.12);
|
||||
--mood-status-fenetre: #d2a13c;
|
||||
--mood-status-fenetre-bg: rgba(210, 161, 60, 0.14);
|
||||
--mood-status-vote: #5f97e0;
|
||||
--mood-status-vote-bg: rgba(95, 151, 224, 0.14);
|
||||
--mood-status-fige: #7d97b5;
|
||||
--mood-status-fige-bg: rgba(125, 151, 181, 0.14);
|
||||
--mood-status-vigueur: #46bf7c;
|
||||
--mood-status-vigueur-bg: rgba(70, 191, 124, 0.13);
|
||||
--mood-status-revoque: #cd7a56;
|
||||
--mood-status-revoque-bg: rgba(205, 122, 86, 0.13);
|
||||
--mood-status-clos: #7d8fa0;
|
||||
--mood-status-clos-bg: rgba(125, 143, 160, 0.11);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Grave — Ambre mineral, ocre lumineux (Dark)
|
||||
Palette: ambre dore / ocre rouge / bleu ardoise / vert mousse
|
||||
Minuit — l'encre, et la lanterne posée sur la margelle (Dark)
|
||||
-------------------------------------------------------------------------- */
|
||||
.mood-grave {
|
||||
--mood-bg: #131210;
|
||||
--mood-surface: #1c1a16;
|
||||
--mood-surface-hover: #28241c;
|
||||
--mood-text: #ece4d4;
|
||||
--mood-text-muted: #a89870;
|
||||
--mood-accent: #d8a030;
|
||||
--mood-accent-soft: rgba(216, 160, 48, 0.11);
|
||||
--mood-accent-text: #131210;
|
||||
--mood-border: #342e24;
|
||||
--mood-secondary: #6898b8;
|
||||
--mood-tertiary: #c07040;
|
||||
--mood-success: #58c870;
|
||||
--mood-warning: #d0a030;
|
||||
--mood-error: #d05050;
|
||||
--mood-gradient: linear-gradient(145deg, #131210 0%, #1c1a16 40%, #16181c 100%);
|
||||
--mood-shadow: rgba(180, 130, 40, 0.10);
|
||||
--mood-input-bg: #1c1a16;
|
||||
--mood-input-border: #3c3428;
|
||||
--mood-input-focus: #d8a030;
|
||||
.mood-minuit {
|
||||
--mood-bg: #101014;
|
||||
--mood-surface: #17171e;
|
||||
--mood-surface-hover: #202029;
|
||||
--mood-text: #e2e2e8;
|
||||
--mood-text-muted: #8e8e9e;
|
||||
--mood-accent: #cf9c3e;
|
||||
--mood-accent-soft: rgba(207, 156, 62, 0.12);
|
||||
--mood-accent-text: #131309;
|
||||
--mood-secondary: #6c94d8;
|
||||
--mood-tertiary: #58ae8e;
|
||||
--mood-success: #52bd7e;
|
||||
--mood-warning: #cfa03a;
|
||||
--mood-error: #d25f55;
|
||||
--mood-gradient: linear-gradient(150deg, #101014 0%, #17171e 45%, #131318 100%);
|
||||
--mood-shadow: rgba(190, 140, 50, 0.11);
|
||||
--mood-input-bg: #17171e;
|
||||
--mood-input-border: #33333f;
|
||||
--mood-input-focus: #cf9c3e;
|
||||
|
||||
--mood-status-prepa: #c07040;
|
||||
--mood-status-prepa-bg: rgba(192, 112, 64, 0.15);
|
||||
--mood-status-vote: #6898b8;
|
||||
--mood-status-vote-bg: rgba(104, 152, 184, 0.14);
|
||||
--mood-status-vigueur: #58c870;
|
||||
--mood-status-vigueur-bg: rgba(88, 200, 112, 0.14);
|
||||
--mood-status-clos: #807860;
|
||||
--mood-status-clos-bg: rgba(128, 120, 96, 0.12);
|
||||
--mood-status-prepa: #9e9584;
|
||||
--mood-status-prepa-bg: rgba(158, 149, 132, 0.12);
|
||||
--mood-status-fenetre: #cfa03a;
|
||||
--mood-status-fenetre-bg: rgba(207, 160, 58, 0.14);
|
||||
--mood-status-vote: #6c94d8;
|
||||
--mood-status-vote-bg: rgba(108, 148, 216, 0.14);
|
||||
--mood-status-fige: #8b8ba0;
|
||||
--mood-status-fige-bg: rgba(139, 139, 160, 0.14);
|
||||
--mood-status-vigueur: #52bd7e;
|
||||
--mood-status-vigueur-bg: rgba(82, 189, 126, 0.13);
|
||||
--mood-status-revoque: #c97a5c;
|
||||
--mood-status-revoque-bg: rgba(201, 122, 92, 0.13);
|
||||
--mood-status-clos: #85859a;
|
||||
--mood-status-clos-bg: rgba(133, 133, 154, 0.11);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Global design tokens — Plus Jakarta Sans, rounded, borderless
|
||||
Tokens dérivés — routes, tailles, ombres (identiques dans toutes les ambiances)
|
||||
========================================================================== */
|
||||
.mood-source, .mood-margelle, .mood-nappe, .mood-minuit {
|
||||
/* Routes du chemin — vocabulaire visuel fixe */
|
||||
--route-solo: var(--mood-accent);
|
||||
--route-mandate: var(--mood-secondary);
|
||||
--route-transmit: var(--mood-text-muted);
|
||||
--route-advice: var(--mood-tertiary);
|
||||
--route-collective: var(--mood-status-vote);
|
||||
--route-record: var(--mood-status-prepa);
|
||||
--route-urgent: var(--mood-error);
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
border-color: transparent;
|
||||
/* Rayons (consignes) */
|
||||
--r-card: 16px;
|
||||
--r-input: 12px;
|
||||
--r-pill: 20px;
|
||||
--r-icon: 14px;
|
||||
|
||||
/* Ombres borderless — deux couches, jamais de bordure */
|
||||
--shadow-card: 0 1px 2px var(--mood-shadow), 0 5px 18px var(--mood-shadow);
|
||||
--shadow-card-hover: 0 2px 4px var(--mood-shadow), 0 10px 28px var(--mood-shadow);
|
||||
--shadow-raised: 0 1px 3px var(--mood-shadow), 0 8px 24px var(--mood-shadow);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Socle global — Plus Jakarta Sans, borderless, gestes
|
||||
========================================================================== */
|
||||
*, *::before, *::after { border-color: transparent; }
|
||||
|
||||
html {
|
||||
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
@@ -160,89 +198,154 @@ html {
|
||||
|
||||
body {
|
||||
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||
background: var(--mood-bg);
|
||||
color: var(--mood-text);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* --- No borders anywhere --- */
|
||||
button, input, select, textarea {
|
||||
border: none !important;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
input, select, textarea {
|
||||
background: var(--mood-input-bg);
|
||||
border-radius: var(--r-input);
|
||||
}
|
||||
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible,
|
||||
button:focus-visible, a:focus-visible, [tabindex]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
|
||||
box-shadow: 0 0 0 2.5px var(--mood-accent-soft), 0 0 0 1.5px var(--mood-accent);
|
||||
}
|
||||
|
||||
/* --- Status pills --- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Cartes --- */
|
||||
.ld-card {
|
||||
background: var(--mood-surface);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.ld-card--hover:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
.ld-card--hover:active { transform: translateY(0); }
|
||||
|
||||
/* --- Boutons pill --- */
|
||||
.ld-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease, filter 0.1s ease;
|
||||
user-select: none;
|
||||
}
|
||||
.ld-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px var(--mood-shadow); }
|
||||
.ld-btn:active { transform: translateY(0); }
|
||||
.ld-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
.ld-btn--ghost {
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
.ld-btn--quiet {
|
||||
background: none;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
.ld-btn--quiet:hover { background: var(--mood-accent-soft); color: var(--mood-text); box-shadow: none; }
|
||||
|
||||
/* --- Pills d'état (mapping unique état→couleur) --- */
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 14px;
|
||||
border-radius: 20px;
|
||||
gap: 0.3rem;
|
||||
padding: 4px 13px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
border: none !important;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.status-pill:hover {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.status-pill.active {
|
||||
box-shadow: 0 0 0 2.5px var(--mood-accent);
|
||||
.status-pill.is-clickable { cursor: pointer; }
|
||||
.status-pill.is-clickable:hover { filter: brightness(1.08); transform: translateY(-1px); }
|
||||
.status-pill.active { box-shadow: 0 0 0 2.5px var(--mood-accent); }
|
||||
|
||||
.status-draft { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); }
|
||||
.status-advice,
|
||||
.status-objection { background: var(--mood-status-fenetre-bg); color: var(--mood-status-fenetre); }
|
||||
.status-framing { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); }
|
||||
.status-voting { background: var(--mood-status-vote-bg); color: var(--mood-status-vote); }
|
||||
.status-frozen { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); }
|
||||
.status-adopted { background: var(--mood-status-vigueur-bg); color: var(--mood-status-vigueur); }
|
||||
.status-rejected { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); text-decoration: line-through; }
|
||||
.status-revoked { background: var(--mood-status-revoque-bg); color: var(--mood-status-revoque); }
|
||||
.status-transmitted { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); }
|
||||
.status-closed { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); }
|
||||
|
||||
/* Suspension (frontière contestée) : fond strié */
|
||||
.status-suspended {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--mood-status-fenetre-bg),
|
||||
var(--mood-status-fenetre-bg) 6px,
|
||||
transparent 6px,
|
||||
transparent 10px
|
||||
);
|
||||
color: var(--mood-status-fenetre);
|
||||
}
|
||||
|
||||
.status-prepa {
|
||||
background: var(--mood-status-prepa-bg);
|
||||
color: var(--mood-status-prepa);
|
||||
}
|
||||
.status-vote {
|
||||
background: var(--mood-status-vote-bg);
|
||||
color: var(--mood-status-vote);
|
||||
}
|
||||
.status-vigueur {
|
||||
background: var(--mood-status-vigueur-bg);
|
||||
/* --- Le tampon 井 (signature) --- */
|
||||
.ld-stamp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
transform: rotate(-10deg);
|
||||
font-weight: 800;
|
||||
font-size: 0.8125rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--mood-status-vigueur);
|
||||
}
|
||||
.status-clos {
|
||||
background: var(--mood-status-clos-bg);
|
||||
color: var(--mood-status-clos);
|
||||
box-shadow: inset 0 0 0 2px var(--mood-status-vigueur);
|
||||
background: var(--mood-status-vigueur-bg);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Nuxt UI overrides — borderless everything
|
||||
========================================================================== */
|
||||
|
||||
:root .mood-peps,
|
||||
:root .mood-zen,
|
||||
:root .mood-chagrine,
|
||||
:root .mood-grave {
|
||||
--ui-border: transparent;
|
||||
--ui-bg: var(--mood-input-bg);
|
||||
--ui-text-highlighted: var(--mood-accent);
|
||||
/* --- Sceau hexagramme #48 Tsing --- */
|
||||
.app-seal {
|
||||
display: block;
|
||||
width: 44px;
|
||||
color: var(--mood-accent);
|
||||
opacity: 0.28;
|
||||
filter: drop-shadow(1px 1px 0.5px rgba(0, 0, 0, 0.25))
|
||||
drop-shadow(-0.5px -0.5px 0.5px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
:root button,
|
||||
:root [class*="UButton"],
|
||||
:root [class*="u-button"],
|
||||
:root [data-variant] {
|
||||
border: none !important;
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
:root input,
|
||||
:root textarea,
|
||||
:root select,
|
||||
:root [class*="UInput"],
|
||||
:root [class*="USelect"],
|
||||
:root [class*="UTextarea"] {
|
||||
border: none !important;
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important;
|
||||
/* --- Impression A4 (PV, affiches de session) --- */
|
||||
@media print {
|
||||
body { background: #ffffff !important; color: #111111 !important; }
|
||||
.app-header, .app-sidebar, .app-fab, .app-seal, .no-print { display: none !important; }
|
||||
.ld-card { box-shadow: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
}
|
||||
.print-only { display: none; }
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// libreDecision v2 — local-first persistence (IndexedDB via idb-keyval).
|
||||
// One key per collective: `ld2:<collectiveId>` holds a Bundle-shaped state.
|
||||
// Seeds and user imports go through the SAME importBundle() path.
|
||||
// Sync-ready invariants live in types/domain.ts — this layer stays dumb.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
import { get, set, del, keys } from 'idb-keyval'
|
||||
import type { Bundle, Collective, Id, ISODate } from '~/types/domain'
|
||||
|
||||
const KEY_PREFIX = 'ld2:'
|
||||
const ACTIVE_KEY = 'ld2-active'
|
||||
|
||||
/** In-memory state of one collective — exactly a Bundle minus export metadata. */
|
||||
export type CollectiveState = Omit<Bundle, 'schemaVersion' | 'exportedAt'>
|
||||
|
||||
export function emptyState(collective: Collective): CollectiveState {
|
||||
return {
|
||||
collective,
|
||||
people: [],
|
||||
circles: [],
|
||||
decisions: [],
|
||||
concerns: [],
|
||||
objections: [],
|
||||
advices: [],
|
||||
assents: [],
|
||||
mandates: [],
|
||||
docs: [],
|
||||
clauses: [],
|
||||
versions: [],
|
||||
protocols: [],
|
||||
sessions: [],
|
||||
votes: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load / save ──────────────────────────────────────────────
|
||||
|
||||
export async function listCollectiveIds(): Promise<Id[]> {
|
||||
const all = await keys()
|
||||
return all
|
||||
.filter((k): k is string => typeof k === 'string' && k.startsWith(KEY_PREFIX))
|
||||
.map(k => k.slice(KEY_PREFIX.length))
|
||||
}
|
||||
|
||||
export async function loadState(collectiveId: Id): Promise<CollectiveState | undefined> {
|
||||
return await get<CollectiveState>(KEY_PREFIX + collectiveId)
|
||||
}
|
||||
|
||||
const pendingSaves = new Map<Id, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/** Debounced write (500 ms) — every mutation calls this; last write wins. */
|
||||
export function saveStateDebounced(state: CollectiveState): void {
|
||||
const id = state.collective.id
|
||||
const existing = pendingSaves.get(id)
|
||||
if (existing) clearTimeout(existing)
|
||||
pendingSaves.set(
|
||||
id,
|
||||
setTimeout(() => {
|
||||
pendingSaves.delete(id)
|
||||
void set(KEY_PREFIX + id, toRaw(state))
|
||||
}, 500),
|
||||
)
|
||||
}
|
||||
|
||||
export async function saveStateNow(state: CollectiveState): Promise<void> {
|
||||
const id = state.collective.id
|
||||
const existing = pendingSaves.get(id)
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
pendingSaves.delete(id)
|
||||
}
|
||||
await set(KEY_PREFIX + id, toRaw(state))
|
||||
}
|
||||
|
||||
export async function deleteCollective(collectiveId: Id): Promise<void> {
|
||||
await del(KEY_PREFIX + collectiveId)
|
||||
const active = await getActiveCollectiveId()
|
||||
if (active === collectiveId) await set(ACTIVE_KEY, null)
|
||||
}
|
||||
|
||||
export async function getActiveCollectiveId(): Promise<Id | null> {
|
||||
return (await get<Id | null>(ACTIVE_KEY)) ?? null
|
||||
}
|
||||
|
||||
export async function setActiveCollectiveId(id: Id | null): Promise<void> {
|
||||
await set(ACTIVE_KEY, id)
|
||||
}
|
||||
|
||||
// ── Export / import — the single bundle path ─────────────────
|
||||
|
||||
export function toBundle(state: CollectiveState, exportedAt: ISODate): Bundle {
|
||||
return { schemaVersion: 2, exportedAt, ...toRaw(state) }
|
||||
}
|
||||
|
||||
export async function sha256Hex(text: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(text)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export interface ImportIssue {
|
||||
level: 'error' | 'warning'
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Structural validation — never throws, returns readable French issues. */
|
||||
export function validateBundle(raw: unknown): { bundle?: Bundle; issues: ImportIssue[] } {
|
||||
const issues: ImportIssue[] = []
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
return { issues: [{ level: 'error', message: 'Ce fichier ne contient pas un collectif lisible.' }] }
|
||||
}
|
||||
const b = raw as Partial<Bundle>
|
||||
if (b.schemaVersion !== 2)
|
||||
issues.push({ level: 'error', message: 'Version de fichier inconnue (schemaVersion ≠ 2).' })
|
||||
if (!b.collective?.id || !b.collective?.slug || !b.collective?.name)
|
||||
issues.push({ level: 'error', message: 'Le collectif du fichier est incomplet (id, slug ou nom manquant).' })
|
||||
for (const key of [
|
||||
'people', 'circles', 'decisions', 'concerns', 'objections', 'advices', 'assents',
|
||||
'mandates', 'docs', 'clauses', 'versions', 'protocols', 'sessions', 'votes',
|
||||
] as const) {
|
||||
if (!Array.isArray(b[key]))
|
||||
issues.push({ level: 'error', message: `Collection manquante ou invalide : ${key}.` })
|
||||
}
|
||||
// Invariant: every collective carries a Consent protocol (import validates it).
|
||||
if (Array.isArray(b.protocols) && !b.protocols.some(p => p.method === 'consent'))
|
||||
issues.push({
|
||||
level: 'warning',
|
||||
message: 'Aucun protocole de consentement — les chemins collectifs retomberont sur « décider sur avis » jusqu\'à sa création.',
|
||||
})
|
||||
if (issues.some(i => i.level === 'error')) return { issues }
|
||||
return { bundle: b as Bundle, issues }
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
state?: CollectiveState
|
||||
issues: ImportIssue[]
|
||||
collided: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a bundle as a new local collective.
|
||||
* Foreign bundles (exportedAt present, not one of ours) get a lineage stamp — « essaimé de … ».
|
||||
*/
|
||||
export async function importBundle(json: string, opts?: { asSeed?: boolean }): Promise<ImportResult> {
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(json)
|
||||
} catch {
|
||||
return { issues: [{ level: 'error', message: 'Fichier illisible : ce n\'est pas du JSON valide.' }], collided: false }
|
||||
}
|
||||
const { bundle, issues } = validateBundle(raw)
|
||||
if (!bundle) return { issues, collided: false }
|
||||
|
||||
const existingIds = await listCollectiveIds()
|
||||
const collided = existingIds.includes(bundle.collective.id)
|
||||
|
||||
const { schemaVersion: _v, exportedAt, ...stateRest } = bundle
|
||||
const state: CollectiveState = stateRest
|
||||
|
||||
if (!opts?.asSeed) {
|
||||
state.collective = {
|
||||
...state.collective,
|
||||
lineage: {
|
||||
sourceSlug: bundle.collective.slug,
|
||||
exportedAt,
|
||||
sha256: await sha256Hex(json),
|
||||
},
|
||||
}
|
||||
}
|
||||
return { state, issues, collided }
|
||||
}
|
||||
|
||||
/** Deep-clone to plain JSON — strips Pinia/Vue reactivity proxies before IndexedDB. */
|
||||
function toRaw<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Perimeter engine — who is concerned by THIS decision.
|
||||
*
|
||||
* Pure functions — no store, no I/O. The store assembles the inputs and
|
||||
* persists the result as Concern rows (origin 'computed').
|
||||
*
|
||||
* The computed union (BLUEPRINT-V2.md « Périmètres et inclusion ») :
|
||||
* members of the scoped circles ∪ persons named by the author
|
||||
* ∪ holders of active mandates whose domain intersects the scoped circles.
|
||||
* Every inclusion carries its human-readable French reason — auditable in
|
||||
* one tap, never « la machine t'a désigné ».
|
||||
*
|
||||
* MEMBERSHIP CHOICE (v2, documented): circle membership is NOMINATIVE per
|
||||
* circle (Circle.memberIds is the ONLY membership rule). Circle nesting
|
||||
* (parentCircleId, « couches d'oignon ») does NOT propagate membership:
|
||||
* a parent circle does NOT automatically include the members of its child
|
||||
* circles, nor the reverse. Nesting serves ONE gesture — widening the
|
||||
* perimeter by one notch (« élargir d'un cran ») — which explicitly adds
|
||||
* the parent circle to the scope; the computation never infers it.
|
||||
*/
|
||||
|
||||
import type { Circle, Decision, Id, Mandate } from '~/types/domain'
|
||||
|
||||
export interface ConcernedEntry {
|
||||
personId: Id
|
||||
/** French reason shown on tap (becomes Concern.reason). */
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten circle ids into the set of their DIRECT nominative members.
|
||||
*
|
||||
* NON-PROPAGATION (see module header): only Circle.memberIds of the listed
|
||||
* circles count. Members of child (or parent) circles are NOT included —
|
||||
* widening is a scope gesture, never an automatic inference.
|
||||
* Unknown circle ids are ignored (robustness over crash).
|
||||
*/
|
||||
export function expandCircleMembers(circleIds: Id[], circles: Circle[]): Set<Id> {
|
||||
const members = new Set<Id>()
|
||||
for (const circleId of circleIds) {
|
||||
const circle = circles.find(c => c.id === circleId)
|
||||
if (!circle) continue
|
||||
for (const personId of circle.memberIds) members.add(personId)
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the concerned persons of a decision scope, each with its reason.
|
||||
*
|
||||
* Deduplication: first reason wins, with source priority
|
||||
* circle membership > named by author > mandate holder.
|
||||
* The author is EXCLUDED from the list (they decide, they are not
|
||||
* « concerned » by their own perimeter).
|
||||
*
|
||||
* Mandates count only when status 'active' and when their domain.circleIds
|
||||
* INTERSECTS the scoped circles (any shared circle is enough — a mandate
|
||||
* holder is concerned as soon as the decision touches their domain).
|
||||
*/
|
||||
export function computeConcerned(
|
||||
scope: Decision['scope'],
|
||||
circles: Circle[],
|
||||
mandates: Mandate[],
|
||||
authorId: Id,
|
||||
): ConcernedEntry[] {
|
||||
// Map preserves insertion order; first reason wins (priority by pass order).
|
||||
const concerned = new Map<Id, string>()
|
||||
|
||||
// 1. Members of the scoped circles (highest priority reason).
|
||||
for (const circleId of scope.circleIds) {
|
||||
const circle = circles.find(c => c.id === circleId)
|
||||
if (!circle) continue
|
||||
for (const personId of circle.memberIds) {
|
||||
if (personId === authorId) continue
|
||||
if (!concerned.has(personId)) {
|
||||
concerned.set(personId, `membre du cercle ${circle.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Persons named by the author.
|
||||
for (const personId of scope.personIds) {
|
||||
if (personId === authorId) continue
|
||||
if (!concerned.has(personId)) {
|
||||
concerned.set(personId, 'nommé·e par l\'auteur')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Holders of active mandates whose domain intersects the scoped circles.
|
||||
const scopedCircleIds = new Set(scope.circleIds)
|
||||
for (const mandate of mandates) {
|
||||
if (mandate.status !== 'active') continue
|
||||
if (!mandate.domain.circleIds.some(id => scopedCircleIds.has(id))) continue
|
||||
if (mandate.holderId === authorId) continue
|
||||
if (!concerned.has(mandate.holderId)) {
|
||||
concerned.set(mandate.holderId, `titulaire du mandat ${mandate.title}`)
|
||||
}
|
||||
}
|
||||
|
||||
return [...concerned].map(([personId, reason]) => ({ personId, reason }))
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Parametric decision engine — « Réglage collectif » (collective tuning).
|
||||
*
|
||||
* Pure functions, no I/O — the single implementation (BLUEPRINT-V2.md Δ2, Δ3,
|
||||
* Δ15, Δ16). Everything here is math over ParamSpec + vote vectors; the human
|
||||
* gesture (crystallization) lives in state.ts/UI, never here.
|
||||
*
|
||||
* LOCKED SPECS (blueprint repairs):
|
||||
* - LOW median, element by element: for an even vote count, take the LOWER
|
||||
* central element (index floor((n-1)/2) after ascending sort). Invariant:
|
||||
* every median value is a value someone actually voted, so the step is
|
||||
* honored BY CONSTRUCTION — « une position que chacun aurait pu proposer ».
|
||||
* - constraint 'sum100' requires EXACTLY ONE 'share' param with derived:true
|
||||
* (the absorption variable), resolved linearly: 100 − Σ other shares.
|
||||
* 'slider' params live outside the constraint and pass through untouched.
|
||||
* - At vote time the resolved derived must stay within its bounds, otherwise
|
||||
* the vote is rejected (validateVote).
|
||||
* - At crystallization the derived is never aggregated: it is resolved from
|
||||
* the median of the voted shares; if it exits its bounds ⇒ clamp to the
|
||||
* violated bound + PROPORTIONAL renormalization of the non-derived shares
|
||||
* (each multiplied by (100 − clampedDerived) / Σ median shares) so the
|
||||
* sum-100 invariant is restored. The renormalized shares may leave the
|
||||
* step grid — accepted and documented: this is the one specified exception.
|
||||
* - Degenerate cases: 0 votes ⇒ baseline vector, never an empty screen.
|
||||
* - computeMyImpact 'linear-share' NEVER invents a number: missing attribute
|
||||
* or empty declaring corpus ⇒ null.
|
||||
* - detectBimodality is a simple documented heuristic, NEVER blocking:
|
||||
* it returns false on any degenerate input instead of throwing.
|
||||
*
|
||||
* Code and comments in English; thrown error messages in French (UI-facing).
|
||||
*/
|
||||
|
||||
import type { ParamDef, ParamSpec } from '../types/domain'
|
||||
|
||||
/** Blueprint limit: a small manipulable space (SejeteralO lesson). */
|
||||
const MAX_PARAMS = 7
|
||||
|
||||
/**
|
||||
* Absolute tolerance for floating-point comparisons (bounds and step grid).
|
||||
* Vote values are human-scale (percent shares, bounded sliders), so an
|
||||
* absolute epsilon is safe: 0.1 + 0.2 must be accepted as a 0.3 step value.
|
||||
*/
|
||||
const FLOAT_EPS = 1e-6
|
||||
|
||||
/** Params that are actually voted, in spec order (derived excluded). */
|
||||
function votableParams(spec: ParamSpec): ParamDef[] {
|
||||
return spec.params.filter(p => p.derived !== true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarded indexed access (project compiles with noUncheckedIndexedAccess).
|
||||
* Every call site is protected by a prior length check or loop bound —
|
||||
* this throw is an internal-invariant guard, not a reachable user error.
|
||||
*/
|
||||
function at(arr: number[], i: number): number {
|
||||
const v = arr[i]
|
||||
if (v === undefined) {
|
||||
throw new Error('Incohérence interne : index hors du vecteur.')
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateParamSpec
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate a ParamSpec at creation time. Throws (French message) when:
|
||||
* - no param, or more than 7 params;
|
||||
* - a business label is missing (never raw a, b, c);
|
||||
* - bounds are inconsistent (min >= max) or step is not strictly positive;
|
||||
* - a baseline lies outside its own bounds;
|
||||
* - constraint 'sum100' does not have EXACTLY ONE 'share' param with
|
||||
* derived:true (0 or 2+ derived, or derived on a 'slider');
|
||||
* - a derived param exists without a constraint able to resolve it.
|
||||
*/
|
||||
export function validateParamSpec(spec: ParamSpec): void {
|
||||
if (spec.params.length === 0) {
|
||||
throw new Error('Au moins un paramètre est requis.')
|
||||
}
|
||||
if (spec.params.length > MAX_PARAMS) {
|
||||
throw new Error(
|
||||
`Trop de paramètres : ${spec.params.length} (maximum ${MAX_PARAMS}).`,
|
||||
)
|
||||
}
|
||||
|
||||
for (const p of spec.params) {
|
||||
if (typeof p.label !== 'string' || p.label.trim() === '') {
|
||||
throw new Error(`Libellé métier manquant pour le paramètre « ${p.key} ».`)
|
||||
}
|
||||
if (!(p.min < p.max)) {
|
||||
throw new Error(
|
||||
`Bornes incohérentes pour « ${p.key} » : min (${p.min}) doit être strictement inférieur à max (${p.max}).`,
|
||||
)
|
||||
}
|
||||
if (!(p.step > 0)) {
|
||||
throw new Error(
|
||||
`Pas invalide pour « ${p.key} » : ${p.step} (doit être strictement positif).`,
|
||||
)
|
||||
}
|
||||
if (p.baseline !== undefined && (p.baseline < p.min || p.baseline > p.max)) {
|
||||
throw new Error(
|
||||
`Statu quo hors bornes pour « ${p.key} » : ${p.baseline} (bornes ${p.min}–${p.max}).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const derived = spec.params.filter(p => p.derived === true)
|
||||
|
||||
if (spec.constraint === 'sum100') {
|
||||
if (derived.some(p => p.kind !== 'share')) {
|
||||
throw new Error(
|
||||
'Un paramètre dérivé doit être une part (kind « share »), pas un curseur.',
|
||||
)
|
||||
}
|
||||
if (derived.length === 0) {
|
||||
throw new Error(
|
||||
'Contrainte sum100 : exactement une part dérivée est requise (aucune trouvée).',
|
||||
)
|
||||
}
|
||||
if (derived.length > 1) {
|
||||
throw new Error(
|
||||
`Contrainte sum100 : exactement une part dérivée est requise (${derived.length} trouvées).`,
|
||||
)
|
||||
}
|
||||
} else if (derived.length > 0) {
|
||||
throw new Error(
|
||||
'Paramètre dérivé sans contrainte : rien ne permet de le résoudre.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateVote
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate one vote vector against the spec.
|
||||
* `values` follows the order of spec.params EXCLUDING derived params
|
||||
* (Vote.values contract). Throws (French message) when:
|
||||
* - the vector length does not match the number of votable params;
|
||||
* - a value is not a finite number (NaN / ±Infinity sanitization);
|
||||
* - a value is out of bounds or off the step grid (FLOAT_EPS tolerance);
|
||||
* - constraint 'sum100': the resolved derived (100 − Σ voted shares) would
|
||||
* exit its own [min, max] bounds ⇒ the vote is rejected.
|
||||
*/
|
||||
export function validateVote(spec: ParamSpec, values: number[]): void {
|
||||
const votable = votableParams(spec)
|
||||
|
||||
if (values.length !== votable.length) {
|
||||
throw new Error(
|
||||
`Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`,
|
||||
)
|
||||
}
|
||||
|
||||
votable.forEach((p, i) => {
|
||||
const v = values[i]
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
||||
throw new Error(
|
||||
`Valeur invalide pour « ${p.label} » : un nombre fini est attendu.`,
|
||||
)
|
||||
}
|
||||
if (v < p.min - FLOAT_EPS || v > p.max + FLOAT_EPS) {
|
||||
throw new Error(
|
||||
`Valeur hors bornes pour « ${p.label} » : ${v} (bornes ${p.min}–${p.max}).`,
|
||||
)
|
||||
}
|
||||
// Step grid: v must equal min + k×step for an integer k (float tolerance).
|
||||
const k = Math.round((v - p.min) / p.step)
|
||||
if (Math.abs(p.min + k * p.step - v) > FLOAT_EPS) {
|
||||
throw new Error(
|
||||
`Valeur non alignée sur le pas pour « ${p.label} » : ${v} (pas de ${p.step} depuis ${p.min}).`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (spec.constraint === 'sum100') {
|
||||
const derivedParam = spec.params.find(p => p.derived === true)
|
||||
if (derivedParam) {
|
||||
let shareSum = 0
|
||||
votable.forEach((p, i) => {
|
||||
if (p.kind === 'share') shareSum += at(values, i)
|
||||
})
|
||||
const resolved = 100 - shareSum
|
||||
if (
|
||||
resolved < derivedParam.min - FLOAT_EPS
|
||||
|| resolved > derivedParam.max + FLOAT_EPS
|
||||
) {
|
||||
throw new Error(
|
||||
`La part calculée « ${derivedParam.label} » sortirait de ses bornes : ${resolved} (bornes ${derivedParam.min}–${derivedParam.max}).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveDerived
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Expand a votable vector into the COMPLETE vector in spec.params order.
|
||||
* The derived share (sum100) is resolved linearly: 100 − Σ other shares.
|
||||
* 'slider' params live outside the constraint and pass through untouched.
|
||||
* Throws on a length mismatch (misuse guard — same message as validateVote).
|
||||
*/
|
||||
export function resolveDerived(spec: ParamSpec, values: number[]): number[] {
|
||||
const votable = votableParams(spec)
|
||||
|
||||
if (values.length !== votable.length) {
|
||||
throw new Error(
|
||||
`Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`,
|
||||
)
|
||||
}
|
||||
|
||||
let shareSum = 0
|
||||
votable.forEach((p, i) => {
|
||||
if (p.kind === 'share') shareSum += at(values, i)
|
||||
})
|
||||
|
||||
let cursor = 0
|
||||
return spec.params.map(p => (p.derived === true ? 100 - shareSum : at(values, cursor++)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// medianByElement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* LOW median, element by element.
|
||||
* Each column is sorted ascending and the element at index floor((n-1)/2) is
|
||||
* taken — for an even n this is the LOWER of the two central elements.
|
||||
* Invariant: every median value is a value actually voted by someone, so the
|
||||
* step grid is honored by construction (« une position que chacun aurait pu
|
||||
* proposer »).
|
||||
* 0 votes ⇒ [] (the caller falls back to the baseline vector).
|
||||
*/
|
||||
export function medianByElement(votesValues: number[][]): number[] {
|
||||
const n = votesValues.length
|
||||
if (n === 0) return []
|
||||
|
||||
const width = votesValues[0]?.length ?? 0
|
||||
const lowMedianIndex = Math.floor((n - 1) / 2)
|
||||
const medians: number[] = []
|
||||
|
||||
for (let j = 0; j < width; j++) {
|
||||
const column = votesValues.map(v => at(v, j)).sort((a, b) => a - b)
|
||||
medians.push(at(column, lowMedianIndex))
|
||||
}
|
||||
return medians
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// crystallize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the crystallized position: LOW median of the VOTED vectors, then
|
||||
* derived resolution. Returns the complete vector in spec.params order.
|
||||
*
|
||||
* sum100 repair (locked spec): the derived is never aggregated — it is
|
||||
* resolved from the median of the voted shares. If it exits its bounds:
|
||||
* - clamp it to the violated bound;
|
||||
* - renormalize the non-derived shares PROPORTIONALLY to restore sum 100:
|
||||
* each share is multiplied by (100 − clampedDerived) / Σ median shares.
|
||||
* (Renormalized shares may leave the step grid — accepted, documented.)
|
||||
* - degenerate sub-case Σ median shares = 0: proportionality is undefined,
|
||||
* the remainder (100 − clampedDerived) is spread equally instead.
|
||||
* 'slider' params are outside the constraint and are never renormalized.
|
||||
*
|
||||
* 0 votes ⇒ the baseline vector (spec.params[i].baseline ?? min) — never an
|
||||
* empty screen; the crystallization GESTURE itself stays human (Δ3).
|
||||
*/
|
||||
export function crystallize(spec: ParamSpec, votesValues: number[][]): number[] {
|
||||
if (votesValues.length === 0) {
|
||||
return spec.params.map(p => p.baseline ?? p.min)
|
||||
}
|
||||
|
||||
const median = medianByElement(votesValues)
|
||||
const full = resolveDerived(spec, median)
|
||||
|
||||
if (spec.constraint !== 'sum100') return full
|
||||
|
||||
const derivedIndex = spec.params.findIndex(p => p.derived === true)
|
||||
const derivedParam = spec.params[derivedIndex]
|
||||
if (derivedIndex === -1 || derivedParam === undefined) {
|
||||
return full // unreachable on a validated spec
|
||||
}
|
||||
const resolved = at(full, derivedIndex)
|
||||
|
||||
const withinBounds
|
||||
= resolved >= derivedParam.min - FLOAT_EPS
|
||||
&& resolved <= derivedParam.max + FLOAT_EPS
|
||||
if (withinBounds) return full
|
||||
|
||||
// Clamp to the violated bound, then restore the sum-100 invariant.
|
||||
const clamped = Math.min(Math.max(resolved, derivedParam.min), derivedParam.max)
|
||||
const remainder = 100 - clamped
|
||||
|
||||
let shareSum = 0
|
||||
let shareCount = 0
|
||||
spec.params.forEach((p, i) => {
|
||||
if (p.derived !== true && p.kind === 'share') {
|
||||
shareSum += at(full, i)
|
||||
shareCount++
|
||||
}
|
||||
})
|
||||
|
||||
return full.map((v, i) => {
|
||||
if (i === derivedIndex) return clamped
|
||||
const p = spec.params[i]
|
||||
if (p === undefined || p.kind !== 'share') return v // sliders pass through untouched
|
||||
if (shareSum === 0) return remainder / shareCount // degenerate: equal spread
|
||||
return v * (remainder / shareSum) // proportional renormalization
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeMyImpact
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One line of the « Pour moi » card: my quota for one share param. */
|
||||
export interface ImpactLine {
|
||||
key: string
|
||||
label: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
/** « Pour moi » card content — per share param + total. */
|
||||
export interface MyImpact {
|
||||
perParam: ImpactLine[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 'linear-share' personal impact (Δ15 — « Pour moi » card).
|
||||
* Applies ONLY when constraint is 'sum100' AND resources.amount is set AND
|
||||
* spec.impactAttrKey is set. For EACH share param p (derived included):
|
||||
* amount(p) = resources.amount × value(p) / 100
|
||||
* myQuota(p) = amount(p) × myAttr / Σ corpusAttrs
|
||||
* `fullValues` is the COMPLETE vector in spec.params order (resolveDerived /
|
||||
* crystallize output). 'slider' params are outside the constraint: no line.
|
||||
*
|
||||
* Returns null — NEVER an invented number — when myAttr is undefined,
|
||||
* when Σ corpusAttrs is 0 (nobody declared), or on a malformed input.
|
||||
*/
|
||||
export function computeMyImpact(
|
||||
spec: ParamSpec,
|
||||
resources: { amount?: number },
|
||||
fullValues: number[],
|
||||
myAttr: number | undefined,
|
||||
corpusAttrs: number[],
|
||||
): MyImpact | null {
|
||||
if (spec.constraint !== 'sum100') return null
|
||||
if (!resources.amount || !Number.isFinite(resources.amount)) return null
|
||||
if (!spec.impactAttrKey) return null
|
||||
if (myAttr === undefined || !Number.isFinite(myAttr)) return null
|
||||
if (fullValues.length !== spec.params.length) return null // misuse guard
|
||||
|
||||
const attrSum = corpusAttrs.reduce(
|
||||
(sum, a) => sum + (Number.isFinite(a) ? a : 0),
|
||||
0,
|
||||
)
|
||||
if (attrSum === 0) return null
|
||||
|
||||
const amount = resources.amount
|
||||
const perParam: ImpactLine[] = []
|
||||
let total = 0
|
||||
|
||||
spec.params.forEach((p, i) => {
|
||||
if (p.kind !== 'share') return
|
||||
const paramAmount = (amount * at(fullValues, i)) / 100
|
||||
const myQuota = (paramAmount * myAttr) / attrSum
|
||||
perParam.push({ key: p.key, label: p.label, amount: myQuota })
|
||||
total += myQuota
|
||||
})
|
||||
|
||||
return { perParam, total }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectBimodality
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Simple documented heuristic over ONE param's voted values — NEVER blocking
|
||||
* (it only feeds the non-blocking banner and the crystallization reminder,
|
||||
* Δ16): two distinct positions are detected when, after ascending sort,
|
||||
* the LARGEST gap between consecutive values satisfies ALL of:
|
||||
* - n >= 4 (below that, no distribution to speak of);
|
||||
* - gap > 40% of the total range (max − min);
|
||||
* - at least 2 values on EACH side of the gap (a single outlier is not a
|
||||
* second position).
|
||||
* Non-finite values are ignored; any degenerate input returns false.
|
||||
*/
|
||||
export function detectBimodality(values: number[]): boolean {
|
||||
const sorted = values.filter(v => Number.isFinite(v)).sort((a, b) => a - b)
|
||||
const n = sorted.length
|
||||
if (n < 4) return false
|
||||
|
||||
const range = at(sorted, n - 1) - at(sorted, 0)
|
||||
if (range <= 0) return false
|
||||
|
||||
let maxGap = 0
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const gap = at(sorted, i + 1) - at(sorted, i)
|
||||
if (gap > maxGap) maxGap = gap
|
||||
}
|
||||
if (maxGap <= 0.4 * range) return false
|
||||
|
||||
// The max gap must split the values 2+ / 2+ (ties: any qualifying position).
|
||||
for (let i = 1; i <= n - 3; i++) {
|
||||
if (at(sorted, i + 1) - at(sorted, i) === maxGap) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Pact settings resolution — the Pact IS the settings store (Δ10).
|
||||
*
|
||||
* resolveSettings() reads the Pact clauses that carry a settingKey, finds the
|
||||
* 'current' ClauseVersion bearing a settingValue for each, and produces the
|
||||
* resolved CollectiveSettings consumed by triage.ts and state.ts. Pure
|
||||
* function — NOT a settings table: every threshold is a voted clause.
|
||||
*
|
||||
* Rules (BLUEPRINT-V2.md « Modèle de domaine » + Δ10):
|
||||
* - any absent key falls back to SETTINGS_DEFAULTS;
|
||||
* - invalid values (wrong type, negative number, unknown enum member) fall
|
||||
* back to the default SILENTLY — a corrupted Pact never crashes the engine;
|
||||
* - any unresolved OPTIONAL protocolByRange key stays undefined: the caller
|
||||
* falls back to the consent protocol;
|
||||
* - a missing 'protocols.consent' (corrupted bundle) still returns a full
|
||||
* settings object with protocolByRange.consent === '' — hasConsentProtocol()
|
||||
* lets the triage route 'advice' with the banner « Aucun protocole — crée-le
|
||||
* ou décide sur avis », NEVER a crash.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Clause,
|
||||
ClauseVersion,
|
||||
CollectiveSettings,
|
||||
Id,
|
||||
InertiaPreset,
|
||||
Json,
|
||||
} from '../types/domain'
|
||||
|
||||
/** Default triage settings — used for every key the Pact does not resolve. */
|
||||
export const SETTINGS_DEFAULTS = {
|
||||
smallGroupMax: 5,
|
||||
collectiveMin: 50,
|
||||
consentMax: 7,
|
||||
objectionWindowHours: 48,
|
||||
adviceWindowHours: 72,
|
||||
framingDays: 14,
|
||||
concernEscalateRatio: 0.5,
|
||||
recurrenceThreshold: 3,
|
||||
reviewDelayDays: 90,
|
||||
requireEffects: 'binding',
|
||||
} as const satisfies CollectiveSettings['triage']
|
||||
|
||||
const INERTIA_PRESETS: readonly InertiaPreset[] = ['low', 'standard', 'high', 'max']
|
||||
|
||||
/** Most recent wins when a clause carries several 'current' versions (data anomaly). */
|
||||
function stampOf(version: ClauseVersion): string {
|
||||
return version.adoptedAt ?? version.updatedAt
|
||||
}
|
||||
|
||||
/** The settingValue of the clause's 'current' version, if any. */
|
||||
function currentValueOf(clause: Clause, versions: ClauseVersion[]): Json | undefined {
|
||||
let best: ClauseVersion | undefined
|
||||
for (const version of versions) {
|
||||
if (version.clauseId !== clause.id || version.archivedAt) continue
|
||||
if (version.status !== 'current' || version.settingValue === undefined) continue
|
||||
if (!best || stampOf(version) > stampOf(best)) best = version
|
||||
}
|
||||
return best?.settingValue
|
||||
}
|
||||
|
||||
/** First non-archived clause holding this settingKey with a resolvable current value. */
|
||||
function resolveRaw(key: string, clauses: Clause[], versions: ClauseVersion[]): Json | undefined {
|
||||
for (const clause of clauses) {
|
||||
if (clause.settingKey !== key || clause.archivedAt) continue
|
||||
const value = currentValueOf(clause, versions)
|
||||
if (value !== undefined) return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Wrong type or negative number ⇒ default, silently. */
|
||||
function numberOrDefault(value: Json | undefined, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
|
||||
}
|
||||
|
||||
/** Unknown enum member ⇒ default, silently. */
|
||||
function requireEffectsOrDefault(value: Json | undefined): 'none' | 'structural' | 'binding' {
|
||||
return value === 'none' || value === 'structural' || value === 'binding'
|
||||
? value
|
||||
: SETTINGS_DEFAULTS.requireEffects
|
||||
}
|
||||
|
||||
/** Protocol ids must be non-empty strings — anything else counts as unresolved. */
|
||||
function idOrUndefined(value: Json | undefined): Id | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the collective's settings from its Pact clauses.
|
||||
*
|
||||
* Recognized setting keys:
|
||||
* triage.smallGroupMax · triage.collectiveMin · triage.consentMax ·
|
||||
* triage.objectionWindowHours · triage.adviceWindowHours ·
|
||||
* triage.framingDays · triage.concernEscalateRatio ·
|
||||
* triage.recurrenceThreshold · triage.reviewDelayDays ·
|
||||
* triage.requireEffects · protocols.consent · protocols.nuanced ·
|
||||
* protocols.large · protocols.parametric · protocols.election ·
|
||||
* protocols.clauseByInertia.{low,standard,high,max}
|
||||
*/
|
||||
export function resolveSettings(clauses: Clause[], versions: ClauseVersion[]): CollectiveSettings {
|
||||
const raw = (key: string): Json | undefined => resolveRaw(key, clauses, versions)
|
||||
|
||||
const triage: CollectiveSettings['triage'] = {
|
||||
smallGroupMax: numberOrDefault(raw('triage.smallGroupMax'), SETTINGS_DEFAULTS.smallGroupMax),
|
||||
collectiveMin: numberOrDefault(raw('triage.collectiveMin'), SETTINGS_DEFAULTS.collectiveMin),
|
||||
consentMax: numberOrDefault(raw('triage.consentMax'), SETTINGS_DEFAULTS.consentMax),
|
||||
objectionWindowHours: numberOrDefault(
|
||||
raw('triage.objectionWindowHours'),
|
||||
SETTINGS_DEFAULTS.objectionWindowHours,
|
||||
),
|
||||
adviceWindowHours: numberOrDefault(
|
||||
raw('triage.adviceWindowHours'),
|
||||
SETTINGS_DEFAULTS.adviceWindowHours,
|
||||
),
|
||||
framingDays: numberOrDefault(raw('triage.framingDays'), SETTINGS_DEFAULTS.framingDays),
|
||||
concernEscalateRatio: numberOrDefault(
|
||||
raw('triage.concernEscalateRatio'),
|
||||
SETTINGS_DEFAULTS.concernEscalateRatio,
|
||||
),
|
||||
recurrenceThreshold: numberOrDefault(
|
||||
raw('triage.recurrenceThreshold'),
|
||||
SETTINGS_DEFAULTS.recurrenceThreshold,
|
||||
),
|
||||
reviewDelayDays: numberOrDefault(raw('triage.reviewDelayDays'), SETTINGS_DEFAULTS.reviewDelayDays),
|
||||
requireEffects: requireEffectsOrDefault(raw('triage.requireEffects')),
|
||||
}
|
||||
|
||||
// Consent is the MANDATORY invariant key. When unfindable (corrupted
|
||||
// bundle), we still return a complete object with consent === '' so the
|
||||
// caller can degrade gracefully (hasConsentProtocol) — never a crash.
|
||||
const protocolByRange: CollectiveSettings['protocolByRange'] = {
|
||||
consent: idOrUndefined(raw('protocols.consent')) ?? '',
|
||||
}
|
||||
|
||||
const nuanced = idOrUndefined(raw('protocols.nuanced'))
|
||||
if (nuanced) protocolByRange.nuanced = nuanced
|
||||
const large = idOrUndefined(raw('protocols.large'))
|
||||
if (large) protocolByRange.large = large
|
||||
const parametric = idOrUndefined(raw('protocols.parametric'))
|
||||
if (parametric) protocolByRange.parametric = parametric
|
||||
const election = idOrUndefined(raw('protocols.election'))
|
||||
if (election) protocolByRange.election = election
|
||||
|
||||
// The Ğ1 heritage lives here, intact: the map is exposed only when ALL four
|
||||
// presets resolve (the type is a complete Record — a partial map would lie).
|
||||
// Otherwise the caller falls back to consent, like any optional key.
|
||||
const byInertia: Partial<Record<InertiaPreset, Id>> = {}
|
||||
for (const preset of INERTIA_PRESETS) {
|
||||
const id = idOrUndefined(raw(`protocols.clauseByInertia.${preset}`))
|
||||
if (id) byInertia[preset] = id
|
||||
}
|
||||
if (INERTIA_PRESETS.every((preset) => byInertia[preset] !== undefined)) {
|
||||
protocolByRange.clauseByInertia = byInertia as Record<InertiaPreset, Id>
|
||||
}
|
||||
|
||||
return { triage, protocolByRange }
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the collective has a resolvable consent protocol.
|
||||
* False ⇒ the triage routes 'advice' with the banner
|
||||
* « Aucun protocole — crée-le ou décide sur avis » — never a crash.
|
||||
*/
|
||||
export function hasConsentProtocol(settings: CollectiveSettings): boolean {
|
||||
return settings.protocolByRange.consent.trim().length > 0
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* The ONE state machine of the decision (Δ29).
|
||||
*
|
||||
* Every mutation goes through canTransition(decision, to, ctx): the doctrinal
|
||||
* guards (subsidiarity, instruction, consent) are tested code, not intentions.
|
||||
* The context is assembled by the store; the function stays pure and testable.
|
||||
*
|
||||
* Guards, in order (BLUEPRINT-V2.md « Cycle de vie » + Δ13, Δ17, Δ27, Δ29):
|
||||
* a) unknown transition;
|
||||
* b) boundary — windowSuspendedAt suspends any exit from objection/advice;
|
||||
* c) influx — concernEscalateRatio reached ⇒ widen or scopeKeptNote;
|
||||
* d) matter — requireEffects at collective session opening;
|
||||
* e) resources — « Ce que ça engage » at window/session opening;
|
||||
* f) assent — non-easy objection windows adopt only on an explicit third-party
|
||||
* agreement, never by pure silence;
|
||||
* g) open objection — no adoption over an open objection;
|
||||
* h) dossier — framing→closed only when every element child is terminal;
|
||||
* i) crystallization — parametric sessions close by a dated human gesture,
|
||||
* the engine NEVER crystallizes.
|
||||
*
|
||||
* Every refusal reason is one French sentence whose subject is the collective
|
||||
* or the person — never the engine.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Assent,
|
||||
CollectiveSettings,
|
||||
Concern,
|
||||
Decision,
|
||||
DecisionStatus,
|
||||
ISODate,
|
||||
Objection,
|
||||
VoteSession,
|
||||
} from '../types/domain'
|
||||
|
||||
/** Allowed transitions — statuses absent from the table are terminal. */
|
||||
export const TRANSITIONS: Readonly<Partial<Record<DecisionStatus, readonly DecisionStatus[]>>> = {
|
||||
draft: ['advice', 'objection', 'framing', 'voting', 'adopted', 'transmitted', 'rejected'],
|
||||
advice: ['adopted', 'voting'],
|
||||
objection: ['adopted', 'framing', 'voting'],
|
||||
framing: ['voting', 'closed'],
|
||||
voting: ['adopted', 'rejected'],
|
||||
adopted: ['revoked', 'closed'],
|
||||
}
|
||||
|
||||
/** Terminal statuses of an element child — condition of the dossier closure. */
|
||||
export const TERMINAL_STATUSES: readonly DecisionStatus[] = [
|
||||
'adopted',
|
||||
'rejected',
|
||||
'revoked',
|
||||
'closed',
|
||||
'transmitted',
|
||||
]
|
||||
|
||||
/** Window states — a boundary objection suspends any exit from these. */
|
||||
const WINDOW_STATUSES: readonly DecisionStatus[] = ['objection', 'advice']
|
||||
|
||||
/** Sources whose closing is subject to the influx guard. */
|
||||
const INFLUX_SOURCES: readonly DecisionStatus[] = ['advice', 'objection', 'voting']
|
||||
|
||||
/** Adoption/closure targets watched by the influx guard. */
|
||||
const INFLUX_TARGETS: readonly DecisionStatus[] = ['adopted', 'rejected', 'closed']
|
||||
|
||||
const REASONS = {
|
||||
unknown: 'La décision ne peut pas prendre ce chemin depuis son état actuel.',
|
||||
boundary:
|
||||
'La frontière est contestée — le compte à rebours reste suspendu jusqu’à l’intégration ou une réponse motivée.',
|
||||
influx: 'Le périmètre déborde — élargis d’un cran ou motive publiquement son maintien.',
|
||||
matterMissing: 'Le collectif s’instruit avant de voter — formule au moins un effet recherché.',
|
||||
matterTarget:
|
||||
'Une décision structurante se mesure — donne une cible à au moins un effet recherché.',
|
||||
resources:
|
||||
'Le collectif doit savoir ce que ça engage — écris la note de ressources avant d’ouvrir.',
|
||||
assent: 'Il manque un accord explicite — la fenêtre se prolonge.',
|
||||
openObjection: 'Une objection reste ouverte — le collectif l’entend avant d’adopter.',
|
||||
dossierEmpty: 'Le dossier n’a pas d’éléments — découpe-le avant de le clore.',
|
||||
dossierPending:
|
||||
'Des éléments du dossier sont encore en cours — le dossier se clôt quand tous ont abouti.',
|
||||
crystallization: 'Les votes sont figés — la cristallisation attend son geste.',
|
||||
} as const
|
||||
|
||||
export interface TransitionContext {
|
||||
concerns: Concern[]
|
||||
settings: CollectiveSettings
|
||||
session?: VoteSession
|
||||
children?: Decision[]
|
||||
assents?: Assent[]
|
||||
objections?: Objection[]
|
||||
now: ISODate
|
||||
}
|
||||
|
||||
export type TransitionResult = { ok: true } | { ok: false; reason: string }
|
||||
|
||||
export type WindowOutcome = 'adopt' | 'extend' | 'wait'
|
||||
|
||||
function refuse(reason: string): TransitionResult {
|
||||
return { ok: false, reason }
|
||||
}
|
||||
|
||||
/** ≥1 non-archived Assent on this decision from someone other than the author. */
|
||||
function hasThirdPartyAssent(decision: Decision, assents: Assent[] | undefined): boolean {
|
||||
return (assents ?? []).some(
|
||||
(assent) =>
|
||||
assent.decisionId === decision.id &&
|
||||
!assent.archivedAt &&
|
||||
assent.personId !== decision.authorId,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this decision move to `to`? Pure — the store assembles ctx.
|
||||
* Refusals carry one French sentence (subject: the collective or the person).
|
||||
*/
|
||||
export function canTransition(
|
||||
decision: Decision,
|
||||
to: DecisionStatus,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
const from = decision.status
|
||||
|
||||
// ── (a) Unknown transition ─────────────────────────────────────────────
|
||||
// A window state may explicitly return to itself (re-arming after a
|
||||
// resolved boundary objection, extension of a window) — nothing else loops.
|
||||
const isWindowSelfReturn = WINDOW_STATUSES.includes(from) && to === from
|
||||
if (!isWindowSelfReturn && !(TRANSITIONS[from] ?? []).includes(to)) {
|
||||
return refuse(REASONS.unknown)
|
||||
}
|
||||
|
||||
// ── (b) Boundary — the contestation of frontiers precedes substance ────
|
||||
if (WINDOW_STATUSES.includes(from) && decision.windowSuspendedAt && to !== from) {
|
||||
return refuse(REASONS.boundary)
|
||||
}
|
||||
|
||||
// ── (c) Influx — the scale-up is DECIDED, never evaporated (Δ17) ───────
|
||||
if (INFLUX_SOURCES.includes(from) && INFLUX_TARGETS.includes(to)) {
|
||||
const concerns = ctx.concerns.filter(
|
||||
(concern) => concern.decisionId === decision.id && !concern.archivedAt,
|
||||
)
|
||||
const computed = concerns.filter((concern) => concern.origin === 'computed').length
|
||||
const declared = concerns.filter((concern) => concern.origin === 'declared').length
|
||||
const ratioReached =
|
||||
computed > 0 && declared >= ctx.settings.triage.concernEscalateRatio * computed
|
||||
if (ratioReached && !decision.scopeKeptNote) {
|
||||
return refuse(REASONS.influx)
|
||||
}
|
||||
}
|
||||
|
||||
const opensCollectiveSession = (from === 'draft' || from === 'framing') && to === 'voting'
|
||||
|
||||
// ── (d) Matter — no engaging collective vote without instruction ───────
|
||||
if (opensCollectiveSession && decision.route === 'collective') {
|
||||
const mode = ctx.settings.triage.requireEffects
|
||||
const underGuard =
|
||||
mode === 'binding'
|
||||
? decision.weight === 'binding' || decision.weight === 'structural'
|
||||
: mode === 'structural' && decision.weight === 'structural'
|
||||
if (underGuard) {
|
||||
const effects = decision.brief?.effects ?? []
|
||||
if (effects.length === 0) {
|
||||
return refuse(REASONS.matterMissing)
|
||||
}
|
||||
const hasMeasurableEffect = effects.some(
|
||||
(effect) => effect.target !== undefined && effect.target.trim().length > 0,
|
||||
)
|
||||
if (decision.weight === 'structural' && !hasMeasurableEffect) {
|
||||
return refuse(REASONS.matterTarget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── (e) Resources — « Ce que ça engage » lives at the opening ──────────
|
||||
const opensWindowOrSession =
|
||||
opensCollectiveSession || (from === 'draft' && (to === 'objection' || to === 'advice'))
|
||||
if (
|
||||
opensWindowOrSession &&
|
||||
decision.weight !== 'light' &&
|
||||
decision.route !== 'solo' &&
|
||||
decision.route !== 'record'
|
||||
) {
|
||||
const note = decision.resources?.note ?? ''
|
||||
if (note.trim().length === 0) {
|
||||
return refuse(REASONS.resources)
|
||||
}
|
||||
}
|
||||
|
||||
// ── (f) Assent — outside easy, agreement is a gesture, not silence (Δ27)
|
||||
if (from === 'objection' && to === 'adopted' && decision.reversibility !== 'easy') {
|
||||
if (!hasThirdPartyAssent(decision, ctx.assents)) {
|
||||
return refuse(REASONS.assent)
|
||||
}
|
||||
}
|
||||
|
||||
// ── (g) Open objection — never adopted over someone's maintained voice ─
|
||||
if (from === 'objection' && to === 'adopted') {
|
||||
const hasOpenObjection = (ctx.objections ?? []).some(
|
||||
(objection) =>
|
||||
objection.decisionId === decision.id &&
|
||||
!objection.archivedAt &&
|
||||
objection.status === 'open',
|
||||
)
|
||||
if (hasOpenObjection) {
|
||||
return refuse(REASONS.openObjection)
|
||||
}
|
||||
}
|
||||
|
||||
// ── (h) Dossier — the closure is a steward gesture on a complete map (Δ13)
|
||||
if (from === 'framing' && to === 'closed') {
|
||||
const elements = (ctx.children ?? []).filter(
|
||||
(child) =>
|
||||
child.chainKind === 'element' &&
|
||||
child.parentDecisionId === decision.id &&
|
||||
!child.archivedAt,
|
||||
)
|
||||
if (elements.length === 0) {
|
||||
return refuse(REASONS.dossierEmpty)
|
||||
}
|
||||
if (elements.some((child) => !TERMINAL_STATUSES.includes(child.status))) {
|
||||
return refuse(REASONS.dossierPending)
|
||||
}
|
||||
}
|
||||
|
||||
// ── (i) Crystallization — the engine NEVER crystallizes ────────────────
|
||||
// A parametric session is recognized by decision.paramSpec, or by the
|
||||
// 'frozen' status (only parametric sessions ever freeze).
|
||||
if (from === 'voting' && (to === 'adopted' || to === 'rejected') && ctx.session) {
|
||||
const isParametric = decision.paramSpec !== undefined || ctx.session.status === 'frozen'
|
||||
const crystallized = ctx.session.status === 'closed' && !!ctx.session.crystallizedById
|
||||
if (isParametric && !crystallized) {
|
||||
return refuse(REASONS.crystallization)
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of an objection window at its deadline, WITHOUT open objection
|
||||
* (the caller handles open objections — escalation or suspension):
|
||||
* - suspended boundary ⇒ 'wait' (the countdown is not running);
|
||||
* - easy ⇒ 'adopt' (silence counts as agreement — only there);
|
||||
* - otherwise ⇒ 'adopt' on a third-party Assent, else 'extend' by one notch
|
||||
* (+objectionWindowHours, Fil reminder) — never adoption by pure silence.
|
||||
*/
|
||||
export function windowOutcome(decision: Decision, ctx: TransitionContext): WindowOutcome {
|
||||
if (decision.windowSuspendedAt) return 'wait'
|
||||
if (decision.reversibility === 'easy') return 'adopt'
|
||||
return hasThirdPartyAssent(decision, ctx.assents) ? 'adopt' : 'extend'
|
||||
}
|
||||
@@ -108,3 +108,106 @@ export function techcommThreshold(cotecSize: number, exponent: number = 0.1): nu
|
||||
}
|
||||
return Math.ceil(cotecSize ** exponent)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Election — simple plurality (BLUEPRINT-V2.md Δ28, « Modalités » #4)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
import type { FormulaParams, Id, Vote } from '~/types/domain'
|
||||
|
||||
/**
|
||||
* Outcome of an election tally.
|
||||
*
|
||||
* Discriminated union on `outcome`:
|
||||
* - 'elected' — a single person leads by simple plurality.
|
||||
* - 'tie' — several persons share the top count. The engine NEVER
|
||||
* breaks a tie (no randomness, no first-come): the closure
|
||||
* flow proposes a chained runoff among `exAequoIds`, or a
|
||||
* draw only if the Pact planned it (FormulaParams.tieBreak).
|
||||
* - 'rejected' — reason 'quorum': participants (blanks included) below
|
||||
* electionMinParticipants; `required` = that quorum.
|
||||
* reason 'no-designation': quorum reached (or absent) but
|
||||
* every vote is blank — nobody was designated, and an
|
||||
* empty tie would be meaningless; `required` still carries
|
||||
* the quorum (0 when none) for display purposes.
|
||||
*/
|
||||
export type ElectionOutcome =
|
||||
| {
|
||||
outcome: 'elected'
|
||||
winnerId: Id
|
||||
counts: Record<Id, number>
|
||||
blanks: number
|
||||
participants: number
|
||||
}
|
||||
| {
|
||||
outcome: 'tie'
|
||||
exAequoIds: Id[]
|
||||
counts: Record<Id, number>
|
||||
blanks: number
|
||||
participants: number
|
||||
}
|
||||
| {
|
||||
outcome: 'rejected'
|
||||
reason: 'quorum' | 'no-designation'
|
||||
participants: number
|
||||
required: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Tally an election by simple plurality.
|
||||
*
|
||||
* CONTRACT — the caller passes the LAST ACTIVE votes only: one vote per
|
||||
* voter, `supersedesVoteId` chains already resolved (the store filters
|
||||
* superseded votes). The engine does NOT deduplicate by voterId;
|
||||
* `participants` is simply `votes.length`.
|
||||
*
|
||||
* Rules (Δ28):
|
||||
* - A vote without `choicePersonId` is a BLANK: it counts for
|
||||
* participation (quorum), never for designation.
|
||||
* - Quorum: when `formula.electionMinParticipants` is set and
|
||||
* participants (blanks included) < quorum ⇒ rejected ('quorum').
|
||||
* - Designation by simple PLURALITY of the non-blank votes.
|
||||
* - Tie at the top ⇒ 'tie' with `exAequoIds` sorted (lexicographic —
|
||||
* a deterministic display order, NEVER a tie-break: the engine does
|
||||
* not pick a winner among equals, no randomness, no first-come).
|
||||
* - Zero designation (all blanks) ⇒ rejected ('no-designation').
|
||||
*
|
||||
* @param votes - Last active votes of the session (see contract above)
|
||||
* @param formula - Protocol formula params (only electionMinParticipants is read)
|
||||
* @returns The election outcome — never a tie silently broken
|
||||
*/
|
||||
export function electionResult(
|
||||
votes: Vote[],
|
||||
formula: Pick<FormulaParams, 'electionMinParticipants'>,
|
||||
): ElectionOutcome {
|
||||
const participants = votes.length
|
||||
const quorum = formula.electionMinParticipants ?? 0
|
||||
|
||||
if (formula.electionMinParticipants !== undefined && participants < formula.electionMinParticipants) {
|
||||
return { outcome: 'rejected', reason: 'quorum', participants, required: formula.electionMinParticipants }
|
||||
}
|
||||
|
||||
const counts: Record<Id, number> = {}
|
||||
let blanks = 0
|
||||
for (const vote of votes) {
|
||||
if (vote.choicePersonId) {
|
||||
counts[vote.choicePersonId] = (counts[vote.choicePersonId] ?? 0) + 1
|
||||
} else {
|
||||
blanks++ // blank: participation only, never designation
|
||||
}
|
||||
}
|
||||
|
||||
const designatedIds = Object.keys(counts)
|
||||
if (designatedIds.length === 0) {
|
||||
return { outcome: 'rejected', reason: 'no-designation', participants, required: quorum }
|
||||
}
|
||||
|
||||
const topCount = Math.max(...designatedIds.map(id => counts[id]!))
|
||||
const leaders = designatedIds.filter(id => counts[id] === topCount)
|
||||
|
||||
if (leaders.length === 1) {
|
||||
return { outcome: 'elected', winnerId: leaders[0]!, counts, blanks, participants }
|
||||
}
|
||||
|
||||
return { outcome: 'tie', exAequoIds: [...leaders].sort(), counts, blanks, participants }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Routing engine — UI name « Le chemin » (BLUEPRINT-V2.md « Algorithme de triage »).
|
||||
*
|
||||
* Pure function triage(input, ctx, settings) → Verdict. No store, no I/O:
|
||||
* the store assembles TriageContext, the engine only reasons on it.
|
||||
* Rule codes (R-U, R0a…R6) NEVER reach the card: the UI shows the one-sentence
|
||||
* French explanation alone; codes live in the « pourquoi ? » disclosure,
|
||||
* the PV and the derogation journal.
|
||||
*
|
||||
* Rule order — first match wins:
|
||||
* R-U (urgency overlay) · R0a already decided · R0b my mandate ·
|
||||
* R0c someone else's mandate · R2 self only · R3 targeted clause ·
|
||||
* R4 small reversible group · R5 collective default · R6 recurrence overlay.
|
||||
*
|
||||
* DOCUMENTED INTERPRETATION CHOICES:
|
||||
* - R0b comes BEFORE R2: a selfOnly decision whose scoped circles are covered
|
||||
* by my mandate still routes 'mandate' (the mandate trace duty prevails).
|
||||
* - R0a yields ONLY when the author explicitly targets the clause
|
||||
* (amendsClauseId set): contesting the rule IS R3, and R3 looks the clause
|
||||
* up in ctx.matchingClauses — R0a fires for a bare match.
|
||||
* - Specified fallback (Δ10): protocolByRange.consent unresolvable ⇒ route
|
||||
* 'advice' with the « Aucun protocole » banner, prior to EVERYTHING except
|
||||
* R2 (solo needs no protocol) and R0a (record needs no protocol).
|
||||
* Its rule code is 'R5' — it degrades the collective default.
|
||||
* - Urgency refused (urgent && irreversible): the underlying route is kept
|
||||
* with its MINIMAL windows (never doubled), rule 'R-U', refusal sentence.
|
||||
* - R6 is an overlay: it sets Verdict.suggestion but never changes route or
|
||||
* rule; 'claim-mandate' wins over 'protocolize' when both thresholds match.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Clause,
|
||||
CollectiveSettings,
|
||||
Id,
|
||||
Mandate,
|
||||
TriageContext,
|
||||
TriageInput,
|
||||
Verdict,
|
||||
} from '~/types/domain'
|
||||
import {
|
||||
BINARY_COST,
|
||||
METHOD_LABELS,
|
||||
NO_PROTOCOL_BANNER,
|
||||
PARAMETRIC_ALT,
|
||||
RECORD_ALT,
|
||||
URGENT_BADGE,
|
||||
URGENT_REFUSED,
|
||||
} from '~/lexicon'
|
||||
|
||||
// TODO-lexicon: alternative costs missing from app/lexicon.ts (frozen file) —
|
||||
// move these two constants there once it thaws.
|
||||
const PARAMETRIC_COST = 'formuler les curseurs avant de voter'
|
||||
const RECORD_COST = 'aucune fenêtre — la décision est déjà prise'
|
||||
|
||||
/** Verdict before the permanent alternatives are attached. */
|
||||
type BaseVerdict = Omit<Verdict, 'alternatives'>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Parametric hint — number / % / amount (€, DU) in the capture sentence
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DIGIT_PERCENT_CURRENCY = /\d|%|€/
|
||||
// Ğ1 universal dividend — uppercase only: the French article « du » never counts.
|
||||
const DU_UNIT = /\bDU\b/
|
||||
|
||||
/** Detect a number, percentage or amount (€, DU, digits) in the title. */
|
||||
export function detectParametricHint(title: string): boolean {
|
||||
return DIGIT_PERCENT_CURRENCY.test(title) || DU_UNIT.test(title)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Main entry
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function triage(
|
||||
input: TriageInput,
|
||||
ctx: TriageContext,
|
||||
settings: CollectiveSettings,
|
||||
): Verdict {
|
||||
const hint = detectParametricHint(input.title)
|
||||
|
||||
let base = routeIgnoringUrgency(input, ctx, settings)
|
||||
|
||||
// R-U — urgency overlay.
|
||||
if (input.urgent) {
|
||||
if (input.reversibility === 'irreversible') {
|
||||
// Refusal: normal route, MINIMAL windows (never doubled), no chain.
|
||||
base = { ...base, rule: 'R-U', explanation: URGENT_REFUSED }
|
||||
} else {
|
||||
// Conservatory: the lightest legitimate route NOW, doubled window,
|
||||
// chained ratification (created by the store when validating).
|
||||
base = {
|
||||
...base,
|
||||
rule: 'R-U',
|
||||
explanation: `${URGENT_BADGE}.`,
|
||||
conservatoryChain: true,
|
||||
...(base.windowHours !== undefined ? { windowHours: base.windowHours * 2 } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R6 — recurrence & maturation overlay (never blocking, never re-routing).
|
||||
const suggestion = recurrenceOverlay(input, ctx, settings)
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(hint ? { parametricHint: true } : {}),
|
||||
...(suggestion ? { suggestion } : {}),
|
||||
alternatives: buildAlternatives(hint),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Rules R0a → R5 (urgency stripped)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function routeIgnoringUrgency(
|
||||
input: TriageInput,
|
||||
ctx: TriageContext,
|
||||
settings: CollectiveSettings,
|
||||
): BaseVerdict {
|
||||
// R0a — already decided. Skipped when the author explicitly targets the
|
||||
// clause (that is R3 — contesting the rule). Route 'record' proposed as
|
||||
// principal: act under the standing rule, and keep the trace.
|
||||
const matched = ctx.matchingClauses[0]
|
||||
if (matched && !input.amendsClauseId) {
|
||||
return {
|
||||
route: 'record',
|
||||
rule: 'R0a',
|
||||
explanation: `C'est déjà décidé (${matched.code}, ${matched.title}) — agis, ou conteste la règle.`,
|
||||
reviewRequired: false,
|
||||
engravingSuggested: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Specified fallback (Δ10): no consent protocol resolvable — prior to
|
||||
// everything except R2/record. Rule code 'R5': the collective default,
|
||||
// degraded to advice because the collective has no protocol yet.
|
||||
if (!settings.protocolByRange.consent) {
|
||||
if (input.scope.selfOnly) return r2SelfOnly(input)
|
||||
return {
|
||||
route: 'advice',
|
||||
rule: 'R5',
|
||||
explanation: `${NO_PROTOCOL_BANNER}.`,
|
||||
windowHours: settings.triage.adviceWindowHours,
|
||||
reviewRequired: false,
|
||||
engravingSuggested: false,
|
||||
}
|
||||
}
|
||||
|
||||
// R0b — my mandate covers. Before R2: even selfOnly, the mandate trace
|
||||
// duty prevails. Empty scope.circleIds never counts as covered (a trivial
|
||||
// ⊆ match would hand every uncircled decision to the first mandate).
|
||||
const myMandate = coveringMandate(input, ctx.myActiveMandates)
|
||||
if (myMandate) {
|
||||
return {
|
||||
route: 'mandate',
|
||||
rule: 'R0b',
|
||||
explanation: `Ton mandat ${myMandate.title} couvre — décide, c'est tracé.`,
|
||||
windowHours: settings.triage.objectionWindowHours,
|
||||
reviewRequired: false,
|
||||
engravingSuggested: false,
|
||||
}
|
||||
}
|
||||
|
||||
// R0c — someone else's mandate covers. TriageContext carries no person
|
||||
// names: the mandate TITLE names the power, the store resolves the holder.
|
||||
const otherMandate = coveringMandate(input, ctx.otherActiveMandates)
|
||||
if (otherMandate) {
|
||||
return {
|
||||
route: 'transmit',
|
||||
rule: 'R0c',
|
||||
explanation: `Le mandat ${otherMandate.title} couvre — transmets à sa ou son titulaire.`,
|
||||
reviewRequired: false,
|
||||
engravingSuggested: false,
|
||||
}
|
||||
}
|
||||
|
||||
// R2 — self only.
|
||||
if (input.scope.selfOnly) return r2SelfOnly(input)
|
||||
|
||||
// R3 — targeted clause: its inertia applies, vote of those it governs.
|
||||
if (input.amendsClauseId) return r3TargetedClause(input, ctx, settings)
|
||||
|
||||
const n = ctx.computedConcernedIds.length
|
||||
|
||||
// R4 — small reversible group: ask for advice, then decide.
|
||||
if (input.reversibility === 'easy' && n <= settings.triage.smallGroupMax) {
|
||||
const plural = n > 1 ? 's' : ''
|
||||
return {
|
||||
route: 'advice',
|
||||
rule: 'R4',
|
||||
explanation: `Réversible et ${n} personne${plural} concernée${plural} — demande leur avis puis décide.`,
|
||||
windowHours: settings.triage.adviceWindowHours,
|
||||
reviewRequired: false,
|
||||
engravingSuggested: false,
|
||||
}
|
||||
}
|
||||
|
||||
// R5 — collective default, modality by perimeter size.
|
||||
return r5Collective(input, n, settings)
|
||||
}
|
||||
|
||||
/** R2 — « Personne d'autre n'est concerné ». Zero window. On irreversible the
|
||||
* engine SUGGESTS a review (reviewRequired) — the UI shows it pre-checked but
|
||||
* REMOVABLE: on oneself the tool never interposes; the pre-check is a UI fact,
|
||||
* the engine only recommends. */
|
||||
function r2SelfOnly(input: TriageInput): BaseVerdict {
|
||||
return {
|
||||
route: 'solo',
|
||||
rule: 'R2',
|
||||
explanation: 'Personne d\'autre n\'est concerné — décide.',
|
||||
reviewRequired: input.reversibility === 'irreversible',
|
||||
engravingSuggested: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** R3 — amendsClauseId set. Protocol resolved by the clause's inertia
|
||||
* (clauseByInertia), specified fallback → consent. The clause is looked up in
|
||||
* ctx.matchingClauses (the Q0 index); if absent, consent applies and the
|
||||
* sentence names « cette règle » instead of a code. */
|
||||
function r3TargetedClause(
|
||||
input: TriageInput,
|
||||
ctx: TriageContext,
|
||||
settings: CollectiveSettings,
|
||||
): BaseVerdict {
|
||||
const clause: Clause | undefined = ctx.matchingClauses.find(
|
||||
c => c.id === input.amendsClauseId,
|
||||
)
|
||||
const protocolId: Id
|
||||
= (clause && settings.protocolByRange.clauseByInertia?.[clause.inertia])
|
||||
?? settings.protocolByRange.consent
|
||||
const name = clause ? clause.code : 'cette règle'
|
||||
return {
|
||||
route: 'collective',
|
||||
rule: 'R3',
|
||||
explanation: `Tu proposes une version de ${name} — son inertie s'applique : vote de ceux qu'elle gouverne.`,
|
||||
protocolId,
|
||||
...(input.weight === 'structural' ? { framingDays: settings.triage.framingDays } : {}),
|
||||
reviewRequired: false,
|
||||
engravingSuggested: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** R5 — modality by computed perimeter size, protocols resolved by the Pact
|
||||
* with the specified fallback → consent. */
|
||||
function r5Collective(
|
||||
input: TriageInput,
|
||||
n: number,
|
||||
settings: CollectiveSettings,
|
||||
): BaseVerdict {
|
||||
const range = settings.protocolByRange
|
||||
let protocolId: Id
|
||||
let explanation: string
|
||||
|
||||
if (n <= settings.triage.consentMax) {
|
||||
protocolId = range.consent
|
||||
explanation = `Vous êtes ${n} — un tour d'accord suffit : sans objection, c'est adopté.`
|
||||
} else if (n <= settings.triage.collectiveMin) {
|
||||
protocolId = range.nuanced ?? range.consent
|
||||
explanation = `Vous êtes ${n} — vote nuancé : chacun se prononce en nuances, pas en camps.`
|
||||
} else {
|
||||
protocolId = range.large ?? range.consent
|
||||
explanation = `Vous êtes ${n} — la modalité que votre Pacte a choisie s'applique.`
|
||||
}
|
||||
|
||||
const structural = input.weight === 'structural'
|
||||
return {
|
||||
route: 'collective',
|
||||
rule: 'R5',
|
||||
explanation,
|
||||
protocolId,
|
||||
...(structural ? { framingDays: settings.triage.framingDays } : {}),
|
||||
reviewRequired: structural || input.reversibility === 'irreversible',
|
||||
engravingSuggested: structural,
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// R6 overlay & permanent alternatives
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** R6 — recurrence & maturation. Overlay only: a suggestion, never a route.
|
||||
* 'claim-mandate' (recent adopted look-alikes) wins over 'protocolize'
|
||||
* (recorded look-alikes) when both thresholds are reached. */
|
||||
function recurrenceOverlay(
|
||||
input: TriageInput,
|
||||
ctx: TriageContext,
|
||||
settings: CollectiveSettings,
|
||||
): Verdict['suggestion'] | undefined {
|
||||
const threshold = settings.triage.recurrenceThreshold
|
||||
if (ctx.similarRecentCount >= threshold) {
|
||||
return {
|
||||
kind: 'claim-mandate',
|
||||
prefill: {
|
||||
title: input.title,
|
||||
domainTags: input.tags,
|
||||
domainCircleIds: input.scope.circleIds,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (ctx.similarRecordedCount >= threshold) {
|
||||
return {
|
||||
kind: 'protocolize',
|
||||
prefill: { title: input.title, tags: input.tags },
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The permanent « Je choisis autrement » alternatives — ALWAYS present.
|
||||
* Parametric comes FIRST when a number/%/amount was detected (parametricHint);
|
||||
* otherwise record leads (the observatory gesture stays one tap away). */
|
||||
function buildAlternatives(parametricFirst: boolean): Verdict['alternatives'] {
|
||||
const parametric = {
|
||||
route: 'collective' as const,
|
||||
label: PARAMETRIC_ALT,
|
||||
cost: PARAMETRIC_COST,
|
||||
}
|
||||
const record = { route: 'record' as const, label: RECORD_ALT, cost: RECORD_COST }
|
||||
const binary = {
|
||||
route: 'collective' as const,
|
||||
label: METHOD_LABELS.binary,
|
||||
cost: BINARY_COST,
|
||||
}
|
||||
return parametricFirst ? [parametric, record, binary] : [record, parametric, binary]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** A mandate covers when scope.circleIds is a NON-EMPTY subset of its
|
||||
* domain.circleIds. Defensive status filter: only 'active' mandates count. */
|
||||
function coveringMandate(input: TriageInput, mandates: Mandate[]): Mandate | undefined {
|
||||
const { circleIds } = input.scope
|
||||
if (circleIds.length === 0) return undefined
|
||||
return mandates.find(
|
||||
m => m.status === 'active' && circleIds.every(id => m.domain.circleIds.includes(id)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Tests du moteur de périmètre — computeConcerned / expandCircleMembers.
|
||||
* Fonctions pures : fixtures assemblées à la main, aucun store.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeConcerned, expandCircleMembers } from '../../app/engine/impact'
|
||||
import type { Circle, Decision, Mandate } from '../../app/types/domain'
|
||||
|
||||
const T0 = '2026-01-01T00:00:00Z'
|
||||
const T9 = '2027-01-01T00:00:00Z'
|
||||
const AUTHOR = 'p-author'
|
||||
|
||||
function mkCircle(over: Partial<Circle> = {}): Circle {
|
||||
return {
|
||||
id: 'c1',
|
||||
collectiveId: 'col',
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
name: 'Forgerons',
|
||||
purpose: '',
|
||||
memberIds: [],
|
||||
domains: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mkMandate(over: Partial<Mandate> = {}): Mandate {
|
||||
return {
|
||||
id: 'm1',
|
||||
collectiveId: 'col',
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
title: 'Modération',
|
||||
holderId: 'p-holder',
|
||||
originDecisionId: 'd0',
|
||||
domain: { circleIds: ['c1'], tags: [] },
|
||||
startsAt: T0,
|
||||
endsAt: T9,
|
||||
electorCircleId: 'c1',
|
||||
nominationMethod: 'consent',
|
||||
reports: [],
|
||||
status: 'active',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mkScope(over: Partial<Decision['scope']> = {}): Decision['scope'] {
|
||||
return { selfOnly: false, circleIds: [], personIds: [], ...over }
|
||||
}
|
||||
|
||||
describe('computeConcerned — l\'union calculée, chaque entrée avec sa raison', () => {
|
||||
it('unit les trois sources : cercles, personnes nommées, titulaires de mandats', () => {
|
||||
const circles = [mkCircle({ id: 'c1', name: 'Forgerons', memberIds: ['p1', 'p2'] })]
|
||||
const mandates = [mkMandate({ holderId: 'p4', title: 'Modération' })]
|
||||
const scope = mkScope({ circleIds: ['c1'], personIds: ['p3'] })
|
||||
|
||||
const result = computeConcerned(scope, circles, mandates, AUTHOR)
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result).toContainEqual({ personId: 'p1', reason: 'membre du cercle Forgerons' })
|
||||
expect(result).toContainEqual({ personId: 'p2', reason: 'membre du cercle Forgerons' })
|
||||
expect(result).toContainEqual({ personId: 'p3', reason: 'nommé·e par l\'auteur' })
|
||||
expect(result).toContainEqual({ personId: 'p4', reason: 'titulaire du mandat Modération' })
|
||||
})
|
||||
|
||||
it('dédoublonne — la première raison gagne, priorité cercle > nommé > mandat', () => {
|
||||
const circles = [mkCircle({ id: 'c1', name: 'Forgerons', memberIds: ['p1'] })]
|
||||
// p1 est membre du cercle ET nommé ET titulaire d'un mandat intersectant.
|
||||
const mandates = [mkMandate({ holderId: 'p1' })]
|
||||
const scope = mkScope({ circleIds: ['c1'], personIds: ['p1'] })
|
||||
|
||||
const result = computeConcerned(scope, circles, mandates, AUTHOR)
|
||||
|
||||
expect(result).toEqual([{ personId: 'p1', reason: 'membre du cercle Forgerons' }])
|
||||
})
|
||||
|
||||
it('dédoublonne — nommé·e gagne sur titulaire de mandat', () => {
|
||||
const mandates = [mkMandate({ holderId: 'p1', domain: { circleIds: ['c1'], tags: [] } })]
|
||||
const scope = mkScope({ circleIds: ['c1'], personIds: ['p1'] })
|
||||
|
||||
const result = computeConcerned(scope, [], mandates, AUTHOR)
|
||||
|
||||
expect(result).toEqual([{ personId: 'p1', reason: 'nommé·e par l\'auteur' }])
|
||||
})
|
||||
|
||||
it('exclut l\'auteur de toutes les sources', () => {
|
||||
const circles = [mkCircle({ id: 'c1', memberIds: [AUTHOR, 'p1'] })]
|
||||
const mandates = [mkMandate({ holderId: AUTHOR })]
|
||||
const scope = mkScope({ circleIds: ['c1'], personIds: [AUTHOR] })
|
||||
|
||||
const result = computeConcerned(scope, circles, mandates, AUTHOR)
|
||||
|
||||
expect(result.map(e => e.personId)).toEqual(['p1'])
|
||||
})
|
||||
|
||||
it('n\'inclut un titulaire que si le domaine du mandat intersecte les cercles du périmètre', () => {
|
||||
const mandates = [
|
||||
mkMandate({ id: 'm1', holderId: 'p4', domain: { circleIds: ['c9'], tags: [] } }),
|
||||
mkMandate({ id: 'm2', holderId: 'p5', title: 'Trésorerie', domain: { circleIds: ['c2', 'c1'], tags: [] } }),
|
||||
]
|
||||
const scope = mkScope({ circleIds: ['c1'] })
|
||||
|
||||
const result = computeConcerned(scope, [], mandates, AUTHOR)
|
||||
|
||||
expect(result).toEqual([{ personId: 'p5', reason: 'titulaire du mandat Trésorerie' }])
|
||||
})
|
||||
|
||||
it('ignore les mandats non actifs', () => {
|
||||
const mandates = [mkMandate({ holderId: 'p4', status: 'expired' })]
|
||||
const scope = mkScope({ circleIds: ['c1'] })
|
||||
|
||||
expect(computeConcerned(scope, [], mandates, AUTHOR)).toEqual([])
|
||||
})
|
||||
|
||||
it('ignore sans erreur un cercle inconnu dans le périmètre', () => {
|
||||
const scope = mkScope({ circleIds: ['c-fantome'], personIds: ['p1'] })
|
||||
|
||||
const result = computeConcerned(scope, [], [], AUTHOR)
|
||||
|
||||
expect(result).toEqual([{ personId: 'p1', reason: 'nommé·e par l\'auteur' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandCircleMembers — appartenance nominative, sans propagation', () => {
|
||||
it('réunit les membres directs de plusieurs cercles, dédoublonnés', () => {
|
||||
const circles = [
|
||||
mkCircle({ id: 'c1', memberIds: ['p1', 'p2'] }),
|
||||
mkCircle({ id: 'c2', name: 'Cuisine', memberIds: ['p2', 'p3'] }),
|
||||
]
|
||||
|
||||
const members = expandCircleMembers(['c1', 'c2'], circles)
|
||||
|
||||
expect(members).toEqual(new Set(['p1', 'p2', 'p3']))
|
||||
})
|
||||
|
||||
it('ne propage PAS l\'appartenance des cercles enfants au parent (emboîtement ≠ appartenance)', () => {
|
||||
// v2 : l'appartenance est nominative par cercle ; l'emboîtement
|
||||
// (parentCircleId) sert l'élargissement d'un cran, jamais le calcul.
|
||||
const parent = mkCircle({ id: 'c-parent', name: 'Village', memberIds: ['p1'] })
|
||||
const child = mkCircle({
|
||||
id: 'c-child',
|
||||
name: 'Hameau',
|
||||
memberIds: ['p2'],
|
||||
parentCircleId: 'c-parent',
|
||||
})
|
||||
|
||||
expect(expandCircleMembers(['c-parent'], [parent, child])).toEqual(new Set(['p1']))
|
||||
// Ni dans l'autre sens : l'enfant n'hérite pas des membres du parent.
|
||||
expect(expandCircleMembers(['c-child'], [parent, child])).toEqual(new Set(['p2']))
|
||||
// L'élargissement est un geste explicite : ajouter le cercle au périmètre.
|
||||
expect(expandCircleMembers(['c-parent', 'c-child'], [parent, child]))
|
||||
.toEqual(new Set(['p1', 'p2']))
|
||||
})
|
||||
|
||||
it('ignore un identifiant de cercle inconnu', () => {
|
||||
expect(expandCircleMembers(['c-fantome'], [mkCircle()])).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Suite du moteur paramétrique — « Réglage collectif » (BLUEPRINT-V2.md
|
||||
* Δ2, Δ3, Δ15, Δ16). Specs verrouillées testées ici :
|
||||
* médiane BASSE élément par élément (pair ⇒ élément central inférieur,
|
||||
* step respecté par construction), sum100 avec EXACTEMENT une part dérivée,
|
||||
* clamp + renormalisation proportionnelle à la cristallisation,
|
||||
* « Pour moi » linear-share (jamais de chiffre inventé), bimodalité
|
||||
* heuristique jamais bloquante, dégénérescences 0 vote.
|
||||
*/
|
||||
import type { ParamSpec } from '../../app/types/domain'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
computeMyImpact,
|
||||
crystallize,
|
||||
detectBimodality,
|
||||
medianByElement,
|
||||
resolveDerived,
|
||||
validateParamSpec,
|
||||
validateVote,
|
||||
} from '../../app/engine/parametric'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Specs de référence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Spec sum100 : 2 parts votées + 1 part dérivée large (bornes 0–100). */
|
||||
function sum100Spec(overrides: Partial<ParamSpec> = {}): ParamSpec {
|
||||
return {
|
||||
constraint: 'sum100',
|
||||
params: [
|
||||
{ key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
|
||||
{ key: 'metal', label: 'Atelier métal', kind: 'share', min: 0, max: 100, step: 5 },
|
||||
{ key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Spec sans contrainte : 2 curseurs bornés. */
|
||||
function sliderSpec(): ParamSpec {
|
||||
return {
|
||||
constraint: 'none',
|
||||
params: [
|
||||
{ key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1, baseline: 12 },
|
||||
{ key: 'taux', label: 'Taux (%)', kind: 'slider', min: 0, max: 10, step: 0.5 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// medianByElement — médiane basse élément par élément
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('medianByElement — médiane basse élément par élément', () => {
|
||||
it('nombre impair de votes : élément central classique', () => {
|
||||
expect(medianByElement([[10], [30], [20]])).toEqual([20])
|
||||
})
|
||||
|
||||
it('nombre pair de votes : élément central INFÉRIEUR (médiane basse)', () => {
|
||||
// Colonne triée [10, 20, 30, 40] : index floor((4-1)/2) = 1 ⇒ 20, pas 25.
|
||||
expect(medianByElement([[40], [10], [30], [20]])).toEqual([20])
|
||||
})
|
||||
|
||||
it('la médiane paire est la valeur votée inférieure des deux centrales', () => {
|
||||
const votes = [[15], [25], [40], [60]]
|
||||
const [median] = medianByElement(votes)
|
||||
expect(median).toBe(25) // jamais 32.5 (moyenne des centrales)
|
||||
expect(votes.flat()).toContain(median) // une position que quelqu'un a votée
|
||||
})
|
||||
|
||||
it('step respecté par construction : la médiane appartient à la grille votée', () => {
|
||||
// Votes conformes au pas 5 : la médiane basse est l'un d'eux, donc conforme.
|
||||
const votes = [[15], [25], [40], [60]]
|
||||
const [median] = medianByElement(votes)
|
||||
expect((median - 0) % 5).toBe(0)
|
||||
})
|
||||
|
||||
it('élément par élément : chaque colonne a sa propre médiane', () => {
|
||||
const votes = [
|
||||
[10, 90],
|
||||
[20, 80],
|
||||
[30, 70],
|
||||
]
|
||||
expect(medianByElement(votes)).toEqual([20, 80])
|
||||
})
|
||||
|
||||
it('deux votes : médiane basse = le plus petit des deux', () => {
|
||||
expect(medianByElement([[50, 10], [40, 20]])).toEqual([40, 10])
|
||||
})
|
||||
|
||||
it('0 vote ⇒ [] (l’appelant gère la baseline)', () => {
|
||||
expect(medianByElement([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateParamSpec
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateParamSpec — validation de la spec à la création', () => {
|
||||
it('accepte une spec sum100 valide (exactement une part dérivée)', () => {
|
||||
expect(() => validateParamSpec(sum100Spec())).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepte une spec de curseurs sans contrainte', () => {
|
||||
expect(() => validateParamSpec(sliderSpec())).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejette plus de 7 paramètres', () => {
|
||||
const spec: ParamSpec = {
|
||||
constraint: 'none',
|
||||
params: Array.from({ length: 8 }, (_, i) => ({
|
||||
key: `p${i}`,
|
||||
label: `Paramètre ${i}`,
|
||||
kind: 'slider' as const,
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 1,
|
||||
})),
|
||||
}
|
||||
expect(() => validateParamSpec(spec)).toThrow(/maximum 7/)
|
||||
})
|
||||
|
||||
it('rejette une spec sans aucun paramètre', () => {
|
||||
expect(() => validateParamSpec({ constraint: 'none', params: [] })).toThrow(/Au moins un paramètre/)
|
||||
})
|
||||
|
||||
it('rejette un libellé métier manquant', () => {
|
||||
const spec = sliderSpec()
|
||||
spec.params[0].label = ' '
|
||||
expect(() => validateParamSpec(spec)).toThrow(/Libellé métier manquant/)
|
||||
})
|
||||
|
||||
it('rejette sum100 sans part dérivée (0 derived)', () => {
|
||||
const spec = sum100Spec()
|
||||
delete spec.params[2].derived
|
||||
expect(() => validateParamSpec(spec)).toThrow(/exactement une part dérivée/)
|
||||
})
|
||||
|
||||
it('rejette sum100 avec deux parts dérivées (2 derived)', () => {
|
||||
const spec = sum100Spec()
|
||||
spec.params[1].derived = true
|
||||
expect(() => validateParamSpec(spec)).toThrow(/2 trouvées/)
|
||||
})
|
||||
|
||||
it('rejette un derived posé sur un curseur (kind slider)', () => {
|
||||
const spec = sum100Spec()
|
||||
spec.params[2].kind = 'slider'
|
||||
expect(() => validateParamSpec(spec)).toThrow(/part/)
|
||||
})
|
||||
|
||||
it('rejette un derived sans contrainte pour le résoudre', () => {
|
||||
const spec = sliderSpec()
|
||||
spec.params[1].derived = true
|
||||
expect(() => validateParamSpec(spec)).toThrow(/sans contrainte/)
|
||||
})
|
||||
|
||||
it('rejette des bornes incohérentes (min >= max)', () => {
|
||||
const spec = sliderSpec()
|
||||
spec.params[0].min = 24
|
||||
spec.params[0].max = 24
|
||||
expect(() => validateParamSpec(spec)).toThrow(/Bornes incohérentes/)
|
||||
})
|
||||
|
||||
it('rejette un pas nul ou négatif (step <= 0)', () => {
|
||||
const spec = sliderSpec()
|
||||
spec.params[1].step = 0
|
||||
expect(() => validateParamSpec(spec)).toThrow(/Pas invalide/)
|
||||
spec.params[1].step = -1
|
||||
expect(() => validateParamSpec(spec)).toThrow(/Pas invalide/)
|
||||
})
|
||||
|
||||
it('rejette une baseline hors bornes', () => {
|
||||
const spec = sliderSpec()
|
||||
spec.params[0].baseline = 25 // max 24
|
||||
expect(() => validateParamSpec(spec)).toThrow(/hors bornes/)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateVote
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateVote — validation d’un vote', () => {
|
||||
it('accepte un vote valide (ordre des params hors derived)', () => {
|
||||
expect(() => validateVote(sum100Spec(), [60, 30])).not.toThrow()
|
||||
expect(() => validateVote(sliderSpec(), [12, 2.5])).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejette une longueur fausse (le derived ne se vote pas)', () => {
|
||||
expect(() => validateVote(sum100Spec(), [60, 30, 10])).toThrow(/Nombre de valeurs invalide/)
|
||||
expect(() => validateVote(sum100Spec(), [60])).toThrow(/Nombre de valeurs invalide/)
|
||||
})
|
||||
|
||||
it('rejette NaN et Infinity (sanitize)', () => {
|
||||
expect(() => validateVote(sum100Spec(), [Number.NaN, 30])).toThrow(/nombre fini/)
|
||||
expect(() => validateVote(sum100Spec(), [60, Number.POSITIVE_INFINITY])).toThrow(/nombre fini/)
|
||||
expect(() => validateVote(sliderSpec(), [Number.NEGATIVE_INFINITY, 2])).toThrow(/nombre fini/)
|
||||
})
|
||||
|
||||
it('rejette une valeur hors bornes', () => {
|
||||
expect(() => validateVote(sliderSpec(), [0, 2])).toThrow(/hors bornes/) // min 1
|
||||
expect(() => validateVote(sliderSpec(), [12, 10.5])).toThrow(/hors bornes/) // max 10
|
||||
})
|
||||
|
||||
it('rejette une valeur non alignée sur le pas', () => {
|
||||
expect(() => validateVote(sum100Spec(), [62, 30])).toThrow(/pas/) // pas de 5
|
||||
expect(() => validateVote(sliderSpec(), [12, 2.3])).toThrow(/pas/) // pas de 0.5
|
||||
})
|
||||
|
||||
it('tolérance flottante : 0.1 + 0.2 est accepté sur une grille de 0.1', () => {
|
||||
const spec: ParamSpec = {
|
||||
constraint: 'none',
|
||||
params: [{ key: 't', label: 'Taux', kind: 'slider', min: 0, max: 1, step: 0.1 }],
|
||||
}
|
||||
expect(() => validateVote(spec, [0.1 + 0.2])).not.toThrow() // 0.30000000000000004
|
||||
})
|
||||
|
||||
it('sum100 : rejette le vote si le derived résolu sort de ses bornes', () => {
|
||||
const spec = sum100Spec()
|
||||
spec.params[2].max = 15 // réserve bornée [0, 15]
|
||||
// 60 + 30 = 90 ⇒ derived 10 : OK ; 50 + 30 = 80 ⇒ derived 20 : rejet.
|
||||
expect(() => validateVote(spec, [60, 30])).not.toThrow()
|
||||
expect(() => validateVote(spec, [50, 30])).toThrow(/sortirait de ses bornes/)
|
||||
// 105 votés ⇒ derived −5 < min 0 : rejet aussi.
|
||||
expect(() => validateVote(spec, [70, 35])).toThrow(/sortirait de ses bornes/)
|
||||
})
|
||||
|
||||
it('sum100 : le derived exactement sur sa borne est accepté', () => {
|
||||
const spec = sum100Spec()
|
||||
spec.params[2].max = 5
|
||||
expect(() => validateVote(spec, [65, 30])).not.toThrow() // derived = 5 pile
|
||||
})
|
||||
|
||||
it('sum100 : un curseur hors contrainte ne compte pas dans la somme', () => {
|
||||
const spec: ParamSpec = {
|
||||
constraint: 'sum100',
|
||||
params: [
|
||||
{ key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
|
||||
{ key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
|
||||
{ key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
|
||||
],
|
||||
}
|
||||
// Le curseur vaut 24 : s'il comptait, derived = 100 − 24 − 60 = 16 ;
|
||||
// il ne compte pas ⇒ derived = 40, dans [0, 100].
|
||||
expect(() => validateVote(spec, [24, 60])).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveDerived
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveDerived — résolution linéaire du derived', () => {
|
||||
it('insère le derived à sa position dans l’ordre de spec.params', () => {
|
||||
expect(resolveDerived(sum100Spec(), [60, 30])).toEqual([60, 30, 10])
|
||||
})
|
||||
|
||||
it('derived en position centrale : l’ordre est préservé', () => {
|
||||
const spec: ParamSpec = {
|
||||
constraint: 'sum100',
|
||||
params: [
|
||||
{ key: 'a', label: 'Part A', kind: 'share', min: 0, max: 100, step: 1 },
|
||||
{ key: 'r', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
|
||||
{ key: 'b', label: 'Part B', kind: 'share', min: 0, max: 100, step: 1 },
|
||||
],
|
||||
}
|
||||
expect(resolveDerived(spec, [40, 35])).toEqual([40, 25, 35])
|
||||
})
|
||||
|
||||
it('les curseurs hors contrainte passent tels quels', () => {
|
||||
expect(resolveDerived(sliderSpec(), [12, 2.5])).toEqual([12, 2.5])
|
||||
})
|
||||
|
||||
it('sum100 avec curseur : seul les parts entrent dans le calcul du derived', () => {
|
||||
const spec: ParamSpec = {
|
||||
constraint: 'sum100',
|
||||
params: [
|
||||
{ key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
|
||||
{ key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
|
||||
{ key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
|
||||
],
|
||||
}
|
||||
expect(resolveDerived(spec, [24, 60])).toEqual([24, 60, 40])
|
||||
})
|
||||
|
||||
it('rejette une longueur fausse', () => {
|
||||
expect(() => resolveDerived(sum100Spec(), [60])).toThrow(/Nombre de valeurs invalide/)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// crystallize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('crystallize — médiane basse des parts votées puis résolution', () => {
|
||||
it('cas nominal : médiane basse puis derived résolu, somme 100', () => {
|
||||
const votes = [
|
||||
[50, 20],
|
||||
[60, 30],
|
||||
[70, 40],
|
||||
]
|
||||
const out = crystallize(sum100Spec(), votes)
|
||||
expect(out).toEqual([60, 30, 10])
|
||||
expect(out[0] + out[1] + out[2]).toBe(100)
|
||||
})
|
||||
|
||||
it('derived dans ses bornes : aucun clamp, valeurs médianes intactes', () => {
|
||||
const spec = sum100Spec()
|
||||
spec.params[2].max = 15
|
||||
const out = crystallize(spec, [[60, 30], [65, 30], [55, 35]])
|
||||
expect(out).toEqual([60, 30, 10]) // derived 10 ≤ 15 : rien à clamper
|
||||
})
|
||||
|
||||
it('cas chiffré exact : derived clampé à 5, parts renormalisées ×95/90', () => {
|
||||
// Médiane des parts votées = {60, 30} ⇒ derived résolu 10 > max 5.
|
||||
// Spec verrouillée : clamp à 5, puis parts × (100 − 5)/(60 + 30).
|
||||
const spec = sum100Spec()
|
||||
spec.params[2].max = 5
|
||||
const votes = [
|
||||
[50, 20],
|
||||
[60, 30],
|
||||
[70, 40],
|
||||
]
|
||||
const out = crystallize(spec, votes)
|
||||
expect(out[2]).toBe(5)
|
||||
expect(out[0]).toBeCloseTo((60 * 95) / 90, 10)
|
||||
expect(out[1]).toBeCloseTo((30 * 95) / 90, 10)
|
||||
expect(out[0] + out[1] + out[2]).toBeCloseTo(100, 10)
|
||||
})
|
||||
|
||||
it('clamp à la borne min : même renormalisation proportionnelle', () => {
|
||||
// Médiane {60, 30} ⇒ derived 10 < min 20 : clamp à 20, parts × 80/90.
|
||||
const spec = sum100Spec()
|
||||
spec.params[2].min = 20
|
||||
const out = crystallize(spec, [[60, 30]])
|
||||
expect(out[2]).toBe(20)
|
||||
expect(out[0]).toBeCloseTo((60 * 80) / 90, 10)
|
||||
expect(out[1]).toBeCloseTo((30 * 80) / 90, 10)
|
||||
expect(out[0] + out[1] + out[2]).toBeCloseTo(100, 10)
|
||||
})
|
||||
|
||||
it('les curseurs hors contrainte ne sont jamais renormalisés', () => {
|
||||
const spec: ParamSpec = {
|
||||
constraint: 'sum100',
|
||||
params: [
|
||||
{ key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
|
||||
{ key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
|
||||
{ key: 'metal', label: 'Atelier métal', kind: 'share', min: 0, max: 100, step: 5 },
|
||||
{ key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 5, step: 1, derived: true },
|
||||
],
|
||||
}
|
||||
const out = crystallize(spec, [[12, 60, 30]])
|
||||
expect(out[0]).toBe(12) // le curseur passe tel quel
|
||||
expect(out[3]).toBe(5)
|
||||
expect(out[1] + out[2] + out[3]).toBeCloseTo(100, 10)
|
||||
})
|
||||
|
||||
it('0 vote ⇒ vecteur des baselines (baseline ?? min)', () => {
|
||||
const spec = sum100Spec()
|
||||
spec.params[0].baseline = 50
|
||||
spec.params[2].baseline = 20
|
||||
// params[1] sans baseline ⇒ min (0).
|
||||
expect(crystallize(spec, [])).toEqual([50, 0, 20])
|
||||
})
|
||||
|
||||
it('0 vote sans aucune baseline ⇒ vecteur des min', () => {
|
||||
expect(crystallize(sliderSpec(), [])).toEqual([12, 0]) // duree a baseline 12, taux min 0
|
||||
})
|
||||
|
||||
it('sans contrainte : médiane basse simple, aucun derived', () => {
|
||||
const out = crystallize(sliderSpec(), [[6, 2], [12, 4], [18, 3], [24, 5]])
|
||||
expect(out).toEqual([12, 3]) // médiane basse de chaque colonne
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeMyImpact
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('computeMyImpact — carte « Pour moi » (linear-share)', () => {
|
||||
function impactSpec(): ParamSpec {
|
||||
const spec = sum100Spec()
|
||||
spec.impactAttrKey = 'heures/mois'
|
||||
return spec
|
||||
}
|
||||
|
||||
it('cas nominal chiffré : quote-parts au prorata de l’attribut déclaré', () => {
|
||||
// 1 200 € ; valeurs [50, 30, 20] ; moi 10 h, corpus [10, 20, 10] ⇒ Σ 40.
|
||||
// montants : 600, 360, 240 ; ma quote-part = montant × 10/40.
|
||||
const result = computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [10, 20, 10])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.perParam).toEqual([
|
||||
{ key: 'bois', label: 'Atelier bois', amount: 150 },
|
||||
{ key: 'metal', label: 'Atelier métal', amount: 90 },
|
||||
{ key: 'reserve', label: 'Réserve (calculé)', amount: 60 },
|
||||
])
|
||||
expect(result!.total).toBeCloseTo(300, 10)
|
||||
})
|
||||
|
||||
it('le derived est inclus dans les quote-parts (chaque part compte)', () => {
|
||||
const result = computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [10, 20, 10])
|
||||
expect(result!.perParam.map(l => l.key)).toContain('reserve')
|
||||
})
|
||||
|
||||
it('null si mon attribut est absent — jamais de chiffre inventé', () => {
|
||||
expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], undefined, [10, 20])).toBeNull()
|
||||
})
|
||||
|
||||
it('null si Σ des attributs du corpus vaut 0', () => {
|
||||
expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [0, 0, 0])).toBeNull()
|
||||
expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('null hors sum100, sans montant ou sans impactAttrKey', () => {
|
||||
const noConstraint = sliderSpec()
|
||||
noConstraint.impactAttrKey = 'heures/mois'
|
||||
expect(computeMyImpact(noConstraint, { amount: 1200 }, [12, 2.5], 10, [10, 20])).toBeNull()
|
||||
|
||||
expect(computeMyImpact(impactSpec(), {}, [50, 30, 20], 10, [10, 20])).toBeNull()
|
||||
|
||||
const noKey = sum100Spec() // pas d'impactAttrKey
|
||||
expect(computeMyImpact(noKey, { amount: 1200 }, [50, 30, 20], 10, [10, 20])).toBeNull()
|
||||
})
|
||||
|
||||
it('les curseurs hors contrainte n’ont pas de quote-part', () => {
|
||||
const spec: ParamSpec = {
|
||||
constraint: 'sum100',
|
||||
impactAttrKey: 'heures/mois',
|
||||
params: [
|
||||
{ key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
|
||||
{ key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
|
||||
{ key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
|
||||
],
|
||||
}
|
||||
const result = computeMyImpact(spec, { amount: 1000 }, [12, 60, 40], 5, [5, 5])
|
||||
expect(result!.perParam.map(l => l.key)).toEqual(['bois', 'reserve'])
|
||||
expect(result!.total).toBeCloseTo(500, 10) // (600 + 400) × 5/10
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectBimodality
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectBimodality — heuristique jamais bloquante', () => {
|
||||
it('bimodal net : deux paquets séparés par un grand écart ⇒ vrai', () => {
|
||||
// Triées : [10, 11, 12, 60, 61, 62] — étendue 52, gap max 48 > 40 %.
|
||||
expect(detectBimodality([10, 60, 11, 61, 12, 62])).toBe(true)
|
||||
})
|
||||
|
||||
it('distribution uniforme ⇒ faux', () => {
|
||||
// Gaps réguliers de 10 sur une étendue de 40 : 10 < 16.
|
||||
expect(detectBimodality([10, 20, 30, 40, 50])).toBe(false)
|
||||
})
|
||||
|
||||
it('moins de 4 valeurs ⇒ toujours faux', () => {
|
||||
expect(detectBimodality([0, 100])).toBe(false)
|
||||
expect(detectBimodality([0, 50, 100])).toBe(false)
|
||||
expect(detectBimodality([])).toBe(false)
|
||||
})
|
||||
|
||||
it('un simple outlier (1 seule valeur d’un côté du gap) ⇒ faux', () => {
|
||||
// Gap max 50 entre 0 et 50, mais une seule valeur à gauche.
|
||||
expect(detectBimodality([0, 50, 51, 52, 53])).toBe(false)
|
||||
})
|
||||
|
||||
it('valeurs toutes identiques (étendue nulle) ⇒ faux', () => {
|
||||
expect(detectBimodality([5, 5, 5, 5])).toBe(false)
|
||||
})
|
||||
|
||||
it('deux paires exactement de part et d’autre du gap ⇒ vrai (cas limite n=4)', () => {
|
||||
expect(detectBimodality([0, 1, 99, 100])).toBe(true)
|
||||
})
|
||||
|
||||
it('jamais bloquante : les valeurs non finies sont ignorées, pas d’exception', () => {
|
||||
expect(() => detectBimodality([Number.NaN, Number.POSITIVE_INFINITY, 1, 2])).not.toThrow()
|
||||
expect(detectBimodality([Number.NaN, 0, 1, 99, 100])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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 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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* canTransition — the ONE state machine of the decision (Δ29).
|
||||
* Every guard (a–i) tested with positive AND negative cases + windowOutcome.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TRANSITIONS, canTransition, windowOutcome } from '../../app/engine/state'
|
||||
import type { TransitionContext } from '../../app/engine/state'
|
||||
import type {
|
||||
Assent,
|
||||
CollectiveSettings,
|
||||
Concern,
|
||||
Decision,
|
||||
Objection,
|
||||
ParamSpec,
|
||||
VoteSession,
|
||||
} from '../../app/types/domain'
|
||||
|
||||
const T0 = '2026-08-01T00:00:00.000Z'
|
||||
const NOW = '2026-08-11T12:00:00.000Z'
|
||||
|
||||
let seq = 0
|
||||
|
||||
function makeEntity() {
|
||||
seq += 1
|
||||
return { id: `id-${seq}`, collectiveId: 'col-1', createdAt: T0, updatedAt: T0 }
|
||||
}
|
||||
|
||||
function makeSettings(overrides: Partial<CollectiveSettings['triage']> = {}): CollectiveSettings {
|
||||
return {
|
||||
triage: {
|
||||
smallGroupMax: 5,
|
||||
collectiveMin: 50,
|
||||
consentMax: 7,
|
||||
objectionWindowHours: 48,
|
||||
adviceWindowHours: 72,
|
||||
framingDays: 14,
|
||||
concernEscalateRatio: 0.5,
|
||||
recurrenceThreshold: 3,
|
||||
reviewDelayDays: 90,
|
||||
requireEffects: 'binding',
|
||||
...overrides,
|
||||
},
|
||||
protocolByRange: { consent: 'proto-consent' },
|
||||
}
|
||||
}
|
||||
|
||||
function makeDecision(overrides: Partial<Decision> = {}): Decision {
|
||||
return {
|
||||
...makeEntity(),
|
||||
id: 'dec-1',
|
||||
authorId: 'p-author',
|
||||
title: 'Décision de test',
|
||||
tags: [],
|
||||
reversibility: 'easy',
|
||||
weight: 'light',
|
||||
urgent: false,
|
||||
scope: { selfOnly: false, circleIds: ['circle-1'], personIds: [] },
|
||||
route: 'collective',
|
||||
triageRule: 'R5',
|
||||
routeOverridden: false,
|
||||
status: 'draft',
|
||||
stewardIds: [],
|
||||
measurerIds: [],
|
||||
visibility: 'scope',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeConcern(origin: Concern['origin'], overrides: Partial<Concern> = {}): Concern {
|
||||
const entity = makeEntity()
|
||||
return {
|
||||
...entity,
|
||||
decisionId: 'dec-1',
|
||||
personId: `p-${entity.id}`,
|
||||
origin,
|
||||
reason: 'membre du cercle',
|
||||
beforeSnapshot: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeAssent(personId: string, overrides: Partial<Assent> = {}): Assent {
|
||||
return { ...makeEntity(), decisionId: 'dec-1', personId, ...overrides }
|
||||
}
|
||||
|
||||
function makeObjection(status: Objection['status'], overrides: Partial<Objection> = {}): Objection {
|
||||
return {
|
||||
...makeEntity(),
|
||||
decisionId: 'dec-1',
|
||||
personId: 'p-objector',
|
||||
kind: 'content',
|
||||
argument: 'Je maintiens mon désaccord.',
|
||||
status,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<VoteSession> = {}): VoteSession {
|
||||
return {
|
||||
...makeEntity(),
|
||||
decisionId: 'dec-1',
|
||||
protocolId: 'proto-1',
|
||||
corpusPersonIds: ['p-1', 'p-2', 'p-3'],
|
||||
corpusSize: 3,
|
||||
opensAt: T0,
|
||||
closesAt: NOW,
|
||||
status: 'open',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCtx(overrides: Partial<TransitionContext> = {}): TransitionContext {
|
||||
return {
|
||||
concerns: [],
|
||||
settings: makeSettings(),
|
||||
assents: [],
|
||||
objections: [],
|
||||
now: NOW,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const PARAM_SPEC: ParamSpec = {
|
||||
params: [
|
||||
{ key: 'partA', label: 'Part ateliers', kind: 'share', min: 0, max: 100, step: 1 },
|
||||
{ key: 'partB', label: 'Part réserve', kind: 'share', min: 0, max: 100, step: 1, derived: true },
|
||||
],
|
||||
constraint: 'sum100',
|
||||
}
|
||||
|
||||
/** 4 computed + 2 declared ⇒ ratio 0.5 atteint (2 ≥ 0.5 × 4). */
|
||||
function influxConcerns(): Concern[] {
|
||||
return [
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('declared'),
|
||||
makeConcern('declared'),
|
||||
]
|
||||
}
|
||||
|
||||
describe('La table des transitions', () => {
|
||||
it('draft ouvre les sept chemins', () => {
|
||||
expect(TRANSITIONS.draft).toEqual([
|
||||
'advice',
|
||||
'objection',
|
||||
'framing',
|
||||
'voting',
|
||||
'adopted',
|
||||
'transmitted',
|
||||
'rejected',
|
||||
])
|
||||
})
|
||||
|
||||
it('chaque état de travail connaît ses sorties', () => {
|
||||
expect(TRANSITIONS.advice).toEqual(['adopted', 'voting'])
|
||||
expect(TRANSITIONS.objection).toEqual(['adopted', 'framing', 'voting'])
|
||||
expect(TRANSITIONS.framing).toEqual(['voting', 'closed'])
|
||||
expect(TRANSITIONS.voting).toEqual(['adopted', 'rejected'])
|
||||
expect(TRANSITIONS.adopted).toEqual(['revoked', 'closed'])
|
||||
})
|
||||
|
||||
it('les états terminaux n’ont aucune sortie', () => {
|
||||
expect(TRANSITIONS.rejected).toBeUndefined()
|
||||
expect(TRANSITIONS.revoked).toBeUndefined()
|
||||
expect(TRANSITIONS.closed).toBeUndefined()
|
||||
expect(TRANSITIONS.transmitted).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde a — la transition inconnue est refusée', () => {
|
||||
it('adopted → voting n’existe pas', () => {
|
||||
const result = canTransition(makeDecision({ status: 'adopted' }), 'voting', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('rejected est terminal — aucune sortie', () => {
|
||||
expect(canTransition(makeDecision({ status: 'rejected' }), 'adopted', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('draft → advice existe', () => {
|
||||
const decision = makeDecision({ status: 'draft', route: 'advice' })
|
||||
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('le retour explicite d’une fenêtre à son propre état est permis', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate' })
|
||||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('un état hors fenêtre ne boucle pas sur lui-même', () => {
|
||||
expect(canTransition(makeDecision({ status: 'voting' }), 'voting', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde b — la frontière contestée suspend toute sortie', () => {
|
||||
it('une fenêtre d’objection suspendue ne peut pas adopter', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
|
||||
const result = canTransition(decision, 'adopted', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('frontière')
|
||||
})
|
||||
|
||||
it('une fenêtre d’avis suspendue ne peut pas non plus escalader vers le vote', () => {
|
||||
const decision = makeDecision({ status: 'advice', route: 'advice', windowSuspendedAt: NOW })
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('le retour explicite au même état reste possible pendant la suspension', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
|
||||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('sans suspension, la fenêtre s’adopte normalement', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('la frontière prime sur l’affluence (ordre des gardes)', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
|
||||
const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('frontière')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde c — l’affluence exige un traitement du périmètre', () => {
|
||||
it('ratio atteint sans scopeKeptNote — la session ne se clôt pas', () => {
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe(
|
||||
'Le périmètre déborde — élargis d’un cran ou motive publiquement son maintien.',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('le maintien motivé publiquement (scopeKeptNote) débloque la clôture', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'voting',
|
||||
scopeKeptNote: 'Le cercle Ateliers reste le bon périmètre : le budget est le sien.',
|
||||
})
|
||||
expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('sous le ratio, la clôture passe', () => {
|
||||
const concerns = [
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('declared'),
|
||||
]
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('sans concerné calculé, la garde ne se déclenche jamais', () => {
|
||||
const concerns = [makeConcern('declared'), makeConcern('declared'), makeConcern('declared')]
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('la garde vaut aussi pour la fenêtre d’avis', () => {
|
||||
const decision = makeDecision({ status: 'advice', route: 'advice' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('la garde vaut pour le rejet d’un vote — clore, c’est clore', () => {
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
expect(canTransition(decision, 'rejected', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('les concernés d’une autre décision ne comptent pas', () => {
|
||||
const concerns = influxConcerns().map((concern) => ({ ...concern, decisionId: 'dec-2' }))
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('l’escalade vers framing ou voting reste ouverte — c’est le traitement, pas la clôture', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate' })
|
||||
expect(canTransition(decision, 'framing', makeCtx({ concerns: influxConcerns() })).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde d — la matière (requireEffects) à l’ouverture de session collective', () => {
|
||||
const resources = { note: 'Deux heures par semaine pendant un mois' }
|
||||
|
||||
it('« binding » + poids binding sans effet recherché — refus', () => {
|
||||
const decision = makeDecision({ status: 'draft', weight: 'binding', resources })
|
||||
const result = canTransition(decision, 'voting', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('effet recherché')
|
||||
})
|
||||
|
||||
it('« binding » + poids binding avec un effet recherché — ouverture', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
weight: 'binding',
|
||||
resources,
|
||||
brief: { effects: [{ label: 'Réduire le temps de réunion' }] },
|
||||
})
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('« binding » + poids structural sans cible mesurable — refus', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
weight: 'structural',
|
||||
resources,
|
||||
brief: { effects: [{ label: 'Assainir le budget' }] },
|
||||
})
|
||||
const result = canTransition(decision, 'voting', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('cible')
|
||||
})
|
||||
|
||||
it('« binding » + poids structural avec une cible — ouverture', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
weight: 'structural',
|
||||
resources,
|
||||
brief: { effects: [{ label: 'Assainir le budget', target: '≤ 400 € par mois' }] },
|
||||
})
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('« structural » : le poids binding n’est pas sous garde', () => {
|
||||
const decision = makeDecision({ status: 'draft', weight: 'binding', resources })
|
||||
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) })
|
||||
expect(canTransition(decision, 'voting', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('« structural » : le poids structural sans effet est refusé', () => {
|
||||
const decision = makeDecision({ status: 'draft', weight: 'structural', resources })
|
||||
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) })
|
||||
expect(canTransition(decision, 'voting', ctx).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('« none » : aucune exigence de matière, même structural', () => {
|
||||
const decision = makeDecision({ status: 'draft', weight: 'structural', resources })
|
||||
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'none' }) })
|
||||
expect(canTransition(decision, 'voting', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('le poids light n’est jamais soumis à la matière', () => {
|
||||
const decision = makeDecision({ status: 'draft', weight: 'light' })
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('la garde est réservée à la route collective', () => {
|
||||
const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding', resources })
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('framing → voting est gardé de la même façon', () => {
|
||||
const decision = makeDecision({ status: 'framing', weight: 'structural', resources })
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde e — « Ce que ça engage » à l’ouverture de fenêtre ou de session', () => {
|
||||
it('poids binding sans note de ressources — la fenêtre d’objection ne s’ouvre pas', () => {
|
||||
const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding' })
|
||||
const result = canTransition(decision, 'objection', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('engage')
|
||||
})
|
||||
|
||||
it('avec la note, la fenêtre s’ouvre', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
route: 'mandate',
|
||||
weight: 'binding',
|
||||
resources: { note: 'Une demi-journée de l’équipe accueil' },
|
||||
})
|
||||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('une note blanche ne compte pas', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
route: 'mandate',
|
||||
weight: 'binding',
|
||||
resources: { note: ' ' },
|
||||
})
|
||||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('le poids light ouvre sans note', () => {
|
||||
const decision = makeDecision({ status: 'draft', route: 'advice', weight: 'light' })
|
||||
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('la route solo est exemptée', () => {
|
||||
const decision = makeDecision({ status: 'draft', route: 'solo', weight: 'binding' })
|
||||
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('la garde vit aussi à l’ouverture de session — après la matière', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
weight: 'binding',
|
||||
brief: { effects: [{ label: 'Un effet recherché' }] },
|
||||
})
|
||||
const result = canTransition(decision, 'voting', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('engage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde f — l’accord explicite (Assent) hors du réversible', () => {
|
||||
it('easy : le silence vaut accord, la fenêtre s’adopte sans Assent', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('costly sans Assent — la fenêtre se prolonge', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||||
const result = canTransition(decision, 'adopted', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('Il manque un accord explicite — la fenêtre se prolonge.')
|
||||
}
|
||||
})
|
||||
|
||||
it('costly : l’accord de l’auteur seul ne suffit pas', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||||
const ctx = makeCtx({ assents: [makeAssent('p-author')] })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('costly : un accord d’un tiers concerné débloque l’adoption', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||||
const ctx = makeCtx({ assents: [makeAssent('p-other')] })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('irreversible sans Assent — refus aussi', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'objection',
|
||||
route: 'mandate',
|
||||
reversibility: 'irreversible',
|
||||
})
|
||||
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('l’Assent d’une autre décision ne compte pas', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||||
const ctx = makeCtx({ assents: [makeAssent('p-other', { decisionId: 'dec-2' })] })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde g — jamais d’adoption sur une objection ouverte', () => {
|
||||
it('une objection ouverte bloque l’adoption, même en easy', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||||
const result = canTransition(decision, 'adopted', makeCtx({ objections: [makeObjection('open')] }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('objection')
|
||||
})
|
||||
|
||||
it('une objection retirée ne bloque plus', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||||
const ctx = makeCtx({ objections: [makeObjection('withdrawn')] })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('une objection intégrée ne bloque plus', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||||
const ctx = makeCtx({ objections: [makeObjection('integrated')] })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('l’Assent d’un tiers ne contourne pas une objection ouverte', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||||
const ctx = makeCtx({ assents: [makeAssent('p-other')], objections: [makeObjection('open')] })
|
||||
const result = canTransition(decision, 'adopted', ctx)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('objection')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde h — la clôture du dossier découpé', () => {
|
||||
const parent = () => makeDecision({ id: 'dossier-1', status: 'framing' })
|
||||
|
||||
function element(status: Decision['status'], overrides: Partial<Decision> = {}): Decision {
|
||||
const entity = makeEntity()
|
||||
return makeDecision({
|
||||
id: entity.id,
|
||||
parentDecisionId: 'dossier-1',
|
||||
chainKind: 'element',
|
||||
status,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
it('sans élément, le dossier ne se clôt pas', () => {
|
||||
const result = canTransition(parent(), 'closed', makeCtx({ children: [] }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('dossier')
|
||||
})
|
||||
|
||||
it('tous les éléments terminaux — le garant peut clore', () => {
|
||||
const children = [
|
||||
element('adopted'),
|
||||
element('rejected'),
|
||||
element('revoked'),
|
||||
element('closed'),
|
||||
element('transmitted'),
|
||||
]
|
||||
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('un élément encore en vote retient le dossier', () => {
|
||||
const children = [element('adopted'), element('voting')]
|
||||
const result = canTransition(parent(), 'closed', makeCtx({ children }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('en cours')
|
||||
})
|
||||
|
||||
it('des enfants non-éléments ne font pas un dossier', () => {
|
||||
const children = [element('adopted', { chainKind: 'revision' })]
|
||||
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('l’élément d’un autre dossier ne compte pas', () => {
|
||||
const children = [element('adopted', { parentDecisionId: 'dossier-2' })]
|
||||
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('framing → voting du parent reste possible pendant que le dossier vit', () => {
|
||||
const decision = makeDecision({ id: 'dossier-1', status: 'framing', weight: 'light' })
|
||||
expect(canTransition(decision, 'voting', makeCtx({ children: [element('voting')] })).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde i — la cristallisation attend le geste du garant', () => {
|
||||
const parametricDecision = () => makeDecision({ status: 'voting', paramSpec: PARAM_SPEC })
|
||||
|
||||
it('session figée — pas d’adoption sans le geste', () => {
|
||||
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
|
||||
const result = canTransition(parametricDecision(), 'adopted', ctx)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('Les votes sont figés — la cristallisation attend son geste.')
|
||||
}
|
||||
})
|
||||
|
||||
it('session close sans crystallizedById — le moteur ne cristallise jamais', () => {
|
||||
const ctx = makeCtx({ session: makeSession({ status: 'closed' }) })
|
||||
expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('session close et geste daté — l’adoption passe', () => {
|
||||
const ctx = makeCtx({
|
||||
session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }),
|
||||
})
|
||||
expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('le rejet (quorum non atteint) exige le même geste', () => {
|
||||
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
|
||||
expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('le rejet constaté au geste passe', () => {
|
||||
const ctx = makeCtx({
|
||||
session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }),
|
||||
})
|
||||
expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('une session figée trahit le paramétrique même sans paramSpec', () => {
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('une session non paramétrique se clôt automatiquement — pas de geste exigé', () => {
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
const ctx = makeCtx({ session: makeSession({ status: 'closed' }) })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('sans session dans le contexte, la garde ne s’applique pas', () => {
|
||||
expect(canTransition(makeDecision({ status: 'voting' }), 'adopted', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('windowOutcome — l’échéance des fenêtres d’objection', () => {
|
||||
it('easy — le silence vaut accord, la fenêtre s’adopte', () => {
|
||||
const decision = makeDecision({ status: 'objection', reversibility: 'easy' })
|
||||
expect(windowOutcome(decision, makeCtx())).toBe('adopt')
|
||||
})
|
||||
|
||||
it('costly sans accord tiers — la fenêtre se prolonge d’un cran', () => {
|
||||
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
|
||||
expect(windowOutcome(decision, makeCtx())).toBe('extend')
|
||||
})
|
||||
|
||||
it('costly avec l’accord d’un tiers — la fenêtre s’adopte', () => {
|
||||
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
|
||||
expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-other')] }))).toBe('adopt')
|
||||
})
|
||||
|
||||
it('l’accord de l’auteur seul ne compte pas', () => {
|
||||
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
|
||||
expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-author')] }))).toBe('extend')
|
||||
})
|
||||
|
||||
it('frontière suspendue — la fenêtre attend, même en easy', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'objection',
|
||||
reversibility: 'easy',
|
||||
windowSuspendedAt: NOW,
|
||||
})
|
||||
expect(windowOutcome(decision, makeCtx())).toBe('wait')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* Tests du moteur de routage — « Le chemin ».
|
||||
* Ordre des règles vérifié : R-U, R0a, R0b, R0c, R2, R3, R4, R5, R6.
|
||||
* Fonctions pures : fixtures assemblées à la main, aucun store.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectParametricHint, triage } from '../../app/engine/triage'
|
||||
import {
|
||||
BINARY_COST,
|
||||
PARAMETRIC_ALT,
|
||||
RECORD_ALT,
|
||||
URGENT_REFUSED,
|
||||
} from '../../app/lexicon'
|
||||
import type {
|
||||
Clause,
|
||||
CollectiveSettings,
|
||||
Mandate,
|
||||
TriageContext,
|
||||
TriageInput,
|
||||
} from '../../app/types/domain'
|
||||
|
||||
const T0 = '2026-01-01T00:00:00Z'
|
||||
const T9 = '2027-01-01T00:00:00Z'
|
||||
|
||||
function mkSettings(over: {
|
||||
triage?: Partial<CollectiveSettings['triage']>
|
||||
protocolByRange?: Partial<CollectiveSettings['protocolByRange']>
|
||||
} = {}): CollectiveSettings {
|
||||
return {
|
||||
triage: {
|
||||
smallGroupMax: 5,
|
||||
collectiveMin: 50,
|
||||
consentMax: 7,
|
||||
objectionWindowHours: 48,
|
||||
adviceWindowHours: 72,
|
||||
framingDays: 14,
|
||||
concernEscalateRatio: 0.5,
|
||||
recurrenceThreshold: 3,
|
||||
reviewDelayDays: 90,
|
||||
requireEffects: 'binding',
|
||||
...over.triage,
|
||||
},
|
||||
protocolByRange: { consent: 'proto-consent', ...over.protocolByRange },
|
||||
}
|
||||
}
|
||||
|
||||
function mkInput(over: Partial<TriageInput> = {}): TriageInput {
|
||||
return {
|
||||
title: 'Repeindre la salle',
|
||||
tags: [],
|
||||
scope: { selfOnly: false, circleIds: [], personIds: [] },
|
||||
reversibility: 'costly',
|
||||
weight: 'light',
|
||||
urgent: false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mkCtx(over: Partial<TriageContext> = {}): TriageContext {
|
||||
return {
|
||||
myActiveMandates: [],
|
||||
otherActiveMandates: [],
|
||||
matchingClauses: [],
|
||||
similarRecentCount: 0,
|
||||
similarRecordedCount: 0,
|
||||
computedConcernedIds: ['p1', 'p2', 'p3'],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mkMandate(over: Partial<Mandate> = {}): Mandate {
|
||||
return {
|
||||
id: 'm1',
|
||||
collectiveId: 'col',
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
title: 'Modération',
|
||||
holderId: 'p-holder',
|
||||
originDecisionId: 'd0',
|
||||
domain: { circleIds: ['c1', 'c2'], tags: [] },
|
||||
startsAt: T0,
|
||||
endsAt: T9,
|
||||
electorCircleId: 'c1',
|
||||
nominationMethod: 'consent',
|
||||
reports: [],
|
||||
status: 'active',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mkClause(over: Partial<Clause> = {}): Clause {
|
||||
return {
|
||||
id: 'cl1',
|
||||
collectiveId: 'col',
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
docId: 'doc1',
|
||||
section: 'E',
|
||||
position: 9,
|
||||
code: 'E9',
|
||||
title: 'Acte Forgeron',
|
||||
inertia: 'high',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const scoped = (circleIds: string[]): TriageInput['scope'] =>
|
||||
({ selfOnly: false, circleIds, personIds: [] })
|
||||
|
||||
describe('detectParametricHint — nombre, %, montant dans la phrase', () => {
|
||||
it('détecte un montant en euros', () => {
|
||||
expect(detectParametricHint('Fixer la cotisation à 25 €')).toBe(true)
|
||||
})
|
||||
|
||||
it('ne détecte rien dans une phrase sans chiffre', () => {
|
||||
expect(detectParametricHint('Repeindre la salle')).toBe(false)
|
||||
})
|
||||
|
||||
it('détecte un pourcentage', () => {
|
||||
expect(detectParametricHint('Porter la part commune à douze %')).toBe(true)
|
||||
})
|
||||
|
||||
it('détecte l\'unité DU en majuscules', () => {
|
||||
expect(detectParametricHint('Verser un DU aux nouveaux venus')).toBe(true)
|
||||
})
|
||||
|
||||
it('ne confond pas l\'article « du » avec l\'unité DU', () => {
|
||||
expect(detectParametricHint('Changer du mobilier dans la salle')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('R-U — urgence', () => {
|
||||
it('conservatoire : route sous-jacente calculée sans urgence, fenêtre doublée, chaîne posée', () => {
|
||||
// Sans urgence, ce cas serait R4 (easy, 3 ≤ 5, fenêtre 72 h).
|
||||
const verdict = triage(
|
||||
mkInput({ urgent: true, reversibility: 'easy' }),
|
||||
mkCtx(),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R-U')
|
||||
expect(verdict.route).toBe('advice')
|
||||
expect(verdict.conservatoryChain).toBe(true)
|
||||
expect(verdict.windowHours).toBe(144)
|
||||
expect(verdict.explanation).toBe('Décidé en urgence — le collectif ratifie.')
|
||||
})
|
||||
|
||||
it('urgence × irréversible : refus — route normale, fenêtres minimales (jamais doublées)', () => {
|
||||
// Sans urgence, ce cas serait R0b (mandat couvrant, fenêtre 48 h).
|
||||
const verdict = triage(
|
||||
mkInput({ urgent: true, reversibility: 'irreversible', scope: scoped(['c1']) }),
|
||||
mkCtx({ myActiveMandates: [mkMandate()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R-U')
|
||||
expect(verdict.route).toBe('mandate')
|
||||
expect(verdict.explanation).toBe(URGENT_REFUSED)
|
||||
expect(verdict.conservatoryChain).toBeUndefined()
|
||||
expect(verdict.windowHours).toBe(48)
|
||||
})
|
||||
})
|
||||
|
||||
describe('R0a — déjà décidé', () => {
|
||||
it('clause en vigueur trouvée : consigner en principal, agir ou contester', () => {
|
||||
const verdict = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), mkSettings())
|
||||
|
||||
expect(verdict.rule).toBe('R0a')
|
||||
expect(verdict.route).toBe('record')
|
||||
expect(verdict.explanation).toBe(
|
||||
'C\'est déjà décidé (E9, Acte Forgeron) — agis, ou conteste la règle.',
|
||||
)
|
||||
expect(verdict.windowHours).toBeUndefined()
|
||||
})
|
||||
|
||||
it('limite : viser explicitement la clause (amendsClauseId) passe à R3, pas R0a', () => {
|
||||
// Choix documenté : contester la règle EST R3 — R0a ne bloque pas l'amendement.
|
||||
const verdict = triage(
|
||||
mkInput({ amendsClauseId: 'cl1' }),
|
||||
mkCtx({ matchingClauses: [mkClause()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R3')
|
||||
expect(verdict.route).toBe('collective')
|
||||
})
|
||||
})
|
||||
|
||||
describe('R0b — mon mandat couvre', () => {
|
||||
it('périmètre inclus dans le domaine du mandat : décide, c\'est tracé', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1']) }),
|
||||
mkCtx({ myActiveMandates: [mkMandate()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R0b')
|
||||
expect(verdict.route).toBe('mandate')
|
||||
expect(verdict.windowHours).toBe(48)
|
||||
expect(verdict.explanation).toBe('Ton mandat Modération couvre — décide, c\'est tracé.')
|
||||
})
|
||||
|
||||
it('limite : couverture partielle du périmètre ne suffit pas', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1', 'c3']) }),
|
||||
mkCtx({ myActiveMandates: [mkMandate({ domain: { circleIds: ['c1', 'c2'], tags: [] } })] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).not.toBe('R0b')
|
||||
})
|
||||
|
||||
it('ordre : selfOnly avec mandat couvrant ⇒ R0b gagne, car R0b précède R2', () => {
|
||||
// Choix documenté : le devoir de trace du mandat prime sur le solo.
|
||||
const verdict = triage(
|
||||
mkInput({ scope: { selfOnly: true, circleIds: ['c1'], personIds: [] } }),
|
||||
mkCtx({ myActiveMandates: [mkMandate()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R0b')
|
||||
expect(verdict.route).toBe('mandate')
|
||||
})
|
||||
|
||||
it('limite : périmètre sans cercle ⇒ jamais de couverture triviale', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped([]) }),
|
||||
mkCtx({ myActiveMandates: [mkMandate()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).not.toBe('R0b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('R0c — le mandat d\'un autre couvre', () => {
|
||||
it('transmets à sa ou son titulaire — le titre du mandat nomme le pouvoir', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1']) }),
|
||||
mkCtx({ otherActiveMandates: [mkMandate({ holderId: 'p-other' })] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R0c')
|
||||
expect(verdict.route).toBe('transmit')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Le mandat Modération couvre — transmets à sa ou son titulaire.',
|
||||
)
|
||||
})
|
||||
|
||||
it('ordre : mon mandat gagne sur celui d\'un autre (R0b avant R0c)', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1']) }),
|
||||
mkCtx({
|
||||
myActiveMandates: [mkMandate({ id: 'm-me', title: 'Trésorerie' })],
|
||||
otherActiveMandates: [mkMandate({ id: 'm-other', holderId: 'p-other' })],
|
||||
}),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R0b')
|
||||
})
|
||||
|
||||
it('limite : un mandat expiré ne couvre pas', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1']) }),
|
||||
mkCtx({ otherActiveMandates: [mkMandate({ status: 'expired' })] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).not.toBe('R0c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('R2 — moi seul', () => {
|
||||
it('selfOnly : décide, zéro fenêtre', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
|
||||
mkCtx({ computedConcernedIds: [] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R2')
|
||||
expect(verdict.route).toBe('solo')
|
||||
expect(verdict.explanation).toBe('Personne d\'autre n\'est concerné — décide.')
|
||||
expect(verdict.windowHours).toBeUndefined()
|
||||
expect(verdict.reviewRequired).toBe(false)
|
||||
})
|
||||
|
||||
it('limite : irréversible sur soi ⇒ revoyure suggérée (pré-coche retirable côté UI)', () => {
|
||||
// Le moteur suggère (reviewRequired) ; la pré-coche retirable est un fait
|
||||
// d'UI — sur soi, l'outil ne s'interpose jamais.
|
||||
const verdict = triage(
|
||||
mkInput({
|
||||
scope: { selfOnly: true, circleIds: [], personIds: [] },
|
||||
reversibility: 'irreversible',
|
||||
}),
|
||||
mkCtx({ computedConcernedIds: [] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R2')
|
||||
expect(verdict.reviewRequired).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('R3 — clause visée', () => {
|
||||
const clauseSettings = mkSettings({
|
||||
protocolByRange: {
|
||||
clauseByInertia: {
|
||||
low: 'proto-low',
|
||||
standard: 'proto-standard',
|
||||
high: 'proto-high',
|
||||
max: 'proto-max',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
it('l\'inertie de la clause choisit le protocole', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ amendsClauseId: 'cl1' }),
|
||||
mkCtx({ matchingClauses: [mkClause({ inertia: 'high' })] }),
|
||||
clauseSettings,
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R3')
|
||||
expect(verdict.route).toBe('collective')
|
||||
expect(verdict.protocolId).toBe('proto-high')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Tu proposes une version de E9 — son inertie s\'applique : vote de ceux qu\'elle gouverne.',
|
||||
)
|
||||
})
|
||||
|
||||
it('repli : sans clauseByInertia, le consentement s\'applique', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ amendsClauseId: 'cl1' }),
|
||||
mkCtx({ matchingClauses: [mkClause()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.protocolId).toBe('proto-consent')
|
||||
})
|
||||
|
||||
it('poids structurel ⇒ fenêtre de formulation posée', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ amendsClauseId: 'cl1', weight: 'structural' }),
|
||||
mkCtx({ matchingClauses: [mkClause()] }),
|
||||
clauseSettings,
|
||||
)
|
||||
|
||||
expect(verdict.framingDays).toBe(14)
|
||||
})
|
||||
|
||||
it('limite : clause introuvable dans l\'index ⇒ consentement et phrase générique', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ amendsClauseId: 'cl-inconnue' }),
|
||||
mkCtx({ matchingClauses: [] }),
|
||||
clauseSettings,
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R3')
|
||||
expect(verdict.protocolId).toBe('proto-consent')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Tu proposes une version de cette règle — son inertie s\'applique : vote de ceux qu\'elle gouverne.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('R4 — petit cercle réversible', () => {
|
||||
it('réversible et 3 personnes : demande leur avis puis décide (72 h)', () => {
|
||||
const verdict = triage(mkInput({ reversibility: 'easy' }), mkCtx(), mkSettings())
|
||||
|
||||
expect(verdict.rule).toBe('R4')
|
||||
expect(verdict.route).toBe('advice')
|
||||
expect(verdict.windowHours).toBe(72)
|
||||
expect(verdict.explanation).toBe(
|
||||
'Réversible et 3 personnes concernées — demande leur avis puis décide.',
|
||||
)
|
||||
})
|
||||
|
||||
it('limite : accord du singulier pour 1 personne', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ reversibility: 'easy' }),
|
||||
mkCtx({ computedConcernedIds: ['p1'] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.explanation).toBe(
|
||||
'Réversible et 1 personne concernée — demande leur avis puis décide.',
|
||||
)
|
||||
})
|
||||
|
||||
it('limite : au-delà de smallGroupMax, l\'escalade collective s\'applique', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ reversibility: 'easy' }),
|
||||
mkCtx({ computedConcernedIds: ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R5')
|
||||
})
|
||||
|
||||
it('limite : coûteux à défaire ⇒ jamais R4, même à 3 personnes', () => {
|
||||
const verdict = triage(mkInput({ reversibility: 'costly' }), mkCtx(), mkSettings())
|
||||
|
||||
expect(verdict.rule).toBe('R5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('R5 — collectif, modalité par taille du périmètre', () => {
|
||||
const ids = (n: number): string[] => Array.from({ length: n }, (_, i) => `p${i}`)
|
||||
const fullSettings = mkSettings({
|
||||
protocolByRange: { nuanced: 'proto-nuanced', large: 'proto-large' },
|
||||
})
|
||||
|
||||
it('n ≤ consentMax : un tour d\'accord suffit', () => {
|
||||
const verdict = triage(
|
||||
mkInput(),
|
||||
mkCtx({ computedConcernedIds: ids(6) }),
|
||||
fullSettings,
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R5')
|
||||
expect(verdict.route).toBe('collective')
|
||||
expect(verdict.protocolId).toBe('proto-consent')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Vous êtes 6 — un tour d\'accord suffit : sans objection, c\'est adopté.',
|
||||
)
|
||||
})
|
||||
|
||||
it('n ≤ collectiveMin : vote nuancé', () => {
|
||||
const verdict = triage(
|
||||
mkInput(),
|
||||
mkCtx({ computedConcernedIds: ids(23) }),
|
||||
fullSettings,
|
||||
)
|
||||
|
||||
expect(verdict.protocolId).toBe('proto-nuanced')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Vous êtes 23 — vote nuancé : chacun se prononce en nuances, pas en camps.',
|
||||
)
|
||||
})
|
||||
|
||||
it('repli : sans protocole nuancé, le consentement s\'applique', () => {
|
||||
const verdict = triage(mkInput(), mkCtx({ computedConcernedIds: ids(23) }), mkSettings())
|
||||
|
||||
expect(verdict.protocolId).toBe('proto-consent')
|
||||
})
|
||||
|
||||
it('n > collectiveMin : la modalité choisie par le Pacte', () => {
|
||||
const verdict = triage(
|
||||
mkInput(),
|
||||
mkCtx({ computedConcernedIds: ids(60) }),
|
||||
fullSettings,
|
||||
)
|
||||
|
||||
expect(verdict.protocolId).toBe('proto-large')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Vous êtes 60 — la modalité que votre Pacte a choisie s\'applique.',
|
||||
)
|
||||
})
|
||||
|
||||
it('repli : sans protocole large, le consentement s\'applique', () => {
|
||||
const verdict = triage(mkInput(), mkCtx({ computedConcernedIds: ids(60) }), mkSettings())
|
||||
|
||||
expect(verdict.protocolId).toBe('proto-consent')
|
||||
})
|
||||
|
||||
it('poids structurel ⇒ formulation + revoyure + gravure suggérée', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ weight: 'structural' }),
|
||||
mkCtx({ computedConcernedIds: ids(6) }),
|
||||
fullSettings,
|
||||
)
|
||||
|
||||
expect(verdict.framingDays).toBe(14)
|
||||
expect(verdict.reviewRequired).toBe(true)
|
||||
expect(verdict.engravingSuggested).toBe(true)
|
||||
})
|
||||
|
||||
it('irréversible ⇒ revoyure exigée, sans gravure automatique', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ reversibility: 'irreversible' }),
|
||||
mkCtx({ computedConcernedIds: ids(6) }),
|
||||
fullSettings,
|
||||
)
|
||||
|
||||
expect(verdict.reviewRequired).toBe(true)
|
||||
expect(verdict.engravingSuggested).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('R6 — récurrence et maturation (surcouche, jamais bloquante)', () => {
|
||||
it('3 décisions similaires récentes ⇒ suggère de réclamer un mandat, sans changer la route', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ tags: ['cuisine', 'achats'] }),
|
||||
mkCtx({ similarRecentCount: 3 }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R5')
|
||||
expect(verdict.suggestion?.kind).toBe('claim-mandate')
|
||||
})
|
||||
|
||||
it('3 consignations similaires ⇒ suggère de protocoliser la pratique', () => {
|
||||
const verdict = triage(mkInput(), mkCtx({ similarRecordedCount: 3 }), mkSettings())
|
||||
|
||||
expect(verdict.suggestion?.kind).toBe('protocolize')
|
||||
})
|
||||
|
||||
it('les deux seuils atteints ⇒ réclamer un mandat passe en premier', () => {
|
||||
const verdict = triage(
|
||||
mkInput(),
|
||||
mkCtx({ similarRecentCount: 3, similarRecordedCount: 3 }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.suggestion?.kind).toBe('claim-mandate')
|
||||
})
|
||||
|
||||
it('sous le seuil ⇒ aucune suggestion', () => {
|
||||
const verdict = triage(
|
||||
mkInput(),
|
||||
mkCtx({ similarRecentCount: 2, similarRecordedCount: 2 }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.suggestion).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Repli sans protocole de consentement (bundle corrompu)', () => {
|
||||
const broken = mkSettings({ protocolByRange: { consent: '' } })
|
||||
|
||||
it('route sur avis avec la bannière — prioritaire même sur un mandat couvrant', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1']) }),
|
||||
mkCtx({ myActiveMandates: [mkMandate()] }),
|
||||
broken,
|
||||
)
|
||||
|
||||
expect(verdict.route).toBe('advice')
|
||||
expect(verdict.explanation).toBe('Aucun protocole — crée-le ou décide sur avis.')
|
||||
expect(verdict.windowHours).toBe(72)
|
||||
})
|
||||
|
||||
it('sauf R2 : moi seul décide sans protocole', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
|
||||
mkCtx(),
|
||||
broken,
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R2')
|
||||
expect(verdict.route).toBe('solo')
|
||||
})
|
||||
|
||||
it('sauf consigner : une clause en vigueur reste consignable sans protocole', () => {
|
||||
const verdict = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), broken)
|
||||
|
||||
expect(verdict.rule).toBe('R0a')
|
||||
expect(verdict.route).toBe('record')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Alternatives permanentes — « Je choisis autrement »', () => {
|
||||
it('toujours présentes : réglage, consigner, binaire avec son coût', () => {
|
||||
const verdict = triage(mkInput(), mkCtx(), mkSettings())
|
||||
|
||||
expect(verdict.alternatives).toHaveLength(3)
|
||||
const labels = verdict.alternatives.map(a => a.label)
|
||||
expect(labels).toContain(PARAMETRIC_ALT)
|
||||
expect(labels).toContain(RECORD_ALT)
|
||||
const binary = verdict.alternatives.find(a => a.cost === BINARY_COST)
|
||||
expect(binary).toBeDefined()
|
||||
expect(binary?.route).toBe('collective')
|
||||
})
|
||||
|
||||
it('présentes sur toutes les routes, solo et consigner comprises', () => {
|
||||
const solo = triage(
|
||||
mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
|
||||
mkCtx(),
|
||||
mkSettings(),
|
||||
)
|
||||
const record = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), mkSettings())
|
||||
|
||||
expect(solo.alternatives).toHaveLength(3)
|
||||
expect(record.alternatives).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('montant détecté ⇒ le réglage passe en premier et parametricHint est posé', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ title: 'Fixer la cotisation à 25 €' }),
|
||||
mkCtx(),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.parametricHint).toBe(true)
|
||||
expect(verdict.alternatives[0]?.label).toBe(PARAMETRIC_ALT)
|
||||
})
|
||||
|
||||
it('sans montant ⇒ pas de mise en avant du réglage', () => {
|
||||
const verdict = triage(mkInput(), mkCtx(), mkSettings())
|
||||
|
||||
expect(verdict.parametricHint).toBeUndefined()
|
||||
expect(verdict.alternatives[0]?.label).not.toBe(PARAMETRIC_ALT)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Anti-lexicon guard (BLUEPRINT-V2.md Δ22).
|
||||
*
|
||||
* Two sweeps, both case- and accent-insensitive (NFD normalization):
|
||||
* 1. Every UI-visible VALUE exported by app/lexicon.ts — strings, Record
|
||||
* values, array items, and the return of template functions called with
|
||||
* dummy params. Export NAMES and Record KEYS are code identifiers and
|
||||
* are NOT tested (e.g. REVIEW_VERDICTS is a legal identifier even
|
||||
* though « verdict » is forbidden in UI text).
|
||||
* 2. The <template> section of every MARKED .vue file under app/.
|
||||
* A file is scanned only when it carries the magic marker comment
|
||||
* <!-- ld-v2 -->. EVERY v2 screen MUST carry this marker (the screen
|
||||
* agents add it); unmarked v1 leftovers are ignored on purpose — they
|
||||
* are scheduled for replacement, not for compliance.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as lexicon from '../app/lexicon'
|
||||
import { FORBIDDEN_UI_TERMS } from '../app/lexicon'
|
||||
|
||||
const APP_DIR = fileURLToPath(new URL('../app', import.meta.url))
|
||||
|
||||
/** Lowercase + strip diacritics: « Délégué » → « delegue ». */
|
||||
function normalize(text: string): string {
|
||||
return text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
const NORMALIZED_TERMS = FORBIDDEN_UI_TERMS.map(term => ({ term, needle: normalize(term) }))
|
||||
|
||||
/** Forbidden terms contained in a text, after normalization. */
|
||||
function forbiddenTermsIn(text: string): string[] {
|
||||
const haystack = normalize(text)
|
||||
return NORMALIZED_TERMS.filter(({ needle }) => haystack.includes(needle)).map(({ term }) => term)
|
||||
}
|
||||
|
||||
interface Violation {
|
||||
source: string // export path or file path
|
||||
term: string
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
/** Call a template function with dummy params (numbers interpolate cleanly
|
||||
* into template literals AND support toLocaleString; fall back to neutral
|
||||
* strings for functions that require them). */
|
||||
function callTemplateFn(fn: (...args: never[]) => unknown): unknown {
|
||||
const arity = Math.max(fn.length, 1)
|
||||
try {
|
||||
return fn(...(Array.from({ length: arity }, (_, i) => i + 1) as never[]))
|
||||
} catch {
|
||||
return fn(...(Array.from({ length: arity }, () => 'exemple') as never[]))
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively collect forbidden-term violations over exported VALUES only. */
|
||||
function walkValue(value: unknown, path: string, violations: Violation[]): void {
|
||||
if (typeof value === 'string') {
|
||||
for (const term of forbiddenTermsIn(value)) {
|
||||
violations.push({ source: path, term, excerpt: value })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, i) => walkValue(item, `${path}[${i}]`, violations))
|
||||
return
|
||||
}
|
||||
if (typeof value === 'function') {
|
||||
walkValue(callTemplateFn(value as (...args: never[]) => unknown), `${path}(…)`, violations)
|
||||
return
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
// Record: VALUES only — keys are code identifiers.
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
walkValue(item, `${path}.${key}`, violations)
|
||||
}
|
||||
}
|
||||
// numbers / booleans / null / undefined: nothing to check
|
||||
}
|
||||
|
||||
/** Recursive .vue listing under dir. */
|
||||
function listVueFiles(dir: string): string[] {
|
||||
const files: string[] = []
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) files.push(...listVueFiles(full))
|
||||
else if (entry.isFile() && entry.name.endsWith('.vue')) files.push(full)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the <template> section of every .vue file under `dir` that carries
|
||||
* the magic marker comment (default <!-- ld-v2 -->). The whole template is
|
||||
* scanned on purpose — the banned vocabulary must appear NOWHERE in it,
|
||||
* bound attributes included. Every v2 screen MUST carry the marker.
|
||||
*/
|
||||
export function scanVueTemplates(dir: string, marker = 'ld-v2'): Violation[] {
|
||||
const markerRe = new RegExp(`<!--\\s*${marker}\\s*-->`)
|
||||
const violations: Violation[] = []
|
||||
for (const file of listVueFiles(dir)) {
|
||||
const content = readFileSync(file, 'utf-8')
|
||||
if (!markerRe.test(content)) continue
|
||||
const start = content.indexOf('<template')
|
||||
const end = content.lastIndexOf('</template>')
|
||||
if (start === -1 || end === -1) continue
|
||||
const template = content.slice(start, end + '</template>'.length)
|
||||
for (const term of forbiddenTermsIn(template)) {
|
||||
violations.push({ source: file, term, excerpt: `terme présent dans <template>` })
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
function formatReport(violations: Violation[]): string {
|
||||
return violations
|
||||
.map(v => `${v.source} → terme interdit « ${v.term} » : ${v.excerpt}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
describe('anti-lexique — lexicon.ts', () => {
|
||||
it('exports at least the known label tables (sanity check of the walk)', () => {
|
||||
expect(Object.keys(lexicon).length).toBeGreaterThan(10)
|
||||
expect(typeof lexicon.thresholdSentence).toBe('function')
|
||||
})
|
||||
|
||||
it('no exported UI value contains a forbidden term', () => {
|
||||
const violations: Violation[] = []
|
||||
for (const [name, value] of Object.entries(lexicon)) {
|
||||
if (name === 'FORBIDDEN_UI_TERMS') continue // the ban list itself
|
||||
walkValue(value, name, violations)
|
||||
}
|
||||
expect(formatReport(violations)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('anti-lexique — marked .vue templates (<!-- ld-v2 -->)', () => {
|
||||
it('no marked template contains a forbidden term', () => {
|
||||
const violations = scanVueTemplates(APP_DIR)
|
||||
expect(formatReport(violations)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,622 @@
|
||||
/**
|
||||
* Seed « Atelier du Canal » — LE test permanent de la marque blanche
|
||||
* (BLUEPRINT-V2.md § Seeds, DEUXIÈME COLLECTIF).
|
||||
*
|
||||
* Chaque état de l'UI v2 doit y trouver sa donnée vivante : tous les
|
||||
* DecisionStatus (y compris 'transmitted'), une session open ET une session
|
||||
* frozen à cristalliser, une consignation, un dossier découpé pondéré,
|
||||
* le paramétrique d'exemple calculable (validé par le moteur réel),
|
||||
* l'accord explicite (Assent), la frontière suspensive, et un Pacte à
|
||||
* seuils DIFFÉRENTS des défauts — résolu par resolveSettings.
|
||||
*
|
||||
* Repère temporel : bundle.exportedAt (2026-08-11) — les comparaisons de
|
||||
* dates sont lexicales sur ISO 8601 UTC, donc déterministes pour toujours.
|
||||
*/
|
||||
import type {
|
||||
Bundle,
|
||||
Decision,
|
||||
DecisionRoute,
|
||||
DecisionStatus,
|
||||
ParamSpec,
|
||||
Reversibility,
|
||||
TriageRule,
|
||||
Vote,
|
||||
VoteSession,
|
||||
Weight,
|
||||
} from '../../app/types/domain'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import raw from '../../app/data/seeds/atelier-du-canal.bundle.json'
|
||||
import {
|
||||
crystallize,
|
||||
medianByElement,
|
||||
resolveDerived,
|
||||
validateParamSpec,
|
||||
validateVote,
|
||||
} from '../../app/engine/parametric'
|
||||
import { resolveSettings } from '../../app/engine/settings'
|
||||
|
||||
const bundle = raw as unknown as Bundle
|
||||
const REF = bundle.exportedAt // '2026-08-11T12:00:00.000Z'
|
||||
|
||||
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
const ALL_STATUSES: DecisionStatus[] = [
|
||||
'draft', 'advice', 'objection', 'framing', 'voting',
|
||||
'adopted', 'rejected', 'revoked', 'transmitted', 'closed',
|
||||
]
|
||||
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'transmit', 'advice', 'collective', 'record']
|
||||
const REVERSIBILITIES: Reversibility[] = ['easy', 'costly', 'irreversible']
|
||||
const WEIGHTS: Weight[] = ['light', 'binding', 'structural']
|
||||
const TRIAGE_RULES: TriageRule[] = ['R-U', 'R0a', 'R0b', 'R0c', 'R2', 'R3', 'R4', 'R5', 'R6']
|
||||
|
||||
const personIds = new Set(bundle.people.map(p => p.id))
|
||||
const circleIds = new Set(bundle.circles.map(c => c.id))
|
||||
const decisionById = new Map(bundle.decisions.map(d => [d.id, d]))
|
||||
const protocolById = new Map(bundle.protocols.map(p => [p.id, p]))
|
||||
const clauseById = new Map(bundle.clauses.map(c => [c.id, c]))
|
||||
|
||||
function decision(titlePart: string): Decision {
|
||||
const found = bundle.decisions.find(d => d.title.includes(titlePart))
|
||||
expect(found, `décision « ${titlePart} » présente`).toBeDefined()
|
||||
return found as Decision
|
||||
}
|
||||
function sessionsOf(decisionId: string): VoteSession[] {
|
||||
return bundle.sessions.filter(s => s.decisionId === decisionId)
|
||||
}
|
||||
function votesOf(sessionId: string): Vote[] {
|
||||
return bundle.votes.filter(v => v.sessionId === sessionId)
|
||||
}
|
||||
|
||||
describe('socle du bundle', () => {
|
||||
it('schemaVersion 2, exportedAt, toutes les collections présentes', () => {
|
||||
expect(bundle.schemaVersion).toBe(2)
|
||||
expect(bundle.exportedAt).toMatch(ISO_RE)
|
||||
const collections = [
|
||||
'people', 'circles', 'decisions', 'concerns', 'objections', 'advices',
|
||||
'assents', 'mandates', 'docs', 'clauses', 'versions', 'protocols',
|
||||
'sessions', 'votes',
|
||||
] as const
|
||||
for (const key of collections) {
|
||||
expect(Array.isArray(bundle[key]), `${key} est un tableau`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('le collectif : slug, gabarit association, transparent, Pacte et racine reliés', () => {
|
||||
const c = bundle.collective
|
||||
expect(c.slug).toBe('atelier-du-canal')
|
||||
expect(c.template).toBe('association')
|
||||
expect(c.isTransparent).toBe(true)
|
||||
const pact = bundle.docs.find(d => d.id === c.pactDocId)
|
||||
expect(pact?.role).toBe('pact')
|
||||
expect(bundle.circles.some(circle => circle.id === c.rootCircleId)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('identifiants et horloges', () => {
|
||||
const entities = [
|
||||
bundle.collective,
|
||||
...bundle.people, ...bundle.circles, ...bundle.decisions, ...bundle.concerns,
|
||||
...bundle.objections, ...bundle.advices, ...bundle.assents, ...bundle.mandates,
|
||||
...bundle.docs, ...bundle.clauses, ...bundle.versions, ...bundle.protocols,
|
||||
...bundle.sessions, ...bundle.votes,
|
||||
]
|
||||
|
||||
it('tous les ids sont des UUID v4-like, sans préfixe lisible, uniques', () => {
|
||||
const ids = entities.map(e => e.id)
|
||||
for (const id of ids) expect(id).toMatch(UUID_V4_RE)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('collectiveId partout, createdAt/updatedAt partout', () => {
|
||||
for (const e of entities) {
|
||||
if (e !== bundle.collective) {
|
||||
expect((e as { collectiveId: string }).collectiveId).toBe(bundle.collective.id)
|
||||
}
|
||||
expect(e.createdAt).toMatch(ISO_RE)
|
||||
expect(e.updatedAt).toMatch(ISO_RE)
|
||||
expect(e.createdAt <= e.updatedAt).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('personnes et cercles', () => {
|
||||
it('12 personnes, isMe sur AUCUNE, 3-4 attributs heures/mois', () => {
|
||||
expect(bundle.people).toHaveLength(12)
|
||||
expect(bundle.people.every(p => p.isMe === false)).toBe(true)
|
||||
const withHours = bundle.people.filter(p => typeof p.attributes?.['heures/mois'] === 'number')
|
||||
expect(withHours.length).toBeGreaterThanOrEqual(3)
|
||||
expect(withHours.length).toBeLessThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('cercles typés : racine complète, team ×4, theme ×7, place ×5, membres valides', () => {
|
||||
const root = bundle.circles.find(c => c.id === bundle.collective.rootCircleId)
|
||||
expect(root?.memberIds).toHaveLength(12)
|
||||
const byKind = (kind: string) => bundle.circles.filter(c => c.kind === kind)
|
||||
expect(byKind('team')).toHaveLength(1)
|
||||
expect(byKind('team')[0].memberIds).toHaveLength(4)
|
||||
expect(byKind('theme')).toHaveLength(1)
|
||||
expect(byKind('theme')[0].memberIds).toHaveLength(7)
|
||||
expect(byKind('place')).toHaveLength(1)
|
||||
expect(byKind('place')[0].memberIds).toHaveLength(5)
|
||||
for (const circle of bundle.circles) {
|
||||
for (const m of circle.memberIds) expect(personIds.has(m)).toBe(true)
|
||||
if (circle.id !== bundle.collective.rootCircleId) {
|
||||
expect(circle.parentCircleId).toBe(bundle.collective.rootCircleId)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('décisions — enums, références, couverture des états', () => {
|
||||
it('chaque décision est structurellement valide', () => {
|
||||
for (const d of bundle.decisions) {
|
||||
expect(personIds.has(d.authorId), `auteur de « ${d.title} »`).toBe(true)
|
||||
expect(ROUTES).toContain(d.route)
|
||||
expect(ALL_STATUSES).toContain(d.status)
|
||||
expect(REVERSIBILITIES).toContain(d.reversibility)
|
||||
expect(WEIGHTS).toContain(d.weight)
|
||||
expect(TRIAGE_RULES).toContain(d.triageRule)
|
||||
expect(['private', 'scope', 'collective']).toContain(d.visibility)
|
||||
expect(Array.isArray(d.tags)).toBe(true)
|
||||
expect(Array.isArray(d.stewardIds)).toBe(true)
|
||||
expect(Array.isArray(d.measurerIds)).toBe(true)
|
||||
for (const cid of d.scope.circleIds) expect(circleIds.has(cid)).toBe(true)
|
||||
for (const pid of d.scope.personIds) expect(personIds.has(pid)).toBe(true)
|
||||
if (d.parentDecisionId) {
|
||||
expect(decisionById.has(d.parentDecisionId)).toBe(true)
|
||||
expect(d.chainKind).toBeDefined()
|
||||
}
|
||||
if (d.protocolId) expect(protocolById.has(d.protocolId)).toBe(true)
|
||||
if (d.amendsClauseId) expect(clauseById.has(d.amendsClauseId)).toBe(true)
|
||||
if (d.status === 'adopted' || d.status === 'closed') {
|
||||
expect(d.decidedAt, `decidedAt de « ${d.title} »`).toBeDefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('TOUS les DecisionStatus sont couverts, transmitted inclus', () => {
|
||||
const present = new Set(bundle.decisions.map(d => d.status))
|
||||
for (const status of ALL_STATUSES) {
|
||||
expect(present.has(status), `état « ${status} » couvert`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('au moins une consignation route record, adoptée immédiatement, decidedHow posé', () => {
|
||||
const records = bundle.decisions.filter(d => d.route === 'record')
|
||||
expect(records.length).toBeGreaterThanOrEqual(1)
|
||||
for (const r of records) {
|
||||
expect(r.status).toBe('adopted')
|
||||
expect(r.decidedHow?.length).toBeGreaterThan(0)
|
||||
}
|
||||
expect(decision('marché du samedi').decidedHow).toContain('comme d’habitude')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions — corpus, fenêtres, cohérence des états', () => {
|
||||
it('une session open et une session frozen existent', () => {
|
||||
expect(bundle.sessions.filter(s => s.status === 'open').length).toBeGreaterThanOrEqual(1)
|
||||
expect(bundle.sessions.filter(s => s.status === 'frozen')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('chaque session : W = |corpus|, corpus ⊆ personnes, concerné·es beforeSnapshot', () => {
|
||||
for (const s of bundle.sessions) {
|
||||
expect(s.corpusSize).toBe(s.corpusPersonIds.length)
|
||||
expect(s.corpusSize).toBeGreaterThan(0)
|
||||
expect(protocolById.has(s.protocolId)).toBe(true)
|
||||
expect(decisionById.has(s.decisionId)).toBe(true)
|
||||
expect(s.opensAt < s.closesAt).toBe(true)
|
||||
for (const pid of s.corpusPersonIds) {
|
||||
expect(personIds.has(pid)).toBe(true)
|
||||
const concern = bundle.concerns.find(
|
||||
c => c.decisionId === s.decisionId && c.personId === pid && c.beforeSnapshot,
|
||||
)
|
||||
expect(concern, `concern beforeSnapshot pour ${pid}`).toBeDefined()
|
||||
expect(concern!.reason.length).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('chaque vote appartient au corpus arrêté de sa session', () => {
|
||||
for (const v of bundle.votes) {
|
||||
const s = bundle.sessions.find(sess => sess.id === v.sessionId)
|
||||
expect(s, 'session du vote').toBeDefined()
|
||||
expect(s!.corpusPersonIds).toContain(v.voterId)
|
||||
}
|
||||
})
|
||||
|
||||
it('statut des décisions cohérent avec leurs sessions', () => {
|
||||
for (const s of bundle.sessions) {
|
||||
const d = decisionById.get(s.decisionId)!
|
||||
if (s.status === 'open') {
|
||||
expect(s.closesAt > REF, `session ouverte non échue (« ${d.title} »)`).toBe(true)
|
||||
expect(d.status).toBe('voting')
|
||||
}
|
||||
if (s.status === 'frozen') {
|
||||
expect(s.closesAt < REF).toBe(true)
|
||||
expect(d.status).toBe('voting')
|
||||
}
|
||||
if (s.status === 'closed') {
|
||||
expect(s.outcome).toBeDefined()
|
||||
expect(['adopted', 'rejected', 'closed', 'revoked']).toContain(d.status)
|
||||
}
|
||||
}
|
||||
for (const d of bundle.decisions.filter(dd => dd.status === 'voting')) {
|
||||
const alive = sessionsOf(d.id).filter(s => s.status === 'open' || s.status === 'frozen')
|
||||
expect(alive, `« ${d.title} » en voting a sa session vivante`).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('votes : exactement un de value/values/choicePersonId — sauf blanc d’élection', () => {
|
||||
for (const v of bundle.votes) {
|
||||
const method = protocolById.get(bundle.sessions.find(s => s.id === v.sessionId)!.protocolId)!.method
|
||||
const set = [v.value !== undefined, v.values !== undefined, v.choicePersonId !== undefined]
|
||||
.filter(Boolean).length
|
||||
if (set === 0) {
|
||||
expect(method, 'seul un blanc d’élection peut ne rien porter').toBe('election')
|
||||
}
|
||||
else {
|
||||
expect(set).toBe(1)
|
||||
}
|
||||
if (v.value === 0 || v.value === 1 || v.value === 'against') {
|
||||
expect(v.comment, 'commentaire obligatoire sur vote négatif').toBeDefined()
|
||||
expect(v.comment!.length).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('le paramétrique d’exemple — « Répartir le budget d’ateliers 2026 »', () => {
|
||||
const d = decision('Répartir le budget d’ateliers 2026')
|
||||
const spec = d.paramSpec as ParamSpec
|
||||
const session = sessionsOf(d.id)[0]
|
||||
const votes = votesOf(session.id)
|
||||
|
||||
it('spec sum100 : 3 parts votées nommées + EXACTEMENT 1 part dérivée « Réserve » [0,30]', () => {
|
||||
expect(spec.constraint).toBe('sum100')
|
||||
expect(() => validateParamSpec(spec)).not.toThrow()
|
||||
const derived = spec.params.filter(p => p.derived === true)
|
||||
expect(derived).toHaveLength(1)
|
||||
expect(derived[0].label).toBe('Réserve')
|
||||
expect(derived[0].min).toBe(0)
|
||||
expect(derived[0].max).toBe(30)
|
||||
const voted = spec.params.filter(p => !p.derived)
|
||||
expect(voted).toHaveLength(3)
|
||||
expect(voted.every(p => p.kind === 'share')).toBe(true)
|
||||
expect(spec.impactAttrKey).toBe('heures/mois')
|
||||
})
|
||||
|
||||
it('la décision connexe de ressources et la session ouverte sont posées', () => {
|
||||
expect(d.resources).toEqual({ note: 'budget annuel ateliers', amount: 1200, unit: '€' })
|
||||
expect(session.status).toBe('open')
|
||||
expect(session.closesAt > REF).toBe(true)
|
||||
expect(d.status).toBe('voting')
|
||||
})
|
||||
|
||||
it('5-6 votes VALIDES de 3 valeurs — la réserve dérivée reste dans [0,30] pour CHAQUE vote', () => {
|
||||
expect(votes.length).toBeGreaterThanOrEqual(5)
|
||||
expect(votes.length).toBeLessThanOrEqual(6)
|
||||
for (const v of votes) {
|
||||
expect(v.values).toHaveLength(3)
|
||||
expect(() => validateVote(spec, v.values!)).not.toThrow()
|
||||
const full = resolveDerived(spec, v.values!)
|
||||
const reserve = full[spec.params.findIndex(p => p.derived === true)]
|
||||
expect(reserve).toBeGreaterThanOrEqual(0)
|
||||
expect(reserve).toBeLessThanOrEqual(30)
|
||||
expect(full.reduce((a, b) => a + b, 0)).toBeCloseTo(100)
|
||||
}
|
||||
})
|
||||
|
||||
it('faisceau, médiane basse et cristallisation sont calculables', () => {
|
||||
const values = votes.map(v => v.values!)
|
||||
const median = medianByElement(values)
|
||||
expect(median).toHaveLength(3)
|
||||
// Médiane basse = une position réellement votée, curseur par curseur.
|
||||
median.forEach((m, j) => {
|
||||
expect(values.some(v => v[j] === m)).toBe(true)
|
||||
})
|
||||
const crystal = crystallize(spec, values)
|
||||
expect(crystal).toHaveLength(4)
|
||||
expect(crystal.reduce((a, b) => a + b, 0)).toBeCloseTo(100)
|
||||
const reserve = crystal[spec.params.findIndex(p => p.derived === true)]
|
||||
expect(reserve).toBeGreaterThanOrEqual(0)
|
||||
expect(reserve).toBeLessThanOrEqual(30)
|
||||
// « Pour moi » : l'attribut déclaré existe chez au moins un membre du corpus.
|
||||
const corpusAttrs = session.corpusPersonIds
|
||||
.map(pid => bundle.people.find(p => p.id === pid)?.attributes?.['heures/mois'])
|
||||
.filter((h): h is number => typeof h === 'number')
|
||||
expect(corpusAttrs.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('la session figée — la carte « À cristalliser » du Fil', () => {
|
||||
const frozen = bundle.sessions.find(s => s.status === 'frozen')!
|
||||
const d = decisionById.get(frozen.decisionId)!
|
||||
|
||||
it('closesAt passé, votes figés valides, PAS de crystallizedById, garant posé', () => {
|
||||
expect(frozen.closesAt < REF).toBe(true)
|
||||
expect(frozen.crystallizedById).toBeUndefined()
|
||||
expect(frozen.crystallizedAt).toBeUndefined()
|
||||
expect(d.stewardIds.length).toBeGreaterThanOrEqual(1)
|
||||
expect(protocolById.get(frozen.protocolId)!.method).toBe('parametric')
|
||||
const votes = votesOf(frozen.id)
|
||||
expect(votes.length).toBeGreaterThanOrEqual(3)
|
||||
const spec = d.paramSpec as ParamSpec
|
||||
for (const v of votes) {
|
||||
expect(() => validateVote(spec, v.values!)).not.toThrow()
|
||||
}
|
||||
expect(medianByElement(votes.map(v => v.values!))).toHaveLength(spec.params.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fenêtres d’objection — accord explicite et frontière', () => {
|
||||
it('l’accord explicite : décision non-easy en fenêtre avec ≥1 Assent d’un concerné ≠ auteur', () => {
|
||||
const d = decision('lave-linge')
|
||||
expect(d.status).toBe('objection')
|
||||
expect(d.reversibility).not.toBe('easy')
|
||||
expect(d.windowEndsAt! > REF).toBe(true)
|
||||
const assents = bundle.assents.filter(a => a.decisionId === d.id && a.personId !== d.authorId)
|
||||
expect(assents.length).toBeGreaterThanOrEqual(1)
|
||||
const concerned = bundle.concerns.some(
|
||||
c => c.decisionId === d.id && c.personId === assents[0].personId,
|
||||
)
|
||||
expect(concerned).toBe(true)
|
||||
expect(bundle.assents.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('la frontière suspend : windowSuspendedAt posé + objection boundary ouverte', () => {
|
||||
const d = decision('serrure connectée')
|
||||
expect(d.status).toBe('objection')
|
||||
expect(d.windowSuspendedAt).toBeDefined()
|
||||
const boundary = bundle.objections.find(
|
||||
o => o.decisionId === d.id && o.kind === 'boundary' && o.status === 'open',
|
||||
)
|
||||
expect(boundary).toBeDefined()
|
||||
expect(boundary!.argument.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('la fenêtre d’avis est ouverte avec 2 avis déposés', () => {
|
||||
const d = decision('façade')
|
||||
expect(d.status).toBe('advice')
|
||||
expect(d.windowEndsAt! > REF).toBe(true)
|
||||
const advices = bundle.advices.filter(a => a.decisionId === d.id)
|
||||
expect(advices).toHaveLength(2)
|
||||
expect(new Set(advices.map(a => a.position)).size).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formulation — 2 contre-propositions avec diff sur le Règlement', () => {
|
||||
const d = decision('Réviser l’accueil')
|
||||
|
||||
it('framing, amendsClauseId sur une clause du Règlement intérieur, 2 versions proposées distinctes', () => {
|
||||
expect(d.status).toBe('framing')
|
||||
const clause = clauseById.get(d.amendsClauseId!)!
|
||||
const ri = bundle.docs.find(doc => doc.role === 'reference')!
|
||||
expect(clause.docId).toBe(ri.id)
|
||||
const proposed = bundle.versions.filter(v => v.decisionId === d.id && v.status === 'proposed')
|
||||
expect(proposed).toHaveLength(2)
|
||||
expect(proposed[0].clauseId).toBe(clause.id)
|
||||
expect(proposed[1].clauseId).toBe(clause.id)
|
||||
expect(proposed[0].content).not.toBe(proposed[1].content)
|
||||
const current = bundle.versions.find(v => v.id === clause.currentVersionId)!
|
||||
expect(proposed.every(p => p.content !== current.content)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('le Pacte résolu — des seuils DIFFÉRENTS des défauts', () => {
|
||||
const settings = resolveSettings(bundle.clauses, bundle.versions)
|
||||
|
||||
it('seuils propres : smallGroupMax 4, fenêtres 24/48, requireEffects binding', () => {
|
||||
expect(settings.triage.smallGroupMax).toBe(4)
|
||||
expect(settings.triage.objectionWindowHours).toBe(24)
|
||||
expect(settings.triage.adviceWindowHours).toBe(48)
|
||||
expect(settings.triage.requireEffects).toBe('binding')
|
||||
// Les autres clés restent aux défauts, mais VOTÉES (clauses présentes).
|
||||
expect(settings.triage.collectiveMin).toBe(50)
|
||||
expect(settings.triage.consentMax).toBe(7)
|
||||
expect(settings.triage.framingDays).toBe(14)
|
||||
expect(settings.triage.reviewDelayDays).toBe(90)
|
||||
})
|
||||
|
||||
it('protocolByRange.large → le protocole NUANCÉ (la démonstration anti-binaire)', () => {
|
||||
const large = protocolById.get(settings.protocolByRange.large!)
|
||||
expect(large?.method).toBe('nuanced')
|
||||
expect(bundle.protocols.some(p => p.method === 'binary')).toBe(false)
|
||||
})
|
||||
|
||||
it('le protocole Consentement OBLIGATOIRE est résolu, tous les autres aussi', () => {
|
||||
const consent = protocolById.get(settings.protocolByRange.consent)
|
||||
expect(consent?.method).toBe('consent')
|
||||
expect(consent?.durationDays).toBe(7)
|
||||
expect(protocolById.get(settings.protocolByRange.nuanced!)?.method).toBe('nuanced')
|
||||
expect(protocolById.get(settings.protocolByRange.parametric!)?.method).toBe('parametric')
|
||||
expect(protocolById.get(settings.protocolByRange.election!)?.method).toBe('election')
|
||||
})
|
||||
|
||||
it('les protocoles portent les seuils demandés, tous ballot open', () => {
|
||||
const nuanced = bundle.protocols.find(p => p.method === 'nuanced')!
|
||||
expect(nuanced.formula.nuancedMinParticipants).toBe(4)
|
||||
expect(nuanced.formula.nuancedThresholdPct).toBe(60)
|
||||
const parametric = bundle.protocols.find(p => p.method === 'parametric')!
|
||||
expect(parametric.formula.parametricMinParticipants).toBe(4)
|
||||
const election = bundle.protocols.find(p => p.method === 'election')!
|
||||
expect(election.formula.electionMinParticipants).toBe(5)
|
||||
expect(election.formula.tieBreak).toBe('runoff')
|
||||
expect(bundle.protocols.every(p => p.ballot === 'open')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('les textes — toute clause naît d’une décision', () => {
|
||||
it('chaque clause courante a sa version current ET sa décision fondatrice adoptée', () => {
|
||||
for (const clause of bundle.clauses) {
|
||||
const current = bundle.versions.find(
|
||||
v => v.id === clause.currentVersionId && v.clauseId === clause.id && v.status === 'current',
|
||||
)
|
||||
expect(current, `version courante de ${clause.code}`).toBeDefined()
|
||||
const founding = decisionById.get(current!.decisionId)
|
||||
expect(founding, `décision fondatrice de ${clause.code}`).toBeDefined()
|
||||
expect(['adopted', 'closed']).toContain(founding!.status)
|
||||
expect(current!.adoptedAt).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('le Règlement intérieur : 8 clauses réparties sur les 4 presets d’inertie', () => {
|
||||
const ri = bundle.docs.find(doc => doc.role === 'reference')!
|
||||
const riClauses = bundle.clauses.filter(c => c.docId === ri.id)
|
||||
expect(riClauses).toHaveLength(8)
|
||||
const presets = new Set(riClauses.map(c => c.inertia))
|
||||
expect(presets).toEqual(new Set(['low', 'standard', 'high', 'max']))
|
||||
})
|
||||
|
||||
it('le Pacte : A1, préambule, et les 10 clés de triage en settingKey', () => {
|
||||
const pactClauses = bundle.clauses.filter(c => c.docId === bundle.collective.pactDocId)
|
||||
expect(pactClauses.some(c => c.code === 'A1' && c.title === 'Notre finalité')).toBe(true)
|
||||
expect(pactClauses.some(c => c.section === 'Préambule')).toBe(true)
|
||||
const keys = new Set(pactClauses.map(c => c.settingKey).filter(Boolean))
|
||||
for (const k of [
|
||||
'triage.smallGroupMax', 'triage.collectiveMin', 'triage.consentMax',
|
||||
'triage.objectionWindowHours', 'triage.adviceWindowHours', 'triage.framingDays',
|
||||
'triage.concernEscalateRatio', 'triage.recurrenceThreshold', 'triage.reviewDelayDays',
|
||||
'triage.requireEffects',
|
||||
]) {
|
||||
expect(keys.has(k), `clé ${k} votée au Pacte`).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('le mandat Trésorerie — les feux de la rampe', () => {
|
||||
const mandate = bundle.mandates.find(m => m.title === 'Trésorerie')!
|
||||
|
||||
it('actif, titulaire au Bureau, cercle électeur = racine, né d’une décision adoptée', () => {
|
||||
expect(mandate.status).toBe('active')
|
||||
const bureau = bundle.circles.find(c => c.kind === 'team')!
|
||||
expect(bureau.memberIds).toContain(mandate.holderId)
|
||||
expect(mandate.electorCircleId).toBe(bundle.collective.rootCircleId)
|
||||
const origin = decisionById.get(mandate.originDecisionId)!
|
||||
expect(origin.status).toBe('adopted')
|
||||
expect(origin.createsMandate?.title).toBe('Trésorerie')
|
||||
expect(mandate.nominationMethod).toBe('election-no-candidate')
|
||||
})
|
||||
|
||||
it('1 rapport rendu + 1 rapport dû non rendu', () => {
|
||||
const delivered = mandate.reports.filter(r => r.deliveredAt)
|
||||
const due = mandate.reports.filter(r => !r.deliveredAt && r.dueAt < REF)
|
||||
expect(delivered).toHaveLength(1)
|
||||
expect(delivered[0].content!.length).toBeGreaterThan(0)
|
||||
expect(due).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('2 décisions prises sous mandat : une adoptée à fenêtre passée, une en fenêtre', () => {
|
||||
const under = bundle.decisions.filter(d => d.underMandateId === mandate.id)
|
||||
expect(under).toHaveLength(2)
|
||||
const adopted = under.find(d => d.status === 'adopted')!
|
||||
expect(adopted.windowEndsAt! < REF).toBe(true)
|
||||
expect(adopted.decidedAt).toBeDefined()
|
||||
const inWindow = under.find(d => d.status === 'objection')!
|
||||
expect(inWindow.windowEndsAt! > REF).toBe(true)
|
||||
expect(under.every(d => d.route === 'mandate')).toBe(true)
|
||||
})
|
||||
|
||||
it('la décision créatrice a été élue sans candidat, blanc compris', () => {
|
||||
const origin = decisionById.get(mandate.originDecisionId)!
|
||||
const session = sessionsOf(origin.id)[0]
|
||||
expect(session.status).toBe('closed')
|
||||
expect(session.outcome).toBe('adopted')
|
||||
const votes = votesOf(session.id)
|
||||
expect(votes.length).toBeGreaterThanOrEqual(5) // quorum electionMinParticipants
|
||||
const blank = votes.filter(v => !v.value && !v.values && !v.choicePersonId)
|
||||
expect(blank).toHaveLength(1)
|
||||
const designations = votes.filter(v => v.choicePersonId === mandate.holderId)
|
||||
expect(designations.length).toBeGreaterThan(votes.length / 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('le dossier découpé — décision incrémentielle', () => {
|
||||
const parent = decision('Réaménager le local du quai')
|
||||
const elements = bundle.decisions.filter(
|
||||
d => d.parentDecisionId === parent.id && d.chainKind === 'element',
|
||||
)
|
||||
|
||||
it('parent structural en framing, 2 éléments : 1 adopté (terminal), 1 en voting', () => {
|
||||
expect(parent.status).toBe('framing')
|
||||
expect(parent.weight).toBe('structural')
|
||||
expect(parent.stewardIds.length).toBeGreaterThanOrEqual(1) // le garant qui clora
|
||||
expect(elements).toHaveLength(2)
|
||||
expect(elements.filter(e => e.status === 'adopted')).toHaveLength(1)
|
||||
expect(elements.filter(e => e.status === 'voting')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('les enjeux sont pondérés : Concern.priority 0-3 posés par 3-4 personnes et par élément', () => {
|
||||
for (const element of elements) {
|
||||
const weighted = bundle.concerns.filter(
|
||||
c => c.decisionId === element.id && c.priority !== undefined,
|
||||
)
|
||||
expect(weighted.length).toBeGreaterThanOrEqual(3)
|
||||
expect(weighted.length).toBeLessThanOrEqual(4)
|
||||
for (const c of weighted) {
|
||||
expect([0, 1, 2, 3]).toContain(c.priority)
|
||||
}
|
||||
}
|
||||
// La palette 0-3 est réellement utilisée à travers le dossier.
|
||||
const used = new Set(
|
||||
bundle.concerns
|
||||
.filter(c => elements.some(e => e.id === c.decisionId) && c.priority !== undefined)
|
||||
.map(c => c.priority),
|
||||
)
|
||||
expect(used).toEqual(new Set([0, 1, 2, 3]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('la boucle apprenante', () => {
|
||||
it('l’épreuve du réel DUE : review échue sans verdict, matière ciblée, ressources, paire garant/mesureur', () => {
|
||||
const d = decision('poêle à granulés')
|
||||
expect(d.status).toBe('adopted')
|
||||
expect(d.review?.dueAt).toBeDefined()
|
||||
expect(d.review!.dueAt < REF).toBe(true)
|
||||
expect(d.review!.verdict).toBeUndefined()
|
||||
expect(d.brief!.effects.length).toBeGreaterThanOrEqual(1)
|
||||
expect(d.brief!.effects.some(e => e.target)).toBe(true) // structural ⇒ cible mesurable
|
||||
expect(d.resources).toMatchObject({ amount: 2400, unit: '€' })
|
||||
expect(d.stewardIds.length).toBeGreaterThanOrEqual(1)
|
||||
expect(d.measurerIds.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('révoquée avec sa chaîne : enfant revocation adoptée — « ce qu’on en a appris »', () => {
|
||||
const revoked = bundle.decisions.find(d => d.status === 'revoked')!
|
||||
const child = bundle.decisions.find(
|
||||
d => d.parentDecisionId === revoked.id && d.chainKind === 'revocation',
|
||||
)!
|
||||
expect(child.status).toBe('adopted')
|
||||
expect(revoked.review?.verdict).toBe('revoke')
|
||||
})
|
||||
|
||||
it('conservatoire ratifiée : urgent:true adoptée + enfant ratification adopté', () => {
|
||||
const urgent = bundle.decisions.find(d => d.urgent && d.status === 'adopted')!
|
||||
const ratification = bundle.decisions.find(
|
||||
d => d.parentDecisionId === urgent.id && d.chainKind === 'ratification',
|
||||
)!
|
||||
expect(ratification.status).toBe('adopted')
|
||||
expect(urgent.reversibility).not.toBe('irreversible') // l’urgence ne contourne pas l’irréversible
|
||||
})
|
||||
|
||||
it('une décision close par l’épreuve du réel confirmée', () => {
|
||||
const closed = bundle.decisions.find(d => d.status === 'closed')!
|
||||
expect(closed.review?.verdict).toBe('confirmed')
|
||||
expect(closed.brief?.effects.some(e => e.measured)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('le vote nuancé en cours — l’histogramme vivant', () => {
|
||||
it('5 votes 0-5, commentaires obligatoires posés sur les 0-1', () => {
|
||||
const d = decision('réseau des ateliers partagés')
|
||||
const session = sessionsOf(d.id).find(s => s.status === 'open')!
|
||||
const votes = votesOf(session.id)
|
||||
expect(votes).toHaveLength(5)
|
||||
for (const v of votes) {
|
||||
expect([0, 1, 2, 3, 4, 5]).toContain(v.value)
|
||||
if (v.value === 0 || v.value === 1) expect(v.comment!.length).toBeGreaterThan(0)
|
||||
}
|
||||
// Distribution nuancée réelle : au moins 4 niveaux distincts.
|
||||
expect(new Set(votes.map(v => v.value)).size).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user