231 lines
8.7 KiB
Python
231 lines
8.7 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
|
"""L'aide « ? » du formulaire de déploiement.
|
||
|
|
|
||
|
|
Une case cochée engage parfois huit poses, et son libellé n'en nomme que
|
||
|
|
trois : « Outils d'assistance IA » installe aussi les hooks git du dépôt, les
|
||
|
|
commandes Claude et une entrée d'historique. Ce que le formulaire fait ne
|
||
|
|
peut pas se lire seulement dans le code de la commande distante — on décide
|
||
|
|
devant l'écran, une heure avant que le journal ne le montre.
|
||
|
|
|
||
|
|
Ce que ces tests gardent :
|
||
|
|
|
||
|
|
- chaque outil du catalogue a une aide, et un outil ajouté sans texte fait
|
||
|
|
tomber le test plutôt que d'afficher un bloc vide ;
|
||
|
|
- l'aide TRAVERSE le contexte : c'est le seul chemin jusqu'aux deux écrans,
|
||
|
|
et un réglage qui n'y entre pas n'existe pas ;
|
||
|
|
- les DEUX formulaires l'ouvrent — c'est en n'en servant qu'un que l'écran
|
||
|
|
Proxmox avait perdu la moitié des réglages ;
|
||
|
|
- la fenêtre se ferme sur Esc sans rien changer, et sans fermer l'écran qui
|
||
|
|
la porte.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import importlib.util
|
||
|
|
import sys
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
sys.argv = ["todo.py"]
|
||
|
|
from script.todo.deploy_form_extras import ( # noqa: E402
|
||
|
|
INSTALL_HELP,
|
||
|
|
ExtrasMixin,
|
||
|
|
extras_tables,
|
||
|
|
)
|
||
|
|
from script.todo.todo import TODO # noqa: E402
|
||
|
|
|
||
|
|
try:
|
||
|
|
import textual # noqa: F401
|
||
|
|
|
||
|
|
TEXTUAL = True
|
||
|
|
except Exception: # pragma: no cover - dépend de l'environnement
|
||
|
|
TEXTUAL = False
|
||
|
|
|
||
|
|
|
||
|
|
def contexte():
|
||
|
|
todo = TODO.__new__(TODO)
|
||
|
|
mod = todo._qemu_import_module()
|
||
|
|
todo._qemu_list_domains = lambda: []
|
||
|
|
todo._qemu_branch_list = lambda: ["develop", "master"]
|
||
|
|
return todo._qemu_form_context(mod)
|
||
|
|
|
||
|
|
|
||
|
|
def contexte_proxmox():
|
||
|
|
"""Le contexte synthétique de l'écran Proxmox, plus le système invité.
|
||
|
|
|
||
|
|
Chargé PAR SON CHEMIN, comme le fait test_qemu_ai_tools de deploy_qemu :
|
||
|
|
« test » est aussi un paquet de la bibliothèque standard, et un import par
|
||
|
|
son nom y mènerait. Le contexte lui-même n'est pas recopié — une seconde
|
||
|
|
copie dériverait de celle que garde test_proxmox_form.
|
||
|
|
"""
|
||
|
|
chemin = Path(__file__).resolve().parent / "test_proxmox_form.py"
|
||
|
|
spec = importlib.util.spec_from_file_location("tpf_ctx", chemin)
|
||
|
|
mod = importlib.util.module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(mod)
|
||
|
|
invite = TODO.__new__(TODO)._qemu_guest_context()
|
||
|
|
return {**invite, **mod.contexte()}
|
||
|
|
|
||
|
|
|
||
|
|
class Ecran(ExtrasMixin):
|
||
|
|
"""Le mixin seul, sans Textual : les blocs sont du texte, pas des widgets."""
|
||
|
|
|
||
|
|
def __init__(self, ctx):
|
||
|
|
self._extras = extras_tables(ctx)
|
||
|
|
|
||
|
|
|
||
|
|
class LeCatalogue(unittest.TestCase):
|
||
|
|
def test_every_tool_says_what_it_installs(self):
|
||
|
|
"""Un outil sans aide afficherait un bloc vide, ce qui se lit comme
|
||
|
|
« il ne pose rien »."""
|
||
|
|
for key, spec in TODO._QEMU_VM_TOOLS.items():
|
||
|
|
with self.subTest(outil=key):
|
||
|
|
self.assertTrue(spec.get("help"), key)
|
||
|
|
for ligne in spec["help"]:
|
||
|
|
self.assertTrue(ligne.strip())
|
||
|
|
|
||
|
|
def test_the_ai_tool_names_all_its_halves(self):
|
||
|
|
"""La case engage des installations ET une pré-configuration : celle
|
||
|
|
qui n'est pas nommée est celle qu'on croit absente."""
|
||
|
|
aide = " ".join(TODO._QEMU_VM_TOOLS["aidev"]["help"])
|
||
|
|
for morceau in (
|
||
|
|
"tig",
|
||
|
|
"rtk",
|
||
|
|
"starship",
|
||
|
|
"zdiff3",
|
||
|
|
"hooks",
|
||
|
|
"commit",
|
||
|
|
".venv.erplibre/bin/activate",
|
||
|
|
):
|
||
|
|
self.assertIn(morceau, aide)
|
||
|
|
|
||
|
|
def test_the_help_crosses_the_context(self):
|
||
|
|
"""Le contexte est le seul chemin jusqu'aux deux écrans."""
|
||
|
|
ctx = contexte()
|
||
|
|
aides = ctx["vm_tool_help"]
|
||
|
|
self.assertEqual(sorted(TODO._QEMU_VM_TOOLS), sorted(aides))
|
||
|
|
for key, lignes in aides.items():
|
||
|
|
self.assertTrue(lignes, key)
|
||
|
|
|
||
|
|
def test_the_common_options_point_at_real_tables(self):
|
||
|
|
"""Une condition sur une clé inexistante lèverait au premier appel de
|
||
|
|
l'aide, sur l'écran, devant l'utilisateur."""
|
||
|
|
tab = extras_tables(contexte())
|
||
|
|
for cle, titre, lignes in INSTALL_HELP:
|
||
|
|
with self.subTest(titre=titre):
|
||
|
|
if cle:
|
||
|
|
self.assertIn(cle, tab)
|
||
|
|
self.assertTrue(titre.strip())
|
||
|
|
self.assertTrue(lignes)
|
||
|
|
|
||
|
|
|
||
|
|
class LesBlocs(unittest.TestCase):
|
||
|
|
def test_it_covers_the_common_settings_and_every_offered_tool(self):
|
||
|
|
blocs = Ecran(contexte()).extras_help_blocks()
|
||
|
|
titres = [titre for titre, _lignes in blocs]
|
||
|
|
self.assertEqual(len(blocs), len(titres))
|
||
|
|
for _key, label, _hint in extras_tables(contexte())["vm_tools"]:
|
||
|
|
self.assertIn(label, titres)
|
||
|
|
# Les réglages communs viennent d'abord : on lit l'écran de haut en
|
||
|
|
# bas, et l'aide suit le même ordre.
|
||
|
|
self.assertLess(len(INSTALL_HELP), len(blocs))
|
||
|
|
|
||
|
|
def test_each_tool_block_ends_with_its_disk_cost(self):
|
||
|
|
"""Le disque est ce qui décide de cocher ou non sur une machine
|
||
|
|
étroite : il est dans le bloc, pas seulement dans le libellé."""
|
||
|
|
blocs = dict(Ecran(contexte()).extras_help_blocks())
|
||
|
|
for _key, label, _hint in extras_tables(contexte())["vm_tools"]:
|
||
|
|
self.assertRegex(blocs[label][-1], r"^\+\d+ Go$")
|
||
|
|
|
||
|
|
def test_a_screen_without_tools_says_nothing_about_them(self):
|
||
|
|
"""Chaque accès aux tables est gardé : un formulaire n'est pas tenu
|
||
|
|
d'offrir tous les réglages."""
|
||
|
|
blocs = Ecran({}).extras_help_blocks()
|
||
|
|
self.assertTrue(blocs)
|
||
|
|
self.assertEqual(len(INSTALL_HELP) - 4, len(blocs))
|
||
|
|
|
||
|
|
|
||
|
|
def ouvre_l_aide(forme="qemu", touche="f1", saisie=False):
|
||
|
|
"""Ce que l'écran montre : {ouvert, texte, ferme, depart, piles}.
|
||
|
|
|
||
|
|
Chaque forme apporte SON contexte : celui de l'autre écran lui manquerait
|
||
|
|
la moitié de ses clés, et l'écran ne se monterait pas.
|
||
|
|
"""
|
||
|
|
from script.todo.proxmox_deploy_form import run_proxmox_form
|
||
|
|
from script.todo.qemu_deploy_form import run_deploy_form
|
||
|
|
|
||
|
|
vu = {}
|
||
|
|
|
||
|
|
async def scenario():
|
||
|
|
app = (
|
||
|
|
run_deploy_form(contexte(), run_app=False)
|
||
|
|
if forme == "qemu"
|
||
|
|
else run_proxmox_form(contexte_proxmox(), run_app=False)
|
||
|
|
)
|
||
|
|
async with app.run_test(size=(200, 60)) as pilote:
|
||
|
|
await pilote.pause()
|
||
|
|
if saisie:
|
||
|
|
# Un champ de saisie avale les touches imprimables : c'est la
|
||
|
|
# situation où « ? » ne peut pas servir, et F1 doit.
|
||
|
|
from textual.widgets import Input
|
||
|
|
|
||
|
|
app.set_focus(app.query_one("#f_key", Input))
|
||
|
|
await pilote.pause()
|
||
|
|
depart = app.screen.__class__.__name__
|
||
|
|
await pilote.press(touche)
|
||
|
|
await pilote.pause()
|
||
|
|
vu["ouvert"] = app.screen.__class__.__name__
|
||
|
|
vu["texte"] = " ".join(
|
||
|
|
str(w.render()) for w in app.screen.query("Static")
|
||
|
|
)
|
||
|
|
await pilote.press("escape")
|
||
|
|
await pilote.pause()
|
||
|
|
vu["ferme"] = app.screen.__class__.__name__
|
||
|
|
vu["depart"] = depart
|
||
|
|
vu["piles"] = len(app.screen_stack)
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
return vu
|
||
|
|
|
||
|
|
|
||
|
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||
|
|
class LEcran(unittest.TestCase):
|
||
|
|
def test_f1_opens_it_on_both_forms(self):
|
||
|
|
"""C'est en ne servant qu'un des deux écrans que la moitié des
|
||
|
|
réglages avait dérivé."""
|
||
|
|
for forme in ("qemu", "proxmox"):
|
||
|
|
with self.subTest(forme=forme):
|
||
|
|
vu = ouvre_l_aide(forme)
|
||
|
|
self.assertEqual("HelpScreen", vu["ouvert"])
|
||
|
|
|
||
|
|
def test_the_question_mark_opens_it_too(self):
|
||
|
|
"""C'est la touche qu'on essaie d'abord, et le panneau l'a au montage
|
||
|
|
— aucun champ de saisie ne prend le focus en arrivant."""
|
||
|
|
vu = ouvre_l_aide("qemu", "?")
|
||
|
|
self.assertEqual("HelpScreen", vu["ouvert"])
|
||
|
|
|
||
|
|
def test_f1_is_the_one_that_works_from_a_text_field(self):
|
||
|
|
"""Un champ de saisie avale les touches imprimables : « ? » s'y écrit
|
||
|
|
au lieu d'ouvrir, et c'est pourquoi F1 existe à côté."""
|
||
|
|
self.assertEqual(
|
||
|
|
"HelpScreen", ouvre_l_aide("qemu", "f1", saisie=True)["ouvert"]
|
||
|
|
)
|
||
|
|
self.assertNotEqual(
|
||
|
|
"HelpScreen", ouvre_l_aide("qemu", "?", saisie=True)["ouvert"]
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_escape_closes_it_and_leaves_the_form_standing(self):
|
||
|
|
"""Une aide qui fermerait le formulaire ferait perdre la saisie."""
|
||
|
|
vu = ouvre_l_aide("qemu")
|
||
|
|
self.assertEqual(vu["depart"], vu["ferme"])
|
||
|
|
self.assertEqual(1, vu["piles"])
|
||
|
|
|
||
|
|
def test_what_it_shows_is_what_the_option_installs(self):
|
||
|
|
vu = ouvre_l_aide("qemu")
|
||
|
|
for morceau in ("tig", "zdiff3", "hooks"):
|
||
|
|
self.assertIn(morceau, vu["texte"])
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|