[ADD] todo : dire la place qui reste sous le plan de déploiement

La ligne de totaux annonçait « ~126 G » sans dire sur quoi : la demande
seule ne dit pas si ça rentre, et on l'apprenait au déploiement. Elle dit
maintenant la demande, ce qui reste et la capacité — « ~126 G / 20 G libres
sur 270 G » — et prévient dès que le plan dépasse. Les trois limites (RAM,
disque, cœurs) s'affichent ensemble : n'en montrer qu'une cachait les
autres. Sur Proxmox, la place vient du stockage choisi, que « pvesm status »
donnait déjà.

Deux défauts trouvés en le faisant : la marque de génération, exigée de tous
les widgets, faisait taire chaque réglage commun de l'écran Proxmox, et
« [x1] » disparaissait, lu comme une balise Rich.

--- EN ---

The totals line said "~126 G" without saying out of what: the demand alone
does not tell whether it fits, and one found out at deploy time. It now says
the demand, what is left and the capacity — "~126 G / 20 G free of 270 G" —
and warns as soon as the plan exceeds it. The three limits (RAM, disk,
cores) show together: showing only one hid the others. On Proxmox the room
comes from the chosen storage, which "pvesm status" already gave.

Two defects found on the way: the generation mark, required of every widget,
silenced each common setting on the Proxmox screen, and "[x1]" vanished,
read as a Rich tag.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 01:11:09 -04:00
parent ccedba3411
commit 562ad1c873
10 changed files with 401 additions and 50 deletions

View file

@ -291,6 +291,31 @@ def plan_rows(vms, domains, extra_disk_gb=0, orphelin=None):
return rows
def gib(nbytes) -> int:
"""Octets -> Gio entiers. Le plan compte en Go partout ailleurs : mêler
des unités sur la même ligne de totaux la rendrait illisible."""
try:
return int(nbytes) // (1 << 30)
except (TypeError, ValueError):
return 0
def disk_note(plan_gb, free_gb, total_gb=0) -> str:
"""« ~50 G / 20 G libres sur 270 G » — la demande, le reste, la capacité.
La demande seule ne dit pas si ça rentre : c'est le rapprochement qui
décide, et c'est pourquoi la place s'affiche là même où la demande était
déjà écrite. Les deux nombres ne sont pas redondants — 20 Go libres sur
270 se lit autrement que 20 sur 24. Sans mesure (0), on n'invente rien :
la demande s'affiche seule.
"""
if not free_gb:
return f"~{plan_gb} G"
if not total_gb:
return f"~{plan_gb} G / {free_gb} G {t('free')}"
return f"~{plan_gb} G / {free_gb} G {t('free of')} {total_gb} G"
def plan_totals(rows):
"""Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de
neuf) : (nb, vcpus, ram_mo, disque_go)."""

View file

