536 lines
20 KiB
Python
536 lines
20 KiB
Python
|
|
#!/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.
|
||
|
|
"""
|
||
|
|
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"
|
||
|
|
):
|
||
|
|
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"
|
||
|
|
):
|
||
|
|
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"
|
||
|
|
):
|
||
|
|
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"]
|
||
|
|
):
|
||
|
|
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"]
|
||
|
|
):
|
||
|
|
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"]
|
||
|
|
):
|
||
|
|
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
|
||
|
|
):
|
||
|
|
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
|
||
|
|
):
|
||
|
|
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=[]
|
||
|
|
):
|
||
|
|
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"
|
||
|
|
):
|
||
|
|
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"
|
||
|
|
):
|
||
|
|
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):
|
||
|
|
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()
|