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>
256 lines
8.7 KiB
TypeScript
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([]);
|
|
});
|
|
});
|