// 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, computeServerOverrides, 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: '07', type: 'Appartement' }, locataire: { nom: 'LATAPY NINA' }, totaux: { total: 1200, regles: 1200, impayes: 0 }, lignes: [{ type: 'loyer', loyers: 500, total: 500 }], }, { 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: 'TOTAL', description: 'elec', montants: { debit: 120 } }, ], } // Nouvelle extraction : rattache le premier locataire à un autre lot, agrège un // libellé dans son nom (régression du parser), change ses montants, ajoute une // opération et en perd une autre. // Les numéros de lot arrivent des deux côtés sous leur forme normalisée : le // backend les normalise avant de renvoyer les données à comparer. 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: '12', 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('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('12') 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('12') 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('réécritures imposées par l’enregistrement', () => { // Le serveur fournit les données actuelles et leur forme canonique ; le front // en déduit ce qu'un refus ne pourrait pas conserver, sans connaître la règle. const enBase = JSON.parse(JSON.stringify(previous)) enBase.situation_locataires[0].lot.numero = '0007' // écriture héritée const canonique = JSON.parse(JSON.stringify(enBase)) canonique.situation_locataires[0].lot.numero = '07' // ce que le serveur écrirait const d = computeExtractionDiff(enBase, next) it('signale le champ que le serveur réécrira', () => { const overrides = computeServerOverrides(d, enBase, canonique) const lot = d.locataires[0].changes.find((c) => c.path === 'lot.numero') expect(overrides[lot.id]).toBe('07') }) it('ne signale rien pour les champs que le serveur laisse tels quels', () => { const overrides = computeServerOverrides(d, enBase, canonique) const nom = d.locataires[0].changes.find((c) => c.path === 'locataire.nom') expect(nom.id in overrides).toBe(false) expect(Object.keys(overrides)).toHaveLength(1) }) it('ne signale rien quand le serveur ne réécrit rien', () => { expect(computeServerOverrides(diff, previous, previous)).toEqual({}) }) }) 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) }) })