diff --git a/script/todo/deploy_form_extras.py b/script/todo/deploy_form_extras.py index c611a6b..dc75a4f 100644 --- a/script/todo/deploy_form_extras.py +++ b/script/todo/deploy_form_extras.py @@ -26,6 +26,10 @@ tous les fragments, et lire un widget absent ne doit pas casser l'écran. from script.todo.deploy_form_lib import FREE, t +# « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le +# rendrait indistinguable de la sentinelle « rien de sélectionné ». +SERVER = "__server__" + # Ce que l'écran lit du contexte. Une seule liste, parce que les deux # formulaires doivent en recevoir autant : c'est en fournissant un # sous-ensemble que l'écran Proxmox avait perdu la moitié des réglages. @@ -122,9 +126,18 @@ class ExtrasMixin: `render_extras()` après chaque recalcul et `extras_values()` au moment de bâtir sa spec.""" - def extras_init(self, ctx) -> None: - """À appeler dans `__init__`, avant tout `compose`.""" + def extras_init(self, ctx, branches=(), profiles=()) -> None: + """À appeler dans `__init__`, avant tout `compose`. + + `branches` et `profiles` viennent du formulaire et non du contexte : + chacun les ordonne à sa façon (branch_order) et l'écran les propose + dans cet ordre-là. Ce sont les mêmes listes que portent les rangées.""" self._extras = extras_tables(ctx) + self._extras["branches"] = list(branches) + self._extras["profiles"] = list(profiles) + self._extras["distro_profiles"] = dict( + ctx.get("distro_profiles") or {} + ) # ------------------------------------------------------------------ # # Les widgets @@ -310,6 +323,165 @@ class ExtrasMixin: def _extras_disk_gb(self, vm, tools) -> int: return extras_disk_gb(vm, tools, self._extras) + # ------------------------------------------------------------------ # + # Une rangée du plan : branche, profil, type — par VM + # ------------------------------------------------------------------ # + def _branch(self) -> str: + """Branche du formulaire : le défaut de chaque VM.""" + widget = self._widget("#f_branch") + valeur = widget.value if widget is not None else None + branches = self._extras["branches"] + return valeur if isinstance(valeur, str) else (branches or [""])[0] + + def _profile_cmd(self) -> str: + """Commande du profil choisi en haut : le défaut de chaque VM.""" + profiles = self._extras["profiles"] + widget = self._widget("#f_profile_install") + if not profiles or widget is None: + return "" + index = widget.value + return profiles[index if isinstance(index, int) else 0][1] + + def _row_default_cmd(self, i) -> str: + """Commande qu'une rangée prend d'elle-même : celle que son système + impose, sinon le choix commun d'en haut. + + C'est le défaut CONTRE LEQUEL on compare une saisie : sur une VM + Proxmox, choisir Odoo 18 est une vraie surcharge même quand c'est + aussi la valeur commune.""" + if i < len(self.rows): + impose = self._extras["distro_profiles"].get( + self.rows[i]["vm"]["distro"] + ) + if impose: + return impose[1] + return self._profile_cmd() + + def _row_profile_index(self, i) -> int: + """Rang du profil que la rangée doit AFFICHER.""" + cmd = "" + if i < len(self.rows): + cmd = self.rows[i]["vm"].get("install_cmd") or "" + cmd = cmd or self._row_default_cmd(i) + for k, (_lbl, c) in enumerate(self._extras["profiles"]): + if c == cmd: + return k + return 0 + + def _type_options(self): + return [(t("Server"), SERVER)] + [ + (label, key) for key, label in self._extras["desktops"] + ] + + def install_row_widgets(self, i, null=None): + """Les trois choix qu'une VM peut prendre à elle seule. + + Ici et non dans un formulaire : ce sont les mêmes trois des deux + côtés, et ils manquaient à l'écran Proxmox — où l'on déploie pourtant + le plus souvent un parc MIXTE, un hyperviseur imbriqué à côté de VM + ERPLibre.""" + from textual.widgets import Select, Static + + tab = self._extras + if null is None: + null = getattr(Select, "NULL", Select.BLANK) + vm = self.rows[i]["vm"] if i < len(self.rows) else {} + widgets = [ + Select( + [(b, b) for b in tab["branches"]], + classes="vmbranch", + # Repli sur la branche du FORMULAIRE, jamais sur branches[0] : + # les rangées sont remontées dès que le jeu de VM change (une + # entrée cochée, une copie ajoutée, un renommage), et elles + # retombaient alors toutes sur « develop » quel que soit le + # choix commun. + value=vm.get("branch") or self._branch(), + allow_blank=False, + id=f"v{i}_branch", + ), + ( + Select( + [(lbl, k) for k, (lbl, _c) in enumerate(tab["profiles"])], + value=self._row_profile_index(i), + allow_blank=False, + classes="vmprof", + id=f"v{i}_prof", + ) + if tab["profiles"] + else Static("", classes="vmprof") + ), + ] + if tab["desktops"]: + widgets.append( + Select( + self._type_options(), + value=vm.get("desktop") or SERVER, + allow_blank=False, + id=f"v{i}_type", + ) + ) + return widgets + + def extras_on_row_select(self, event, index, field, null=None) -> bool: + """Traite branche / profil / type d'UNE rangée. True quand c'est fait. + + Ce que ces trois partagent, et qui n'est pas évident : poser + « value= » au montage fait émettre un Changed que Textual délivre + APRÈS coup, et un verrou temporel ne l'attrape pas — mesuré, les + champs de chaque VM se retrouvaient surchargés dès l'affichage et le + profil x1..x4 devenait inopérant. On compare donc à ce que le modèle + dit DÉJÀ : une valeur identique n'est pas une saisie, c'est l'écho. + + Cas limite assumé : choisir explicitement la valeur que le profil + donne déjà n'enregistre pas de surcharge. La VM suivra donc le profil + s'il change — ce qui est aussi le plus attendu quand on n'a rien + changé de visible.""" + if field not in ("branch", "prof", "type"): + return False + vm_now = self.rows[index]["vm"] + if field == "prof": + # L'écho se reconnaît à ceci : la valeur est CELLE QU'ON VIENT + # D'AFFICHER. Comparer les commandes ne suffisait pas — quand la + # commande imposée par le système n'est pas dans la liste + # proposée, la liste retombe sur le rang 0, et l'écho de ce + # rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait + # ainsi ERPLibre et Odoo 18, le défaut qu'on venait de corriger. + if event.value == self._row_profile_index(index): + return True + label, cmd = self._extras["profiles"][event.value] + defaut = self._row_default_cmd(index) + if cmd == (vm_now.get("install_cmd") or defaut): + return True + meme = cmd == defaut + self._set_override(index, "install_cmd", "" if meme else cmd) + self._set_override(index, "install_label", "" if meme else label) + self._recompute() + return True + if field == "branch": + # « la branche du formulaire » n'est pas une surcharge : la VM + # doit suivre si on la change en haut. + if event.value == (vm_now.get("branch") or self._branch()): + return True + self._set_override( + index, + "branch", + "" if event.value == self._branch() else event.value, + ) + self._recompute() + return True + nouveau = "" if event.value == SERVER else event.value + if nouveau == (vm_now.get("desktop") or ""): + return True + self._set_override(index, "desktop", nouveau) + # « Serveur » est un choix légitime, pas un retrait : on le note + # explicitement pour qu'il tienne face au défaut. + if event.value == SERVER: + cle = self._row_key(index) + if cle is not None: + self.overrides.setdefault(cle, {})["desktop"] = "" + self._recompute() + return True + # ------------------------------------------------------------------ # # Redessiner # ------------------------------------------------------------------ # diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index 80245a4..6efe4b9 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -187,7 +187,14 @@ def run_proxmox_form(ctx, run_app: bool = True): + """ SelectionList { height: 10; border: solid $panel; } RadioSet { height: auto; layout: horizontal; } + /* Ces deux règles portent « .vmrow Select » EN PLUS de leur + classe : « .vmrow Select » (une classe + un type) l'emporte sur + « .vmbranch » (une classe) par spécificité CSS. Écrites simplement, + elles étaient silencieusement écrasées. La branche porte des noms + longs (« 1.6.0 », « develop », « feature/xyz ») : trop étroite, la + liste les tronque et on ne sait plus ce qu'on a choisi. */ .vmrow Select.vmbranch { width: 34; } + .vmrow Select.vmprof { width: 40; } #hostline { height: 1; color: $accent; padding: 0 1; } """ ) @@ -210,7 +217,7 @@ def run_proxmox_form(ctx, run_app: bool = True): self._syncing = False # La liste des ponts GRANDIT : l'écran sait en créer un. self._ponts = list(ponts) - self.extras_init(ctx) + self.extras_init(ctx, branches, profiles) # ---------------------------------------------------------------- # # L'écran @@ -594,6 +601,11 @@ def run_proxmox_form(ctx, run_app: bool = True): if item.get("instance") else Static("", classes="vmcopy") ), + # Branche, profil, type — par VM. On déploie ici le plus + # souvent un parc MIXTE : un hyperviseur Proxmox imbriqué + # à côté de VM ERPLibre, et l'écran n'offrait qu'un choix + # commun pour les deux. + *self.install_row_widgets(i, SELECT_NULL), classes="vmrow", ) cartes.append( @@ -739,6 +751,14 @@ def run_proxmox_form(ctx, run_app: bool = True): if ident in ("f_storage", "f_bridge"): self._refresh_after() return + if ident in ("f_branch", "f_profile_install"): + # Un réglage commun reprend la main sur les VM non figées : + # c'est le sens même du mot « commun ». + self._clear_overrides( + ("branch",) if ident == "f_branch" else ("install_cmd",) + ) + self._refresh_after(remonter=True) + return # Réglage commun : « libre… » révèle la saisie, une valeur # s'applique à toutes les VM non figées. champ = SELECT_TO_FIELD.get(ident) @@ -755,9 +775,16 @@ def run_proxmox_form(ctx, run_app: bool = True): # Réglage d'UNE rangée. if ident.startswith("v") and "_" in ident: rang, champ = ident[1:].split("_", 1) - if not rang.isdigit() or champ not in RES_FIELDS: + if not rang.isdigit(): return index = int(rang) + if index >= len(self.rows): + return + if self.extras_on_row_select(event, index, champ): + self._refresh_after() + return + if champ not in RES_FIELDS: + return if event.value is FREE: self._row_free(index, champ, True) self._set_override( diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 79be598..d45274e 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -1427,6 +1427,21 @@ class ProxmoxMenuMixin: } finale = cartes if self._qemu_per_vm(cartes, commun) else commun branche = (install or {}).get("branch") or "" + # Même règle pour la branche et pour le type de VM : depuis que le + # plan les porte PAR RANGÉE, lire la seule valeur commune revenait à + # jeter le choix. Un parc mixte — un hyperviseur imbriqué à côté de VM + # ERPLibre — est justement ce qu'on déploie ici le plus souvent. + branches_vm = { + vm["name"]: (vm.get("branch") or branche) for vm in joignables + } + if self._qemu_per_vm(branches_vm, branche): + branche = branches_vm + bureau = spec.get("desktop") or "" + bureaux = { + vm["name"]: (vm.get("desktop") or bureau) for vm in joignables + } + if self._qemu_per_vm(bureaux, bureau): + bureau = bureaux if suivi: # Rien à installer ? La commande distante regarde alors la VM # ARRIVER (cloud-init, puis relevé système) : c'est ce que le @@ -1458,7 +1473,7 @@ class ProxmoxMenuMixin: # Les réglages du système invité, qui n'atteignaient pas la # commande distante : la VM naissait serveur nu, sans outils. prod=bool(spec.get("prod")), - desktop=spec.get("desktop") or "", + desktop=bureau, python_provider=spec.get("python_provider") or "", app_store=spec.get("app_store") or "deb", vm_tools=spec.get("vm_tools") or (), @@ -1477,16 +1492,17 @@ class ProxmoxMenuMixin: return resultat # Sans suivi mais avec quelque chose à installer : en série, sortie à # l'écran. C'est le pendant exact de la voie QEMU/KVM. - print(f"\n{t('Installing ERPLibre on each VM')} ({branche})…") + etiquette = branche if isinstance(branche, str) else t("per VM") + print(f"\n{t('Installing ERPLibre on each VM')} ({etiquette})…") for vm in joignables: self._qemu_install_erplibre_vm( vm["name"], cle_locale, - branche, + branches_vm.get(vm["name"], ""), alias.get(vm["name"], vm["name"]), vm.get("install_cmd") or commun, bool(spec.get("prod")), - desktop=spec.get("desktop") or "", + desktop=bureaux.get(vm["name"], ""), python_provider=spec.get("python_provider") or "", app_store=spec.get("app_store") or "deb", vm_tools=spec.get("vm_tools") or (), diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index f2f81d6..15fd899 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -70,7 +70,7 @@ from script.todo.deploy_form_lib import ( # noqa: F401 # Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté # tel quel : les appelants historiques importent encore ces noms ICI. -from script.todo.deploy_form_extras import ExtrasMixin +from script.todo.deploy_form_extras import SERVER, ExtrasMixin from script.todo.deploy_form_plan import ( # noqa: F401 PlanMixin, preview_screen, @@ -128,8 +128,6 @@ def run_deploy_form(ctx, run_app: bool = True): 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 - # [(clé, libellé)] — la liste vient de todo.py, source unique. - desktops = list(ctx.get("desktops") or []) # {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les # saveurs — on ne le redéfinit pas ici. desktop_suffixes = dict(ctx.get("desktop_suffixes") or {}) @@ -137,9 +135,6 @@ def run_deploy_form(ctx, run_app: bool = True): result = {"spec": None} AUTO = "__auto__" - # « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le - # rendrait indistinguable de la sentinelle « rien de sélectionné ». - SERVER = "__server__" def entry_label(e): star = " *" if e.get("default") else "" @@ -194,7 +189,7 @@ def run_deploy_form(ctx, run_app: bool = True): self._syncing = False # Jeu de VM actuellement monté dans le panneau droit. self._shown_ids = () - self.extras_init(ctx) + self.extras_init(ctx, branches, profiles) # Génération du jeu de rangées monté. Les identifiants de widgets # portent un RANG, et le rang se décale quand on coche ou décoche # une entrée : un événement émis par un widget déjà détruit @@ -518,48 +513,7 @@ def run_deploy_form(ctx, run_app: bool = True): self._render_plan() self.render_extras() - def _profile_cmd(self): - """Commande du profil choisi en haut : le défaut de chaque VM.""" - if not profiles: - return "" - index = self.query_one("#f_profile_install", Select).value - return profiles[index if isinstance(index, int) else 0][1] - - def _row_default_cmd(self, i): - """Commande qu'une rangée prend d'elle-même : celle que son - système impose, sinon le choix commun d'en haut. - - C'est le défaut CONTRE LEQUEL on compare une saisie : sur une VM - Proxmox, choisir Odoo 18 est une vraie surcharge même quand c'est - aussi la valeur commune.""" - if i < len(self.rows): - impose = distro_profiles.get(self.rows[i]["vm"]["distro"]) - if impose: - return impose[1] - return self._profile_cmd() - - def _row_profile_index(self, i): - """Rang du profil que la rangée doit AFFICHER.""" - cmd = "" - if i < len(self.rows): - cmd = self.rows[i]["vm"].get("install_cmd") or "" - cmd = cmd or self._row_default_cmd(i) - for k, (_lbl, c) in enumerate(profiles): - if c == cmd: - return k - return 0 - - def _branch(self): - """Branche du formulaire : le défaut de chaque VM.""" - value = self.query_one("#f_branch", Select).value - return value if isinstance(value, str) else branches[0] - # -- panneau droit : une rangée de widgets par VM ---------------- # - def _type_options(self): - return [(t("Server"), SERVER)] + [ - (label, key) for key, label in desktops - ] - def _mount_rows(self) -> None: """(Re)construit le panneau droit. @@ -610,40 +564,7 @@ def run_deploy_form(ctx, run_app: bool = True): if item.get("instance") else Static("", classes="vmcopy") ), - Select( - [(b, b) for b in branches], - classes="vmbranch", - # Repli sur la branche du FORMULAIRE, jamais sur - # branches[0] : les rangées sont remontées dès que le - # jeu de VM change (une entrée cochée, une copie - # ajoutée, un renommage), et elles retombaient alors - # toutes sur « develop » quel que soit le choix commun. - value=( - self.rows[i]["vm"].get("branch") - if i < len(self.rows) - else "" - ) - or self._branch(), - allow_blank=False, - id=f"v{i}_branch", - ), - ( - Select( - [(lbl, i) for i, (lbl, _c) in enumerate(profiles)], - value=self._row_profile_index(i), - allow_blank=False, - classes="vmprof", - id=f"v{i}_prof", - ) - if profiles - else Static("", classes="vmprof") - ), - Select( - self._type_options(), - value=vm.get("desktop") or SERVER, - allow_blank=False, - id=f"v{i}_type", - ), + *self.install_row_widgets(i, SELECT_NULL), classes="vmrow", ) widgets.append( @@ -854,63 +775,18 @@ def run_deploy_form(ctx, run_app: bool = True): # profil donne déjà n'enregistre pas de surcharge. La VM # suivra donc le profil s'il change — ce qui est aussi le plus # attendu quand on n'a rien changé de visible. - vm_now = self.rows[index]["vm"] - if field == "prof": - label, cmd = profiles[event.value] - if cmd == ( - vm_now.get("install_cmd") - or self._row_default_cmd(index) - ): - return - same = cmd == self._row_default_cmd(index) - self._set_override( - index, "install_cmd", "" if same else cmd - ) - self._set_override( - index, "install_label", "" if same else label - ) - self._recompute() + if self.extras_on_row_select(event, index, field): return - if field == "branch": - # « la branche du formulaire » n'est pas une surcharge : - # la VM doit suivre si on la change en haut. - current = vm_now.get("branch") or self._branch() - if event.value == current: - return - self._set_override( - index, - "branch", - "" if event.value == self._branch() else event.value, - ) - self._recompute() - return - if field == "type": - new_desk = "" if event.value == SERVER else event.value - if new_desk == (vm_now.get("desktop") or ""): - return - elif ( - event.value is not FREE and event.value is not SELECT_NULL - ): - if self._row_echo(index, field, event.value): - return - if field == "type": - self._set_override( - index, - "desktop", - "" if event.value == SERVER else event.value, - ) - # « Serveur » est un choix légitime, pas un retrait : on le - # note explicitement pour qu'il tienne face au défaut. - if event.value == SERVER: - key = self._row_key(index) - if key is not None: - self.overrides.setdefault(key, {})["desktop"] = "" - elif event.value is FREE: + # Ne restent ici que les RESSOURCES : elles n'ont pas de + # défaut à comparer, mais une saisie libre à révéler. + if event.value is FREE: self._row_free(index, field, True) self._set_override( index, field, self._read_row_free(index, field) ) elif event.value is not SELECT_NULL: + if self._row_echo(index, field, event.value): + return self._row_free(index, field, False) self._set_override(index, field, event.value) self._recompute() diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d47cfc9..603064a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4351,6 +4351,10 @@ TRANSLATIONS = { "fr": "Clonage d'ERPLibre sur chaque VM", "en": "Cloning ERPLibre on each VM", }, + "per VM": { + "fr": "par VM", + "en": "per VM", + }, "Installing ERPLibre on each VM": { "fr": "Installation d'ERPLibre sur chaque VM", "en": "Installing ERPLibre on each VM", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index f972c72..aeb0358 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -598,6 +598,94 @@ class TestDeuxVmDuMemeNom(unittest.TestCase): ) +class TestUnParcMixte(unittest.TestCase): + """Le plan porte branche, profil et type PAR RANGÉE — le déploiement + lisait encore la seule valeur commune. + + C'est le cas qu'on déploie le plus souvent sur un Proxmox : un + hyperviseur imbriqué à côté de VM ERPLibre. Une seule VM qui porte sa + propre valeur suffit à rendre la carte nécessaire — « len(set) > 1 » ne + l'aurait pas vu, et tout le parc serait retombé sur le commun.""" + + def _capture(self, vms): + import contextlib + import io + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + vu = {} + todo = TODO.__new__(TODO) + todo._write_ssh_config_entry = lambda *a, **k: None + todo._ssh_private_key = lambda k: None + todo._ssh_config_block = lambda nom: {} + todo._qemu_list_domains = lambda: [] + todo._pve_guest_ip = lambda vmid, attente=120: "" + todo._pve_write_guide = lambda *a, **k: True + todo._pve_set_timezone = lambda *a, **k: True + todo._qemu_import_module = lambda: None + + def prise(noms, branche, alias, finale, **kw): + vu.update(branche=branche, finale=finale, kw=kw) + + todo._qemu_install_erplibre_monitored = prise + spec = { + "host": {"target": "pve1"}, + "vms": vms, + "user": "erplibre", + "add_ssh_config": True, + "install": { + "branch": "develop", + "cmd": "make install_odoo_18", + "label": "X", + }, + "monitor": True, + "desktop": "", + } + with contextlib.redirect_stdout(io.StringIO()): + todo._pve_after_create( + spec["host"], spec, [v["name"] for v in vms], "" + ) + return vu + + def _vm(self, nom, **extra): + base = { + "name": nom, + "vmid": 100, + "ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1", + "install_cmd": "", + } + base.update(extra) + return base + + def test_a_single_vm_with_its_own_branch_forces_the_map(self): + vu = self._capture( + [ + self._vm("vm-a", branch="master"), + self._vm("vm-b", vmid=101), + ] + ) + self.assertEqual(vu["branche"], {"vm-a": "master", "vm-b": "develop"}) + + def test_a_uniform_fleet_keeps_the_common_value(self): + vu = self._capture([self._vm("vm-a"), self._vm("vm-b", vmid=101)]) + self.assertEqual(vu["branche"], "develop") + + def test_a_per_vm_desktop_reaches_the_install(self): + vu = self._capture( + [ + self._vm("vm-a", desktop="gnome"), + self._vm("vm-b", vmid=101), + ] + ) + self.assertEqual(vu["kw"]["desktop"], {"vm-a": "gnome", "vm-b": ""}) + + def test_a_uniform_fleet_keeps_the_common_desktop(self): + vu = self._capture([self._vm("vm-a"), self._vm("vm-b", vmid=101)]) + self.assertEqual(vu["kw"]["desktop"], "") + + class TestLEcranDUneVmProxmox(unittest.TestCase): """« Console de l'hyperviseur » conseillait des commandes virsh sur une machine qui n'a pas libvirt. diff --git a/test/test_todo_deploy_form_parity.py b/test/test_todo_deploy_form_parity.py index 3402929..17a3b19 100644 --- a/test/test_todo_deploy_form_parity.py +++ b/test/test_todo_deploy_form_parity.py @@ -108,6 +108,7 @@ def releve(fabrique, ctx, gestes=None): vu["ids"] = {w.id for w in app.query("#fields *") if w.id} vu["spec"] = app._form_values() vu["noms"] = [r["vm"]["name"] for r in app.rows] + vu["rangee"] = [w.id for w in app.query("#plan *") if w.id] vu["disques"] = [r["disk_gb"] for r in app.rows] asyncio.run(scenario()) @@ -163,6 +164,17 @@ class TestLesDeuxEcrans(unittest.TestCase): vide.extras_init({}) return vide + def test_both_screens_offer_the_same_per_vm_choices(self): + """Branche, profil, type : ce qu'une VM peut prendre à elle seule. + + L'écran Proxmox n'en offrait aucun, alors qu'on y déploie le plus + souvent un parc MIXTE — un hyperviseur imbriqué à côté de VM + ERPLibre — c'est-à-dire le cas où ils servent le plus.""" + for ident in ("v0_branch", "v0_prof", "v0_type"): + with self.subTest(reglage=ident): + self.assertIn(ident, self.qemu["rangee"], "QEMU/KVM") + self.assertIn(ident, self.pve["rangee"], "Proxmox") + def test_both_screens_bind_the_same_catalog_shortcuts(self): # « Versions principales » (F7) manquait à l'écran Proxmox, qui # affiche pourtant le même catalogue, drapeau « default » compris.