Files
openmind/frontend/src/services/mapOrderSerialization.test.ts
snoopy 2afbf637ce Phase 1.2 : productivite de creation
Construire une grande carte demande beaucoup moins de gestes.

Ordre des freres (format v3)
`MindMapNode.order` devient la position parmi les freres. `parentId` reste la
seule source de verite de la relation hierarchique : une source par
preoccupation, jamais deux versions contradictoires. L'ordre est renumerote a
chaque modification structurelle dans `withEdges`, unique point de passage.
Le format passe en v3 : un lecteur v2 ignorerait `order` et reordonnerait
silencieusement la carte. Migration v2 -> v3 depuis la position dans le tableau,
v1 -> v2 -> v3 s'enchainent, et les noeuds sont ecrits en parcours de profondeur
pour rester corrects meme pour un lecteur anterieur.

Creation rapide
- Ajouter plusieurs enfants : une ligne = un noeud, avec apercu avant creation.
- Collage d'une liste multi-ligne sur un noeud : le presse-papiers interne reste
  prioritaire, l'edition de texte n'est jamais interceptee.
- Import d'une arborescence indentee : espaces ou tabulations, puces retirees,
  indentation incoherente signalee avec le numero de ligne plutot que devinee.
- Les nouveaux noeuds sont repartis autour du parent au lieu de se superposer.

Hierarchie
Monter, descendre, placer en premier ou en dernier, remonter d'un niveau,
"Deplacer vers..." avec recherche, suppression d'un noeud en conservant ses
enfants a la place qu'il occupait. Le re-parentage passe toujours par
`reparentNode` : la detection de cycle n'est ecrite qu'une fois.

Branches
Selectionner, dupliquer, copier, coller comme enfant, deplacer avec ses
descendants (reglage, ou Alt pour l'inverser le temps d'un glisser), replier et
developper une branche, tout replier, tout developper, replier au-dela d'un
niveau.

Clavier
Maj+Tab remonte d'un niveau, Maj+Entree ouvre la creation multiple, Alt+Haut et
Alt+Bas reordonnent. Tab, Entree et F2 gardent leur comportement.

Historique
Chaque operation logique reste une seule entree : creation multiple, import
d'outline, reordonnancement, deplacement de branche, duplication, collage,
suppression, tout replier.

Tests : 172 vitest (103 -> 172) et 36 pytest (31 -> 36).

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

