Build and deploy / test-python (push) Failing after 1m15s
Build and deploy / test-js (push) Successful in 54s
Build and deploy / lint-python (push) Failing after 1m12s
Build and deploy / lint-js (push) Failing after 1m8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m4s
Retour utilisateur : "une consequence peut mener a d'autres choix et
ainsi de suite, il n'y a pas de notion vrai/faux, il faut une modale
avec la possibilite de construire un veritable arbre de choix
consequence". Remplace le modele plat (situation + 2-4 choix + un seul
bon choix + une consequence terminale) par un vrai graphe de nœuds :
{"title", "nodes": [{"id", "text", "choices": [{"text", "target_id"}]}]}
- nodes[0] est la situation initiale, chaque choix peut pointer vers
n'importe quel autre nœud (branchement, convergence, fins multiples),
un nœud sans choix est une fin de branche valide. Aucune notion de
bonne/mauvaise reponse.
L'arbre se construit desormais dans une modale dediee (trop de
structure pour la colonne etroite du panneau Proprietes) : liste de
nœuds, chaque choix avec un menu deroulant "mene a" listant les autres
nœuds ou "fin de branche". La modale est un composant generique
(.docModal*) independant de tout framework externe.
sanitize_scenario_config degrade silencieusement tout target_id
orphelin (nœud supprime) vers None plutot que de faire echouer le
scenario entier. Le lecteur cote client navigue le graphe nœud par
nœud, le texte du nœud visite remplace le precedent (toujours pas un
Quiz), jusqu'a une fin de branche puis passage au scenario suivant.
Verifie via simulation DOM reelle (jsdom) : navigation ramifiee
(branchement, convergence, fin via nœud vide ET via choix sans cible),
plusieurs arbres a la suite, et l'editeur modal complet (ouverture,
ajout/suppression de nœud avec reparation des references pendantes,
changement de cible, fermeture bouton/fond/Echap).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
251 lines
8.8 KiB
Python
251 lines
8.8 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), 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.",
|
|
"choices": [
|
|
{"text": "Le lui donner", "target_id": "n2"},
|
|
{"text": "Refuser", "target_id": None},
|
|
],
|
|
},
|
|
{"id": "n2", "text": "Violation de sécurité.", "choices": []},
|
|
],
|
|
}
|
|
],
|
|
}
|
|
config = doc_engine.sanitize_scenario_config(raw)
|
|
assert config["theme_color"] == "#123456"
|
|
assert config["scenarios"] == raw["scenarios"]
|
|
|
|
|
|
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
|