"""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": '">', "choices": []}],
}
]
}
)
html = doc_engine.render_document_element(_scenario_element(config), {})
assert "" not in html
assert "<script>" in html