152 lines
5.5 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
import { getOrderedChildren } from '@/utils/tree';
/** Carte v2 : pas de champ `order`, l'ordre est celui du tableau. */
const V2_MAP = {
version: 2,
id: 'v2-map',
name: 'Reseau v2',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
nodes: [
{ id: 'root', type: 'root', text: 'Reseau', position: { x: 0, y: 0 }, parentId: null },
{ id: 'dns', type: 'standard', text: 'DNS', position: { x: 200, y: 0 }, parentId: 'root' },
{ id: 'dhcp', type: 'standard', text: 'DHCP', position: { x: 200, y: 60 }, parentId: 'root' },
{ id: 'vlan', type: 'standard', text: 'VLAN', position: { x: 200, y: 120 }, parentId: 'root' },
],
edges: [
{ id: 'e-root-dns', source: 'root', target: 'dns' },
{ id: 'e-root-dhcp', source: 'root', target: 'dhcp' },
{ id: 'e-root-vlan', source: 'root', target: 'vlan' },
],
viewport: { x: 0, y: 0, zoom: 1 },
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier', showGrid: true },
};
const V1_MAP = {
...V2_MAP,
version: 1,
id: 'v1-map',
nodes: V2_MAP.nodes.map((node) =>
node.id === 'root' ? { ...node, style: { width: 240, textAlign: 'center' } } : node,
),
};
function texts(map: MindMap, parentId: string | null): string[] {
return getOrderedChildren(map.nodes, parentId).map((node) => node.text);
}
describe('migration vers la v3', () => {
it('materialise l ordre d une carte v2 depuis la position dans le tableau', () => {
const map = normalizeMapDocument(V2_MAP);
expect(map.version).toBe(MAP_FORMAT_VERSION);
expect(texts(map, 'root')).toEqual(['DNS', 'DHCP', 'VLAN']);
expect(map.nodes.map((node) => node.order)).toEqual([0, 0, 1, 2]);
});
it('migre une carte v1 jusqu a la v3 sans perdre la mise en page', () => {
const map = normalizeMapDocument(V1_MAP);
expect(map.version).toBe(MAP_FORMAT_VERSION);
expect(texts(map, 'root')).toEqual(['DNS', 'DHCP', 'VLAN']);
expect(map.nodes[0]?.layout?.width).toBe(240);
expect(map.nodes[0]?.style?.textAlign).toBe('center');
});
it('respecte un ordre explicite plutot que la position dans le tableau', () => {
const desordonne = {
...V2_MAP,
version: 3,
nodes: [
V2_MAP.nodes[0],
{ ...V2_MAP.nodes[1], order: 2 },
{ ...V2_MAP.nodes[2], order: 1 },
{ ...V2_MAP.nodes[3], order: 0 },
],
};
expect(texts(normalizeMapDocument(desordonne), 'root')).toEqual(['VLAN', 'DHCP', 'DNS']);
});
it('refuse une version plus recente', () => {
expect(() => normalizeMapDocument({ ...V2_MAP, version: MAP_FORMAT_VERSION + 1 })).toThrow(
/plus recente/i,
);
});
});
describe('aller-retour de l ordre', () => {
it('conserve un ordre reordonne apres export puis import', () => {
const map = normalizeMapDocument(V2_MAP);
const reordonne: MindMap = {
...map,
nodes: map.nodes.map((node) => {
if (node.id === 'vlan') return { ...node, order: 0 };
if (node.id === 'dns') return { ...node, order: 1 };
if (node.id === 'dhcp') return { ...node, order: 2 };
return node;
}),
};
const restaure = normalizeMapDocument(JSON.parse(serializeMap(reordonne)));
expect(texts(restaure, 'root')).toEqual(['VLAN', 'DNS', 'DHCP']);
});
/** VLAN en tete, comme le ferait "Placer en premier". */
function withVlanFirst(): MindMap {
const map = normalizeMapDocument(V2_MAP);
const ordres: Record<string, number> = { vlan: 0, dns: 1, dhcp: 2 };
return {
...map,
nodes: map.nodes.map((node) =>
ordres[node.id] === undefined ? node : { ...node, order: ordres[node.id] as number },
),
};
}
it('ecrit les noeuds dans l ordre hierarchique', () => {
const serialise = JSON.parse(serializeMap(withVlanFirst())) as MindMap;
// Un lecteur anterieur a la v3, qui ignore `order`, retrouve le bon ordre.
expect(serialise.nodes.map((node) => node.id)).toEqual(['root', 'vlan', 'dns', 'dhcp']);
});
it('emet les liens dans l ordre de fratrie', () => {
const serialise = JSON.parse(serializeMap(withVlanFirst())) as MindMap;
expect(serialise.edges.map((edge) => edge.target)).toEqual(['vlan', 'dns', 'dhcp']);
});
it('departage deux freres de meme rang par leur position dans le tableau', () => {
const map = normalizeMapDocument(V2_MAP);
const ambigu: MindMap = {
...map,
nodes: map.nodes.map((node) => (node.id === 'vlan' ? { ...node, order: 0 } : node)),
};
// `dns` et `vlan` ont tous deux le rang 0 : le tableau tranche, sans erreur.
expect(texts(normalizeMapDocument(JSON.parse(serializeMap(ambigu))), 'root')).toEqual([
'DNS',
'VLAN',
'DHCP',
]);
});
it('conserve l ordre d une arborescence sur plusieurs niveaux', () => {
const map = normalizeMapDocument({
version: 3,
id: 'profond',
nodes: [
{ id: 'r', type: 'root', text: 'R', parentId: null, position: { x: 0, y: 0 } },
{ id: 'a', text: 'A', parentId: 'r', order: 1, position: { x: 0, y: 0 } },
{ id: 'b', text: 'B', parentId: 'r', order: 0, position: { x: 0, y: 0 } },
{ id: 'a1', text: 'A1', parentId: 'a', order: 1, position: { x: 0, y: 0 } },
{ id: 'a2', text: 'A2', parentId: 'a', order: 0, position: { x: 0, y: 0 } },
],
});
expect(texts(map, 'r')).toEqual(['B', 'A']);
expect(texts(map, 'a')).toEqual(['A2', 'A1']);
});
});