Files
openmind/frontend/src/services/mapRelationSerialization.test.ts
snoopy e3c530348e Phase 2 : mind map bilaterale, relations, templates et Markdown
Format .mmap v4 : `node.side` (cote d'une branche de niveau 1) et
`MindMap.relations` (relations secondaires). Migration v3 -> v4 sans perte, les
cartes v1/v2/v3 restent ouvrables telles quelles.

Une relation est une connexion purement visuelle : elle ne touche jamais
parentId, order, les enfants, le repli ni l'auto-layout. `withEdges()` continue
de deriver les liens de parentId et se contente de retirer les relations dont
une extremite a disparu.

Mind map bilaterale : repartition gloutonne et deterministe des branches selon
la taille des sous-arbres, heritage du cote par sous-arbre, deux passes dagre
recalees sur la racine. Choisir une disposition ne deplace plus la carte : seul
Reorganiser applique.

Ajoute aussi une galerie de huit modeles locaux, l'import Markdown (fichier ou
texte, nouvelle carte ou sous-arbre) reutilisant l'analyseur d'outline existant,
et l'export Markdown de la hierarchie.

Corrige un bug de rendu : React Flow ne remesure pas les poignees quand elles
changent de cote, ce qui faisait disparaitre les liens concernes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 08:12:00 +02:00

256 lines
8.7 KiB
TypeScript

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<string, unknown> {
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<Record<string, unknown>>)[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<Record<string, unknown>>)[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<Record<string, unknown>>)[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<Record<string, unknown>>;
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<Record<string, unknown>>)[0] as Record<string, unknown>;
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<Record<string, unknown>>)[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<string, unknown> {
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([]);
});
});