Phase 1.1.1 : consolidation et coherence UX

Trois corrections ciblees, sans changement d'architecture ni d'interface.

1. Le style ne transporte plus l'icone
L'icone d'un noeud est du contenu, comme son texte ou sa note. Copier le style
de "Reseau" et le coller sur "Stockage" et "Securite" applique desormais les
couleurs, la forme et la mise en page en laissant chaque noeud avec sa propre
icone. Seule la presentation de l'icone (position, taille, espacement) reste
une propriete de style.

2. Le cercle conserve un rapport 1:1
`keepAspectRatio` sur le redimensionneur, et contrainte cote store pour les
trois autres chemins : redimensionnement direct, saisie d'une largeur ou d'une
hauteur dans le panneau, et conversion d'un noeud rectangulaire en cercle.
Un cercle ne peut plus devenir un ovale.

3. Point d'entree unique des documents
`normalizeMapDocument()` remplace `parseMapDocument()` et devient le seul
chemin par lequel une carte entre dans l'editeur, qu'elle vienne d'un fichier
.mmap, d'IndexedDB ou d'un partage. Le pipeline est toujours validation ->
migration -> normalisation. Les cartes locales passaient jusqu'ici a cote de la
migration : elles y passent maintenant a l'ouverture, sans etre reecrites en
base tant que l'utilisateur ne les modifie pas. Une carte locale videe de ses
noeuds reste ouvrable, et le message d'erreur s'adapte a la provenance.

Egalement
- Zoom minimum abaisse de 0.1 a 0.02 : "Adapter a l'ecran" butait sur ce
  plancher et ne montrait pas toute une carte de 181 noeuds.
- README : pipeline de normalisation, comportement du style et du cercle,
  limitations mesurees.

Tests : 103 vitest (85 -> 103) et 31 pytest, ruff, eslint et tsc au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 05:48:22 +02:00
parent bf83292e65
commit a23a68629d
13 changed files with 502 additions and 66 deletions

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
import { db } from '@/db/database';
import { createMap, getMap, importMap } from '@/services/mapRepository';
import {
MapFormatError,
normalizeMapDocument,
serializeMap,
} from '@/services/mapSerialization';
/** Carte v1 telle qu'elle pouvait etre enregistree en IndexedDB avant la v2. */
function legacyStoredMap(id: string): Record<string, unknown> {
return {
version: 1,
id,
name: 'Homelab v1',
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,
icon: '🖥️',
style: { backgroundColor: '#eef2ff', width: 240, textAlign: 'center' },
},
{
id: 'n1',
type: 'standard',
text: 'Reseau',
position: { x: 260, y: 0 },
parentId: 'root',
},
],
edges: [{ id: 'e-root-n1', source: 'root', target: 'n1' }],
viewport: { x: 12, y: 34, zoom: 1.5 },
settings: { theme: 'ocean', layout: 'tree-vertical', branchStyle: 'curved', showGrid: false },
};
}
beforeEach(async () => {
await db.maps.clear();
await db.shares.clear();
await db.meta.clear();
});
describe('pipeline de normalisation', () => {
it('normalise une carte v1 venue d IndexedDB comme un fichier v1', async () => {
await db.maps.put(legacyStoredMap('stored-1') as unknown as MindMap);
const fromStorage = await getMap('stored-1');
const fromFile = normalizeMapDocument(legacyStoredMap('stored-1'), { source: 'file' });
expect(fromStorage).toBeDefined();
expect(fromStorage?.nodes).toEqual(fromFile.nodes);
expect(fromStorage?.settings).toEqual(fromFile.settings);
expect(fromStorage?.version).toBe(MAP_FORMAT_VERSION);
});
it('migre la largeur v1 d une carte stockee sans la reecrire en base', async () => {
await db.maps.put(legacyStoredMap('stored-2') as unknown as MindMap);
const opened = await getMap('stored-2');
expect(opened?.nodes[0]?.layout?.width).toBe(240);
expect(opened?.nodes[0]?.layout?.widthMode).toBe('fixed');
expect(opened?.nodes[0]?.style?.width).toBeUndefined();
// La base contient toujours le document d'origine : rien n'a ete reecrit.
const raw = (await db.maps.get('stored-2')) as unknown as Record<string, unknown>;
expect(raw.version).toBe(1);
expect((raw.nodes as Array<Record<string, unknown>>)[0]?.style).toMatchObject({ width: 240 });
});
it('conserve le contenu et les reglages d une carte v1', async () => {
await db.maps.put(legacyStoredMap('stored-3') as unknown as MindMap);
const opened = await getMap('stored-3');
expect(opened?.name).toBe('Homelab v1');
expect(opened?.createdAt).toBe('2026-01-01T00:00:00.000Z');
expect(opened?.updatedAt).toBe('2026-01-02T00:00:00.000Z');
expect(opened?.nodes[0]?.icon).toBe('🖥️');
expect(opened?.nodes[0]?.style?.textAlign).toBe('center');
expect(opened?.settings.theme).toBe('ocean');
expect(opened?.settings.layout).toBe('tree-vertical');
expect(opened?.viewport).toEqual({ x: 12, y: 34, zoom: 1.5 });
});
it('ne modifie jamais le document fourni', () => {
const original = legacyStoredMap('immutable');
const copy = JSON.parse(JSON.stringify(original));
normalizeMapDocument(original, { source: 'storage' });
expect(original).toEqual(copy);
});
it('laisse une carte v2 inchangee', () => {
const v2 = normalizeMapDocument(legacyStoredMap('x'), { source: 'file' });
const again = normalizeMapDocument(JSON.parse(serializeMap(v2)), { source: 'file' });
expect(again.nodes).toEqual(v2.nodes);
expect(again.settings).toEqual(v2.settings);
expect(again.version).toBe(MAP_FORMAT_VERSION);
});
it('accepte une carte locale videe de ses noeuds', async () => {
const map = await createMap('Carte videe');
await db.maps.put({ ...map, nodes: [], edges: [] });
const opened = await getMap(map.id);
expect(opened?.nodes).toHaveLength(0);
expect(opened?.name).toBe('Carte videe');
});
it('refuse un fichier sans noeud exploitable', () => {
expect(() => normalizeMapDocument({ version: 1, id: 'x', nodes: [] })).toThrow(MapFormatError);
});
it('adapte le message d erreur a la provenance', () => {
expect(() => normalizeMapDocument('pas un objet', { source: 'file' })).toThrow(/fichier/i);
expect(() => normalizeMapDocument('pas un objet', { source: 'storage' })).toThrow(
/enregistree/i,
);
expect(() => normalizeMapDocument('pas un objet', { source: 'share' })).toThrow(/partagee/i);
});
it('fait passer un import par le meme pipeline', async () => {
const normalised = normalizeMapDocument(legacyStoredMap('file-1'), { source: 'file' });
const stored = await importMap(normalised);
const reopened = await getMap(stored.id);
expect(reopened?.nodes).toEqual(normalised.nodes);
expect(reopened?.version).toBe(MAP_FORMAT_VERSION);
});
});