@ -21,6 +21,7 @@ Le formulaire ne touche à rien : il rend une spec. C'est l'appelant
"""
import os
import re
from script.todo.deploy_form_lib import (
CSS_BASE,
@ -28,7 +29,9 @@ from script.todo.deploy_form_lib import (
RES_FIELDS,
SELECT_TO_FIELD,
build_vms,
disk_note,
entry_key,
gib,
plan_rows,
plan_totals,
res_row_widgets,
@ -466,14 +469,18 @@ def run_proxmox_form(ctx, run_app: bool = True):
pass
n, cpu, ram, disque = plan_totals(self.rows)
libre = ctx.get("free_ram") or 0
alerte = (
f" ⚠ {t('more RAM than the host has free')}"
if libre and ram > libre
else ""
)
# La place du stockage CHOISI : elle change avec la liste, donc
# elle se relit à chaque rendu plutôt qu'une fois au montage.
place = gib((ctx.get("storage_avail") or {}).get(self._storage()))
alertes = []
if libre and ram > libre:
alertes.append(t("more RAM than the host has free"))
if place and disque > place:
alertes.append(t("more disk than the storage has free"))
alerte = f" ⚠ {' · '.join(alertes)}" if alertes else ""
self.query_one("#totals", Static).update(
f" {n} {t('VM')} {cpu} vCPU {ram} Mo RAM "
f"{disque} Go [{res_label(self.profile)}]"
f"{disk_note(disque, place)} {res_label(self.profile)}"
f" {t('storage')} {self._storage() or '?'}"
f" {t('bridge')} {self._bridge() or '?'}{alerte}"
)
@ -537,9 +544,15 @@ def run_proxmox_form(ctx, run_app: bool = True):
self._refresh_after()
def on_select_changed(self, event) -> None:
if self._syncing or not self._is_current(event.select):
if self._syncing:
return
ident = event.select.id or ""
# La marque de génération ne concerne QUE les widgets de rangée :
# un widget global n'en porte pas. L'exiger de tous revenait à
# ignorer chaque réglage commun — mesuré, ni le stockage, ni la
# RAM générale n'atteignaient le plan.
if re.match(r"v\d+_", ident) and not self._is_current(event.select):
return
if ident in ("f_storage", "f_bridge"):
self._refresh_after()
return

View file

@ -142,9 +142,7 @@ class ProxmoxMenuMixin:
def _pve_host_from_ssh_config(self):
"""Un alias de ~/.ssh/config : il porte déjà utilisateur, port et
ProxyJump — rien à redemander, et le rebond traverse."""
entrees = self._ssh_config_entries(
os.path.expanduser("~/.ssh/config")
)
entrees = self._ssh_config_entries(os.path.expanduser("~/.ssh/config"))
if not entrees:
print(f"\n{t('No SSH hosts found in ~/.ssh/config')}")
return None
@ -205,7 +203,9 @@ class ProxmoxMenuMixin:
chemin = os.path.expanduser("~/.ssh/known_hosts")
os.makedirs(os.path.dirname(chemin), exist_ok=True)
with open(chemin, "a", encoding="utf-8") as fh:
fh.write(res.stdout if res.stdout.endswith("\n") else res.stdout + "\n")
fh.write(
res.stdout if res.stdout.endswith("\n") else res.stdout + "\n"
)
lignes = len(res.stdout.strip().splitlines())
print(f" ✓ {lignes} {t('key(s) recorded in ~/.ssh/known_hosts')}")
return True
@ -233,7 +233,9 @@ class ProxmoxMenuMixin:
print(f" ✗ {t('Not a Proxmox host (or unreachable):')}")
premiere = (out or "").strip().splitlines()
print(f" {premiere[0] if premiere else t('no answer')}")
print(f" → {t('Check the address, the SSH access and pveversion.')}")
print(
f" → {t('Check the address, the SSH access and pveversion.')}"
)
return None
# « qm » exige les privilèges. La voie « VM QEMU locale » donne
# l'accès d'erplibre, pas de root : il faut donc sudo, et il faut le
@ -244,7 +246,9 @@ class ProxmoxMenuMixin:
if qui.strip() != "0":
code, _o = pve.run(host, "sudo -n true", timeout=20)
if code:
print(f" ✗ {t('qm needs root: no root, and sudo asks for a password.')}")
print(
f" ✗ {t('qm needs root: no root, and sudo asks for a password.')}"
)
print(f" → {t('Connect as root@, or allow NOPASSWD sudo.')}")
return None
prefixe = "sudo "
@ -295,9 +299,7 @@ class ProxmoxMenuMixin:
return [] if multiple else None
print(f"\n{titre or t('VMs on this host:')}")
for i, vm in enumerate(vms, 1):
print(
f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}"
)
print(f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}")
if multiple:
print(f" [all] {t('select all')}")
brut = input(t("Selection (number): ")).strip()
@ -390,9 +392,7 @@ class ProxmoxMenuMixin:
if not vm:
return
print(f"\n ⚠ {t('Proxmox can only GROW a disk, never shrink it.')}")
taille = input(
t("Size (+10G to add, 40G for a target): ")
).strip()
taille = input(t("Size (+10G to add, 40G for a target): ")).strip()
if not re.match(r"^\+?\d+[MGT]$", taille):
print(t("Invalid selection!"))
return
@ -485,7 +485,9 @@ class ProxmoxMenuMixin:
"""Recopie la clé publique SUR l'hôte : « qm set --sshkeys » attend un
FICHIER là-bas, pas une clé en ligne."""
try:
with open(os.path.expanduser(chemin_local), encoding="utf-8") as fh:
with open(
os.path.expanduser(chemin_local), encoding="utf-8"
) as fh:
cle = fh.read().strip()
except OSError as exc:
print(f" ⚠ {t('SSH key unreadable:')} {exc}")
@ -525,11 +527,11 @@ class ProxmoxMenuMixin:
print(" address <ip-de-l-hôte>/24")
print(" gateway <passerelle>")
print(" bridge-ports <interface>")
print(f" ⚠ {t('This moves the host address: do it from a console.')}")
print(
f" ⚠ {t('This moves the host address: do it from a console.')}"
)
return ""
_c, sortie = self._pve_show(
"ip -o -4 route show default", quiet=True
)
_c, sortie = self._pve_show("ip -o -4 route show default", quiet=True)
uplink = ""
parts = (sortie or "").split()
if "dev" in parts:
@ -629,6 +631,12 @@ class ProxmoxMenuMixin:
"next_vmid": pve.next_vmid(vms),
"storages": [s["name"] for s in stockages if s.get("actif")],
"storage": pve.pick_storage(stockages),
# La place libre par stockage, en octets : « pvesm status » la
# donne dans la même sortie, donc l'écran peut dire si le plan
# rentre sans un aller-retour de plus vers l'hôte.
"storage_avail": {
s["name"]: s.get("avail") or 0 for s in stockages
},
"bridges": ponts,
"bridge": pve.pick_bridge(ponts),
"ipconfig": ipconfig,
@ -797,7 +805,9 @@ class ProxmoxMenuMixin:
print(f"\n {t('Waiting for the VM address…')} {vm['name']}")
ip = self._pve_guest_ip(vm["vmid"])
if not ip:
print(f" ⚠ {vm['name']} : {t('No address yet. Try [6] later.')}")
print(
f" ⚠ {vm['name']} : {t('No address yet. Try [6] later.')}"
)
continue
print(f" ✓ {vm['name']} : {ip}")
if spec.get("add_ssh_config"):
@ -839,9 +849,10 @@ class ProxmoxMenuMixin:
distro = self._qemu_prompt_distro()
version = self._qemu_prompt_version(distro)
arch = "amd64"
nom = input(
t("VM name (default: erplibre-<distro>): ")
).strip() or f"erplibre-{distro}"
nom = (
input(t("VM name (default: erplibre-<distro>): ")).strip()
or f"erplibre-{distro}"
)
memoire = (
self._qemu_ask_ram(t("RAM in MB, blank = 4096"), 4096) or 4096
)
@ -884,7 +895,9 @@ class ProxmoxMenuMixin:
# l'afficher avant de l'avoir choisi ne pouvait pas marcher.
vmid = pve.next_vmid(self._pve_vms())
ipconfig = pve.ipconfig_for(infos_ponts.get(pont, {}), vmid)
print(f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})")
print(
f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})"
)
print(f" {t('bridge')} : {pont}")
print(f" {t('address')} {ipconfig}")
print(f" VMID : {vmid}")
@ -989,7 +1002,9 @@ class ProxmoxMenuMixin:
identity_file=cle,
proxy_jump=host["target"],
)
print(f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})")
print(
f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})"
)
def _pve_test_vm(self):
"""Ouvre Odoo (:8069) d'une VM Proxmox dans un navigateur en ligne.
@ -1007,7 +1022,9 @@ class ProxmoxMenuMixin:
return
if not self._qemu_ip_reachable(ip, port=8069, timeout=3):
print(f"\n ⚠ {ip}:8069 {t('unreachable from here.')}")
print(f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}")
print(
f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}"
)
return
navigateur = self._qemu_choose_cli_browser()
if not navigateur:
@ -1032,7 +1049,9 @@ class ProxmoxMenuMixin:
"sshkey_path": "/root/.ssh/erplibre-deploy.pub",
}
print(f"\n── {t('Example: demo-vm, Debian 13, on a Proxmox host')} ──")
print(f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}")
print(
f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}"
)
for cmd in pve.create_cmds(101, spec):
print(f" {cmd}")
@ -1091,8 +1110,16 @@ class ProxmoxMenuMixin:
"SSH configuration (~/.ssh/config, ProxyJump)"
)
},
{"prompt_description": t("Remote desktop tunnel (VNC/RDP over SSH)")},
{"prompt_description": t("Android emulator (start, tunnel, scrcpy)")},
{
"prompt_description": t(
"Remote desktop tunnel (VNC/RDP over SSH)"
)
},
{
"prompt_description": t(
"Android emulator (start, tunnel, scrcpy)"
)
},
{"section": t("Catalog")},
{"prompt_description": t("List available images and their specs")},
{"prompt_description": t("Proxmox - example sequence (dry-run)")},

