Phase 1.1 : mise en page et edition visuelle des noeuds
Evolution incrementale : aucun changement d'architecture, aucune fonctionnalite retiree, compatibilite .mmap v1 et IndexedDB preservee. Modele (format v2) - MindMapNode gagne `layout`, `iconStyle` et `branch`, tous optionnels. - NodeStyle gagne fontFamily, verticalAlign, lineHeight et shadow. - MapSettings gagne layoutSpacing, snapToGrid, snapGrid et alignmentGuides. - Un champ absent signifie "herite du theme" : rien n'est ecrit dans le document pour materialiser un defaut, donc les cartes deja stockees restent lisibles telles quelles. Migration v1 -> v2 : style.width devient layout.width, toujours lu en repli par le resolveur. Mise en page - Nouveau resolveur de style qui fusionne theme et personnalisations. - Taille automatique fidele au contenu, taille personnalisee bornee, redimensionnement a la souris via NodeResizer. - Padding horizontal et vertical, retour a la ligne, largeur maximale du texte, interligne, police, alignement horizontal et vertical, ombre. - Icones : emojis conserves, bibliotheque Lucide ajoutee, position, taille et espacement configurables. Multi-selection - Barre contextuelle d'alignement (6 axes) et de distribution (2 axes). - Application d'un reglage a toute la selection, avec etat "Mixte". Style - Copier / coller / reinitialiser le style, au clavier et par clic droit. Auto-layout - Espacement parent/enfant et entre branches configurable, presets Compact, Normal et Aere. Le layout reste declenche uniquement a la demande. Historique - Une seule entree par redimensionnement complet, par alignement, par distribution, par application multiple et par collage de style. Panneau Proprietes - Reorganise en cinq sections repliables dont l'etat est conserve pendant la session. Vraies icones d'alignement, tooltips et aria-labels partout. Corrections - Largeur des noeuds : word-break et overflow-wrap:anywhere ramenaient la largeur min-content a un caractere, ce qui faisait s'effondrer tous les noeuds automatiques. - Largeur auto en max-content bornee, pour qu'une phrase longue occupe la largeur maximale au lieu de former une colonne etroite. Tests - 85 tests vitest (43 -> 85) et 31 tests pytest (25 -> 31). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
120
backend/tests/test_layout_payload.py
Normal file
120
backend/tests/test_layout_payload.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Le backend doit accepter la mise en page v2 sans relacher sa validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
from tests.conftest import build_map
|
||||
|
||||
RICH_NODE = {
|
||||
"id": "styled",
|
||||
"type": "standard",
|
||||
"text": "Firewall",
|
||||
"position": {"x": 120, "y": 40},
|
||||
"parentId": "root",
|
||||
"icon": "server",
|
||||
"iconStyle": {"kind": "builtin", "position": "top", "size": 24, "gap": 12},
|
||||
"style": {
|
||||
"backgroundColor": "#112233",
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "middle",
|
||||
"lineHeight": 1.6,
|
||||
"fontFamily": "mono",
|
||||
"shadow": "strong",
|
||||
},
|
||||
"layout": {
|
||||
"widthMode": "fixed",
|
||||
"width": 320,
|
||||
"heightMode": "fixed",
|
||||
"height": 140,
|
||||
"paddingX": 28,
|
||||
"paddingY": 18,
|
||||
"wrap": False,
|
||||
"maxTextWidth": 400,
|
||||
},
|
||||
"branch": {"style": "straight", "width": 4, "color": "#ff8800"},
|
||||
}
|
||||
|
||||
|
||||
def build_v2_map() -> dict:
|
||||
payload = build_map(node_count=2, name="Infrastructure")
|
||||
payload["version"] = 2
|
||||
payload["nodes"].append(RICH_NODE)
|
||||
payload["edges"].append({"id": "e-root-styled", "source": "root", "target": "styled"})
|
||||
payload["settings"]["layoutSpacing"] = {"parentGap": 140, "siblingGap": 80}
|
||||
payload["settings"]["snapToGrid"] = True
|
||||
payload["settings"]["snapGrid"] = 10
|
||||
payload["settings"]["alignmentGuides"] = False
|
||||
return payload
|
||||
|
||||
|
||||
def test_share_accepts_layout_properties(client: TestClient) -> None:
|
||||
response = client.post("/api/shares", json={"title": "v2", "map": build_v2_map()})
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
|
||||
def test_shared_map_is_returned_unchanged(client: TestClient) -> None:
|
||||
created = client.post("/api/shares", json={"title": "v2", "map": build_v2_map()})
|
||||
token = created.json()["share_token"]
|
||||
|
||||
public = client.get("/api/shares/" + token).json()
|
||||
assert public["map_version"] == 2
|
||||
|
||||
node = next(item for item in public["map"]["nodes"] if item["id"] == "styled")
|
||||
assert node["layout"] == RICH_NODE["layout"]
|
||||
assert node["style"] == RICH_NODE["style"]
|
||||
assert node["iconStyle"] == RICH_NODE["iconStyle"]
|
||||
assert node["branch"] == RICH_NODE["branch"]
|
||||
assert node["icon"] == "server"
|
||||
assert node["position"] == {"x": 120, "y": 40}
|
||||
|
||||
settings = public["map"]["settings"]
|
||||
assert settings["layoutSpacing"] == {"parentGap": 140, "siblingGap": 80}
|
||||
assert settings["snapToGrid"] is True
|
||||
assert settings["alignmentGuides"] is False
|
||||
|
||||
|
||||
def test_version_1_maps_are_still_accepted(client: TestClient) -> None:
|
||||
response = client.post("/api/shares", json={"title": "v1", "map": build_map()})
|
||||
assert response.status_code == 201
|
||||
assert response.json()["share_token"]
|
||||
|
||||
|
||||
def test_node_limit_still_applies_to_v2_maps(client: TestClient) -> None:
|
||||
os.environ["MAX_NODES"] = "5"
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
payload = build_v2_map()
|
||||
for index in range(20):
|
||||
payload["nodes"].append(
|
||||
{
|
||||
"id": f"extra{index}",
|
||||
"type": "standard",
|
||||
"text": "x",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"parentId": "root",
|
||||
"layout": {"paddingX": 10},
|
||||
}
|
||||
)
|
||||
response = client.post("/api/shares", json={"title": "x", "map": payload})
|
||||
assert response.status_code == 422
|
||||
finally:
|
||||
os.environ["MAX_NODES"] = "5000"
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_unknown_top_level_field_is_still_rejected(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/shares", json={"title": "x", "map": build_v2_map(), "premium": True}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_node_without_id_is_still_rejected(client: TestClient) -> None:
|
||||
payload = build_v2_map()
|
||||
payload["nodes"].append({"text": "sans id", "layout": {"paddingX": 4}})
|
||||
response = client.post("/api/shares", json={"title": "x", "map": payload})
|
||||
assert response.status_code == 422
|
||||
Reference in New Issue
Block a user