// La fusion décide de ce qui est écrit en base : elle est testée pièce par pièce. import { describe, expect, it } from 'vitest' import { computeExtractionDiff, listChangeIds } from '../src/utils/diffExtraction.js' import { countAccepted, mergeExtraction } from '../src/utils/mergeExtraction.js' // Données en base, inspirées d'un compte rendu réel. const previous = { metadata: { document: { reference: '33680000', date: '2026-06-22' }, immeuble: { code: '33689020', adresse: '4 RUE SERVIENT' }, solde: { montant: 11214.51, type: 'crediteur' }, }, situation_locataires: [ { lot: { numero: '0007', type: 'Appartement' }, locataire: { nom: 'LATAPY NINA' }, totaux: { total: 1200, regles: 1200, impayes: 0 }, lignes: [{ type: 'loyer', loyers: 500, total: 500 }], }, { lot: { numero: '0008', type: 'Appartement' }, locataire: { nom: 'BESSON Léa' }, totaux: { total: 800, regles: 800, impayes: 0 }, lignes: [{ type: 'loyer', loyers: 800, total: 800 }], }, ], recapitulatif_operations: [ { categorie: 'DL', fournisseur: 'PPR', description: 'entretien', montants: { debit: 80 } }, { categorie: 'DL', fournisseur: 'TOTAL', description: 'elec', montants: { debit: 120 } }, ], } // Nouvelle extraction : corrige les numéros de lot, mais agrège un libellé dans // le nom du locataire (régression du parser), change des montants, ajoute une // opération et en perd une autre. const next = { metadata: { document: { reference: '33680000', date: '2026-06-22' }, immeuble: { code: '33689020', adresse: '4 R. SERVIENT' }, solde: { montant: 11214.51, type: 'crediteur' }, }, situation_locataires: [ { lot: { numero: '07', type: 'Appartement' }, locataire: { nom: 'LATAPY NINA Complément loyer' }, totaux: { total: 1250, regles: 1200, impayes: 50 }, lignes: [ { type: 'loyer', loyers: 500, total: 500 }, { type: 'complement', loyers: 50, total: 50 }, ], }, { lot: { numero: '08', type: 'Appartement' }, locataire: { nom: 'BESSON Léa' }, totaux: { total: 800, regles: 800, impayes: 0 }, lignes: [{ type: 'loyer', loyers: 800, total: 800 }], }, ], recapitulatif_operations: [ { categorie: 'DL', fournisseur: 'PPR', description: 'entretien', montants: { debit: 80 } }, { categorie: 'DL', fournisseur: 'NOUVEAU', description: 'ajout', montants: { debit: 42 } }, ], } const diff = computeExtractionDiff(previous, next) const merge = (selection) => mergeExtraction(previous, next, diff, selection) describe('découpage du diff', () => { it('sépare les champs identitaires du bloc de montants', () => { const latapy = diff.locataires.find((l) => l.index === 0) expect(latapy.changes.map((c) => c.id)).toEqual([ 'loc:0:locataire.nom', 'loc:0:lot.numero', 'loc:0:montants', ]) // Le bloc de montants porte le détail, mais reste un seul changement. const montants = latapy.changes.at(-1) expect(montants.kind).toBe('montants') expect(montants.fields.length).toBeGreaterThan(1) }) it('marque comme imposée la seule réécriture de forme d’un numéro de lot', () => { // Le backend normalise tout numéro de lot à l'enregistrement : refuser // "0007" → "07" n'aurait aucun effet, la case ne doit pas le laisser croire. const lot = diff.locataires[0].changes.find((c) => c.path === 'lot.numero') expect(lot.forced).toBe(true) expect(lot.editable).toBe(false) const merged = merge({ [lot.id]: { accepted: false } }) expect(merged.situation_locataires[0].lot.numero).toBe('07') }) it('laisse refusable un vrai changement de lot', () => { const autreLot = { ...next, situation_locataires: [ { ...next.situation_locataires[0], lot: { numero: '12', type: 'Appartement' } }, next.situation_locataires[1], ], } const d = computeExtractionDiff(previous, autreLot) const lot = d.locataires[0].changes.find((c) => c.path === 'lot.numero') expect(lot.forced).toBe(false) // "0007" et "12" ne sont pas le même lot const merged = mergeExtraction(previous, autreLot, d, { [lot.id]: { accepted: false } }) expect(merged.situation_locataires[0].lot.numero).toBe('0007') }) it('retient l’index d’origine des opérations supprimées', () => { const removed = diff.operations.find((o) => o.kind === 'removed') expect(removed.oldIndex).toBe(1) // TOTAL / elec }) }) describe('fusion sélective', () => { it('sans sélection, reproduit la nouvelle extraction', () => { expect(merge({})).toEqual(next) }) it('refuse un nom tout en gardant la correction du numéro de lot', () => { // Le cas qui motive la maille fine : la régression sur le nom est écartée, // la correction du lot est conservée, sur le même locataire. const merged = merge({ 'loc:0:locataire.nom': { accepted: false } }) const latapy = merged.situation_locataires[0] expect(latapy.locataire.nom).toBe('LATAPY NINA') expect(latapy.lot.numero).toBe('07') expect(latapy.totaux.total).toBe(1250) // montants acceptés }) it('garde les anciens montants et lignes quand le bloc est refusé', () => { const merged = merge({ 'loc:0:montants': { accepted: false } }) const latapy = merged.situation_locataires[0] expect(latapy.totaux).toEqual(previous.situation_locataires[0].totaux) expect(latapy.lignes).toEqual(previous.situation_locataires[0].lignes) // Le refus des chiffres n'annule pas les champs identitaires acceptés. expect(latapy.lot.numero).toBe('07') expect(latapy.locataire.nom).toBe('LATAPY NINA Complément loyer') }) it('écrit la valeur saisie à la main plutôt que celle proposée', () => { const merged = merge({ 'loc:0:locataire.nom': { accepted: true, value: 'LATAPY Nina' }, 'meta:immeuble.adresse': { accepted: true, value: '4 rue Servient' }, }) expect(merged.situation_locataires[0].locataire.nom).toBe('LATAPY Nina') expect(merged.metadata.immeuble.adresse).toBe('4 rue Servient') }) it('laisse une métadonnée refusée à sa valeur en base', () => { const merged = merge({ 'meta:immeuble.adresse': { accepted: false } }) expect(merged.metadata.immeuble.adresse).toBe('4 RUE SERVIENT') }) it('écarte une opération ajoutée dont on ne veut pas', () => { const added = diff.operations.find((o) => o.kind === 'added') const merged = merge({ [added.id]: { accepted: false } }) expect(merged.recapitulatif_operations.map((o) => o.fournisseur)).toEqual(['PPR']) }) it('réinsère à sa place une opération dont on refuse la suppression', () => { const removed = diff.operations.find((o) => o.kind === 'removed') const merged = merge({ [removed.id]: { accepted: false } }) expect(merged.recapitulatif_operations.map((o) => o.fournisseur)).toEqual([ 'PPR', 'TOTAL', // restaurée à son index d'origine 'NOUVEAU', ]) }) it('ne modifie pas les données d’origine', () => { const avant = JSON.stringify(previous) merge({ 'loc:0:montants': { accepted: false } }) expect(JSON.stringify(previous)).toBe(avant) }) }) describe('locataires ajoutés ou supprimés', () => { const avecAjout = { ...next, situation_locataires: [ ...next.situation_locataires, { lot: { numero: '09' }, locataire: { nom: 'NOUVEAU LOCATAIRE' }, totaux: {}, lignes: [] }, ], } const diffAjout = computeExtractionDiff(previous, avecAjout) it('n’ajoute pas un locataire refusé', () => { const merged = mergeExtraction(previous, avecAjout, diffAjout, { 'loc:2:entier': { accepted: false }, }) expect(merged.situation_locataires).toHaveLength(2) }) it('conserve un locataire dont on refuse la disparition', () => { const sansBesson = { ...next, situation_locataires: next.situation_locataires.slice(0, 1), } const diffSuppr = computeExtractionDiff(previous, sansBesson) const merged = mergeExtraction(previous, sansBesson, diffSuppr, { 'loc:1:entier': { accepted: false }, }) expect(merged.situation_locataires.map((l) => l.locataire.nom)).toEqual([ 'LATAPY NINA Complément loyer', 'BESSON Léa', ]) }) }) describe('comptage pour l’affichage', () => { it('compte les changements retenus', () => { const ids = listChangeIds(diff) expect(countAccepted(ids, {})).toEqual({ accepted: ids.length, total: ids.length }) expect(countAccepted(ids, { [ids[0]]: { accepted: false } }).accepted).toBe(ids.length - 1) }) })