View file

@ -1215,6 +1215,7 @@ class QemuDeployMixin:
catalog[a] = entries
print(f"\n{t('Loading (VM list, branches)...')}")
disque_libre, disque_total = self._host_disk_gb()
return {
"catalog": catalog,
"arches": arches,
@ -1226,6 +1227,11 @@ class QemuDeployMixin:
"timezone": self._qemu_host_timezone(),
"host_cpu": os.cpu_count() or 2,
"free_ram": self._host_free_ram_mb(),
# La place du système de fichiers qui portera les qcow2. Mesurée
# ICI, comme le reste : une lecture disque pendant que Textual
# affiche n'a pas sa place.
"free_disk": disque_libre,
"total_disk": disque_total,
"base_vcpus": self._QEMU_BASE_VCPUS,
"cpu_presets": self._QEMU_CPU_PRESETS,
"ram_presets": self._QEMU_RAM_PRESETS,

View file

@ -47,9 +47,11 @@ from script.todo.deploy_form_lib import ( # noqa: F401
clip_payload,
copy_name,
disk_gb,
disk_note,
entry_key,
expand_copies,
fmt_dur,
gib,
parse_disk,
parse_ram,
plan_rows,
@ -107,6 +109,8 @@ def run_deploy_form(ctx, run_app: bool = True):
branches = ctx.get("branches") or ["master"]
host_cpu = ctx.get("host_cpu") or 2
free_ram = ctx.get("free_ram") or 0
free_disk = ctx.get("free_disk") or 0
total_disk = ctx.get("total_disk") or 0
base_vcpus = ctx.get("base_vcpus") or 2
extra_disk = ctx.get("extra_disk_gb") or 0
desktop_disk = ctx.get("desktop_disk_gb") or 0
@ -919,11 +923,18 @@ def run_deploy_form(ctx, run_app: bool = True):
)
return
n, cpus, ram, disk = plan_totals(self.rows)
warn = ""
# Une liste et non un seul avertissement : la RAM, les cœurs et le
# disque sont trois limites distinctes, et n'en montrer qu'une
# cachait les autres — on corrigeait la première pour découvrir la
# suivante au déploiement.
alertes = []
if free_ram and ram > free_ram:
warn = f" ⚠ {t('> host free RAM')}"
elif cpus > host_cpu:
warn = f" ⚠ {t('> host cores')} ({host_cpu})"
alertes.append(t("> host free RAM"))
if free_disk and disk > free_disk:
alertes.append(t("> host free disk"))
if cpus > host_cpu:
alertes.append(f"{t('> host cores')} ({host_cpu})")
warn = f" ⚠ {' · '.join(alertes)}" if alertes else ""
dupes = len({vm["name"] for vm in self.vms}) != len(self.vms)
dup_txt = (
f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}"
@ -942,7 +953,8 @@ def run_deploy_form(ctx, run_app: bool = True):
else ""
)
self.query_one("#totals", Static).update(
f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · ~{disk} G"
f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · "
f"{disk_note(disk, free_disk, total_disk)}"
f"{skip_txt}{warn}{dup_txt}"
)

