Build and deploy / test-python (push) Successful in 7m32s
Build and deploy / test-js (push) Successful in 49s
Build and deploy / lint-python (push) Successful in 5m21s
Build and deploy / lint-js (push) Failing after 1m12s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m53s
Retour utilisateur : "passons a un veritable graphe visuel". Chaque nœud gagne une position x/y (document_engine/labels/scenario_config.py : un x/y manquant/invalide retombe sur un quadrillage en cascade derive de l'index du nœud, jamais (0, 0) pour tous les nœuds qui les empilerait au meme endroit). Cote editeur (static/document/js/document-editor.js) : les nœuds sont des cartes deplacables a la souris sur un canevas (glisser-deposer reel, meme principe que le glisser des formes libres), les choix relies a une cible sont dessines comme des fleches SVG etiquetees par leur texte (jamais un menu deroulant). Editer le texte/les choix d'un nœud se fait dans un panneau inspecteur (colonne de droite) pour le nœud selectionne ; relier un choix se fait en cliquant "Relier" puis le nœud cible sur le graphe (mode connexion, Echap annule sans fermer la modale). La modale generique (.docModal*) est agrandie specifiquement pour ce graphe (jusqu'a 1180px) sans toucher sa taille par defaut. Verifie via simulation DOM reelle (jsdom) : rendu des nœuds/positions, glisser-deposer avec persistance au relachement, traces des fleches SVG + etiquettes, workflow complet du mode connexion, suppression d'un nœud avec reparation des references pendantes, et les 3 façons de fermer la modale (bouton/fond/Echap) y compris l'annulation du mode connexion sans fermer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
149 lines
6.7 KiB
Python
149 lines
6.7 KiB
Python
"""Modèle de données du mini-jeu Scénario (voir docs/plan/PLAN.md §3.2) —
|
|
l'apprenant lit une situation initiale, choisit une option, et découvre
|
|
la conséquence de SON choix ; cette conséquence peut elle-même mener à
|
|
de nouveaux choix, et ainsi de suite (arbre de décision, voir
|
|
document_engine/rendering/render_document_element.py::
|
|
_render_scenario_player). Aucune notion de bonne/mauvaise réponse
|
|
(retour utilisateur du 20/09/2026 : "il n'y a pas de notion vrai/faux,
|
|
l'utilisateur observe les conséquences") — l'apprenant explore
|
|
simplement les branches. Le créateur peut définir plusieurs scénarios
|
|
(plusieurs arbres indépendants), joués les uns après les autres dans
|
|
l'ordre d'écriture. Même convention resolve_X/sanitize_X que
|
|
quiz_config.py/association_config.py/memory_config.py/mots_config.py
|
|
(aucun import croisé) : sanitize_scenario_config est pure, ne lève
|
|
jamais, et renvoie toujours un dict complet.
|
|
|
|
Structure d'un scénario : {"title": str, "nodes": [...]}. `nodes[0]` est
|
|
TOUJOURS la situation initiale (racine de l'arbre, jamais supprimable
|
|
depuis le panneau Propriétés). Chaque nœud est
|
|
{"id": str, "text": str, "x": float, "y": float, "choices": [...]} —
|
|
`id` est une référence STABLE (générée côté client, jamais recalculée
|
|
ici) vers laquelle un choix d'un AUTRE nœud peut pointer via son
|
|
`target_id` ; un nœud sans choix est une fin de branche. `x`/`y`
|
|
positionnent le nœud sur le canevas du graphe visuel (voir
|
|
document_engine/rendering/render_document_element.py::
|
|
_render_scenario_player et forgeDocRenderScenarioGraph, static/document/
|
|
js/document-editor.js) — une position manquante/invalide retombe sur un
|
|
quadrillage en cascade calculé depuis l'INDEX du nœud dans la liste
|
|
(_scenario_node_fallback_position), jamais (0, 0) pour tous les nœuds,
|
|
ce qui les empilerait exactement au même endroit. Un choix est
|
|
{"text": str, "target_id": str | None} — `target_id` à None signifie
|
|
"fin de branche à ce choix" (aucun nœud suivant)."""
|
|
|
|
from typing import Any
|
|
|
|
MAX_SCENARIO_CHOICES = 4
|
|
_NODE_GRID_COLUMNS = 4
|
|
_NODE_GRID_STEP_X = 220
|
|
_NODE_GRID_STEP_Y = 160
|
|
_NODE_GRID_MARGIN = 40
|
|
|
|
DEFAULT_SCENARIO_CONFIG: dict[str, Any] = {
|
|
"theme_color": "#ff5f2e",
|
|
"scenarios": [],
|
|
}
|
|
|
|
|
|
def _sanitize_scenario_choice(raw: Any) -> dict[str, Any] | None:
|
|
"""None si le choix est invalide (texte vide) — filtrée par
|
|
_sanitize_scenario_node plutôt que de faire échouer tout le nœud,
|
|
même convention que quiz_config.py::_sanitize_question. `target_id`
|
|
est vérifié/nettoyé une seconde fois par _sanitize_scenario (une fois
|
|
l'ensemble des ids valides du scénario connu), jamais ici."""
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
text = str(raw.get("text", "")).strip()
|
|
if not text:
|
|
return None
|
|
target_id = raw.get("target_id")
|
|
return {"text": text, "target_id": target_id if isinstance(target_id, str) and target_id else None}
|
|
|
|
|
|
def _scenario_node_fallback_position(index: int) -> tuple[float, float]:
|
|
"""Position par défaut d'un nœud dont x/y est manquant/invalide — un
|
|
quadrillage en cascade dérivé de son INDEX dans la liste, jamais
|
|
(0, 0) pour tous les nœuds (qui les empilerait exactement au même
|
|
endroit, rendant le graphe visuel illisible à la première ouverture
|
|
d'un scénario créé avant l'ajout de x/y au modèle, ou d'un nœud
|
|
ajouté par un client qui n'enverrait pas encore de position)."""
|
|
col = index % _NODE_GRID_COLUMNS
|
|
row = index // _NODE_GRID_COLUMNS
|
|
return (
|
|
_NODE_GRID_MARGIN + col * _NODE_GRID_STEP_X,
|
|
_NODE_GRID_MARGIN + row * _NODE_GRID_STEP_Y,
|
|
)
|
|
|
|
|
|
def _sanitize_scenario_node(raw: Any, index: int) -> dict[str, Any] | None:
|
|
"""None si le nœud est invalide (id absent, ou texte vide) — même
|
|
convention que les autres sanitize_X de ce module. Un nœud SANS choix
|
|
est parfaitement valide (fin de branche), contrairement à l'ancien
|
|
modèle où un scénario exigeait 2 à 4 choix : ici, 0 choix est un état
|
|
normal, pas une erreur."""
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
node_id = raw.get("id")
|
|
if not isinstance(node_id, str) or not node_id:
|
|
return None
|
|
text = str(raw.get("text", "")).strip()
|
|
if not text:
|
|
return None
|
|
raw_choices = raw.get("choices")
|
|
choices = []
|
|
if isinstance(raw_choices, list):
|
|
choices = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None]
|
|
choices = choices[:MAX_SCENARIO_CHOICES]
|
|
fallback_x, fallback_y = _scenario_node_fallback_position(index)
|
|
raw_x, raw_y = raw.get("x"), raw.get("y")
|
|
x = raw_x if isinstance(raw_x, (int, float)) and not isinstance(raw_x, bool) else fallback_x
|
|
y = raw_y if isinstance(raw_y, (int, float)) and not isinstance(raw_y, bool) else fallback_y
|
|
return {"id": node_id, "text": text, "x": x, "y": y, "choices": choices}
|
|
|
|
|
|
def _sanitize_scenario(raw: Any) -> dict[str, Any] | None:
|
|
"""None si le scénario est invalide (titre vide, ou aucun nœud
|
|
valide restant après nettoyage — il faut au moins la situation
|
|
initiale). Un choix qui pointait vers un nœud devenu invalide/
|
|
supprimé dégrade silencieusement vers `target_id: None` ("fin de
|
|
branche") plutôt que de faire échouer tout le scénario — même
|
|
philosophie que le reste de ce module : ne jamais lever, ne jamais
|
|
faire échouer une structure entière pour une seule référence
|
|
cassée."""
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
title = str(raw.get("title", "")).strip()
|
|
if not title:
|
|
return None
|
|
raw_nodes = raw.get("nodes")
|
|
if not isinstance(raw_nodes, list):
|
|
return None
|
|
nodes: list[dict[str, Any]] = []
|
|
seen_ids: set[str] = set()
|
|
for item in raw_nodes:
|
|
node = _sanitize_scenario_node(item, len(nodes))
|
|
if node is None or node["id"] in seen_ids:
|
|
continue
|
|
seen_ids.add(node["id"])
|
|
nodes.append(node)
|
|
if not nodes:
|
|
return None
|
|
valid_ids = {n["id"] for n in nodes}
|
|
for node in nodes:
|
|
node["choices"] = [
|
|
{**c, "target_id": c["target_id"] if c["target_id"] in valid_ids else None} for c in node["choices"]
|
|
]
|
|
return {"title": title, "nodes": nodes}
|
|
|
|
|
|
def sanitize_scenario_config(raw_config: Any) -> dict[str, Any]:
|
|
config = dict(DEFAULT_SCENARIO_CONFIG)
|
|
if not isinstance(raw_config, dict):
|
|
return config
|
|
theme_color = raw_config.get("theme_color")
|
|
if isinstance(theme_color, str) and theme_color:
|
|
config["theme_color"] = theme_color
|
|
raw_scenarios = raw_config.get("scenarios")
|
|
if isinstance(raw_scenarios, list):
|
|
config["scenarios"] = [s for s in (_sanitize_scenario(item) for item in raw_scenarios) if s is not None]
|
|
return config
|