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