erplibre/test/test_assistant_menu.py

558 lines
21 KiB
Python
Raw Permalink Normal View History

2026-09-09 06:41:56 -04:00
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce que le câblage du menu assistant doit tenir.
Six sites de `todo.py` participent à un sous-menu — l'import, les bases de la
classe, l'étiquette du fil d'Ariane, les deux listes jumelles du menu parent,
et les clés de traduction. Aucun n'échoue bruyamment quand il manque : une
entrée mal branchée appelle simplement autre chose, une étiquette absente
retire une miette du fil, et `t()` rend une clé inconnue telle quelle, donc une
faute de frappe s'affiche en clair à l'utilisateur sans que rien ne lève.
Ce fichier est le SEUL de la famille assistant à importer `TODO` : cet import
coûte près d'une seconde et imprime sur la sortie, et le faire payer aux neuf
autres fichiers rendrait la boucle d'écriture inutilisable. La contrepartie est
vérifiée ici même — le paquet, lui, doit rester importable seul.
`_menu_header()` enregistre une télémétrie dans `~/.erplibre` : tout test qui
appelle une méthode de menu la neutralise, sinon il écrit pour de vrai.
"""
2026-09-09 06:41:56 -04:00
from __future__ import annotations
import ast
import collections
import os
import subprocess
import sys
import unittest
from unittest.mock import patch
from script.todo.todo_i18n import t
RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MENU = os.path.join(RACINE, "script", "todo", "assistant_menu.py")
def _litteral(noeud):
"""La chaîne d'un nœud constant, ou `None`."""
if isinstance(noeud, ast.Constant) and isinstance(noeud.value, str):
return noeud.value
return None
def cles_de_traduction(chemin):
"""Les clés littérales que le fichier confie à `t()`, directement ou non.
Lit l'arbre syntaxique plutôt que le texte : une expression régulière
attraperait aussi les appels commentés et raterait les appels sur
plusieurs lignes.
Deux formes comptent. `t("clé")` est la forme directe. `_llm_count(n,
"singulier", "pluriel")` en est une INDIRECTE : ses deux derniers
arguments sont des clés que l'accord choisit à l'exécution, et une faute
de frappe y afficherait « hosts swept » en clair sans que rien ne lève —
exactement ce que la forme directe protège déjà.
"""
with open(chemin) as fichier:
arbre = ast.parse(fichier.read())
cles = set()
for noeud in ast.walk(arbre):
if not isinstance(noeud, ast.Call):
continue
cible = noeud.func
nom = getattr(cible, "id", None) or getattr(cible, "attr", None)
if nom == "t" and noeud.args:
valeur = _litteral(noeud.args[0])
if valeur is not None:
cles.add(valeur)
elif nom == "_llm_count" and len(noeud.args) >= 3:
for argument in noeud.args[1:3]:
valeur = _litteral(argument)
if valeur is not None:
cles.add(valeur)
return cles
class Cablage(unittest.TestCase):
"""Les six sites de `todo.py` que le sous-menu réclame."""
def test_todo_expose_le_sous_menu_llm(self):
from script.todo.todo import TODO
self.assertTrue(hasattr(TODO, "prompt_assistant_llm"))
def test_l_etiquette_de_fil_d_ariane_existe(self):
"""Le fil se dérive de la pile d'appels : une méthode absente de
`_MENU_LABELS` ne contribue AUCUNE miette, en silence. Le sous-menu
VPN a été livré ainsi et ne se situe donc nulle part."""
from script.todo.todo import TODO
self.assertEqual(TODO._MENU_LABELS.get("prompt_assistant_llm"), "LLM")
def test_un_dispatche_vers_le_sous_menu_llm_seulement(self):
"""Les deux listes du menu parent sont tenues à la main et rien ne
les rapproche : `[1]` peut afficher une entrée et appeler l'autre."""
from script.todo.todo import TODO
todo = TODO()
with (
patch.object(TODO, "prompt_assistant_llm") as mock_llm,
patch("script.todo.mail.menu.prompt_execute_mail") as mock_mail,
patch("click.prompt", side_effect=["1", "0"]),
patch("script.todo.todo_telemetry.record"),
2026-09-09 06:41:56 -04:00
):
todo.prompt_assistant()
mock_llm.assert_called_once_with()
mock_mail.assert_not_called()
def test_deux_dispatche_toujours_vers_le_courriel_seulement(self):
from script.todo.todo import TODO
todo = TODO()
with (
patch.object(TODO, "prompt_assistant_llm") as mock_llm,
patch("script.todo.mail.menu.prompt_execute_mail") as mock_mail,
patch("click.prompt", side_effect=["2", "0"]),
patch("script.todo.todo_telemetry.record"),
2026-09-09 06:41:56 -04:00
):
todo.prompt_assistant()
mock_mail.assert_called_once()
mock_llm.assert_not_called()
def test_le_sous_menu_s_ouvre_sans_aucun_serveur_configure(self):
"""Une machine sans serveur est le cas de la PREMIÈRE utilisation.
La sonde et le registre sont injectés : le menu ne doit ni ouvrir de
socket, ni lire la configuration réelle du poste qui lance la suite.
"""
from script.todo.todo import TODO
todo = TODO()
with (
patch(
"script.todo.assistant.fingerprint.collect", return_value={}
),
patch("script.todo.assistant.servers.load", return_value=[]),
patch("click.prompt", side_effect=["0"]),
patch("script.todo.todo_telemetry.record"),
2026-09-09 06:41:56 -04:00
):
todo.prompt_assistant_llm()
class ClesDeTraduction(unittest.TestCase):
"""Ce qu'une clé manquante coûte : du texte anglais brut à l'écran."""
def test_chaque_cle_du_menu_est_declaree(self):
from script.todo.todo_i18n import TRANSLATIONS
cles = cles_de_traduction(MENU)
self.assertTrue(cles, "aucune clé t() trouvée dans le menu")
manquantes = sorted(c for c in cles if c not in TRANSLATIONS)
self.assertEqual(manquantes, [])
def test_chaque_commande_annoncee_est_traduite(self):
"""« /? » imprime l'aide de chaque commande servie : une aide non
traduite s'y afficherait en anglais au milieu du français."""
from script.todo.assistant.chat import COMMANDS
from script.todo.assistant_menu import COMMANDES_PHASE_1
from script.todo.todo_i18n import TRANSLATIONS
self.assertTrue(COMMANDES_PHASE_1)
for nom in COMMANDES_PHASE_1:
self.assertIn(nom, COMMANDS)
self.assertIn(COMMANDS[nom], TRANSLATIONS)
class JournalDuTransport(unittest.TestCase):
"""La conversation ne doit pas être coupée par des lignes de journal.
`todo.py` pose un gestionnaire sur le logger racine à l'import, et le
transport HTTP journalise chaque requête en INFO : sans réglage, une ligne
« HTTP Request: POST … » paraît à chaque réponse du modèle.
"""
def test_le_transport_du_client_openai_est_silencieux(self):
"""Le nom du transport n'est pas stable : le venv porte `httpx` ET
`httpx2`, et c'est la version du client `openai` qui décide lequel
émet. Ce test part du transport RÉELLEMENT importé, pour tomber en
rouge le jour où le client en change plutôt que de laisser la ligne
revenir en silence."""
import logging
from script.todo.assistant_menu import AssistantMenuMixin
AssistantMenuMixin._llm_quiet_http()
import types
import openai._base_client as base
# Le client importe son transport sous le nom du paquet, qui est
# justement ce qui change : on cherche donc tout module dont le nom
# de tête commence par « httpx », plutôt qu'un attribut fixe.
noms = {
valeur.__name__.split(".")[0]
for valeur in vars(base).values()
if isinstance(valeur, types.ModuleType)
and valeur.__name__.split(".")[0].startswith("httpx")
}
self.assertTrue(noms, "aucun transport httpx dans le client openai")
for nom in sorted(noms):
self.assertGreaterEqual(
logging.getLogger(nom).getEffectiveLevel(),
logging.WARNING,
f"le logger « {nom} » parlerait pendant la conversation",
)
def test_les_deux_transports_connus_sont_nommes(self):
import logging
from script.todo.assistant_menu import AssistantMenuMixin
AssistantMenuMixin._llm_quiet_http()
for nom in ("httpx", "httpx2"):
self.assertGreaterEqual(
logging.getLogger(nom).getEffectiveLevel(), logging.WARNING
)
class Balayage(unittest.TestCase):
"""Ce que le balayage doit atteindre, et ce qu'il ne doit pas taire.
Un serveur vit souvent sur un réseau que la machine ne PORTE pas,
joignable par la passerelle. Les deux mécanismes qui semblaient couvrir ce
cas ne le couvrent pas : la saisie d'une adresse ne prend qu'un hôte, et
la table de voisinage est link-local, donc elle ne connaît jamais un hôte
routé. Sans réseau saisi à la main, un tel serveur est hors d'atteinte.
"""
def _todo(self):
from script.todo.todo import TODO
todo = TODO()
todo._llm_session = {
"serveur": None,
"sonde": [],
"confirmes": set(),
}
return todo
def test_un_reseau_non_porte_est_balayable(self):
"""Aucune interface ne porte ce préfixe, et il doit être planifiable
quand même : c'est le cas d'un CLI qui tourne dans une VM, dont le
« réseau local » est celui de l'hyperviseur."""
from script.todo.assistant import discover as llm_disc
todo = self._todo()
vus = {}
with (
patch.object(llm_disc, "local_networks", return_value=[]),
patch.object(llm_disc, "run_ip", return_value=""),
patch.object(
todo, "_qemu_host_addresses", staticmethod(lambda: set())
),
patch.object(
todo,
"_llm_probe_and_keep",
lambda adresses, **kw: vus.update(
{"n": len(adresses), "kw": kw}
),
),
patch("click.prompt", side_effect=["198.51.100.0/24", "o"]),
2026-09-09 06:41:56 -04:00
):
todo._llm_search_cidr()
self.assertEqual(vus.get("n"), 254)
self.assertEqual(vus["kw"]["cible"], "198.51.100.0/24")
self.assertFalse(vus["kw"]["restreint"])
def test_le_defaut_est_le_reseau_entier_pas_les_hotes_deja_vus(self):
"""Rétrécir par défaut se retourne : la table de voisinage ne porte
souvent que la passerelle, le balayage tombe à une adresse, et le
résumé annonce un réseau vide là où une seule adresse a été vue."""
from script.todo.assistant import discover as llm_disc
todo = self._todo()
vus = {}
voisinage = "198.51.100.1 dev lien0 lladdr aa:bb:cc:dd:ee:01 REACHABLE"
with (
patch.object(llm_disc, "run_ip", return_value=voisinage),
patch.object(
todo, "_qemu_host_addresses", staticmethod(lambda: set())
),
patch.object(
todo,
"_llm_probe_and_keep",
lambda adresses, **kw: vus.update(
{"n": len(adresses), "kw": kw}
),
),
patch("click.prompt", side_effect=["o"]),
2026-09-09 06:41:56 -04:00
):
todo._llm_sweep_cidr("198.51.100.0/24")
self.assertEqual(vus.get("n"), 254)
self.assertFalse(vus["kw"]["restreint"])
def test_la_lettre_v_restreint_et_le_dit(self):
from script.todo.assistant import discover as llm_disc
todo = self._todo()
vus = {}
voisinage = "198.51.100.1 dev lien0 lladdr aa:bb:cc:dd:ee:01 REACHABLE"
with (
patch.object(llm_disc, "run_ip", return_value=voisinage),
patch.object(
todo, "_qemu_host_addresses", staticmethod(lambda: set())
),
patch.object(
todo,
"_llm_probe_and_keep",
lambda adresses, **kw: vus.update(
{"n": len(adresses), "kw": kw}
),
),
patch("click.prompt", side_effect=["v"]),
2026-09-09 06:41:56 -04:00
):
todo._llm_sweep_cidr("198.51.100.0/24")
self.assertEqual(vus.get("n"), 1)
self.assertTrue(vus["kw"]["restreint"])
def test_un_balayage_restreint_sans_trouvaille_le_dit(self):
"""« Aucun serveur sur ce /24 » après une adresse regardée est un
faux négatif présenté comme un fait."""
import io
from contextlib import redirect_stdout
from script.todo.assistant import discover as llm_disc
todo = self._todo()
sortie = io.StringIO()
with (
patch.object(llm_disc, "sweep", return_value=[]),
redirect_stdout(sortie),
2026-09-09 06:41:56 -04:00
):
todo._llm_probe_and_keep(
["198.51.100.1"], cible="198.51.100.0/24", restreint=True
)
texte = sortie.getvalue()
self.assertIn(t("Only part of that network was swept."), texte)
def test_un_balayage_complet_sans_trouvaille_ne_le_dit_pas(self):
import io
from contextlib import redirect_stdout
from script.todo.assistant import discover as llm_disc
todo = self._todo()
sortie = io.StringIO()
with (
patch.object(llm_disc, "sweep", return_value=[]),
redirect_stdout(sortie),
2026-09-09 06:41:56 -04:00
):
todo._llm_probe_and_keep(["198.51.100.1"], cible="198.51.100.0/24")
self.assertNotIn(
t("Only part of that network was swept."), sortie.getvalue()
)
def test_plus_large_qu_un_slash24_est_refuse(self):
from script.todo.assistant import discover as llm_disc
todo = self._todo()
appels = []
with (
patch.object(
todo, "_qemu_host_addresses", staticmethod(lambda: set())
),
patch.object(llm_disc, "run_ip", return_value=""),
patch.object(
todo, "_llm_probe_and_keep", lambda *a, **k: appels.append(a)
),
patch("click.prompt", side_effect=[]),
2026-09-09 06:41:56 -04:00
):
todo._llm_sweep_cidr("10.0.0.0/8")
self.assertEqual(appels, [])
class ReglagesDuBalayage(unittest.TestCase):
"""Les réglages viennent des préférences, et une valeur abîmée n'y passe
pas.
Le délai est le seul réglage du balayage qui fabrique des faux négatifs :
sous mille connexions simultanées, un hôte joignable en une milliseconde
se manque à cinq centièmes de délai. Une valeur illisible ou négative doit
donc retomber sur le défaut du module, jamais s'appliquer.
"""
def test_les_prefs_sont_passees_au_balayage(self):
from script.todo import todo_prefs
from script.todo.assistant_menu import AssistantMenuMixin
with patch.object(
todo_prefs,
"get",
lambda cle, defaut=None: {
"assistant_sweep_workers": 64,
"assistant_sweep_timeout": 0.75,
}[cle],
):
reglages = AssistantMenuMixin._llm_sweep_tuning()
self.assertEqual(reglages, {"workers": 64, "timeout": 0.75})
def test_une_valeur_illisible_retombe_sur_le_defaut_du_module(self):
from script.todo import todo_prefs
from script.todo.assistant_menu import AssistantMenuMixin
for mauvais in ("beaucoup", None, -1, 0):
with patch.object(
todo_prefs, "get", lambda cle, defaut=None: mauvais
):
reglages = AssistantMenuMixin._llm_sweep_tuning()
self.assertNotIn("workers", reglages, f"accepté : {mauvais!r}")
self.assertNotIn("timeout", reglages, f"accepté : {mauvais!r}")
def test_le_defaut_du_module_ne_suit_pas_le_nombre_de_coeurs(self):
"""Dimensionner par cœurs est l'erreur que ce module refuse : ces
fils attendent le réseau. Le plafond réel est le nombre de sondes,
que le balayage applique lui-même."""
import os
from script.todo.assistant import discover as llm_disc
self.assertNotEqual(llm_disc.MAX_WORKERS, os.cpu_count())
self.assertGreaterEqual(llm_disc.MAX_WORKERS, 1024)
self.assertNotIn("cpu_count", _source_de_la_decouverte())
def test_les_prefs_declarent_les_deux_reglages(self):
from script.todo import todo_prefs
for cle in ("assistant_sweep_workers", "assistant_sweep_timeout"):
self.assertIn(cle, todo_prefs.DEFAULTS)
def _source_de_la_decouverte():
chemin = os.path.join(RACINE, "script", "todo", "assistant", "discover.py")
with open(chemin) as fichier:
return fichier.read()
class SessionsClaudeCode(unittest.TestCase):
"""Le câblage de la phase 4, sous « GPT code » et non sous le LLM.
Une session est un processus adressé par identifiant ; un serveur est un
hôte adressé par port. Les mêler dans une liste numérotée ferait partager
des numéros à deux modèles mentaux, et toutes les entrées Claude vivent
déjà sous ce menu-là.
"""
def test_todo_expose_le_sous_menu_des_sessions(self):
from script.todo.todo import TODO
self.assertTrue(hasattr(TODO, "prompt_claude_sessions"))
def test_l_etiquette_de_fil_d_ariane_existe(self):
from script.todo.todo import TODO
self.assertEqual(
TODO._MENU_LABELS.get("prompt_claude_sessions"), "Claude Code"
)
def test_six_dispatche_vers_les_sessions_seulement(self):
"""Les deux listes de `prompt_execute_gpt_code` sont tenues à la
main : l'entrée peut s'afficher et appeler autre chose."""
from script.todo.todo import TODO
todo = TODO()
with (
patch.object(TODO, "prompt_claude_sessions") as mock_sessions,
patch.object(
TODO, "prompt_execute_claude_plugins"
) as mock_plugins,
patch("click.prompt", side_effect=["6", "0"]),
patch("script.todo.todo_telemetry.record"),
2026-09-09 06:41:56 -04:00
):
todo.prompt_execute_gpt_code()
mock_sessions.assert_called_once_with()
mock_plugins.assert_not_called()
def test_le_sous_menu_s_ouvre_sans_aucune_session(self):
"""Une machine sans Claude Code n'est pas une panne du menu."""
from script.todo.todo import TODO
todo = TODO()
with (
patch(
"script.todo.assistant.claude_sessions.fleet", return_value=[]
),
patch("click.prompt", side_effect=["0"]),
patch("script.todo.todo_telemetry.record"),
2026-09-09 06:41:56 -04:00
):
todo.prompt_claude_sessions()
def test_un_claude_absent_est_un_message_pas_un_plantage(self):
import io
from contextlib import redirect_stdout
from script.todo.todo import TODO
todo = TODO()
sortie = io.StringIO()
with patch("shutil.which", return_value=None), redirect_stdout(sortie):
todo._claude_questionner([])
todo._claude_reprendre([])
self.assertIn(t("claude is not on the PATH."), sortie.getvalue())
def test_aucune_session_a_reprendre_le_dit(self):
import io
from contextlib import redirect_stdout
from script.todo.todo import TODO
todo = TODO()
sortie = io.StringIO()
with (
patch("shutil.which", return_value="/usr/bin/claude"),
patch("click.prompt", side_effect=["0"]),
redirect_stdout(sortie),
):
2026-09-09 06:41:56 -04:00
todo._claude_reprendre([])
self.assertIn(t("No session on this machine."), sortie.getvalue())
class Frontiere(unittest.TestCase):
"""Le paquet doit vivre sans le CLI qui l'appelle."""
def test_le_paquet_assistant_n_importe_pas_todo(self):
"""Vérifié dans un processus NEUF : ce fichier-ci importe `TODO`, donc
`sys.modules` le porte déjà et l'assertion passerait ici pour de
mauvaises raisons.
Ce que la frontière achète est mesurable : importer `todo.py` coûte
près d'une seconde et imprime sur la sortie, et neuf fichiers de test
le paieraient à chaque exécution.
"""
code = (
"import sys;"
"import script.todo.assistant.backends;"
"import script.todo.assistant.capabilities;"
"import script.todo.assistant.chat;"
"import script.todo.assistant.fingerprint;"
"import script.todo.assistant.servers;"
"print('script.todo.todo' in sys.modules)"
)
res = subprocess.run(
[sys.executable, "-c", code],
cwd=RACINE,
capture_output=True,
text=True,
env={**os.environ, "PYTHONPATH": RACINE},
timeout=60,
)
self.assertEqual(res.returncode, 0, res.stderr)
self.assertEqual(res.stdout.strip(), "False", res.stdout)
if __name__ == "__main__":
unittest.main()