Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+162 -35
View File
@@ -1,12 +1,22 @@
"""ai/chat.py — la boucle tool-use (voir plan Phase 2, §5). Jamais un
vrai appel à l'API Claude ici : client.messages.create est monkeypatché
par une fausse classe qui rejoue une séquence de réponses programmée."""
from collections.abc import Callable
from typing import Any
import pytest
from flask.testing import FlaskClient
import ai
import db
import screens
from tests.conftest import not_none
def _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup, name="pytest_ai_chat"):
def _create_jeu2d_game_with_conversation(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_ai_chat"
) -> Any:
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
@@ -15,7 +25,9 @@ def _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup, name="py
class _FakeBlock:
def __init__(self, type, text=None, id=None, name=None, input=None):
def __init__(
self, type: str, text: str | None = None, id: str | None = None, name: str | None = None, input: Any = None
) -> None:
self.type = type
self.text = text
self.id = id
@@ -24,18 +36,18 @@ class _FakeBlock:
class _FakeResponse:
def __init__(self, content, stop_reason):
def __init__(self, content: Any, stop_reason: Any) -> None:
self.content = content
self.stop_reason = stop_reason
class _FakeMessages:
def __init__(self, responses):
def __init__(self, responses: Any) -> None:
self._responses = list(responses)
self.call_count = 0
self.last_kwargs = None
self.last_kwargs: dict[str, Any] | None = None
def create(self, **kwargs):
def create(self, **kwargs: Any) -> Any:
self.call_count += 1
self.last_kwargs = kwargs
if len(self._responses) > 1:
@@ -44,11 +56,13 @@ class _FakeMessages:
class _FakeClient:
def __init__(self, responses):
def __init__(self, responses: Any) -> None:
self.messages = _FakeMessages(responses)
def test_run_chat_turn_returns_text_directly_when_no_tool_is_used(client, tmp_game_slug_cleanup, monkeypatch):
def test_run_chat_turn_returns_text_directly_when_no_tool_is_used(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
fake_client = _FakeClient([_FakeResponse([_FakeBlock("text", text="Bonjour, que veux-tu créer ?")], "end_turn")])
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
@@ -58,10 +72,19 @@ def test_run_chat_turn_returns_text_directly_when_no_tool_is_used(client, tmp_ga
assert fake_client.messages.call_count == 1
def test_run_chat_turn_dispatches_a_tool_call_then_returns_the_final_text(client, tmp_game_slug_cleanup, monkeypatch):
def test_run_chat_turn_dispatches_a_tool_call_then_returns_the_final_text(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
tool_use_response = _FakeResponse(
[_FakeBlock("tool_use", id="t1", name="create_global_variable", input={"name": "score_ia", "var_type": "nombre_entier"})],
[
_FakeBlock(
"tool_use",
id="t1",
name="create_global_variable",
input={"name": "score_ia", "var_type": "nombre_entier"},
)
],
"tool_use",
)
final_response = _FakeResponse([_FakeBlock("text", text="Variable créée.")], "end_turn")
@@ -74,7 +97,9 @@ def test_run_chat_turn_dispatches_a_tool_call_then_returns_the_final_text(client
assert "score_ia" in names
def test_run_chat_turn_stops_after_the_max_iterations_even_if_claude_keeps_calling_tools(client, tmp_game_slug_cleanup, monkeypatch):
def test_run_chat_turn_stops_after_the_max_iterations_even_if_claude_keeps_calling_tools(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
always_tool_use = _FakeResponse(
[_FakeBlock("tool_use", id="t1", name="create_global_variable", input={"name": "boucle_infinie"})],
@@ -88,7 +113,9 @@ def test_run_chat_turn_stops_after_the_max_iterations_even_if_claude_keeps_calli
assert reply == "(pas de réponse textuelle)"
def test_run_chat_turn_explains_when_cut_short_by_max_tokens(client, tmp_game_slug_cleanup, monkeypatch):
def test_run_chat_turn_explains_when_cut_short_by_max_tokens(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Bug corrigé : max_tokens=4096 pouvait couper Claude EN PLEINE
RÉFLEXION sur une demande riche, avant le moindre appel d'outil —
symptôme observé : "(pas de réponse textuelle)" dès le premier tour,
@@ -103,20 +130,24 @@ def test_run_chat_turn_explains_when_cut_short_by_max_tokens(client, tmp_game_sl
assert fake_client.messages.call_count == 1 # stop_reason != "tool_use" -> sort dès le premier tour
def test_run_chat_turn_uses_a_generous_max_tokens_not_the_old_lowballed_value(client, tmp_game_slug_cleanup, monkeypatch):
def test_run_chat_turn_uses_a_generous_max_tokens_not_the_old_lowballed_value(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
fake_client = _FakeClient([_FakeResponse([_FakeBlock("text", text="ok")], "end_turn")])
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Salut")
assert fake_client.messages.last_kwargs["max_tokens"] >= 16000
assert not_none(fake_client.messages.last_kwargs)["max_tokens"] >= 16000
def test_run_chat_turn_raises_when_anthropic_is_not_configured(client, tmp_game_slug_cleanup):
def test_run_chat_turn_raises_when_anthropic_is_not_configured(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
try:
ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Salut")
assert False, "devrait lever AnthropicNotConfiguredError"
raise AssertionError("devrait lever AnthropicNotConfiguredError")
except ai.AnthropicNotConfiguredError:
pass
@@ -124,21 +155,32 @@ def test_run_chat_turn_raises_when_anthropic_is_not_configured(client, tmp_game_
# ---------- État de scène ré-injecté à chaque tour (bug corrigé : Ruby
# dupliquait des objets faute de voir ce qui existait déjà) ----------
def test_describe_scene_state_reports_dimensions_and_no_objects(client, tmp_game_slug_cleanup):
def test_describe_scene_state_reports_dimensions_and_no_objects(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
description = ai.chat._describe_scene_state(slug, screen_id)
assert "0 à 960" in description and "0 à 540" in description
assert "Aucun objet" in description
def test_describe_scene_state_lists_existing_objects_with_role_and_trigger_flag(client, tmp_game_slug_cleanup):
def test_describe_scene_state_lists_existing_objects_with_role_and_trigger_flag(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
player_id = screens.add_scene_object(slug, screen_id, kind="personnage")
screens.set_scene_object_role(slug, player_id, "joueur")
pnj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
screens.set_scene_object_collision_rules(slug, pnj_id, screens.sanitize_collision_rules([
{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
]))
screens.set_scene_object_collision_rules(
slug,
pnj_id,
screens.sanitize_collision_rules(
[
{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
]
),
)
description = ai.chat._describe_scene_state(slug, screen_id)
assert f"id={player_id}" in description
@@ -148,41 +190,60 @@ def test_describe_scene_state_lists_existing_objects_with_role_and_trigger_flag(
assert "[déclencheur déjà configuré]" in description
def test_describe_scene_state_reports_no_screen_trigger_by_default(client, tmp_game_slug_cleanup):
def test_describe_scene_state_reports_no_screen_trigger_by_default(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
description = ai.chat._describe_scene_state(slug, screen_id)
assert "Aucun déclencheur d'écran" in description
def test_describe_scene_state_reports_an_existing_screen_trigger(client, tmp_game_slug_cleanup):
def test_describe_scene_state_reports_an_existing_screen_trigger(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
screens.set_screen_triggers(slug, screen_id, screens.sanitize_screen_triggers([
{"trigger": "affichage", "action": {"type": "dialogue"}},
]))
screens.set_screen_triggers(
slug,
screen_id,
screens.sanitize_screen_triggers(
[
{"trigger": "affichage", "action": {"type": "dialogue"}},
]
),
)
description = ai.chat._describe_scene_state(slug, screen_id)
assert "1 déclencheur(s) D'ÉCRAN" in description
def test_system_prompt_documents_the_new_triggers_and_actions():
def test_system_prompt_documents_the_new_triggers_and_actions() -> None:
"""Bug à éviter : ajouter un type de déclencheur/action côté moteur
(screens/rendering/collision_rules.py) sans jamais le documenter dans
le system prompt le rendrait invisible pour Ruby malgré l'outil qui
l'accepte techniquement."""
prompt = ai.chat._SYSTEM_PROMPT
for keyword in ("\"clic\"", "\"survol\"", "\"affichage\"", "surbrillance", "visibilite", "\"son\"", "\"video\"", "indication"):
for keyword in (
'"clic"',
'"survol"',
'"affichage"',
"surbrillance",
"visibilite",
'"son"',
'"video"',
"indication",
):
assert keyword in prompt, keyword
def test_system_prompt_asks_ruby_to_clarify_ambiguous_trigger_choice():
def test_system_prompt_asks_ruby_to_clarify_ambiguous_trigger_choice() -> None:
assert "pose la question" in ai.chat._SYSTEM_PROMPT
def test_system_prompt_asks_ruby_to_clarify_interagir_vs_immediate_action():
def test_system_prompt_asks_ruby_to_clarify_interagir_vs_immediate_action() -> None:
assert "interagir" in ai.chat._SYSTEM_PROMPT
assert "APPUYER SUR UNE TOUCHE" in ai.chat._SYSTEM_PROMPT
def test_system_prompt_documents_the_quiz_box_config_tool():
def test_system_prompt_documents_the_quiz_box_config_tool() -> None:
"""Demande explicite : "Ruby dois pouvoir aussi piloter ces réglages"
(plein écran/minuteur/modèle d'un quiz autonome) — pas seulement
l'éditeur manuel."""
@@ -192,11 +253,11 @@ def test_system_prompt_documents_the_quiz_box_config_tool():
assert keyword in prompt, keyword
def test_system_prompt_asks_ruby_to_clarify_the_timer_before_enabling_it():
def test_system_prompt_asks_ruby_to_clarify_the_timer_before_enabling_it() -> None:
assert "jamais imposé" in ai.chat._SYSTEM_PROMPT
def test_system_prompt_documents_the_two_quiz_template_categories():
def test_system_prompt_documents_the_two_quiz_template_categories() -> None:
"""Demande explicite : "Ruby doit également comprendre cette
distinction" (modèles "boîte de dialogue" hors plein écran vs modèles
"page de quiz" en plein écran, structures HTML totalement séparées)."""
@@ -205,13 +266,79 @@ def test_system_prompt_documents_the_two_quiz_template_categories():
assert "manga" in prompt
def test_run_chat_turn_passes_the_scene_state_in_the_system_prompt(client, tmp_game_slug_cleanup, monkeypatch):
# ---------- Écran supprimé pendant qu'une conversation IA existe encore
# ----------
#
# Comportement RÉEL vérifié en creusant ce chantier de typage (voir
# ai/chat.py::ScreenDeletedError, screens/screens_repo/delete_screen.py) :
# _ia_conversations.screen_id porte déjà ON DELETE CASCADE vers _screens
# (voir screens/ia/ensure_ia_chat_schema.py) — supprimer un écran nettoie
# donc DÉJÀ ses conversations/messages automatiquement, sans code
# applicatif dédié. ScreenDeletedError (ai/chat.py) reste un garde-fou
# purement défensif pour get_screen() -> None (Optional), pas le
# traitement d'un cas normalement atteignable via l'appli.
def test_deleting_the_screen_cascades_to_remove_its_ia_conversations_and_messages(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
screens.add_ia_chat_message(slug, conversation_id, "user", "Salut")
screens.delete_screen(slug, screen_id)
conn = db.connect(slug)
remaining_conversations = conn.execute(
"SELECT COUNT(*) AS n FROM _ia_conversations WHERE screen_id = ?", (screen_id,)
).fetchone()["n"]
remaining_messages = conn.execute(
"SELECT COUNT(*) AS n FROM _ia_chat_messages WHERE conversation_id = ?", (conversation_id,)
).fetchone()["n"]
conn.close()
assert remaining_conversations == 0
assert remaining_messages == 0
def test_describe_scene_state_raises_a_clear_error_for_a_nonexistent_screen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""screens.get_screen() renvoie dict | None — jamais atteint via
l'appli normale (voir le commentaire de module ci-dessus), mais
_describe_scene_state doit rester correcte pour ce cas plutôt que de
planter avec un TypeError cru sur un accès à None."""
slug, _screen_id, _conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
try:
ai.chat._describe_scene_state(slug, 999999)
raise AssertionError("devrait lever ScreenDeletedError")
except ai.chat.ScreenDeletedError:
pass
def test_run_chat_turn_gives_a_clear_message_instead_of_crashing_for_a_nonexistent_screen(
client: FlaskClient,
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
# screen_id inexistant (jamais créé) plutôt que l'écran réel de la
# conversation : reproduit directement l'état que ScreenDeletedError
# doit couvrir, sans avoir à contourner la contrainte FK réelle.
# Aucun monkeypatch de get_client ici, exprès : si le garde-fou
# appelait le client Claude avant de vérifier l'écran, ce test
# échouerait avec AnthropicNotConfiguredError (aucune clé en
# environnement de test) plutôt qu'avec le bon message.
reply = ai.run_chat_turn(slug, 999999, conversation_id, 1, "Salut")
assert reply == "Cet écran a été supprimé — cette conversation n'est plus utilisable."
def test_run_chat_turn_passes_the_scene_state_in_the_system_prompt(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="personnage")
fake_client = _FakeClient([_FakeResponse([_FakeBlock("text", text="ok")], "end_turn")])
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Salut")
system_prompt = fake_client.messages.last_kwargs["system"]
system_prompt = not_none(fake_client.messages.last_kwargs)["system"]
assert "0 à 960" in system_prompt and "0 à 540" in system_prompt
assert "1 objet(s)" in system_prompt