#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """GPU de l'hôte : détection, déploiement, et réglage d'une VM éteinte. Une VM graphique sans accélération rend tout par le processeur — le bureau comme l'émulateur Android qui tourne dedans. L'hôte a un GPU ou non ; s'il en a un, la VM doit le prendre, et c'est le défaut. Ce que ces tests gardent, appris en le cassant : - UN SEUL « --video » : la 3D remplace le « --video virtio », elle ne s'y ajoute pas — deux écrans, et l'invité n'en peuple qu'un. - « --add-device --graphics egl-headless » n'est PAS idempotent : deux appels, deux affichages. D'où l'état lu avant tout plan. - « virt-xml --memory N » ne touche que : la RAM plafonnait en silence à l'ancien maximum. - Le retrait de la 3D cible le TYPE egl-headless : sans ce ciblage, c'est la console VNC de la VM qui disparaît. - « --define » sur chaque commande : sans lui, virt-xml POSE UNE QUESTION et le menu se bloque sans rien dire. """ import contextlib import importlib.util import io import os import subprocess import sys import tempfile import unittest from pathlib import Path from unittest import mock sys.argv = ["todo.py"] from script.todo import qemu_hardware as hw # noqa: E402 from script.todo.todo import TODO # noqa: E402 def _deploy_qemu(): """deploy_qemu.py chargé comme module, comme le fait todo.py.""" path = Path(__file__).resolve().parents[1] / "script/qemu/deploy_qemu.py" spec = importlib.util.spec_from_file_location("deploy_qemu", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod DQ = _deploy_qemu() # XML réel d'une VM du parc, réduit à ce qui décide du matériel. XML_SANS_3D = """ erplibre-ubuntu-2604-gnome 33554432 33554432 8 """ XML_AVEC_3D = XML_SANS_3D.replace( "", "" "", ).replace( "", "\n " "", ) # XML persistant complet : mode CPU, écrans, interface réseau. C'est cette # forme-là que « virsh dumpxml --inactive » rend, sans les décorations que # libvirt ajoute au démarrage (portid, vnetN, alias). XML_COMPLET = """ erplibre-ubuntu-2604-gnome 33554432 33554432 8 """ XML_SERVEUR = """ erplibre-serveur 2097152 2 """ NODE = "/dev/dri/renderD128" class TestDetection(unittest.TestCase): """Le GPU se lit dans /dev/dri, pas dans une liste de cartes connues.""" def _dri(self, *names): tmp = tempfile.TemporaryDirectory() self.addCleanup(tmp.cleanup) for n in names: Path(tmp.name, n).touch() return tmp.name def test_it_keeps_render_nodes_and_ignores_the_rest(self): """« card0 » est le nœud d'affichage, pas de rendu : QEMU ouvre renderD*, et lui donner card0 échouerait au démarrage.""" found = DQ.host_render_nodes( self._dri("card0", "renderD128", "by-path") ) self.assertEqual(1, len(found)) self.assertTrue(found[0].endswith("renderD128")) def test_several_cards_come_out_sorted(self): found = DQ.host_render_nodes(self._dri("renderD129", "renderD128")) self.assertEqual( ["renderD128", "renderD129"], [Path(f).name for f in found] ) def test_a_host_without_dri_answers_empty(self): """Cet hyperviseur-ci est lui-même une VM sans GPU : /dev/dri n'existe pas du tout. Une exception ici ferait échouer TOUT déploiement.""" self.assertEqual([], DQ.host_render_nodes("/nexistepas/dri")) self.assertEqual("", DQ.host_gpu_node("/nexistepas/dri")) def test_presence_is_the_test_not_our_own_access(self): """Le nœud appartient au groupe « render » ; libvirt donne l'accès au démarrage du domaine. Tester nos droits rejetterait un hôte valable.""" d = self._dri("renderD128") os.chmod(Path(d, "renderD128"), 0o000) self.assertTrue(DQ.host_gpu_node(d).endswith("renderD128")) class TestDecision(unittest.TestCase): """« Par défaut avec GPU s'il existe » — et le silence n'est pas permis.""" def test_auto_takes_the_gpu_when_there_is_one(self): on, msg = DQ.gpu_decision("auto", NODE, True) self.assertTrue(on) self.assertIn(NODE, msg) def test_auto_without_gpu_says_why_it_falls_back(self): """Sans ce message, une VM en rendu logiciel ne s'explique pas — et on cherche la lenteur ailleurs pendant des heures.""" on, msg = DQ.gpu_decision("auto", "", True) self.assertFalse(on) self.assertTrue(msg.strip()) def test_forcing_it_without_a_node_refuses_and_warns(self): """libvirt refuse de démarrer un domaine dont le rendernode manque : obéir aveuglément à « --gpu on » livrerait une VM qui ne démarre pas. """ on, msg = DQ.gpu_decision("on", "", True) self.assertFalse(on) self.assertIn("⚠", msg) def test_off_stays_off_and_silent(self): self.assertEqual((False, ""), DQ.gpu_decision("off", NODE, True)) def test_no_screen_no_3d(self): """Une VM serveur n'a pas d'écran : la 3D n'y accélérerait rien, et le « --edit --video » échouerait faute de périphérique vidéo.""" on, _ = DQ.gpu_decision("auto", NODE, False) self.assertFalse(on) class TestDeployArgs(unittest.TestCase): def test_the_args_carry_both_halves(self): """L'accélération sur le virtio-gpu ET un affichage capable de contexte GL : l'une sans l'autre ne donne aucune 3D.""" args = DQ.gpu_install_args(NODE) self.assertIn("model.acceleration.accel3d=on", " ".join(args)) self.assertIn(f"gl.rendernode={NODE}", " ".join(args)) def test_only_one_video_device_survives(self): """Le bug qui donne deux écrans : garder « --video virtio » à côté du « --video » de la 3D. L'invité n'en peuple alors qu'un.""" video, gpu_args, _ = DQ.gpu_apply( ["--video", "virtio"], "auto", NODE, True ) self.assertEqual([], video) self.assertEqual(1, (video + gpu_args).count("--video")) def test_without_gpu_the_plain_video_stays(self): video, gpu_args, _ = DQ.gpu_apply( ["--video", "virtio"], "auto", "", True ) self.assertEqual(["--video", "virtio"], video) self.assertEqual([], gpu_args) def test_the_egl_display_is_added_beside_vnc_not_instead(self): """« egl-headless » n'ouvre aucun port : il ne remplace pas la console VNC, il porte le contexte OpenGL. Les deux cohabitent.""" args = DQ.gpu_install_args(NODE) self.assertIn("type=egl-headless", " ".join(args)) self.assertNotIn("vnc", " ".join(args)) class TestReadState(unittest.TestCase): def test_it_reads_what_the_vm_has(self): st = hw.hw_state(XML_SANS_3D) self.assertEqual("erplibre-ubuntu-2604-gnome", st["name"]) self.assertEqual(8, st["vcpus"]) self.assertEqual(32768, st["mem_mib"]) self.assertEqual("virtio", st["video"]) self.assertFalse(st["accel3d"]) self.assertTrue(st["screen"]) def test_it_sees_an_existing_3d_setup(self): st = hw.hw_state(XML_AVEC_3D) self.assertTrue(st["accel3d"]) self.assertTrue(st["egl"]) self.assertEqual(NODE, st["render"]) def test_egl_headless_alone_is_not_a_screen(self): """Il n'affiche rien et n'ouvre aucun port. Le compter comme écran proposerait la 3D à une VM qui n'a rien à accélérer.""" xml = XML_SERVEUR.replace( "", "", ) self.assertFalse(hw.hw_state(xml)["screen"]) def test_the_balloon_target_is_what_the_vm_gets(self): """ est le maximum, ce que la VM voit. Lire le premier annoncerait 32 Go à une VM qui en a 4.""" xml = XML_SANS_3D.replace( "33554432", "4194304", ) self.assertEqual(4096, hw.hw_state(xml)["mem_mib"]) def test_units_are_exact(self): """KB vaut mille octets, KiB en vaut 1024 : le schéma libvirt autorise les deux, et « à peu près » se voit dans le tableau.""" for unit, value, mib in ( ("KiB", 1048576, 1024), ("MiB", 2048, 2048), ("GiB", 4, 4096), ("bytes", 1073741824, 1024), ): xml = XML_SERVEUR.replace( "2097152", f"{value}", ) self.assertEqual(mib, hw.hw_state(xml)["mem_mib"], unit) def test_broken_xml_gives_an_empty_state(self): st = hw.hw_state(" : la VM plafonne à son ancien maximum, sans un mot.""" plan = self._plan(XML_SANS_3D, {"ram": 65536}) arg = [c for c in plan[0]["cmd"] if c.startswith("memory=")][0] self.assertIn("memory=65536", arg) self.assertIn("currentMemory=65536", arg) def test_3d_without_a_host_node_is_refused_with_a_reason(self): plan = hw.hw_plan(hw.hw_state(XML_SANS_3D), {"gpu": True}, "") self.assertEqual(1, len(plan)) self.assertNotIn("cmd", plan[0]) self.assertTrue(plan[0]["skip"]) def test_3d_on_a_screenless_vm_is_refused(self): """« --edit --video » échouerait : il n'y a pas de périphérique vidéo à modifier. Mieux vaut le dire que laisser virt-xml protester.""" plan = self._plan(XML_SERVEUR, {"gpu": True}) self.assertEqual(1, len(plan)) self.assertIn("skip", plan[0]) def test_autostart_only_moves_when_it_differs(self): self.assertEqual( [], self._plan(XML_SANS_3D, {"autostart": True}, autostart=True) ) plan = self._plan(XML_SANS_3D, {"autostart": False}, autostart=True) self.assertIn("--disable", plan[0]["cmd"]) def test_every_command_defines_and_names_its_uri(self): """Sans « --define », virt-xml INTERROGE l'utilisateur quand le domaine tourne : le menu se bloque sur une question qu'on ne voit pas. Sans « --connect », un appel non root viserait qemu:///session, où les VM du parc n'existent pas.""" plan = self._plan(XML_SANS_3D, {"vcpus": 4, "ram": 8192, "gpu": True}) self.assertTrue(plan) for entry in plan: self.assertIn("--define", entry["cmd"]) self.assertIn("qemu:///system", entry["cmd"]) def test_a_state_without_a_name_plans_nothing(self): """Un dumpxml illisible ne doit pas produire une commande sans cible.""" self.assertEqual([], hw.hw_plan({}, {"vcpus": 4}, NODE)) class TestWant(unittest.TestCase): def test_empty_fields_keep_the_current_values(self): """Valider sans rien saisir ne doit pas rétrécir la VM à néant.""" st = hw.hw_state(XML_SANS_3D) want = hw.build_want(st, "", "", False, False) self.assertEqual(8, want["vcpus"]) self.assertEqual(32768, want["ram"]) def test_gigabytes_are_understood(self): st = hw.hw_state(XML_SANS_3D) self.assertEqual(8192, hw.build_want(st, "", "8G", 0, 0)["ram"]) def test_nonsense_does_not_shrink_the_vm(self): st = hw.hw_state(XML_SANS_3D) want = hw.build_want(st, "beaucoup", "gros", 0, 0) self.assertEqual(8, want["vcpus"]) self.assertEqual(32768, want["ram"]) class TestDisplay(unittest.TestCase): def test_the_ram_field_stays_short_enough_to_read(self): """« 32768 » ne tient pas dans le champ et s'affichait « 3276 » : un nombre tronqué qu'on valide sans regarder rétrécit la machine.""" self.assertEqual("32G", hw.ram_field(32768)) self.assertEqual("1536", hw.ram_field(1536)) for mib in (1024, 12288, 32768, 65536): self.assertLessEqual(len(hw.ram_field(mib)), 4) def test_sizes_read_like_sizes(self): self.assertEqual("1 Go", hw.fmt_mib(1024)) self.assertEqual("1,5 Go", hw.fmt_mib(1536)) self.assertEqual("512 Mo", hw.fmt_mib(512)) def test_the_summary_names_the_render_node(self): self.assertIn("renderD128", hw.hw_summary(hw.hw_state(XML_AVEC_3D))) def test_a_screen_without_3d_says_software_rendering(self): summary = hw.hw_summary(hw.hw_state(XML_SANS_3D)) self.assertIn("8 vCPU", summary) self.assertIn(hw.t("software rendering"), summary) class TestMenuGlue(unittest.TestCase): """Le raccord dans todo.py : ce qui est éteint, et ce qui s'exécute.""" def _todo(self, states, xml=XML_SANS_3D, node=NODE): todo = TODO.__new__(TODO) todo._qemu_domstate = lambda name: states[name] todo._qemu_dumpxml = lambda name: xml.replace( "erplibre-ubuntu-2604-gnome", name ) todo._qemu_autostart = lambda name: False todo._qemu_host_gpu_node = lambda: node # Aucun test ne doit atteindre virsh : la liste des réseaux est fournie. todo._qemu_net_choices = lambda: ["network:default"] todo.launched = [] todo.execute = mock.Mock() todo.execute.exec_command_live = ( lambda cmd, **kw: todo.launched.append(cmd) ) return todo def _run(self, todo, names, answers): it = iter(answers) out = io.StringIO() with mock.patch("builtins.input", lambda *a: next(it, "")): with contextlib.redirect_stdout(out): todo._qemu_adjust_hardware(names) return out.getvalue() def test_a_running_vm_is_left_alone_and_said_so(self): """virt-xml y écrirait une définition qui ne prend effet qu'au prochain démarrage : un réglage qui paraît appliqué et ne l'est pas.""" todo = self._todo({"vm-a": "running"}) todo._qemu_hw_form = lambda rows, node, nets=None: {} out = self._run(todo, ["vm-a"], []) self.assertIn("vm-a", out) self.assertEqual([], todo.launched) def test_a_shut_off_vm_is_adjusted(self): todo = self._todo({"vm-a": "shut off"}) todo._qemu_hw_form = lambda rows, node, nets=None: { "vm-a": {"vcpus": 4, "ram": 8192, "gpu": True} } self._run(todo, ["vm-a"], ["o"]) joined = " ".join(todo.launched) self.assertIn("--vcpus 4", joined) self.assertIn("accel3d=on", joined) self.assertTrue(all(c.startswith("sudo ") for c in todo.launched)) def test_nothing_to_change_launches_nothing(self): todo = self._todo({"vm-a": "shut off"}) todo._qemu_hw_form = lambda rows, node, nets=None: { "vm-a": {"vcpus": 8, "ram": 32768, "gpu": False} } out = self._run(todo, ["vm-a"], []) self.assertEqual([], todo.launched) self.assertIn(hw.t("Nothing to change."), out) def test_refusing_the_confirmation_launches_nothing(self): todo = self._todo({"vm-a": "shut off"}) todo._qemu_hw_form = lambda rows, node, nets=None: { "vm-a": {"vcpus": 4} } self._run(todo, ["vm-a"], ["n"]) self.assertEqual([], todo.launched) def test_cancelling_the_form_launches_nothing(self): todo = self._todo({"vm-a": "shut off"}) todo._qemu_hw_form = lambda rows, node, nets=None: None self._run(todo, ["vm-a"], []) self.assertEqual([], todo.launched) def test_the_skipped_3d_is_explained_not_silent(self): todo = self._todo({"vm-a": "shut off"}, node="") todo._qemu_hw_form = lambda rows, node, nets=None: { "vm-a": {"gpu": True} } out = self._run(todo, ["vm-a"], []) self.assertIn(hw.t("no render node on the host"), out) self.assertEqual([], todo.launched) def test_the_host_gpu_is_announced_before_anything_else(self): todo = self._todo({"vm-a": "shut off"}) todo._qemu_hw_form = lambda rows, node, nets=None: None out = self._run(todo, ["vm-a"], []) self.assertIn(NODE, out) def test_the_prompts_take_over_when_textual_is_absent(self): """Le repli en ligne n'est pas décoratif : sans Textual, c'est la SEULE voie, et un {} mal interprété annulerait tout.""" todo = self._todo({"vm-a": "shut off"}) todo._qemu_hw_form = lambda rows, node, nets=None: {} # vCPU, RAM, 3D, démarrage auto, mode CPU, écrans, puis la validation. self._run(todo, ["vm-a"], ["6", "", "o", "n", "", "", "o"]) self.assertIn("--vcpus 6", " ".join(todo.launched)) def test_an_empty_answer_keeps_the_current_state(self): """Le défaut d'une question fermée est l'état ACTUEL de la VM : sur un formulaire de matériel, le silence ne modifie rien.""" todo = TODO.__new__(TODO) with mock.patch("builtins.input", lambda *a: ""): self.assertTrue(todo._qemu_ask_bool("? ", True)) self.assertFalse(todo._qemu_ask_bool("? ", False)) with mock.patch("builtins.input", lambda *a: "n'importe quoi"): self.assertTrue(todo._qemu_ask_bool("? ", True)) def test_autostart_is_read_from_virsh(self): out = "Id: -\nName: vm-a\nAutostart: enable\n" with mock.patch( "subprocess.run", return_value=subprocess.CompletedProcess([], 0, out, ""), ): self.assertTrue(TODO._qemu_autostart("vm-a")) with mock.patch("subprocess.run", side_effect=OSError): self.assertFalse(TODO._qemu_autostart("vm-a")) class TestForm(unittest.IsolatedAsyncioTestCase): """Le formulaire monté pour de vrai : ce qu'il propose et ce qu'il rend.""" async def _mount(self, rows, node): app = hw.run_hardware_form(rows, node, run_app=False) return app async def test_it_returns_the_intention_on_apply(self): from textual.widgets import Checkbox, Input app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE) async with app.run_test() as pilot: await pilot.pause() app.query_one("#vcpus0", Input).value = "6" app.query_one("#ram0", Input).value = "8G" app.query_one("#gpu0", Checkbox).value = True await pilot.press("ctrl+s") await pilot.pause() want = app.want["erplibre-ubuntu-2604-gnome"] self.assertEqual(6, want["vcpus"]) self.assertEqual(8192, want["ram"]) self.assertTrue(want["gpu"]) async def test_escape_returns_nothing(self): app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE) async with app.run_test() as pilot: await pilot.press("escape") await pilot.pause() self.assertIsNone(app.want) async def test_3d_is_out_of_reach_without_a_host_node(self): """Cocher une case qui ne peut rien produire ferait attendre une accélération que l'hôte ne sait pas donner.""" from textual.widgets import Checkbox app = await self._mount([hw.hw_state(XML_SANS_3D)], "") async with app.run_test() as pilot: await pilot.pause() self.assertTrue(app.query_one("#gpu0", Checkbox).disabled) async def test_3d_is_out_of_reach_for_a_screenless_vm(self): from textual.widgets import Checkbox app = await self._mount([hw.hw_state(XML_SERVEUR)], NODE) async with app.run_test() as pilot: await pilot.pause() self.assertTrue(app.query_one("#gpu0", Checkbox).disabled) async def test_the_fields_start_on_the_current_values(self): from textual.widgets import Input app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE) async with app.run_test() as pilot: await pilot.pause() self.assertEqual("8", app.query_one("#vcpus0", Input).value) self.assertEqual("32G", app.query_one("#ram0", Input).value) async def test_the_second_row_carries_cpu_screens_and_network(self): from textual.widgets import Input, Select app = hw.run_hardware_form( [hw.hw_state(XML_COMPLET)], NODE, nets=["network:default", "bridge:br0"], run_app=False, ) async with app.run_test() as pilot: await pilot.pause() app.query_one("#cpu0", Select).value = "host-model" app.query_one("#heads0", Input).value = "2" app.query_one("#net0", Select).value = "bridge:br0" await pilot.press("ctrl+s") await pilot.pause() want = app.want["erplibre-ubuntu-2604-gnome"] self.assertEqual("host-model", want["cpu"]) self.assertEqual(2, want["heads"]) self.assertEqual("bridge:br0", want["net"]) async def test_the_screens_value_is_actually_visible(self): """Sous six colonnes, Textual dessine le cadre du champ mais PAS son contenu : la valeur devient invisible, et on valide un champ qu'on croit vide. Pire qu'une troncature, donc vérifié à l'écran.""" import re app = hw.run_hardware_form( [dict(hw.hw_state(XML_COMPLET), heads=3)], NODE, run_app=False ) async with app.run_test(size=(80, 24)) as pilot: await pilot.pause() svg = app.export_screenshot() rendu = re.findall(r">([^<>]+)", svg) self.assertIn("3", [txt.strip() for txt in rendu]) async def test_without_networks_to_offer_there_is_no_network_field(self): """Un hôte sans pont n'a qu'une voie : une liste à un seul choix ne vaut pas la place qu'elle prend.""" app = hw.run_hardware_form( [dict(hw.hw_state(XML_COMPLET), net="")], NODE, run_app=False ) async with app.run_test() as pilot: await pilot.pause() self.assertEqual(0, len(app.query("#net0"))) await pilot.press("ctrl+s") await pilot.pause() self.assertEqual("", app.want["erplibre-ubuntu-2604-gnome"]["net"]) async def test_the_current_network_stays_selected(self): """La liste montre ce que la VM a : sans cela, valider sans y toucher la basculerait sur le premier choix de la liste.""" from textual.widgets import Select app = hw.run_hardware_form( [hw.hw_state(XML_COMPLET)], NODE, nets=["bridge:br0", "network:default"], run_app=False, ) async with app.run_test() as pilot: await pilot.pause() self.assertEqual( "network:default", app.query_one("#net0", Select).value ) await pilot.press("ctrl+s") await pilot.pause() want = app.want["erplibre-ubuntu-2604-gnome"] self.assertEqual("network:default", want["net"]) self.assertEqual([], hw.hw_plan(hw.hw_state(XML_COMPLET), want, NODE)) async def test_it_fits_in_eighty_columns(self): """Un terminal de 80 colonnes est le plus petit qu'on rencontre ; au-delà, les libellés se tronquent en « Démarrage automatiq… ».""" import re rows = [hw.hw_state(XML_COMPLET), hw.hw_state(XML_SERVEUR)] app = hw.run_hardware_form( rows, NODE, nets=["network:default", "bridge:br0"], run_app=False ) async with app.run_test(size=(80, 24)) as pilot: await pilot.pause() svg = app.export_screenshot() texte = " ".join(re.findall(r">([^<>]+)", svg)) self.assertNotIn("…", texte) class TestCpuScreensNetwork(unittest.TestCase): """Les trois réglages ajoutés, et pourquoi chacun est celui-là. Mesuré sur l'hôte avant de les offrir : « heads » atteint QEMU (max_outputs), « vram » NON sur un virtio-gpu — il n'est donc pas proposé. """ def _plan(self, xml, want, node=NODE): return hw.hw_plan(hw.hw_state(xml), want, node) def test_it_reads_the_cpu_mode_screens_and_network(self): st = hw.hw_state(XML_COMPLET) self.assertEqual("host-passthrough", st["cpu"]) self.assertEqual(1, st["heads"]) self.assertEqual("network:default", st["net"]) def test_a_vm_without_an_interface_has_no_network(self): self.assertEqual("", hw.hw_state(XML_SERVEUR)["net"]) def test_a_bridge_is_read_as_a_bridge(self): """libvirt refuse type='network' avec un pont pour source : le type et le nom doivent voyager ensemble.""" xml = XML_COMPLET.replace( "", "" ).replace("", "") self.assertEqual("bridge:br0", hw.hw_state(xml)["net"]) def test_the_label_marks_the_bridge_only(self): """Le réseau libvirt est le cas ordinaire ; le suffixe allongeait le libellé au-delà de la liste déroulante, qui se repliait.""" self.assertEqual("default", hw.net_label("network:default")) self.assertIn("br0", hw.net_label("bridge:br0")) self.assertIn(hw.t("bridge"), hw.net_label("bridge:br0")) def test_the_spec_names_the_right_virt_xml_key(self): self.assertEqual("network=default", hw.net_spec("network:default")) self.assertEqual("bridge=br0", hw.net_spec("bridge:br0")) def test_passthrough_keeps_check_and_migratable(self): """C'est ce que virt-install écrit, et ce que veut la virtualisation imbriquée : sans eux libvirt vérifie un modèle qu'il n'a pas calculé. """ xml = XML_COMPLET.replace( "", "", ) plan = self._plan(xml, {"cpu": "host-passthrough"}) arg = plan[0]["cmd"][-1] self.assertIn("check=none", arg) self.assertIn("migratable=on", arg) def test_host_model_carries_nothing_extra(self): plan = self._plan(XML_COMPLET, {"cpu": "host-model"}) self.assertEqual("host-model", plan[0]["cmd"][-1]) def test_the_same_cpu_mode_changes_nothing(self): self.assertEqual( [], self._plan(XML_COMPLET, {"cpu": "host-passthrough"}) ) def test_screens_go_through_the_video_model(self): """« heads » devient max_outputs sur la ligne QEMU — vérifié par domxml-to-native. C'est le seul réglage vidéo qui y arrive.""" plan = self._plan(XML_COMPLET, {"heads": 2}) self.assertIn("model.heads=2", plan[0]["cmd"]) def test_screens_on_a_screenless_vm_are_refused_not_attempted(self): """« --edit --video » sortirait en erreur au milieu du lot, et les commandes suivantes ne partiraient pas.""" plan = self._plan(XML_SERVEUR, {"heads": 2}) self.assertEqual(1, len(plan)) self.assertIn("skip", plan[0]) def test_screens_and_3d_are_two_separate_edits(self): """Vérifié sur un domaine réel : le second « --edit --video » ne remet pas heads à 1, et le premier ne perd pas l'accélération.""" plan = self._plan(XML_COMPLET, {"heads": 2, "gpu": True}) videos = [e for e in plan if "--video" in e.get("cmd", [])] self.assertEqual(2, len(videos)) heads = [e for e in videos if "model.heads=2" in e["cmd"]] self.assertEqual(1, len(heads)) self.assertNotIn("accel3d", " ".join(heads[0]["cmd"])) def test_switching_to_a_bridge(self): """Le MAC et l'adresse PCI survivent — vérifié sur un domaine réel, démarré : sans cela l'invité verrait une carte neuve, et son bail DHCP comme son nom d'interface changeraient.""" plan = self._plan(XML_COMPLET, {"net": "bridge:br0"}) self.assertIn("bridge=br0", plan[0]["cmd"]) self.assertNotIn("mac", " ".join(plan[0]["cmd"])) def test_switching_back_to_a_libvirt_network(self): xml = XML_COMPLET.replace( "", "" ).replace("", "") plan = hw.hw_plan(hw.hw_state(xml), {"net": "network:default"}, NODE) self.assertIn("network=default", plan[0]["cmd"]) def test_the_same_network_changes_nothing(self): self.assertEqual( [], self._plan(XML_COMPLET, {"net": "network:default"}) ) def test_a_vm_without_an_interface_is_told_not_attempted(self): plan = self._plan(XML_SERVEUR, {"net": "bridge:br0"}) self.assertEqual(1, len(plan)) self.assertIn("skip", plan[0]) def test_an_unknown_current_cpu_mode_stays_offered(self): """Une VM en mode « custom » ne doit pas voir son réglage disparaître d'une liste qui l'ignore : la liste afficherait un AUTRE mode que le sien, et valider le formulaire le changerait sans le dire.""" modes = hw.cpu_choices([{"cpu": "custom"}]) self.assertIn("custom", modes) self.assertIn("host-passthrough", modes) def test_the_network_list_merges_the_host_and_the_current_value(self): choices = hw.net_choices( [{"net": "bridge:br9"}], ["network:default", "bridge:br9"] ) self.assertEqual( ["network:default", "bridge:br9"], [tok for tok, _ in choices] ) def test_empty_answers_keep_the_current_hardware(self): st = hw.hw_state(XML_COMPLET) want = hw.build_want( st, "", "", False, False, cpu="", heads="", net="" ) self.assertEqual("host-passthrough", want["cpu"]) self.assertEqual(1, want["heads"]) self.assertEqual("network:default", want["net"]) self.assertEqual([], hw.hw_plan(st, want, "")) class TestHostNetworks(unittest.TestCase): """Ce que l'hôte propose : ses réseaux libvirt, et ses ponts à lui.""" def _choices(self, nets, infos, bridges): todo = TODO.__new__(TODO) sorties = {} sorties["net-list"] = nets sorties["bridge"] = bridges sorties.update(infos) def fake(cmd): if "net-list" in cmd: return sorties["net-list"] if "net-info" in cmd: return sorties.get(cmd[-1], []) return sorties["bridge"] todo._qemu_cmd_lines = fake return todo._qemu_net_choices() def test_a_libvirt_owned_bridge_is_not_offered_twice(self): """virbr0 EST le réseau « default » : l'offrir aussi comme pont proposerait deux fois le même chemin, dont un qui contourne la gestion du réseau par libvirt.""" got = self._choices( ["default"], {"default": ["Name: default", "Bridge: virbr0"]}, ["3: virbr0: "], ) self.assertEqual(["network:default"], got) def test_a_real_bridge_is_offered(self): got = self._choices( ["default"], {"default": ["Bridge: virbr0"]}, [ "3: virbr0: ", "4: br0: ", ], ) self.assertEqual(["network:default", "bridge:br0"], got) def test_a_host_without_libvirt_answers_nothing(self): todo = TODO.__new__(TODO) todo._qemu_cmd_lines = lambda cmd: [] self.assertEqual([], todo._qemu_net_choices()) def test_the_persistent_definition_is_what_gets_read(self): """Sur une VM allumée, « dumpxml » sans --inactive rend la vue VIVANTE (portid, vnetN, alias) — pas la définition que virt-xml modifie.""" vu = {} def fake_run(cmd, **kw): vu["cmd"] = cmd return subprocess.CompletedProcess([], 0, "", "") with mock.patch("subprocess.run", side_effect=fake_run): TODO._qemu_dumpxml("vm-a") self.assertIn("--inactive", vu["cmd"]) def test_a_numbered_pick_defaults_to_the_current_value(self): todo = TODO.__new__(TODO) out = io.StringIO() for reponse, attendu in ( ("", "b"), ("mille", "b"), ("9", "b"), ("1", "a"), ("2", "b"), ): with mock.patch("builtins.input", lambda *a, r=reponse: r): with contextlib.redirect_stdout(out): got = todo._qemu_pick("t", ["a", "b"], "b") self.assertEqual(attendu, got, reponse) if __name__ == "__main__": unittest.main(verbosity=1)