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>
121 lines
4.0 KiB
Python
121 lines
4.0 KiB
Python
"""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
|