import { describe, expect, it } from 'vitest'; import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map'; import { MapFormatError, normalizeMapDocument, serializeMap } from '@/services/mapSerialization'; /** Document v4 minimal : deux branches opposees et une relation entre feuilles. */ function documentV4(): Record { return { version: 4, id: 'map-1', name: 'Infrastructure', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', nodes: [ { id: 'root', type: 'root', text: 'Infrastructure', position: { x: 0, y: 0 }, parentId: null }, { id: 'reseau', type: 'standard', text: 'Reseau', position: { x: 240, y: 0 }, parentId: 'root', order: 0, side: 'right', }, { id: 'firewall', type: 'standard', text: 'Firewall', position: { x: 480, y: 0 }, parentId: 'reseau', order: 0, }, { id: 'services', type: 'standard', text: 'Services', position: { x: -240, y: 0 }, parentId: 'root', order: 1, side: 'left', }, { id: 'web', type: 'standard', text: 'Web', position: { x: -480, y: 0 }, parentId: 'services', order: 0, }, ], edges: [], relations: [ { id: 'r-1', sourceId: 'firewall', targetId: 'web', label: 'protege', style: 'bezier', color: '#a855f7', width: 2, dashed: true, markerEnd: 'arrow', }, ], viewport: { x: 0, y: 0, zoom: 1 }, settings: { theme: 'default', layout: 'mindmap-both', branchStyle: 'bezier', showGrid: true }, }; } describe('lecture d un document v4', () => { it('conserve les cotes et les relations', () => { const map = normalizeMapDocument(documentV4(), { source: 'file' }); expect(map.version).toBe(MAP_FORMAT_VERSION); const sides = new Map(map.nodes.map((node) => [node.id, node.side])); expect(sides.get('reseau')).toBe('right'); expect(sides.get('services')).toBe('left'); expect(map.relations).toHaveLength(1); expect(map.relations[0]?.label).toBe('protege'); expect(map.relations[0]?.dashed).toBe(true); expect(map.relations[0]?.markerEnd).toBe('arrow'); expect(map.settings.layout).toBe('mindmap-both'); }); it('ne modifie pas le document fourni', () => { const raw = documentV4(); const before = JSON.stringify(raw); normalizeMapDocument(raw, { source: 'file' }); expect(JSON.stringify(raw)).toBe(before); }); it('est idempotent : renormaliser ne change plus rien', () => { const once = normalizeMapDocument(documentV4(), { source: 'file' }); const twice = normalizeMapDocument(JSON.parse(JSON.stringify(once)), { source: 'file' }); expect(twice.nodes).toEqual(once.nodes); expect(twice.relations).toEqual(once.relations); }); it('survit a un aller-retour complet', () => { const map = normalizeMapDocument(documentV4(), { source: 'file' }); const again = normalizeMapDocument(JSON.parse(serializeMap(map)), { source: 'file' }); expect(again.relations).toEqual(map.relations); expect(again.nodes.map((node) => [node.id, node.side])).toEqual( map.nodes.map((node) => [node.id, node.side]), ); }); }); describe('relations incoherentes', () => { it('retire une relation dont la source n existe pas', () => { const raw = documentV4(); (raw.relations as Array>)[0]!.sourceId = 'fantome'; expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0); }); it('retire une relation dont la cible n existe pas', () => { const raw = documentV4(); (raw.relations as Array>)[0]!.targetId = 'fantome'; expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0); }); it('retire une relation bouclant sur elle-meme', () => { const raw = documentV4(); (raw.relations as Array>)[0]!.targetId = 'firewall'; expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0); }); it('ne garde qu une relation par couple source / cible', () => { const raw = documentV4(); const relations = raw.relations as Array>; relations.push({ ...relations[0], id: 'r-2' }); expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(1); }); it('ignore les champs de style invalides sans perdre la relation', () => { const raw = documentV4(); const relation = (raw.relations as Array>)[0] as Record; relation.style = 'zigzag'; relation.color = 'javascript:alert(1)'; relation.markerEnd = 'etoile'; const parsed = normalizeMapDocument(raw, { source: 'file' }).relations[0]; expect(parsed?.style).toBeUndefined(); expect(parsed?.color).toBeUndefined(); expect(parsed?.markerEnd).toBeUndefined(); expect(parsed?.label).toBe('protege'); }); it('ignore un cote invalide', () => { const raw = documentV4(); (raw.nodes as Array>)[1]!.side = 'haut'; const map = normalizeMapDocument(raw, { source: 'file' }); expect(map.nodes.find((node) => node.id === 'reseau')?.side).toBeUndefined(); }); it('refuse un document depassant la limite de relations', () => { const raw = documentV4(); raw.relations = Array.from({ length: 30 }, (_item, index) => ({ id: `r-${index}`, sourceId: 'firewall', targetId: 'web', })); expect(() => normalizeMapDocument(raw, { source: 'file', maxNodes: 10 })).toThrow( MapFormatError, ); }); }); describe('migrations vers la v4', () => { function legacyDocument(version: number): Record { return { version, id: `map-v${version}`, name: `Carte v${version}`, createdAt: '2025-01-01T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z', nodes: [ { id: 'root', type: 'root', text: 'Racine', position: { x: 0, y: 0 }, parentId: null, style: version === 1 ? { width: 220, backgroundColor: '#ffffff' } : undefined, }, { id: 'a', type: 'standard', text: 'A', position: { x: 200, y: 0 }, parentId: 'root', }, { id: 'b', type: 'standard', text: 'B', position: { x: 200, y: 80 }, parentId: 'root', }, ], edges: [ { id: 'e-root-a', source: 'root', target: 'a' }, { id: 'e-root-b', source: 'root', target: 'b' }, ], viewport: { x: 0, y: 0, zoom: 1 }, settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier' }, }; } it('ouvre une carte v1 et la ramene au format courant', () => { const map = normalizeMapDocument(legacyDocument(1), { source: 'file' }); expect(map.version).toBe(4); expect(map.relations).toEqual([]); // La largeur fixe de la v1 a rejoint layout.width. const root = map.nodes.find((node) => node.id === 'root'); expect(root?.layout?.width).toBe(220); expect(root?.layout?.widthMode).toBe('fixed'); expect(root?.style?.backgroundColor).toBe('#ffffff'); }); it('ouvre une carte v2 en materialisant l ordre puis la v4', () => { const map = normalizeMapDocument(legacyDocument(2), { source: 'file' }); expect(map.version).toBe(4); expect(map.nodes.find((node) => node.id === 'a')?.order).toBe(0); expect(map.nodes.find((node) => node.id === 'b')?.order).toBe(1); expect(map.relations).toEqual([]); }); it('ouvre une carte v3 sans rien inventer', () => { const map = normalizeMapDocument(legacyDocument(3), { source: 'file' }); expect(map.version).toBe(4); expect(map.relations).toEqual([]); // Aucun cote : la carte reste exactement telle qu'elle etait. for (const node of map.nodes) expect(node.side).toBeUndefined(); }); it('accepte une carte historique lue depuis IndexedDB', () => { const stored = legacyDocument(2) as unknown as MindMap; const map = normalizeMapDocument(stored, { source: 'storage', allowEmpty: true }); expect(map.version).toBe(4); expect(map.nodes).toHaveLength(3); expect(map.relations).toEqual([]); }); it('refuse un document plus recent que le format connu', () => { const raw = { ...documentV4(), version: 99 }; expect(() => normalizeMapDocument(raw, { source: 'file' })).toThrow(MapFormatError); }); it('accepte une carte v4 sans champ relations', () => { const raw = documentV4(); delete raw.relations; expect(normalizeMapDocument(raw, { source: 'file' }).relations).toEqual([]); }); });