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