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>
311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""Modèle de données du mini-jeu Scénario
|
|
(document_engine/labels/scenario_config.py) — sans Flask, teste
|
|
directement sanitize_scenario_config (arbre de décision, graphe visuel
|
|
avec positions x/y), et le rendu du plateau."""
|
|
|
|
from typing import Any
|
|
|
|
import document_engine as doc_engine
|
|
|
|
|
|
def test_sanitize_scenario_config_on_missing_input_returns_full_defaults() -> None:
|
|
assert doc_engine.sanitize_scenario_config(None) == {"theme_color": "#ff5f2e", "scenarios": []}
|
|
|
|
|
|
def test_sanitize_scenario_config_keeps_a_valid_tree_as_is() -> None:
|
|
raw = {
|
|
"theme_color": "#123456",
|
|
"scenarios": [
|
|
{
|
|
"title": "Mot de passe",
|
|
"nodes": [
|
|
{
|
|
"id": "n1",
|
|
"text": "Un collègue vous demande son mot de passe.",
|
|
"x": 40,
|
|
"y": 60,
|
|
"choices": [
|
|
{"text": "Le lui donner", "target_id": "n2"},
|
|
{"text": "Refuser", "target_id": None},
|
|
],
|
|
},
|
|
{"id": "n2", "text": "Violation de sécurité.", "x": 300, "y": 60, "choices": []},
|
|
],
|
|
}
|
|
],
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
assert config["theme_color"] == "#123456"
|
|
assert config["scenarios"] == raw["scenarios"]
|
|
|
|
|
|
def test_sanitize_scenario_config_keeps_explicit_xy_position() -> None:
|
|
raw = {
|
|
"scenarios": [
|
|
{"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": 500, "y": 300, "choices": []}]}
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
node = config["scenarios"][0]["nodes"][0]
|
|
assert (node["x"], node["y"]) == (500, 300)
|
|
|
|
|
|
def test_sanitize_scenario_config_falls_back_to_a_cascading_grid_when_xy_is_missing() -> None:
|
|
"""Jamais (0, 0) pour tous les nœuds (qui les empilerait exactement au
|
|
même endroit sur le graphe visuel) : une position par défaut dérivée
|
|
de l'INDEX du nœud dans la liste."""
|
|
raw = {
|
|
"scenarios": [
|
|
{
|
|
"title": "Titre",
|
|
"nodes": [
|
|
{"id": "n1", "text": "A", "choices": []},
|
|
{"id": "n2", "text": "B", "choices": []},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
nodes = config["scenarios"][0]["nodes"]
|
|
assert (nodes[0]["x"], nodes[0]["y"]) != (nodes[1]["x"], nodes[1]["y"])
|
|
|
|
|
|
def test_sanitize_scenario_config_falls_back_to_grid_when_xy_is_not_a_number() -> None:
|
|
raw = {
|
|
"scenarios": [
|
|
{"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": "beaucoup", "y": None, "choices": []}]}
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
node = config["scenarios"][0]["nodes"][0]
|
|
assert isinstance(node["x"], (int, float))
|
|
assert isinstance(node["y"], (int, float))
|
|
|
|
|
|
def test_sanitize_scenario_config_rejects_a_boolean_as_xy() -> None:
|
|
"""True/False sont des int en Python (bool hérite de int) — même
|
|
garde-fou que pour correct_index dans l'ancien modèle : un JSON
|
|
malformé pourrait glisser `true` là où une coordonnée est attendue."""
|
|
raw = {
|
|
"scenarios": [
|
|
{"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": True, "y": False, "choices": []}]}
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
node = config["scenarios"][0]["nodes"][0]
|
|
assert node["x"] is not True
|
|
assert node["y"] is not False
|
|
|
|
|
|
def test_sanitize_scenario_config_drops_a_scenario_with_empty_title() -> None:
|
|
raw = {"scenarios": [{"title": " ", "nodes": [{"id": "n1", "text": "Situation", "choices": []}]}]}
|
|
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
|
|
|
|
|
def test_sanitize_scenario_config_drops_a_scenario_with_no_valid_node() -> None:
|
|
raw = {"scenarios": [{"title": "Titre", "nodes": [{"id": "n1", "text": " ", "choices": []}]}]}
|
|
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
|
|
|
|
|
def test_sanitize_scenario_config_drops_a_node_missing_its_id() -> None:
|
|
raw = {
|
|
"scenarios": [
|
|
{
|
|
"title": "Titre",
|
|
"nodes": [
|
|
{"id": "n1", "text": "Situation", "choices": []},
|
|
{"text": "Nœud sans id", "choices": []},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
assert len(config["scenarios"][0]["nodes"]) == 1
|
|
|
|
|
|
def test_sanitize_scenario_config_drops_a_node_with_empty_text() -> None:
|
|
raw = {
|
|
"scenarios": [
|
|
{
|
|
"title": "Titre",
|
|
"nodes": [
|
|
{"id": "n1", "text": "Situation", "choices": []},
|
|
{"id": "n2", "text": " ", "choices": []},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
assert [n["id"] for n in config["scenarios"][0]["nodes"]] == ["n1"]
|
|
|
|
|
|
def test_sanitize_scenario_config_drops_duplicate_node_ids() -> None:
|
|
raw = {
|
|
"scenarios": [
|
|
{
|
|
"title": "Titre",
|
|
"nodes": [
|
|
{"id": "n1", "text": "Situation", "choices": []},
|
|
{"id": "n1", "text": "Doublon", "choices": []},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
assert len(config["scenarios"][0]["nodes"]) == 1
|
|
assert config["scenarios"][0]["nodes"][0]["text"] == "Situation"
|
|
|
|
|
|
def test_sanitize_scenario_config_drops_a_choice_with_empty_text() -> None:
|
|
raw = {
|
|
"scenarios": [
|
|
{
|
|
"title": "Titre",
|
|
"nodes": [
|
|
{
|
|
"id": "n1",
|
|
"text": "Situation",
|
|
"choices": [{"text": " ", "target_id": None}, {"text": "Valide", "target_id": None}],
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
assert [c["text"] for c in config["scenarios"][0]["nodes"][0]["choices"]] == ["Valide"]
|
|
|
|
|
|
def test_sanitize_scenario_config_caps_choices_per_node_at_max() -> None:
|
|
raw = {
|
|
"scenarios": [
|
|
{
|
|
"title": "Titre",
|
|
"nodes": [
|
|
{
|
|
"id": "n1",
|
|
"text": "Situation",
|
|
"choices": [
|
|
{"text": f"Choix {i}", "target_id": None}
|
|
for i in range(doc_engine.MAX_SCENARIO_CHOICES + 3)
|
|
],
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
assert len(config["scenarios"][0]["nodes"][0]["choices"]) == doc_engine.MAX_SCENARIO_CHOICES
|
|
|
|
|
|
def test_sanitize_scenario_config_allows_a_node_with_zero_choices() -> None:
|
|
"""Un nœud sans choix est une fin de branche VALIDE — contrairement à
|
|
l'ancien modèle (2 à 4 choix obligatoires), il n'y a plus de notion
|
|
de bonne/mauvaise réponse à valider ici."""
|
|
raw = {"scenarios": [{"title": "Titre", "nodes": [{"id": "n1", "text": "Fin.", "choices": []}]}]}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
assert config["scenarios"][0]["nodes"][0]["choices"] == []
|
|
|
|
|
|
def test_sanitize_scenario_config_nulls_a_target_id_pointing_to_a_missing_node() -> None:
|
|
raw = {
|
|
"scenarios": [
|
|
{
|
|
"title": "Titre",
|
|
"nodes": [
|
|
{
|
|
"id": "n1",
|
|
"text": "Situation",
|
|
"choices": [{"text": "Vers un nœud supprimé", "target_id": "n99"}],
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
assert config["scenarios"][0]["nodes"][0]["choices"][0]["target_id"] is None
|
|
|
|
|
|
def test_sanitize_scenario_config_keeps_a_target_id_pointing_to_a_later_node() -> None:
|
|
"""L'ordre des nœuds dans la liste ne contraint pas les références —
|
|
un choix du nœud 0 peut viser un nœud défini plus loin dans la liste
|
|
(voir le second passage de _sanitize_scenario, après avoir construit
|
|
l'ensemble complet des ids valides)."""
|
|
raw = {
|
|
"scenarios": [
|
|
{
|
|
"title": "Titre",
|
|
"nodes": [
|
|
{"id": "n1", "text": "Situation", "choices": [{"text": "Continuer", "target_id": "n2"}]},
|
|
{"id": "n2", "text": "Suite", "choices": []},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
assert config["scenarios"][0]["nodes"][0]["choices"][0]["target_id"] == "n2"
|
|
|
|
|
|
def test_sanitize_scenario_config_keeps_multiple_scenarios_in_order() -> None:
|
|
raw = {
|
|
"scenarios": [
|
|
{"title": "Scénario 1", "nodes": [{"id": "n1", "text": "Situation 1", "choices": []}]},
|
|
{"title": "Scénario 2", "nodes": [{"id": "n1", "text": "Situation 2", "choices": []}]},
|
|
]
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
assert [s["title"] for s in config["scenarios"]] == ["Scénario 1", "Scénario 2"]
|
|
|
|
|
|
def test_sanitize_scenario_config_ignores_garbage_top_level_input() -> None:
|
|
assert doc_engine.sanitize_scenario_config("n'importe quoi") == doc_engine.DEFAULT_SCENARIO_CONFIG
|
|
|
|
|
|
def _scenario_element(attributes: dict[str, Any]) -> dict[str, Any]:
|
|
return {"id": 1, "kind": "scenario", "parent_id": None, "order_index": 0, "attributes": attributes}
|
|
|
|
|
|
def test_render_scenario_without_scenarios_shows_only_the_badge() -> None:
|
|
html = doc_engine.render_document_element(_scenario_element(doc_engine.DEFAULT_SCENARIO_CONFIG), {})
|
|
assert "0 scénario" in html
|
|
assert "docScenarioPlayer" not in html
|
|
|
|
|
|
def test_render_scenario_with_scenarios_includes_the_interactive_player() -> None:
|
|
config = doc_engine.sanitize_scenario_config(
|
|
{
|
|
"scenarios": [
|
|
{
|
|
"title": "Mot de passe",
|
|
"nodes": [
|
|
{
|
|
"id": "n1",
|
|
"text": "Un collègue vous demande son mot de passe.",
|
|
"choices": [{"text": "Refuser", "target_id": None}],
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
)
|
|
html = doc_engine.render_document_element(_scenario_element(config), {})
|
|
assert "docScenarioPlayer" in html
|
|
assert "docQuizOptions" in html
|
|
assert "data-scenario-config=" in html
|
|
assert "1 scénario" in html
|
|
|
|
|
|
def test_render_scenario_escapes_node_text_in_embedded_json() -> None:
|
|
config = doc_engine.sanitize_scenario_config(
|
|
{
|
|
"scenarios": [
|
|
{
|
|
"title": "Titre",
|
|
"nodes": [{"id": "n1", "text": '"><script>alert(1)</script>', "choices": []}],
|
|
}
|
|
]
|
|
}
|
|
)
|
|
html = doc_engine.render_document_element(_scenario_element(config), {})
|
|
assert "<script>alert(1)</script>" not in html
|
|
assert "<script>" in html
|