View file

@ -1514,6 +1514,28 @@ class TODO(
invites « défaut oui » où tout sauf « non » vaut oui."""
return ans.strip().lower() in ("n", "no", "non")
@staticmethod
def _host_disk_gb(path="/var/lib/libvirt/images"):
"""(libre, total) en Go du système de fichiers qui portera les disques.
On remonte vers le premier parent qui existe : le répertoire d'images
n'est créé qu'au premier déploiement, et « /var/lib/libvirt » ou « / »
répondent de la même partition dans la quasi-totalité des cas. (0, 0)
si rien ne répond — la place libre s'affiche alors comme inconnue
plutôt qu'inventée.
"""
chemin = path
while chemin and not os.path.isdir(chemin):
parent = os.path.dirname(chemin)
if parent == chemin:
break
chemin = parent
try:
usage = shutil.disk_usage(chemin or "/")
except OSError:
return 0, 0
return usage.free // (1 << 30), usage.total // (1 << 30)
@staticmethod
def _host_free_ram_mb():
"""RAM disponible de l'hôte en Mo (MemAvailable), 0 si inconnu."""

View file

@ -1253,6 +1253,14 @@ TRANSLATIONS = {
"fr": "> RAM libre de l'hôte",
"en": "> host free RAM",
},
"> host free disk": {
"fr": "> disque libre de l'hôte",
"en": "> host free disk",
},
"free of": {
"fr": "libres sur",
"en": "free of",
},
"total RAM": {
"fr": "RAM totale",
"en": "total RAM",
@ -3359,6 +3367,10 @@ TRANSLATIONS = {
"fr": "Aucun pont sur l'hôte.",
"en": "No bridge on the host.",
},
"more disk than the storage has free": {
"fr": "plus de disque que le stockage n'en a de libre",
"en": "more disk than the storage has free",
},
"more RAM than the host has free": {
"fr": "plus de RAM que l'hôte n'en a de libre",
"en": "more RAM than the host has free",

View file

@ -78,9 +78,7 @@ class TestVmid(unittest.TestCase):
def test_the_address_is_derived_from_the_vmid(self):
rows = [rangee("a"), rangee("b")]
assign_vmids(
rows, [], 100, lambda v: f"ip=10.10.10.{50 + v % 200}/24"
)
assign_vmids(rows, [], 100, lambda v: f"ip=10.10.10.{50 + v % 200}/24")
self.assertEqual(rows[0]["vm"]["ipconfig"], "ip=10.10.10.150/24")
self.assertEqual(rows[1]["vm"]["ipconfig"], "ip=10.10.10.151/24")
@ -100,7 +98,11 @@ class TestSpec(unittest.TestCase):
"ssh_key": "/home/x/.ssh/id_ed25519.pub",
"start": True,
"add_ssh_config": True,
"install": {"branch": "develop", "label": "Odoo 18", "cmd": "make"},
"install": {
"branch": "develop",
"label": "Odoo 18",
"cmd": "make",
},
"monitor": True,
"parallelism": 2,
}
@ -141,7 +143,11 @@ def contexte():
}
return {
"host": {"target": "erplibre@10.0.0.5", "sudo": "sudo ", "label": "pve"},
"host": {
"target": "erplibre@10.0.0.5",
"sudo": "sudo ",
"label": "pve",
},
"node": "pve1",
"catalog": {
"amd64": [
@ -158,6 +164,10 @@ def contexte():
"next_vmid": 102,
"storages": ["local-lvm", "local"],
"storage": "local-lvm",
"storage_avail": {
"local-lvm": 90 * (1 << 30),
"local": 12 * (1 << 30),
},
"bridges": ["vmbr0"],
"bridge": "vmbr0",
"ipconfig": lambda pont, vmid: f"ip=10.10.10.{50 + vmid % 200}/24",
@ -195,6 +205,14 @@ class TestEcran(unittest.TestCase):
await pilote.pause()
await pilote.pause()
await gestes(app, pilote)
# Relevé AVANT la sortie du contexte : `run_test` démonte
# l'écran, et « #totals » n'existe plus après.
from textual.widgets import Static
widget = app.query_one("#totals", Static)
app.ligne_totaux = str(
getattr(widget, "_content", "") or widget.render()
)
resultat["app"] = app
asyncio.run(scenario())
@ -256,9 +274,7 @@ class TestEcran(unittest.TestCase):
app = self._rendu(gestes)
self.assertEqual(len(app.rows), 4)
vmids = [
r["vm"]["vmid"] for r in app.rows if r["state"] != "exists"
]
vmids = [r["vm"]["vmid"] for r in app.rows if r["state"] != "exists"]
self.assertEqual(len(vmids), len(set(vmids)))
def test_deploying_yields_a_spec_the_engine_can_run(self):
@ -276,6 +292,64 @@ class TestEcran(unittest.TestCase):
self.assertIn("ip=", vm["ipconfig"])
self.assertEqual(spec["install"]["branch"], "develop")
def _totaux(self, app):
return app.ligne_totaux
def test_the_totals_line_shows_the_room_left_on_the_storage(self):
async def rien(app, pilote):
pass
ligne = self._totaux(self._rendu(rien))
# « pvesm status » donne déjà la place : la demande du plan s'affiche
# donc à côté d'elle, sans un aller-retour de plus vers l'hôte.
self.assertIn("/ 90 G", ligne)
self.assertIn("local-lvm", ligne)
def test_changing_the_storage_changes_the_room(self):
# La marque de génération ne vaut que pour les widgets de RANGÉE :
# l'exiger des widgets globaux faisait taire tous les réglages
# communs, stockage compris.
async def gestes(app, pilote):
from textual.widgets import Select
app.query_one("#f_storage", Select).value = "local"
await pilote.pause()
await pilote.pause()
ligne = self._totaux(self._rendu(gestes))
self.assertIn("/ 12 G", ligne)
self.assertIn("local", ligne)
def test_a_plan_bigger_than_the_storage_is_flagged(self):
async def gestes(app, pilote):
from textual.widgets import Select
app.query_one("#f_storage", Select).value = "local"
await pilote.pause()
await pilote.pause()
self.assertIn("⚠", self._totaux(self._rendu(gestes)))
def test_a_common_setting_reaches_every_vm(self):
async def gestes(app, pilote):
# « value = True » sur le bouton : action_next_button() ne
# déplace que la surbrillance et n'émet aucun message.
list(app.query("#f_profile RadioButton"))[2].value = True
await pilote.pause()
await pilote.pause()
app = self._rendu(gestes)
self.assertEqual(app.profile, "3")
self.assertTrue(all(r["vm"]["ram"] == 6144 for r in app.rows))
def test_the_resource_label_survives_the_markup(self):
# « [x1] » se faisait manger : Static lit le balisage Rich, et une
# balise inconnue disparaît avec son contenu.
async def rien(app, pilote):
pass
self.assertIn("x1", self._totaux(self._rendu(rien)))
def test_text_prompts_are_not_a_cancellation(self):
# {} n'est pas None : l'appelant distingue « annulé » de
# « pose-moi les questions à l'ancienne ».

View file

@ -0,0 +1,129 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La ligne de totaux du formulaire QEMU/KVM : la place, pas seulement la
demande.
La ligne disait « ~126 G » sans dire sur quoi : on découvrait au déploiement
qu'il ne restait pas la place. Elle dit maintenant la demande, ce qui reste et
la capacité — et elle prévient quand ça ne rentre pas, pour les trois limites
(RAM, disque, cœurs) au lieu d'une seule à la fois.
La sonde disque est vérifiée à part : elle doit tomber sur une partition qui
existe même quand le répertoire d'images n'a jamais été créé.
"""
import asyncio
import sys
import unittest
sys.argv = ["todo.py"]
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
class TestSondeDisque(unittest.TestCase):
def test_it_measures_the_filesystem_that_will_hold_the_disks(self):
libre, total = TODO._host_disk_gb()
self.assertGreater(total, 0)
self.assertLessEqual(libre, total)
def test_a_missing_directory_falls_back_to_a_parent(self):
# /var/lib/libvirt/images n'existe qu'après le premier déploiement :
# sans repli, la place s'afficherait comme inconnue sur une machine
# neuve, là où elle est justement la plus utile.
libre, total = TODO._host_disk_gb("/n/existe/pas/du/tout")
self.assertGreater(total, 0)
def test_it_never_raises(self):
self.assertEqual(len(TODO._host_disk_gb("")), 2)
def contexte():
"""Contexte minimal du formulaire, avec des mesures CHOISIES : la ligne
doit se lire pareil quelle que soit la machine qui lance le test."""
todo = TODO.__new__(TODO)
mod = todo._qemu_import_module()
todo._qemu_list_domains = lambda: []
todo._qemu_branch_list = lambda: ["develop", "master"]
ctx = todo._qemu_form_context(mod)
ctx["free_disk"] = 500
ctx["total_disk"] = 900
ctx["free_ram"] = 64000
ctx["host_cpu"] = 64
return ctx
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestLigneDeTotaux(unittest.TestCase):
def _ligne(self, combien, ctx=None):
from script.todo.qemu_deploy_form import run_deploy_form
ctx = ctx or contexte()
vu = {}
async def scenario():
from textual.widgets import SelectionList, Static
app = run_deploy_form(ctx, run_app=False)
async with app.run_test(size=(200, 50)) as pilote:
await pilote.pause()
liste = app.query_one(SelectionList)
for i in range(combien):
liste.select(liste.get_option_at_index(i).value)
await pilote.pause()
await pilote.pause()
widget = app.query_one("#totals", Static)
vu["ligne"] = str(
getattr(widget, "_content", "") or widget.render()
)
vu["vms"] = len(app.vms)
asyncio.run(scenario())
return vu
def test_nothing_ticked_says_how_to_fill_the_list(self):
# Un total à zéro n'apprend rien.
self.assertNotIn("~", self._ligne(0)["ligne"])
def test_it_shows_the_demand_the_room_and_the_capacity(self):
ligne = self._ligne(2)["ligne"]
self.assertIn("~", ligne)
self.assertIn("500 G", ligne)
self.assertIn("900 G", ligne)
def test_a_plan_bigger_than_the_room_is_flagged(self):
ctx = contexte()
ctx["free_disk"] = 1
ctx["total_disk"] = 900
self.assertIn("⚠", self._ligne(2, ctx)["ligne"])
def test_the_three_limits_are_reported_together(self):
# N'en montrer qu'une cachait les autres : on corrigeait la première
# pour découvrir la suivante au déploiement.
ctx = contexte()
ctx["free_disk"] = 1
ctx["free_ram"] = 1
ctx["host_cpu"] = 1
ligne = self._ligne(2, ctx)["ligne"]
self.assertEqual(ligne.count("⚠"), 1)
for morceau in ("RAM", "disque", "cœurs"):
self.assertIn(morceau, ligne, ligne)
def test_an_unknown_measure_shows_no_room_at_all(self):
ctx = contexte()
ctx["free_disk"] = 0
ctx["total_disk"] = 0
ligne = self._ligne(2, ctx)["ligne"]
self.assertIn("~", ligne)
self.assertNotIn("/", ligne)
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -52,11 +52,38 @@ class TestRessourcesLibres(unittest.TestCase):
self.assertEqual(lib.res_value("8", "", 2), 8)
class TestPlaceDisque(unittest.TestCase):
"""La demande du plan ne dit pas si ça rentre : il faut la place à côté."""
def test_it_shows_the_demand_the_room_and_the_capacity(self):
self.assertEqual(
lib.disk_note(50, 20, 270), "~50 G / 20 G libres sur 270 G"
)
def test_without_a_capacity_it_still_shows_the_room(self):
self.assertEqual(lib.disk_note(50, 20), "~50 G / 20 G libres")
def test_without_a_measure_it_invents_nothing(self):
# Une place inconnue ne doit pas se lire comme « 0 Go libres ».
self.assertEqual(lib.disk_note(50, 0, 0), "~50 G")
self.assertEqual(lib.disk_note(50, 0, 270), "~50 G")
def test_bytes_become_whole_gibibytes(self):
self.assertEqual(lib.gib(90 * (1 << 30)), 90)
self.assertEqual(lib.gib(0), 0)
# Une mesure absente ou illisible vaut zéro, pas une exception : la
# ligne de totaux doit s'afficher même quand l'hôte n'a rien répondu.
self.assertEqual(lib.gib(None), 0)
self.assertEqual(lib.gib("x"), 0)
class TestEtatDuNom(unittest.TestCase):
"""Un nom déjà pris, un disque resté seul : deux gravités différentes."""
def test_a_defined_vm_is_skipped(self):
etat, note = lib.vm_status("erplibre-debian-13", ["erplibre-debian-13"])
etat, note = lib.vm_status(
"erplibre-debian-13", ["erplibre-debian-13"]
)
self.assertEqual(etat, "exists")
self.assertTrue(note)
@ -105,7 +132,9 @@ def entree(distro, version, arch="amd64"):
class TestSocleDuPlan(unittest.TestCase):
def setUp(self):
self.app = FauxPlan([entree("debian", "13"), entree("ubuntu", "26.04")])
self.app = FauxPlan(
[entree("debian", "13"), entree("ubuntu", "26.04")]
)
self.app.rows = [
{
"vm": {
@ -190,7 +219,9 @@ def membres(chemin, classe=None):
arbre = ast.parse(pathlib.Path(chemin).read_text(encoding="utf-8"))
trouves = set()
for n in ast.walk(arbre):
if isinstance(n, ast.ClassDef) and (classe is None or n.name == classe):
if isinstance(n, ast.ClassDef) and (
classe is None or n.name == classe
):
trouves |= {
m.name for m in n.body if isinstance(m, ast.FunctionDef)
}