"""Textual pilot tests for cv4pve_tui.app; spec: docs/plans/guest-tui-mvp.md ("UI contract"). All cli functions are monkeypatched: cv4pve-cli is never executed. """ import contextlib import itertools import re import subprocess import threading from datetime import datetime import pytest from hypothesis import given from hypothesis import strategies as st from textual.screen import ModalScreen from textual.widgets import ( Button, Checkbox, DataTable, Input, OptionList, RadioButton, Select, Static, ) from cv4pve_tui import app as appmod from cv4pve_tui import cli from cv4pve_tui.app import ConfirmScreen, ContextScreen, EditScreen, GuestApp VM = {"vmid": 108, "name": "docker", "node": "mainframe", "type": "qemu", "status": "running", "maxcpu": 4, "maxmem": 8192 * 2**20, "maxdisk": 10 * 2**30} # fmt: skip CT = {"vmid": 100, "name": "backrest", "node": "mainframe", "type": "lxc", "status": "stopped", "maxcpu": 1, "maxmem": 512 * 2**20, "maxdisk": 8 * 2**30} # fmt: skip class Fake: """Recorder standing in for the cli module functions the app calls.""" def __init__(self): self.guests = [VM, CT] self.pending = [] self.disk_map = {"scsi0": "10G"} self.list_calls = 0 self.actions, self.sets, self.resizes, self.pending_calls = [], [], [], [] self.action_error = None self.action_hook = None self.live_status, self.status_calls = {}, [] self.current = "mainframe" self.names = ["mainframe", "mainframe-edge"] self.contexts_error = None self.contexts_calls = 0 self.use_calls = [] self.use_error = None self.use_hook = None self.guests_by_context = {} self.cli_version, self.version_error = "2.4.0", None self.unprotected, self.unprotected_error = set(), None self.list_hook = None # delivery A (guest info screen) self.net_configs = {} # vmid -> extra config keys (netN, ipconfigN) self.storages = ["truenas-backup", "local"] self.jobs = [BACKUP_JOB] self.backup_list = list(BACKUPS) # newest first, as cli.backups returns them self.iface_list, self.iface_error = [], None self.iface_calls, self.backups_calls, self.backup_runs = [], [], [] self.backup_error = None self.backup_hook = None # delivery B (network editing) self.bridge_list = ["vmbr0"] self.set_calls, self.set_error, self.set_hook = [], None, None # issue #8 (snapshots) self.snap_list = [dict(s) for s in SNAPSHOTS] # newest first, no "current" self.snap_error = None self.snap_calls = [] self.take_calls, self.rollback_calls, self.delete_calls = [], [], [] self.snap_error_op = None self.snap_hook = None # issue #12 (live usage); NB the name "current" is taken by the context-name attribute above self.usage = {} # vmid -> overrides merged into the status/current dict self.current_error, self.current_calls = None, [] # issue #10 (task log) self.task_list = [ dict(t) for t in TASKS ] # newest first, as cli.tasks returns them self.tasks_error, self.tasks_calls = None, 0 self.log_text, self.log_error, self.log_calls = "TASK OK\n", None, [] # issue #13 (node package updates): node -> rows; one online node, nothing pending self.updates = {"mainframe": []} self.updates_by_context = {} # context name -> {node: rows}, overrides self.updates self.nodes_error, self.updates_errors = None, {} # node -> exception self.nodes_calls, self.updates_calls = 0, [] self.updates_hook = None # issue #19 (fstrim via the guest agent) self.events = [] # "ping"/"fstrim" in call order self.ping_error, self.fstrim_error, self.fstrim_hook, self.ping_hook = ( None, None, None, None, ) self.fstrim_paths = [{"path": "/", "trimmed": 2**30, "minimum": 0}] # issue #15 (restore from a backup) self.nextid, self.nextid_error = 105, None self.restore_store = ["local-lvm", "zfs-pool"] self.restore_calls, self.restore_error, self.restore_hook = [], None, None def list_guests(self): self.list_calls += 1 # copies: the app writes live status into these dicts; shared module constants must not change snapshot = [ dict(g) for g in self.guests_by_context.get(self.current, self.guests) ] if ( self.list_hook ): # runs after reading: simulates a slow reply from that cluster self.list_hook() return snapshot def guest_config(self, guest): return {"cores": guest["maxcpu"], **self.net_configs.get(guest["vmid"], {})} def disks(self, config, kind): return dict(self.disk_map) def action(self, name, guest): self.actions.append((name, guest["vmid"])) if self.action_hook: self.action_hook() if self.action_error: raise self.action_error def set_resources(self, guest, cores=None, memory_mb=None): given = {"cores": cores, "memory_mb": memory_mb} self.sets.append({k: v for k, v in given.items() if v is not None}) def resize_disk(self, guest, disk, grow_gb): self.resizes.append((guest["vmid"], disk, grow_gb)) def pending_keys(self, guest): self.pending_calls.append(guest["vmid"]) return list(self.pending) def status(self, guest): self.status_calls.append(guest["vmid"]) return self.live_status.get(guest["vmid"], guest["status"]) def current_of(self, guest): """Stands in for cli.current: status/current-like dict built from the guest dict.""" self.status_calls.append(guest["vmid"]) self.current_calls.append(guest["vmid"]) if self.current_error: raise self.current_error data = {k: v for k, v in guest.items() if k in ("cpu", "mem", "maxmem", "disk", "maxdisk")} # fmt: skip data["status"] = self.live_status.get(guest["vmid"], guest["status"]) return data | self.usage.get(guest["vmid"], {}) def not_backed_up(self): if self.unprotected_error: raise self.unprotected_error return set(self.unprotected) def version(self): if self.version_error: raise self.version_error return self.cli_version def contexts(self): self.contexts_calls += 1 if self.contexts_error: raise self.contexts_error return [ {"name": n, "host": f"{n}.home.arpa:8006", "auth": "api-token", "current": n == self.current} for n in self.names ] # fmt: skip def use_context(self, name): self.use_calls.append(name) if self.use_hook: self.use_hook() if self.use_error: raise self.use_error self.current = name def interfaces(self, guest): self.iface_calls.append(guest["vmid"]) if self.iface_error: raise self.iface_error return list(self.iface_list) def backup_storages(self, guest): return list(self.storages) def backup_jobs(self): return list(self.jobs) def backups(self, guest, storages): self.backups_calls.append((guest["vmid"], list(storages))) return list(self.backup_list) def backup_now(self, guest, storage): self.backup_runs.append((guest["vmid"], storage)) if self.backup_hook: self.backup_hook() if self.backup_error: raise self.backup_error def bridges(self, guest): return list(self.bridge_list) def set_net(self, guest, changes): self.set_calls.append((guest["vmid"], dict(changes))) if self.set_hook: self.set_hook() if self.set_error: raise self.set_error config = self.net_configs.setdefault(guest["vmid"], {}) for ( key, value, ) in changes.items(): # mirror the server so reloads see the change if value is None: config.pop(key, None) else: config[key] = value def snapshots(self, guest): self.snap_calls.append(guest["vmid"]) if self.snap_error: raise self.snap_error return [dict(s) for s in self.snap_list] def _snap_op(self, calls, entry): calls.append(entry) if self.snap_hook: self.snap_hook() if self.snap_error_op: raise self.snap_error_op def take_snapshot(self, guest, name, description="", vmstate=False): self._snap_op(self.take_calls, (guest["vmid"], name, description, vmstate)) def rollback_snapshot(self, guest, name, start=False): self._snap_op(self.rollback_calls, (guest["vmid"], name, start)) def delete_snapshot(self, guest, name): self._snap_op(self.delete_calls, (guest["vmid"], name)) def tasks(self): self.tasks_calls += 1 if self.tasks_error: raise self.tasks_error return [dict(t) for t in self.task_list] def _cluster(self): return self.updates_by_context.get(self.current, self.updates) def nodes(self): self.nodes_calls += 1 if self.nodes_error: raise self.nodes_error return sorted(self._cluster()) def package_updates(self, node): self.updates_calls.append(node) rows = [dict(r) for r in self._cluster()[node]] if ( self.updates_hook ): # runs after reading: simulates a slow reply from that cluster self.updates_hook() if node in self.updates_errors: raise self.updates_errors[node] return rows def agent_ping(self, guest): self.events.append(("ping", guest["vmid"])) if self.ping_hook: self.ping_hook() if self.ping_error: raise self.ping_error def fstrim(self, guest): self.events.append(("fstrim", guest["vmid"])) if self.fstrim_hook: self.fstrim_hook() if self.fstrim_error: raise self.fstrim_error return [dict(p) for p in self.fstrim_paths] def next_id(self): if self.nextid_error: raise self.nextid_error return self.nextid def restore_storages(self, guest): return list(self.restore_store) def restore(self, guest, volid, vmid, storage, force=False, start=False): self.restore_calls.append((guest["vmid"], volid, vmid, storage, force, start)) if self.restore_hook: self.restore_hook() if self.restore_error: raise self.restore_error def task_log(self, upid): self.log_calls.append(upid) if self.log_error: raise self.log_error return self.log_text T_TASK = 1_762_600_000 TASKS = [ # newest first; one running (no endtime/status), one failed, one node task {"upid": "UPID:mainframe:0001:qmstop:108:root@pam:", "type": "qmstop", "id": "108", "node": "mainframe", "user": "root@pam", "starttime": T_TASK + 1000}, {"upid": "UPID:mainframe:0002:qmstart:108:root@pam:", "type": "qmstart", "id": "108", "node": "mainframe", "user": "root@pam", "starttime": T_TASK, "endtime": T_TASK + 192, "status": "OK"}, {"upid": "UPID:mainframe:0003:aptupdate::root@pam:", "type": "aptupdate", "id": "", "node": "mainframe", "user": "root@pam", "starttime": T_TASK - 5000, "endtime": T_TASK - 5000 + 3720, "status": "OK"}, {"upid": "UPID:mainframe:0004:vzsnapshot:100:root@pam:", "type": "vzsnapshot", "id": "100", "node": "mainframe", "user": "root@pam", "starttime": T_TASK - 9000, "endtime": T_TASK - 9000 + 45, "status": "snapshot 'x' already exists"}, ] # fmt: skip BACKUP_JOB = {"id": "backup-1", "enabled": 1, "storage": "truenas-backup", "all": 1, "vmid": None, "exclude": None, "node": None, "schedule": "mon,wed,fri 04:00", "mode": "snapshot", "compress": "zstd", "prune-backups": {"keep-last": "3"}} # fmt: skip T_OLD, T_NEW = 1_762_000_000, 1_762_500_000 # epoch seconds SNAPSHOTS = [ # as cli.snapshots returns them: newest first, without "current" {"name": "newer", "description": "second one", "snaptime": T_NEW, "parent": "older", "vmstate": 1}, {"name": "older", "description": "first one", "snaptime": T_OLD, "parent": None, "vmstate": 0}, ] # fmt: skip BACKUPS = [ {"volid": "truenas-backup:backup/vzdump-qemu-108-new.vma.zst", "ctime": T_NEW, "size": 3 * 2**30, "format": "vma.zst", "notes": "docker (manual)", "storage": "truenas-backup"}, {"volid": "local:backup/vzdump-qemu-108-old.vma.zst", "ctime": T_OLD, "size": int(1.5 * 2**30), "format": "vma.zst", "notes": "", "storage": "local"}, ] # fmt: skip CT_CONFIG = { "net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,ip6=auto,type=veth" } VM_CONFIG = { "net0": "virtio=02:24:A6:49:A8:25,bridge=vmbr0,tag=20,firewall=1", "ipconfig0": "ip=dhcp", } @pytest.fixture def fake(monkeypatch): f = Fake() # raising=False: the new names do not exist before the implementation (red run) monkeypatch.setattr(cli, "current", f.current_of, raising=False) for name in ( "interfaces", "backup_storages", "backup_jobs", "backups", "backup_now", "bridges", "set_net", "not_backed_up", "version", "snapshots", "take_snapshot", "rollback_snapshot", "delete_snapshot", "tasks", "task_log", "nodes", "package_updates", "agent_ping", "fstrim", "next_id", "restore_storages", "restore", ): monkeypatch.setattr(cli, name, getattr(f, name), raising=False) for name in ("list_guests", "guest_config", "disks", "action", "set_resources", "resize_disk", "pending_keys", "status", "contexts", "use_context"): # fmt: skip monkeypatch.setattr(cli, name, getattr(f, name)) return f def make_app(): app = GuestApp() app.seen = [] # (message, severity) of every notify original = app.notify def spy(message, **kwargs): app.seen.append((str(message), kwargs.get("severity", "information"))) return original(message, **kwargs) app.notify = spy return app async def settle(app, pilot): await pilot.pause() await app.workers.wait_for_complete() await pilot.pause() def text_of(screen): return " ".join(str(w.render()) for w in screen.query(Static)) def errors(app): return [m for m, sev in app.seen if sev == "error"] async def press_button(app, pilot, selector): app.screen.query_one(selector, Button).press() await settle(app, pilot) # --- table ------------------------------------------------------------------ async def test_table_loads_one_row_per_guest_in_list_order(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) table = app.query_one("#guests", DataTable) assert table.row_count == 2 assert [k.value for k in table.rows] == ["108", "100"] cells = [str(c) for c in table.get_row("108")] assert "docker" in cells and "mainframe" in cells and "running" in cells assert "8192" in cells # memory in MiB async def test_refresh_key_reloads_guests(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) before = fake.list_calls fake.guests = [CT] await pilot.press("g") await settle(app, pilot) assert fake.list_calls == before + 1 assert app.query_one("#guests", DataTable).row_count == 1 # --- lifecycle actions ------------------------------------------------------ async def test_start_runs_without_confirm_and_refreshes_table(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) before = fake.list_calls await pilot.press("s") await settle(app, pilot) assert fake.actions == [("start", 108)] assert not isinstance(app.screen, ModalScreen) assert fake.list_calls > before async def test_action_targets_the_cursor_row(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press("down", "s") await settle(app, pilot) assert fake.actions == [("start", 100)] @pytest.mark.parametrize( "key,name", [("S", "shutdown"), ("x", "stop"), ("r", "reboot")] ) async def test_confirmed_action_calls_cli_only_after_yes(fake, key, name): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press(key) await pilot.pause() assert isinstance(app.screen, ConfirmScreen) assert "docker" in text_of(app.screen) and "108" in text_of(app.screen) assert fake.actions == [] await press_button(app, pilot, "#yes") assert fake.actions == [(name, 108)] @pytest.mark.parametrize("key", ["S", "x", "r"]) async def test_declined_action_does_not_call_cli(fake, key): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press(key) await pilot.pause() await press_button(app, pilot, "#no") assert fake.actions == [] assert not isinstance(app.screen, ModalScreen) async def test_stop_confirm_says_hard_power_off(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press("x") await pilot.pause() assert "hard power-off" in text_of(app.screen).lower() @pytest.mark.parametrize("key", ["s", "S", "x", "r", "e"]) async def test_keys_on_empty_table_do_nothing(fake, key): fake.guests = [] app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press(key) await settle(app, pilot) assert fake.actions == [] and fake.sets == [] and fake.resizes == [] assert not isinstance(app.screen, ModalScreen) async def test_cli_error_is_notified_with_summary(fake, monkeypatch): with monkeypatch.context() as m: # build a real CliError through cli.run m.setattr(cli.subprocess, "run", lambda argv, **kw: subprocess.CompletedProcess( argv, 5, "UPID:mainframe:0:0:0:qmstart:108:root@pam:\nstart failed: no quorum\n", "")) # fmt: skip with pytest.raises(cli.CliError) as exc: cli.run("do", "start") fake.action_error = exc.value app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press("s") await settle(app, pilot) assert any(exc.value.summary in m for m in errors(app)) assert not any("UPID" in m for m in errors(app)) async def test_busy_guest_refuses_second_action(fake): started, release = threading.Event(), threading.Event() fake.action_hook = lambda: (started.set(), release.wait(10)) app = make_app() async with app.run_test() as pilot: await settle(app, pilot) try: await pilot.press("s") for _ in range(200): if started.is_set(): break await pilot.pause(0.01) assert started.is_set() assert app.seen, "a working notice is expected while the action runs" await pilot.press("s") await pilot.pause() assert fake.actions == [("start", 108)] finally: release.set() await settle(app, pilot) assert fake.actions == [("start", 108)] # --- edit flow -------------------------------------------------------------- async def open_edit(app, pilot): """Edit lives in the info screen (user request): Enter, then e.""" await settle(app, pilot) await pilot.press("enter") await settle(app, pilot) await pilot.press("e") await settle(app, pilot) assert isinstance(app.screen, EditScreen) def fill(app, **values): for key, value in values.items(): w = app.screen.query_one(f"#{key}", Select if key == "disk" else Input) w.value = value async def test_edit_applies_only_given_fields_as_ints(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, cores="8") await press_button(app, pilot, "#apply") assert fake.sets == [{"cores": 8}] await pilot.press("e") await settle(app, pilot) fill(app, cores="2", memory="4096") await press_button(app, pilot, "#apply") assert fake.sets[-1] == {"cores": 2, "memory_mb": 4096} assert all(isinstance(v, int) for v in fake.sets[-1].values()) @pytest.mark.parametrize("field", ["cores", "memory"]) @pytest.mark.parametrize("bad", ["abc", "0", "-4", "2.5"]) async def test_edit_invalid_resource_input_errors_without_cli_call(fake, field, bad): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, **{field: bad}) await press_button(app, pilot, "#apply") assert errors(app) assert fake.sets == [] and fake.resizes == [] @pytest.mark.parametrize("bad", ["abc", "0", "-1"]) async def test_edit_invalid_grow_errors_without_cli_call(fake, bad): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, disk="scsi0", grow=bad) await press_button(app, pilot, "#apply") assert errors(app) assert fake.resizes == [] async def test_edit_with_empty_fields_changes_nothing(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) await press_button(app, pilot, "#apply") assert fake.sets == [] and fake.resizes == [] async def test_edit_cancel_changes_nothing(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, cores="8") await press_button(app, pilot, "#cancel") assert fake.sets == [] and fake.resizes == [] assert isinstance(app.screen, appmod.InfoScreen) # edit closed, back in info async def test_grow_needs_confirm_then_resizes(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, disk="scsi0", grow="5") await press_button(app, pilot, "#apply") assert isinstance(app.screen, ConfirmScreen) assert fake.resizes == [] await press_button(app, pilot, "#yes") assert fake.resizes == [(108, "scsi0", 5)] async def test_grow_declined_does_not_resize(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, disk="scsi0", grow="5") await press_button(app, pilot, "#apply") await press_button(app, pilot, "#no") assert fake.resizes == [] @pytest.mark.parametrize( "pending,expect_notice", [(["memory"], True), (["cores"], True), ([], False)] ) async def test_pending_cores_or_memory_notifies_reboot(fake, pending, expect_notice): fake.pending = pending app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, memory="4096") await press_button(app, pilot, "#apply") assert fake.pending_calls == [108] assert any("reboot" in m.lower() for m, _ in app.seen) is expect_notice # --- review findings: robustness -------------------------------------------- async def test_guest_without_size_fields_still_listed(fake): # e.g. a guest on an unreachable node: the API omits maxcpu/maxmem/maxdisk fake.guests = [ { "vmid": 999, "name": "ghost", "node": "down", "type": "qemu", "status": "unknown", } ] app = make_app() async with app.run_test() as pilot: await settle(app, pilot) assert app.query_one("#guests", DataTable).row_count == 1 async def test_unexpected_error_in_action_is_notified_not_fatal(fake): fake.action_error = KeyError("key") app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press("s") await settle(app, pilot) assert errors(app) assert app.is_running and app.busy == set() async def test_unexpected_error_in_listing_is_notified_not_fatal(fake, monkeypatch): def broken(): raise ValueError("bad json") monkeypatch.setattr(cli, "list_guests", broken) app = make_app() async with app.run_test() as pilot: await settle(app, pilot) assert errors(app) and app.is_running async def test_pending_check_failure_does_not_skip_confirmed_grow(fake, monkeypatch): def broken(guest): raise cli.CliError("Error: boom") monkeypatch.setattr(cli, "pending_keys", broken) app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, memory="4096", disk="scsi0", grow="5") await press_button(app, pilot, "#apply") await press_button(app, pilot, "#yes") assert fake.sets == [{"memory_mb": 4096}] assert fake.resizes == [(108, "scsi0", 5)] async def test_declining_grow_cancels_whole_edit(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, cores="8", disk="scsi0", grow="5") await press_button(app, pilot, "#apply") await press_button(app, pilot, "#no") assert fake.sets == [] and fake.resizes == [] @pytest.mark.parametrize("keys", [["S"], ["enter", "e"]]) async def test_escape_closes_modal_without_cli_call(fake, keys): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) under = app.screen for key in keys: under = app.screen await pilot.press(key) await settle(app, pilot) assert isinstance(app.screen, (ConfirmScreen, EditScreen)) await pilot.press("escape") await settle(app, pilot) assert app.screen is under # back where the dialog was opened from assert fake.actions == [] and fake.sets == [] async def test_table_shows_live_status_after_action_not_lagging_list(fake): # /cluster/resources lags a few seconds behind; the acted-on guest must show live status app = make_app() async with app.run_test() as pilot: await settle(app, pilot) fake.live_status = {108: "stopped"} # list_guests still says "running" await pilot.press("S") await settle(app, pilot) await press_button(app, pilot, "#yes") assert fake.status_calls == [108] assert "stopped" in [ str(c) for c in app.query_one("#guests", DataTable).get_row("108") ] # --- context switching ------------------------------------------------------ # spec: docs/plans/guest-tui-mvp.md ("Context switching" > "app.py (UI contract)") async def open_contexts(app, pilot): await settle(app, pilot) await pilot.press("c") await settle(app, pilot) assert isinstance(app.screen, ContextScreen) return app.screen.query_one("#contexts", OptionList) async def choose(app, pilot, index): ol = app.screen.query_one("#contexts", OptionList) ol.focus() ol.highlighted = index await pilot.pause() await pilot.press("enter") await settle(app, pilot) async def test_sub_title_shows_active_context_after_mount(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) assert app.sub_title == "context: mainframe" async def test_contexts_failure_notifies_and_sub_title_is_unknown(fake): fake.contexts_error = cli.CliError("Error: no config") app = make_app() async with app.run_test() as pilot: await settle(app, pilot) assert errors(app) assert app.sub_title == "context: ?" async def test_c_opens_context_screen_listing_contexts_with_current_marked(fake): app = make_app() async with app.run_test() as pilot: ol = await open_contexts(app, pilot) assert ol.option_count == 2 options = [ol.get_option_at_index(i) for i in range(2)] assert [o.id for o in options] == ["mainframe", "mainframe-edge"] assert "*" in str(options[0].prompt) assert "*" not in str(options[1].prompt) assert "mainframe-edge" in str(options[1].prompt) async def test_selecting_other_context_switches_and_reloads(fake): app = make_app() async with app.run_test() as pilot: await open_contexts(app, pilot) before = fake.list_calls await choose(app, pilot, 1) assert fake.use_calls == ["mainframe-edge"] assert app.sub_title == "context: mainframe-edge" assert fake.list_calls > before assert not isinstance(app.screen, ModalScreen) assert app.query_one("#guests", DataTable).row_count == 2 async def test_table_is_cleared_while_switch_is_in_progress(fake): started, release = threading.Event(), threading.Event() fake.use_hook = lambda: (started.set(), release.wait(10)) app = make_app() async with app.run_test() as pilot: await open_contexts(app, pilot) assert app.query_one("#guests", DataTable).row_count == 2 try: ol = app.screen.query_one("#contexts", OptionList) ol.focus() ol.highlighted = 1 await pilot.pause() await pilot.press("enter") for _ in range(200): if started.is_set(): break await pilot.pause(0.01) assert started.is_set() await pilot.pause() assert app.query_one("#guests", DataTable).row_count == 0 finally: release.set() await settle(app, pilot) assert app.query_one("#guests", DataTable).row_count == 2 async def test_selecting_current_context_makes_no_cli_call(fake): app = make_app() async with app.run_test() as pilot: await open_contexts(app, pilot) before = fake.list_calls await choose(app, pilot, 0) assert fake.use_calls == [] assert not isinstance(app.screen, ModalScreen) assert app.sub_title == "context: mainframe" assert fake.list_calls == before async def test_escape_closes_context_screen_without_cli_call(fake): app = make_app() async with app.run_test() as pilot: await open_contexts(app, pilot) await pilot.press("escape") await settle(app, pilot) assert not isinstance(app.screen, ModalScreen) assert fake.use_calls == [] assert app.sub_title == "context: mainframe" async def test_c_while_guest_busy_warns_and_opens_nothing(fake): started, release = threading.Event(), threading.Event() fake.action_hook = lambda: (started.set(), release.wait(10)) app = make_app() async with app.run_test() as pilot: await settle(app, pilot) try: await pilot.press("s") for _ in range(200): if started.is_set(): break await pilot.pause(0.01) assert started.is_set() await pilot.press("c") await pilot.pause() assert not isinstance(app.screen, ContextScreen) assert any(sev == "warning" for _, sev in app.seen) finally: release.set() await settle(app, pilot) assert fake.use_calls == [] async def test_use_context_failure_notifies_rereads_context_and_reloads(fake): err = cli.CliError("Error: Context 'mainframe-edge' not found.") def hook(): # the CLI state differs from what the app assumed fake.current = "mainframe-edge" fake.use_hook, fake.use_error = hook, err app = make_app() async with app.run_test() as pilot: await open_contexts(app, pilot) contexts_before, list_before = fake.contexts_calls, fake.list_calls await choose(app, pilot, 1) assert any(err.summary in m for m in errors(app)) assert fake.contexts_calls > contexts_before assert app.sub_title == "context: mainframe-edge" # whatever contexts() says assert fake.list_calls > list_before assert app.query_one("#guests", DataTable).row_count == 2 # --- review finding: no old-cluster rows after a switch ---------------------- EDGE = {"vmid": 300, "name": "edge-vm", "node": "edge", "type": "qemu", "status": "running", "maxcpu": 2, "maxmem": 2**30, "maxdisk": 2**31} # fmt: skip async def until(pilot, cond): for _ in range(300): if cond(): return await pilot.pause(0.01) raise AssertionError("condition not reached") def blocking_once(): started, release = threading.Event(), threading.Event() def hook(): if not started.is_set(): started.set() release.wait(10) return hook, started, release def rows(app): return [k.value for k in app.query_one("#guests", DataTable).rows] async def switch_to_edge(app, pilot): await pilot.press("c") await until(pilot, lambda: isinstance(app.screen, ContextScreen)) ol = app.screen.query_one("#contexts", OptionList) ol.focus() ol.highlighted = 1 await pilot.pause() await pilot.press("enter") async def test_reload_in_flight_before_switch_cannot_restore_old_rows(fake): fake.guests_by_context = {"mainframe-edge": [EDGE]} app = make_app() async with app.run_test() as pilot: await settle(app, pilot) hook, started, release = blocking_once() fake.list_hook = hook try: await pilot.press("g") # reads mainframe, then stalls await until(pilot, started.is_set) await switch_to_edge(app, pilot) await until(pilot, lambda: rows(app) == ["300"]) finally: release.set() await settle(app, pilot) assert rows(app) == ["300"] assert app.sub_title == "context: mainframe-edge" async def test_refresh_refused_while_switching(fake): started, release = threading.Event(), threading.Event() fake.use_hook = lambda: (started.set(), release.wait(10)) app = make_app() async with app.run_test() as pilot: await settle(app, pilot) try: await switch_to_edge(app, pilot) await until(pilot, started.is_set) before = fake.list_calls await pilot.press("g") await pilot.pause() assert fake.list_calls == before assert any(sev == "warning" for _, sev in app.seen) finally: release.set() await settle(app, pilot) async def test_context_refused_while_post_action_reload_runs(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) hook, started, release = blocking_once() fake.list_hook = hook try: await pilot.press("s") # start is instant; the reload after it stalls await until(pilot, started.is_set) await pilot.press("c") await pilot.pause(0.1) assert not isinstance(app.screen, ContextScreen) assert any(sev == "warning" for _, sev in app.seen) finally: release.set() await settle(app, pilot) # --- LXC shell -------------------------------------------------------------- # spec: docs/plans/guest-tui-mvp.md ("LXC shell" > "app.py (UI contract)") RUNNING_CT = {"vmid": 104, "name": "debian", "node": "mainframe", "type": "lxc", "status": "running", "maxcpu": 1, "maxmem": 512 * 2**20, "maxdisk": 8 * 2**30} # fmt: skip SSH_ARGV = ["ssh", "-t", "root@mainframe", "pct", "enter", "104"] def patch_shell(monkeypatch, app, rc=0, error=None): """Replace subprocess.run and app.suspend with recorders; returns the event log.""" events = [] def run(argv, **kwargs): events.append(("run", list(argv))) if error: raise error return subprocess.CompletedProcess(argv, rc) @contextlib.contextmanager def suspend(): events.append(("enter", None)) yield events.append(("exit", None)) monkeypatch.setattr("cv4pve_tui.app.subprocess.run", run) app.suspend = suspend return events def ssh_runs(events): return [argv for kind, argv in events if kind == "run"] async def press_t(app, pilot): await settle(app, pilot) await pilot.press("t") await settle(app, pilot) async def test_t_on_running_lxc_runs_ssh_pct_enter_inside_suspend(fake, monkeypatch): # spec: runs the ssh argv once, on the main thread, inside `with self.suspend()` fake.guests = [RUNNING_CT] app = make_app() events = patch_shell(monkeypatch, app) async with app.run_test() as pilot: await press_t(app, pilot) assert events == [("enter", None), ("run", SSH_ARGV), ("exit", None)] assert not errors(app) assert app.is_running async def test_shell_exit_nonzero_notifies_error_with_guest_and_code(fake, monkeypatch): # spec: non-zero exit -> error notify naming the guest and the exit code fake.guests = [RUNNING_CT] app = make_app() events = patch_shell(monkeypatch, app, rc=255) async with app.run_test() as pilot: await press_t(app, pilot) assert ssh_runs(events) == [SSH_ARGV] assert any(("debian" in m or "104" in m) and "255" in m for m in errors(app)) assert app.is_running @pytest.mark.parametrize("guest,why", [(VM, "not a container"), (CT, "not running")]) async def test_t_on_ineligible_guest_warns_and_runs_nothing( fake, monkeypatch, guest, why ): # spec: only type lxc and status running; otherwise warning (says why), nothing runs fake.guests = [guest] app = make_app() events = patch_shell(monkeypatch, app) async with app.run_test() as pilot: await press_t(app, pilot) assert events == [] assert any(sev == "warning" for _, sev in app.seen) assert not errors(app) async def test_t_on_empty_table_does_nothing(fake, monkeypatch): # spec: empty table -> nothing fake.guests = [] app = make_app() events = patch_shell(monkeypatch, app) async with app.run_test() as pilot: await press_t(app, pilot) assert events == [] assert app.seen == [] assert app.is_running async def test_t_on_busy_guest_warns_and_runs_nothing(fake, monkeypatch): # spec: busy guest (action running) -> warning notify, nothing runs started, release = threading.Event(), threading.Event() fake.guests = [RUNNING_CT] fake.action_hook = lambda: (started.set(), release.wait(10)) app = make_app() events = patch_shell(monkeypatch, app) async with app.run_test() as pilot: await settle(app, pilot) try: await pilot.press("s") await until(pilot, started.is_set) warnings_before = sum(sev == "warning" for _, sev in app.seen) await pilot.press("t") await pilot.pause() assert events == [] assert sum(sev == "warning" for _, sev in app.seen) > warnings_before finally: release.set() await settle(app, pilot) assert events == [] async def test_suspend_not_supported_notifies_error_and_app_keeps_running( fake, monkeypatch ): # spec: SuspendNotSupported (headless driver, suspend left unpatched) -> error notify fake.guests = [RUNNING_CT] app = make_app() calls = [] monkeypatch.setattr( "cv4pve_tui.app.subprocess.run", lambda argv, **kw: calls.append(argv) ) async with app.run_test() as pilot: await press_t(app, pilot) assert calls == [] assert errors(app) assert app.is_running async def test_missing_ssh_binary_notifies_error_and_app_keeps_running( fake, monkeypatch ): # spec: OSError (no ssh binary) -> error notify, app keeps running fake.guests = [RUNNING_CT] app = make_app() events = patch_shell(monkeypatch, app, error=FileNotFoundError("ssh")) async with app.run_test() as pilot: await press_t(app, pilot) assert ssh_runs(events) == [SSH_ARGV] assert errors(app) assert app.is_running # --- review findings: LXC shell ---------------------------------------------- async def test_missing_ssh_binary_still_resumes_app(fake, monkeypatch): # Textual's suspend() has no try/finally: an exception inside it skips the resume fake.guests = [RUNNING_CT] app = make_app() events = patch_shell(monkeypatch, app, error=FileNotFoundError("ssh")) async with app.run_test() as pilot: await press_t(app, pilot) assert events[-1] == ("exit", None) async def test_shell_exit_code_from_inside_container_is_not_an_error(fake, monkeypatch): # pct enter returns the shell's last status; only 255 means ssh itself failed fake.guests = [RUNNING_CT] app = make_app() patch_shell(monkeypatch, app, rc=1) async with app.run_test() as pilot: await press_t(app, pilot) assert errors(app) == [] async def test_table_reloads_after_shell(fake, monkeypatch): fake.guests = [RUNNING_CT] app = make_app() patch_shell(monkeypatch, app) async with app.run_test() as pilot: await settle(app, pilot) before = fake.list_calls await press_t(app, pilot) await settle(app, pilot) assert fake.list_calls > before async def test_terminal_is_reset_inside_suspend_before_ssh(fake, monkeypatch): # user request: a clean screen (and no leftover TUI terminal modes) in the container shell fake.guests = [RUNNING_CT] app = make_app() events = patch_shell(monkeypatch, app) resets = [] monkeypatch.setattr( "cv4pve_tui.app._reset_terminal", lambda: resets.append(list(events)) ) async with app.run_test() as pilot: await press_t(app, pilot) assert resets == [[("enter", None)]] # inside suspend, before ssh ran # --- delivery A: guest info screen / backup now ------------------------------ # spec: docs/plans/guest-tui-mvp.md ("Guest info screen ... delivery A" > app.py) def local_date(epoch): return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M") def cells(table, row): return [str(c) for c in table.get_row_at(row)] def cli_error(text): with pytest.MonkeyPatch.context() as m: m.setattr( cli.subprocess, "run", lambda argv, **kw: subprocess.CompletedProcess(argv, 5, text, ""), ) with pytest.raises(cli.CliError) as exc: cli.run("do", "x") return exc.value async def open_info(app, pilot, down=0): await settle(app, pilot) for _ in range(down): await pilot.press("down") await pilot.press("enter") await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) async def open_backup(app, pilot): await pilot.press("b") await settle(app, pilot) assert isinstance(app.screen, appmod.BackupScreen) async def test_i_opens_info_screen_with_backups_newest_first(fake): app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) table = app.screen.query_one("#backups", DataTable) assert table.row_count == 2 new, old = cells(table, 0), cells(table, 1) assert local_date(T_NEW) in new and "truenas-backup" in new assert any("3.0" in c for c in new) and "docker (manual)" in new assert local_date(T_OLD) in old and "local" in old assert any("1.5" in c for c in old) assert fake.backups_calls == [(108, ["truenas-backup", "local"])] async def test_info_jobs_line_names_schedule_storage_and_keep_last(fake): app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) text = str(app.screen.query_one("#jobs", Static).render()) assert ( "mon,wed,fri 04:00" in text and "truenas-backup" in text and "keep-last" in text ) async def test_info_jobs_line_when_no_job_covers_guest(fake): fake.jobs = [] app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) text = str(app.screen.query_one("#jobs", Static).render()) assert "No backup job covers this guest" in text async def test_info_nets_row_for_ct_sample(fake): fake.net_configs[100] = CT_CONFIG app = make_app() async with app.run_test() as pilot: await open_info(app, pilot, down=1) # CT 100 (stopped) table = app.screen.query_one("#nets", DataTable) assert table.row_count == 1 assert cells(table, 0) == [ "net0", "vmbr0", "-", "BC:24:11:36:DE:78", "dhcp", "auto", "no", ] async def test_info_nets_row_for_vm_uses_ipconfig_tag_and_firewall(fake): fake.net_configs[108] = VM_CONFIG app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) table = app.screen.query_one("#nets", DataTable) assert cells(table, 0) == [ "net0", "vmbr0", "20", "02:24:A6:49:A8:25", "dhcp", "-", "yes", ] async def test_info_addresses_show_runtime_ips_for_running_guest(fake): fake.iface_list = [{"name": "ens18", "mac": "02:24:A6:49:A8:25", "ipv4": ["172.16.41.50/23"], "ipv6": ["fe80::1/64"]}] # fmt: skip app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) row = cells(app.screen.query_one("#addresses", DataTable), 0) assert row[0] == "ens18" and row[1] == "02:24:A6:49:A8:25" assert "172.16.41.50/23" in row[2] and "fe80::1/64" in row[3] assert fake.iface_calls == [108] async def test_info_does_not_query_interfaces_for_stopped_guest(fake): app = make_app() async with app.run_test() as pilot: await open_info(app, pilot, down=1) # CT 100 is stopped assert fake.iface_calls == [] async def test_info_interfaces_failure_is_inline_unavailable_not_fatal(fake): fake.iface_error = cli_error("Error: QEMU guest agent is not running") app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) text = text_of(app.screen).lower() assert "unavailable" in text and "guest agent is not running" in text assert errors(app) == [] and app.is_running assert ( app.screen.query_one("#backups", DataTable).row_count == 2 ) # rest still loaded async def test_i_on_empty_table_does_nothing(fake): fake.guests = [] app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press("enter") await settle(app, pilot) assert not isinstance(app.screen, appmod.InfoScreen) assert fake.backups_calls == [] async def test_escape_closes_info_and_g_reloads_it(fake): app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) before = len(fake.backups_calls) await pilot.press("g") await settle(app, pilot) assert len(fake.backups_calls) == before + 1 await pilot.press("escape") await settle(app, pilot) assert not isinstance(app.screen, appmod.InfoScreen) async def test_b_opens_backup_screen_preselecting_job_storage(fake): fake.storages = ["local", "truenas-backup"] # job storage is not the first app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) await open_backup(app, pilot) assert app.screen.query_one("#storage", Select).value == "truenas-backup" text = text_of(app.screen).lower() assert "snapshot" in text and "zstd" in text and "docker" in text assert fake.backup_runs == [] async def test_backup_storage_falls_back_to_first_without_job(fake): fake.jobs = [] fake.storages = ["local", "truenas-backup"] app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) await open_backup(app, pilot) assert app.screen.query_one("#storage", Select).value == "local" async def test_backup_yes_runs_backup_now_once_busy_then_done_and_reloads(fake): app = make_app() busy_seen = [] fake.backup_hook = lambda: busy_seen.append(set(app.busy)) async with app.run_test() as pilot: await open_info(app, pilot) before = len(fake.backups_calls) await open_backup(app, pilot) await press_button(app, pilot, "#yes") assert fake.backup_runs == [(108, "truenas-backup")] assert busy_seen and busy_seen[0], ( "guest must be marked busy while the backup runs" ) assert app.busy == set() msgs = [m.lower() for m, _ in app.seen] assert any("backup started" in m for m in msgs) assert any("backup done" in m for m in msgs) assert errors(app) == [] assert len(fake.backups_calls) > before # list reloaded assert isinstance(app.screen, appmod.InfoScreen) @pytest.mark.parametrize("how", ["no", "escape"]) async def test_backup_declined_makes_no_call(fake, how): app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) await open_backup(app, pilot) if how == "no": await press_button(app, pilot, "#no") else: await pilot.press("escape") await settle(app, pilot) assert fake.backup_runs == [] assert not isinstance(app.screen, appmod.BackupScreen) assert app.busy == set() async def test_backup_while_guest_busy_warns_and_makes_no_second_call(fake): started, release = threading.Event(), threading.Event() fake.backup_hook = lambda: (started.set(), release.wait(10)) app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) try: await open_backup(app, pilot) app.screen.query_one("#yes", Button).press() for _ in range(200): if started.is_set(): break await pilot.pause(0.01) assert started.is_set() await pilot.press("b") # second attempt while the first still runs await pilot.pause() if isinstance(app.screen, appmod.BackupScreen): # refusal may come at "yes" app.screen.query_one("#yes", Button).press() await pilot.pause() assert any(sev == "warning" for _, sev in app.seen) assert fake.backup_runs == [(108, "truenas-backup")] finally: release.set() await settle(app, pilot) assert fake.backup_runs == [(108, "truenas-backup")] async def test_backup_now_failure_notifies_error_and_releases_busy(fake): fake.backup_error = cli_error("Error: storage 'truenas-backup' is not online") app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) await open_backup(app, pilot) await press_button(app, pilot, "#yes") assert any("is not online" in m for m in errors(app)) assert app.busy == set() and app.is_running assert not any("backup done" in m.lower() for m, _ in app.seen) # --- review findings: info screen robustness --------------------------------- async def test_info_backup_with_missing_size_shows_dash_and_app_survives(fake): fake.backup_list = [dict(BACKUPS[0], size=None)] app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) assert "-" in cells(app.screen.query_one("#backups", DataTable), 0) assert app.is_running async def test_info_uses_live_status_not_table_status(fake): # the table status can be stale (listing lags); runtime IPs follow the live status fake.live_status = {100: "running"} # CT 100 is "stopped" in the listing app = make_app() async with app.run_test() as pilot: await open_info(app, pilot, down=1) assert fake.iface_calls == [100] # --- backups of earlier guests with a reused VMID ------------------------------ EARLIER = {"volid": "truenas-backup:backup/vzdump-qemu-108-planka.vma.zst", "ctime": T_OLD - 60, "size": 2**30, "format": "vma.zst", "notes": "planka", "storage": "truenas-backup"} # fmt: skip def backup_notes(app): t = app.screen.query_one("#backups", DataTable) return [cells(t, i)[4] for i in range(t.row_count)] async def test_backups_of_earlier_guest_hidden_by_default_with_hint(fake): # VMIDs are reused: a deleted guest's backups (notes = its name) are not this guest's fake.backup_list = [*BACKUPS, EARLIER] app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) assert "planka" not in backup_notes(app) assert backup_notes(app) == [ "docker (manual)", "", ] # own name and no notes stay assert "1" in str(app.screen.query_one("#hidden", Static).render()) async def test_a_toggles_showing_earlier_guest_backups(fake): fake.backup_list = [*BACKUPS, EARLIER] app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) await pilot.press("a") await pilot.pause() assert any("planka" in n for n in backup_notes(app)) await pilot.press("a") await pilot.pause() assert not any("planka" in n for n in backup_notes(app)) async def test_no_hint_when_nothing_hidden(fake): app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) assert str(app.screen.query_one("#hidden", Static).render()).strip() == "" # --- delivery B: network editing ---------------------------------------------- # spec: docs/plans/guest-tui-mvp.md ("Network editing - delivery B" > app.py) CT_OLD = CT_CONFIG["net0"] VM_CI = { **VM_CONFIG, "ide2": "local-lvm:vm-108-cloudinit,media=cdrom", } # VM with cloud-init CT_STATIC6 = ("name=eth0,bridge=vmbr0,hwaddr=BC:24:11:36:DE:78,ip=dhcp," "ip6=2001:db8::5/64,gw6=2001:db8::1,type=veth") # fmt: skip async def open_net(app, pilot, down=1): """Open NetScreen; default row is CT 100 (stopped), down=0 is VM 108 (running).""" await open_info(app, pilot, down=down) await pilot.press("n") await settle(app, pilot) assert isinstance(app.screen, appmod.NetScreen) def val(app, sel): return app.screen.query_one(f"#{sel}").value async def setv(app, pilot, **values): for key, value in values.items(): app.screen.query_one(f"#{key}").value = value await pilot.pause() async def to_info(app, pilot): for _ in range(4): if isinstance(app.screen, appmod.InfoScreen): return await pilot.press("escape") await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) async def apply_to_confirm(app, pilot): await press_button(app, pilot, "#apply") assert isinstance(app.screen, ConfirmScreen) return text_of(app.screen) async def change_and_confirm(app, pilot, **values): await setv(app, pilot, **values) text = await apply_to_confirm(app, pilot) await press_button(app, pilot, "#yes") return text def keys_of(changes): return {k: cli.parse_net(v) if v is not None else None for k, v in changes.items()} async def test_n_opens_net_screen_prefilled_for_ct_sample(fake): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) assert val(app, "iface") == "net0" assert val(app, "bridge") == "vmbr0" assert val(app, "tag") == "" assert val(app, "firewall") is False assert val(app, "ipv4") == "dhcp" assert val(app, "ipv6") == "auto" async def test_net_screen_prefilled_for_vm_from_ipconfig(fake): fake.net_configs[108] = dict(VM_CI) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot, down=0) assert val(app, "bridge") == "vmbr0" assert val(app, "tag") == "20" assert val(app, "firewall") is True assert val(app, "ipv4") == "dhcp" assert val(app, "ipv6") == "none" assert not app.screen.query_one("#ipv4", Select).disabled async def test_n_on_guest_without_nets_opens_nothing(fake): app = make_app() # default Fake config has no netN async with app.run_test() as pilot: await open_info(app, pilot) await pilot.press("n") await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) async def test_changing_iface_prefills_again(fake): fake.bridge_list = ["vmbr0", "vmbr1"] fake.net_configs[100] = { **CT_CONFIG, "net1": "name=eth1,bridge=vmbr1,hwaddr=BC:24:11:36:DE:79,tag=7,firewall=1," "ip=10.1.1.5/24,gw=10.1.1.1,ip6=manual,type=veth", } # fmt: skip app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) await setv(app, pilot, iface="net1") assert val(app, "bridge") == "vmbr1" and val(app, "tag") == "7" assert val(app, "firewall") is True assert val(app, "ipv4") == "static" assert ( val(app, "ipv4_cidr") == "10.1.1.5/24" and val(app, "ipv4_gw") == "10.1.1.1" ) assert val(app, "ipv6") == "none" async def test_vm_without_cloudinit_disables_ip_widgets_and_explains(fake): fake.net_configs[108] = dict(VM_CONFIG) # no cloud-init drive app = make_app() async with app.run_test() as pilot: await open_net(app, pilot, down=0) for wid in ("ipv4", "ipv4_cidr", "ipv4_gw", "ipv6", "ipv6_cidr", "ipv6_gw"): assert app.screen.query_one(f"#{wid}").disabled, wid assert str(app.screen.query_one("#ip_note", Static).render()).strip() != "" assert not app.screen.query_one("#tag").disabled async def test_vm_without_cloudinit_link_change_keeps_key_position_and_no_ipconfig( fake, ): fake.net_configs[108] = dict(VM_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot, down=0) await change_and_confirm(app, pilot, tag="30") assert fake.set_calls == [ (108, {"net0": "virtio=02:24:A6:49:A8:25,bridge=vmbr0,tag=30,firewall=1"}) ] async def test_set_vlan_99_confirm_shows_old_and_new_then_sets_once_busy_and_reloads( fake, ): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() busy_seen = [] fake.set_hook = lambda: busy_seen.append(set(app.busy)) new = CT_OLD + ",tag=99" async with app.run_test() as pilot: await open_net(app, pilot) before = len(fake.backups_calls) await setv(app, pilot, tag="99") text = await apply_to_confirm(app, pilot) assert CT_OLD in text and new in text assert fake.set_calls == [] # nothing before "yes" await press_button(app, pilot, "#yes") assert fake.set_calls == [(100, {"net0": new})] assert busy_seen and busy_seen[0] assert app.busy == set() and errors(app) == [] assert len(fake.backups_calls) > before # InfoScreen reloaded assert isinstance(app.screen, appmod.InfoScreen) assert cells(app.screen.query_one("#nets", DataTable), 0)[2] == "99" @pytest.mark.parametrize("bad", ["5000", "0", "abc"]) async def test_invalid_tag_errors_without_call_and_screen_stays(fake, bad): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) await setv(app, pilot, tag=bad) await press_button(app, pilot, "#apply") assert errors(app) and fake.set_calls == [] assert isinstance(app.screen, appmod.NetScreen) async def test_static_ipv4_without_prefix_errors_without_call(fake): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) await setv(app, pilot, ipv4="static", ipv4_cidr="10.0.0.5") await press_button(app, pilot, "#apply") assert errors(app) and fake.set_calls == [] assert isinstance(app.screen, appmod.NetScreen) async def test_no_change_gives_info_notify_no_confirm_no_call(fake): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) await press_button(app, pilot, "#apply") assert not isinstance(app.screen, ConfirmScreen) assert fake.set_calls == [] and errors(app) == [] assert any(sev == "information" for _, sev in app.seen) async def test_static_ipv4_with_gateway_keeps_hwaddr_and_other_keys(fake): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) await change_and_confirm( app, pilot, ipv4="static", ipv4_cidr="172.16.41.200/23", ipv4_gw="172.16.40.1", ) assert len(fake.set_calls) == 1 vmid, changes = fake.set_calls[0] assert vmid == 100 and set(changes) == {"net0"} assert cli.parse_net(changes["net0"]) == { "name": "eth0", "bridge": "vmbr0", "hwaddr": "BC:24:11:36:DE:78", "ip": "172.16.41.200/23", "gw": "172.16.40.1", "ip6": "auto", "type": "veth", } # fmt: skip async def test_ipv6_static_to_dhcp_removes_gw6(fake): fake.net_configs[100] = {"net0": CT_STATIC6} app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) assert val(app, "ipv6") == "static" and val(app, "ipv6_gw") == "2001:db8::1" await change_and_confirm(app, pilot, ipv6="dhcp") new = cli.parse_net(fake.set_calls[0][1]["net0"]) assert new["ip6"] == "dhcp" and "gw6" not in new assert new["hwaddr"] == "BC:24:11:36:DE:78" and new["ip"] == "dhcp" async def test_vm_ipconfig_change_goes_into_ipconfig0_and_confirm_mentions_next_boot( fake, ): fake.net_configs[108] = dict(VM_CI) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot, down=0) await setv( app, pilot, ipv4="static", ipv4_cidr="10.0.0.5/24", ipv4_gw="10.0.0.1" ) text = await apply_to_confirm(app, pilot) assert "next boot" in text.lower() assert "ip=dhcp" in text and "10.0.0.5/24" in text # old and new raw strings await press_button(app, pilot, "#yes") assert [v for v, _ in fake.set_calls] == [108] changes = fake.set_calls[0][1] assert set(changes) == {"ipconfig0"} # net0 is unchanged and not sent assert cli.parse_net(changes["ipconfig0"]) == { "ip": "10.0.0.5/24", "gw": "10.0.0.1", } async def test_vm_ipconfig_none_none_deletes_ipconfig0(fake): fake.net_configs[108] = dict(VM_CI) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot, down=0) await change_and_confirm(app, pilot, ipv4="none") # ipv6 already none assert fake.set_calls == [(108, {"ipconfig0": None})] async def test_link_confirm_for_running_guest_says_applies_immediately(fake): fake.net_configs[108] = dict(VM_CI) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot, down=0) await setv(app, pilot, firewall=False) text = await apply_to_confirm(app, pilot) assert "immediately" in text.lower() @pytest.mark.parametrize("how", ["cancel", "escape"]) async def test_cancel_or_escape_in_net_screen_makes_no_call(fake, how): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) await setv(app, pilot, tag="99") if how == "cancel": await press_button(app, pilot, "#cancel") else: await pilot.press("escape") await settle(app, pilot) assert not isinstance(app.screen, appmod.NetScreen) assert fake.set_calls == [] and app.busy == set() async def test_declining_confirm_makes_no_call(fake): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) await setv(app, pilot, tag="99") await apply_to_confirm(app, pilot) await press_button(app, pilot, "#no") assert fake.set_calls == [] and app.busy == set() async def test_apply_while_guest_busy_warns_and_makes_no_second_call(fake): fake.net_configs[100] = dict(CT_CONFIG) started, release = threading.Event(), threading.Event() fake.set_hook = lambda: (started.set(), release.wait(10)) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) try: await setv(app, pilot, tag="99") await apply_to_confirm(app, pilot) app.screen.query_one("#yes", Button).press() for _ in range(200): if started.is_set(): break await pilot.pause(0.01) assert started.is_set() await pilot.press( "escape" ) # leave whatever is on top; the guest is still busy await pilot.pause() await to_info(app, pilot) await pilot.press("n") await pilot.pause() if isinstance( app.screen, appmod.NetScreen ): # refusal may come at apply/yes await setv(app, pilot, tag="55") app.screen.query_one("#apply", Button).press() await pilot.pause() if isinstance(app.screen, ConfirmScreen): app.screen.query_one("#yes", Button).press() await pilot.pause() assert any(sev == "warning" for _, sev in app.seen) assert len(fake.set_calls) == 1 finally: release.set() await settle(app, pilot) assert len(fake.set_calls) == 1 async def test_set_net_failure_notifies_error_releases_busy_and_stores_no_undo(fake): fake.net_configs[100] = dict(CT_CONFIG) fake.set_error = cli_error("Error: unable to apply net0") app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) await change_and_confirm(app, pilot, tag="99") assert any("unable to apply net0" in m for m in errors(app)) assert app.busy == set() and app.is_running await to_info(app, pilot) calls = len(fake.set_calls) await pilot.press("u") await settle(app, pilot) assert not isinstance(app.screen, ConfirmScreen) # no undo entry was stored assert len(fake.set_calls) == calls # --- undo --------------------------------------------------------------------- async def test_u_without_undo_entry_gives_info_notify_and_no_call(fake): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_info(app, pilot, down=1) await pilot.press("u") await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) assert any(sev == "information" for _, sev in app.seen) assert fake.set_calls == [] async def test_u_after_change_confirms_then_restores_old_net_string_and_clears_entry( fake, ): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) await change_and_confirm(app, pilot, tag="99") await to_info(app, pilot) await pilot.press("u") await settle(app, pilot) assert isinstance(app.screen, ConfirmScreen) assert CT_OLD in text_of(app.screen) # what will be restored assert len(fake.set_calls) == 1 # nothing before "yes" await press_button(app, pilot, "#yes") assert fake.set_calls[1] == (100, {"net0": CT_OLD}) assert app.busy == set() and errors(app) == [] assert isinstance(app.screen, appmod.InfoScreen) assert cells(app.screen.query_one("#nets", DataTable), 0)[2] == "-" await pilot.press("u") # entry cleared after success await settle(app, pilot) assert not isinstance(app.screen, ConfirmScreen) and len(fake.set_calls) == 2 async def test_undo_declined_makes_no_call_and_keeps_entry(fake): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) await change_and_confirm(app, pilot, tag="99") await to_info(app, pilot) await pilot.press("u") await settle(app, pilot) await press_button(app, pilot, "#no") assert len(fake.set_calls) == 1 await pilot.press("u") await settle(app, pilot) assert isinstance(app.screen, ConfirmScreen) # entry still there async def test_undo_of_key_that_did_not_exist_deletes_it(fake): ci_no_ipconfig = {k: v for k, v in VM_CI.items() if k != "ipconfig0"} fake.net_configs[108] = ci_no_ipconfig app = make_app() async with app.run_test() as pilot: await open_net(app, pilot, down=0) await change_and_confirm(app, pilot, ipv4="dhcp") assert fake.set_calls == [(108, {"ipconfig0": "ip=dhcp"})] await to_info(app, pilot) await pilot.press("u") await settle(app, pilot) await press_button(app, pilot, "#yes") assert fake.set_calls[1] == (108, {"ipconfig0": None}) async def test_undo_restores_both_net_and_ipconfig_of_one_change(fake): fake.net_configs[108] = dict(VM_CI) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot, down=0) await change_and_confirm(app, pilot, tag="30", ipv4="none") assert set(fake.set_calls[0][1]) == {"net0", "ipconfig0"} await to_info(app, pilot) await pilot.press("u") await settle(app, pilot) await press_button(app, pilot, "#yes") assert fake.set_calls[1] == ( 108, {"net0": VM_CONFIG["net0"], "ipconfig0": VM_CONFIG["ipconfig0"]}, ) async def test_undo_entry_is_per_guest(fake): fake.net_configs[100] = dict(CT_CONFIG) fake.net_configs[108] = dict(VM_CI) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) # CT 100 await change_and_confirm(app, pilot, tag="99") await pilot.press("escape") # close InfoScreen await settle(app, pilot) await pilot.press("up") await pilot.press("enter") # VM 108: no change was made there await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) await pilot.press("u") await settle(app, pilot) assert not isinstance(app.screen, ConfirmScreen) # --- review findings: network editing ------------------------------------------- async def test_context_switch_clears_undo_entries(fake): # VMIDs repeat across clusters: an undo from one cluster must never hit the other app = make_app() async with app.run_test() as pilot: await settle(app, pilot) app.undo[108] = {"net0": "virtio=02:24:A6:49:A8:25,bridge=vmbr0"} await switch_to_edge(app, pilot) await settle(app, pilot) assert app.undo == {} async def test_untouched_fields_are_not_rewritten(fake): # ip6=manual is left alone when only the VLAN changes; firewall=0 == absent (spec: off removes it) fake.net_configs[100] = { "net0": "name=eth0,bridge=vmbr0,firewall=0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,ip6=manual,type=veth" } app = make_app() async with app.run_test() as pilot: await open_net(app, pilot) await press_button(app, pilot, "#apply") assert not isinstance( app.screen, ConfirmScreen ) # nothing touched -> "no change" await setv(app, pilot, tag="99") await press_button(app, pilot, "#apply") await press_button(app, pilot, "#yes") assert fake.set_calls[-1][1] == { "net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,ip6=manual,type=veth,tag=99" } # --- live finding: a running CT does not follow IP mode changes ------------------- async def test_ip_change_on_running_ct_warns_reboot_may_be_needed(fake): # live on CT 104: static -> dhcp left the CT without IPv4 until a reboot fake.guests = [RUNNING_CT] fake.net_configs[104] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot, down=0) await setv(app, pilot, ipv4="static", ipv4_cidr="172.16.41.200/23") text = await apply_to_confirm(app, pilot) assert "reboot" in text.lower() async def test_link_only_change_on_running_ct_does_not_mention_reboot(fake): fake.guests = [RUNNING_CT] fake.net_configs[104] = dict(CT_CONFIG) app = make_app() async with app.run_test() as pilot: await open_net(app, pilot, down=0) await setv(app, pilot, tag="99") text = await apply_to_confirm(app, pilot) assert "reboot" not in text.lower() # --- info screen layout: backups and network clearly separated -------------------- async def test_info_has_separate_titled_backup_and_network_sections(fake): app = make_app() async with app.run_test() as pilot: await open_info(app, pilot) backup = app.screen.query_one("#backup_section") network = app.screen.query_one("#network_section") assert "Backups" in str(backup.border_title) assert "Network" in str(network.border_title) assert {w.id for w in backup.query("*")} >= {"jobs", "backups", "hidden"} assert {w.id for w in network.query("*")} >= { "nets", "unavailable", "addresses", } # --- boxed design: every dialog a titled box, main table in a titled frame ---------- from textual.content import Content from textual.widgets import Label SIZE = (100, 40) def visible(app, widget): r = widget.region return ( r.width >= 3 and r.height >= 1 and r.right <= app.size.width and r.bottom <= app.size.height ) def label_texts(screen): return {str(w.render()).strip() for w in screen.query(Label)} async def test_net_screen_shows_every_field_inside_the_screen(fake): # bug: in the old rows Select got width 1 and Input 100 %, pushing fields off-screen fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_net(app, pilot) for wid in ("iface", "bridge", "tag", "firewall", "ipv4", "ipv4_cidr", "ipv4_gw", "ipv6", "ipv6_cidr", "ipv6_gw", "apply", "cancel"): # fmt: skip assert visible(app, app.screen.query_one(f"#{wid}")), wid async def test_net_screen_labels_and_ip_sections(fake): fake.net_configs[100] = dict(CT_CONFIG) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_net(app, pilot) assert {"Interface", "Bridge", "VLAN"} <= label_texts(app.screen) assert "IPv4" in str(app.screen.query_one("#ipv4_section").border_title) assert "IPv6" in str(app.screen.query_one("#ipv6_section").border_title) async def test_edit_screen_fields_have_labels(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await open_edit(app, pilot) assert {"Cores", "Memory (MiB)", "Disk", "Grow by (GiB)"} <= label_texts( app.screen ) async def open_each_dialog(app, pilot): """Yields after each dialog is open; closes it with escape afterwards.""" await settle(app, pilot) for keys in (["S"], ["enter", "e"], ["c"], ["enter", "b"], ["enter", "n"]): for k in keys: await pilot.press(k) await settle(app, pilot) yield keys for _ in keys: await pilot.press("escape") await settle(app, pilot) async def test_every_dialog_is_a_titled_centred_box(fake): fake.net_configs[108] = dict(VM_CONFIG) app = make_app() async with app.run_test(size=SIZE) as pilot: async for keys in open_each_dialog(app, pilot): box = app.screen.query_one("#dialog") assert str(box.border_title).strip(), keys assert box.region.width < app.size.width, keys # a box, not full screen assert box.region.x > 0, keys # centred async def test_main_table_is_framed_with_title_and_context(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) frame = app.query_one("#guests_section") assert "Guests" in str(frame.border_title) assert "mainframe" in str(frame.border_subtitle) @pytest.mark.parametrize("keys", [["enter", "e"], ["enter", "b"], ["enter", "n"]]) async def test_guest_name_with_markup_characters_does_not_break_dialogs(fake, keys): # border titles are parsed as markup; a name like "a[/b]" must be shown literally fake.guests = [dict(VM, name="a[/b]")] fake.net_configs[108] = dict(VM_CONFIG) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) for k in keys: await pilot.press(k) await settle(app, pilot) title = app.screen.query_one("#dialog").border_title assert ( "a[/b]" in Content.from_markup(title).plain ) # what is shown, not how it is stored assert app.is_running # --- keys: Enter opens the info screen; edit lives there (user request) ------------ async def test_enter_on_a_guest_opens_its_info_screen(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press("down") # CT 100 await pilot.press("enter") await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) assert app.screen.guest["vmid"] == 100 async def test_i_and_e_do_nothing_on_the_main_screen(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) for key in ("i", "e"): await pilot.press(key) await settle(app, pilot) assert not isinstance(app.screen, ModalScreen), key async def test_e_in_info_screen_edits_that_guest_and_returns_to_info(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, cores="8") await press_button(app, pilot, "#apply") assert fake.sets == [{"cores": 8}] assert isinstance(app.screen, appmod.InfoScreen) async def test_footer_on_main_screen_shows_enter_for_info(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) shown = [ab.binding for ab in app.active_bindings.values() if ab.binding.show] assert any(b.key == "enter" and b.description == "Info" for b in shown) # --- info screen: resources (CPU, RAM, disk) ------------------------------------------ def resource_rows(app): t = app.screen.query_one("#resources", DataTable) return {cells(t, i)[0]: cells(t, i)[1] for i in range(t.row_count)} async def test_info_shows_vm_resources(fake): fake.net_configs[108] = {"sockets": 2, "memory": 8192, "scsi0": "local-lvm:vm-108-disk-0,discard=on,size=10G,ssd=1", "ide2": "local-lvm:vm-108-cloudinit,media=cdrom"} # fmt: skip app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) # VM 108, cores 4 in the fake config assert "Resources" in str( app.screen.query_one("#resources_section").border_title ) rows = resource_rows(app) assert rows["CPU"] == "4 cores × 2 sockets" assert rows["Memory"] == "8192 MiB" assert rows["Disk scsi0"] == "10G" assert not any( "ide2" in k for k in rows ) # cloud-init drive is not a disk to show async def test_info_shows_ct_resources_with_swap_and_mount_points(fake): fake.net_configs[100] = {"memory": 512, "swap": 256, "rootfs": "local-lvm:vm-100-disk-0,size=8G", "mp0": "local-lvm:vm-100-disk-1,mp=/data,size=32G"} # fmt: skip fake.disk_map = { "rootfs": "8G", "mp0": "32G", } # cli.disks is faked; its parsing has cli tests app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) # CT 100, cores 1 rows = resource_rows(app) assert rows["CPU"] == "1 core" assert rows["Memory"] == "512 MiB" assert rows["Swap"] == "256 MiB" assert rows["Disk rootfs"] == "8G" and rows["Disk mp0"] == "32G" async def test_info_ct_without_cores_is_unlimited(fake, monkeypatch): monkeypatch.setattr(cli, "guest_config", lambda guest: {"memory": 512}) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) assert resource_rows(app)["CPU"] == "unlimited" async def test_info_resources_reload_after_edit(fake, monkeypatch): config = { "cores": 4, "memory": 8192, } # what Proxmox stores; set_resources changes it monkeypatch.setattr(cli, "guest_config", lambda guest: dict(config)) monkeypatch.setattr( cli, "set_resources", lambda guest, cores=None, memory_mb=None: config.update(cores=cores), ) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_edit(app, pilot) # Enter, then e on VM 108 fill(app, cores="8") await press_button(app, pilot, "#apply") await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) assert resource_rows(app)["CPU"].startswith("8 cores") # --- #9: flag guests that no backup job covers -------------------------------------- def first_cell(app, vmid): return str(app.query_one("#guests", DataTable).get_row(str(vmid))[0]) async def test_unprotected_guests_are_marked_and_counted(fake): fake.unprotected = {100} app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert first_cell(app, 100) == "!" assert first_cell(app, 108) == "" subtitle = str(app.query_one("#guests_section").border_subtitle) assert "mainframe" in subtitle and "1 without backup job" in subtitle async def test_no_count_when_every_guest_is_covered(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert str(app.query_one("#guests_section").border_subtitle) == "mainframe" assert first_cell(app, 100) == "" and first_cell(app, 108) == "" async def test_backup_coverage_failure_still_lists_guests(fake): fake.unprotected_error = cli.CliError("Error: boom") app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert app.query_one("#guests", DataTable).row_count == 2 assert any(sev == "warning" for _, sev in app.seen) # coverage unknown, said so assert app.is_running # --- snapshots in the info screen (issue #8) --------------------------------- # spec: docs/plans/guest-tui-mvp.md ("Snapshots in the info screen (issue #8)" > app.py) async def open_snapshot_dialog(app, pilot): await pilot.press("p") await settle(app, pilot) assert isinstance(app.screen, appmod.SnapshotScreen) async def wait_started(pilot, event): for _ in range(200): if event.is_set(): return await pilot.pause(0.01) raise AssertionError("worker never started") async def test_info_snapshots_section_lists_newest_first_with_ram_for_vm(fake): # InfoScreen: Vertical#snapshots_section "Snapshots", DataTable#snapshots Name/Date/Description/RAM app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) section = app.screen.query_one("#snapshots_section") assert "Snapshots" in str(section.border_title) table = app.screen.query_one("#snapshots", DataTable) assert table.row_count == 2 newer, older = cells(table, 0), cells(table, 1) assert "newer" in newer and local_date(T_NEW) in newer and "second one" in newer assert "older" in older and local_date(T_OLD) in older and "first one" in older assert "yes" in newer and "no" in older assert fake.snap_calls == [108] async def test_info_snapshots_ct_has_no_ram_column(fake): # DataTable#snapshots: RAM column for VMs only app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) # CT 100 table = app.screen.query_one("#snapshots", DataTable) assert table.row_count == 2 assert len(table.columns) == 3 assert "yes" not in cells(table, 0) async def test_info_snapshot_without_date_shows_dash(fake): # Date: YYYY-MM-DD HH:MM or "-" fake.snap_list = [ { "name": "nodate", "description": "d", "snaptime": None, "parent": None, "vmstate": 0, } ] app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) assert "-" in cells(app.screen.query_one("#snapshots", DataTable), 0) assert app.is_running async def test_info_no_snapshots_shows_note(fake): # empty list -> #snap_note "No snapshots" fake.snap_list = [] app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) assert app.screen.query_one("#snapshots", DataTable).row_count == 0 assert "No snapshots" in str( app.screen.query_one("#snap_note", Static).render() ) async def test_info_snapshots_failure_is_inline_unavailable_and_rest_works(fake): # a failure of cli.snapshots -> "unavailable: " in #snap_note; rest of the screen still works fake.snap_error = cli_error("Error: snapshot listing exploded") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) note = str(app.screen.query_one("#snap_note", Static).render()) assert "unavailable" in note and "exploded" in note assert app.screen.query_one("#backups", DataTable).row_count == 2 assert isinstance(app.screen, appmod.InfoScreen) and app.is_running async def test_p_opens_snapshot_dialog_with_prefilled_name_and_title(fake): # p: SnapshotScreen, title "Take snapshot: ()", Input#snapname tui_YYYYMMDD_HHMM app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) before = datetime.now().strftime("tui_%Y%m%d_%H%M") await open_snapshot_dialog(app, pilot) after = datetime.now().strftime("tui_%Y%m%d_%H%M") assert app.screen.query_one("#snapname", Input).value in {before, after} title = str(app.screen.query_one("#dialog").border_title) assert "Take snapshot" in title and "docker" in title and "108" in title app.screen.query_one("#snapdesc", Input) app.screen.query_one("#yes", Button) app.screen.query_one("#no", Button) assert fake.take_calls == [] async def test_snapshot_dialog_vmstate_checkbox_only_for_vm(fake): # Checkbox#vmstate: VMs only; not shown for CTs app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) await open_snapshot_dialog(app, pilot) assert len(app.screen.query("#vmstate")) == 0 await pilot.press("escape") await settle(app, pilot) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await open_snapshot_dialog(app, pilot) assert len(app.screen.query("#vmstate")) == 1 @pytest.mark.parametrize("how", ["no", "escape"]) async def test_snapshot_dialog_declined_makes_no_call(fake, how): # SnapshotScreen: buttons #yes/#no, escape = no app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await open_snapshot_dialog(app, pilot) if how == "no": await press_button(app, pilot, "#no") else: await pilot.press("escape") await settle(app, pilot) assert fake.take_calls == [] assert isinstance(app.screen, appmod.InfoScreen) assert app.busy == set() @pytest.mark.parametrize("bad", ["", "my snap", "1abc", "current", "a;rm", "x" * 41]) async def test_snapshot_invalid_name_error_notify_dialog_stays_no_call(fake, bad): # Invalid name -> error notify, dialog stays, no call app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await open_snapshot_dialog(app, pilot) app.screen.query_one("#snapname", Input).value = bad await press_button(app, pilot, "#yes") assert errors(app), "an error notification is expected" assert isinstance(app.screen, appmod.SnapshotScreen) assert fake.take_calls == [] and app.busy == set() async def test_snapshot_yes_takes_vm_snapshot_once_busy_then_reloads(fake): # Yes -> busy guard, worker cli.take_snapshot(guest, name, desc, vmstate), notify, info reloads app = make_app() busy_seen = [] fake.snap_hook = lambda: busy_seen.append(set(app.busy)) async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) before = len(fake.snap_calls) await open_snapshot_dialog(app, pilot) app.screen.query_one("#snapname", Input).value = "before_upgrade" app.screen.query_one("#snapdesc", Input).value = "pre upgrade" app.screen.query_one("#vmstate", Checkbox).value = True await press_button(app, pilot, "#yes") assert fake.take_calls == [(108, "before_upgrade", "pre upgrade", True)] assert busy_seen and busy_seen[0], ( "guest must be busy while the snapshot is taken" ) assert app.busy == set() assert errors(app) == [] and app.seen assert len(fake.snap_calls) > before # info reloaded assert isinstance(app.screen, appmod.InfoScreen) async def test_snapshot_vmstate_unticked_passes_false(fake): # Checkbox#vmstate off -> vmstate False app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await open_snapshot_dialog(app, pilot) app.screen.query_one("#snapname", Input).value = "plain" app.screen.query_one("#snapdesc", Input).value = "d" app.screen.query_one("#vmstate", Checkbox).value = False await press_button(app, pilot, "#yes") assert fake.take_calls == [(108, "plain", "d", False)] async def test_snapshot_ct_never_passes_vmstate(fake): # CT: no checkbox, so vmstate is False app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) await open_snapshot_dialog(app, pilot) app.screen.query_one("#snapname", Input).value = "ctsnap" app.screen.query_one("#snapdesc", Input).value = "d" await press_button(app, pilot, "#yes") assert fake.take_calls == [(100, "ctsnap", "d", False)] async def test_snapshot_failure_notifies_error_and_releases_busy(fake): # CliError -> error notify, busy released fake.snap_error_op = cli_error("Error: snapshot is not possible: no space left") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await open_snapshot_dialog(app, pilot) app.screen.query_one("#snapname", Input).value = "willfail" await press_button(app, pilot, "#yes") assert any("no space left" in m for m in errors(app)) assert app.busy == set() and app.is_running # --- rollback --------------------------------------------------------------- async def open_confirm_on_row(app, pilot, key, row=1): await open_info(app, pilot) app.screen.query_one("#snapshots", DataTable).move_cursor(row=row) await pilot.pause() await pilot.press(key) await settle(app, pilot) assert isinstance(app.screen, ConfirmScreen) async def test_R_confirm_names_second_snapshot_and_rolls_back_running_vm_with_start( fake, ): # R: ConfirmScreen "Roll back" naming snapshot+date, data-loss warning, running -> started again, start=True app = make_app() async with app.run_test(size=SIZE) as pilot: await open_confirm_on_row(app, pilot, "R") assert "Roll back" in str(app.screen.query_one("#dialog").border_title) text = text_of(app.screen) assert "older" in text and "newer" not in text assert local_date(T_OLD)[:10] in text assert "lost" in text.lower() assert "started" in text.lower() assert fake.rollback_calls == [] await press_button(app, pilot, "#yes") assert fake.rollback_calls == [(108, "older", True)] assert app.busy == set() and errors(app) == [] async def test_R_uses_live_status_for_start_flag(fake): # "running (live status)": CT is "stopped" in the listing but running live -> start=True fake.live_status = {100: "running"} app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) app.screen.query_one("#snapshots", DataTable).move_cursor(row=0) await pilot.press("R") await settle(app, pilot) await press_button(app, pilot, "#yes") assert fake.rollback_calls == [(100, "newer", True)] @pytest.mark.parametrize("guest_down,vmid", [(0, 108), (1, 100)]) async def test_R_stopped_guest_rolls_back_without_start(fake, guest_down, vmid): # stopped (live status; VM listing says running but live says stopped) -> start=False fake.live_status = {108: "stopped"} app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=guest_down) app.screen.query_one("#snapshots", DataTable).move_cursor(row=1) await pilot.press("R") await settle(app, pilot) await press_button(app, pilot, "#yes") assert fake.rollback_calls == [(vmid, "older", False)] @pytest.mark.parametrize("key,calls", [("R", "rollback_calls"), ("D", "delete_calls")]) @pytest.mark.parametrize("how", ["no", "escape"]) async def test_R_D_declined_make_no_call(fake, key, calls, how): app = make_app() async with app.run_test(size=SIZE) as pilot: await open_confirm_on_row(app, pilot, key) if how == "no": await press_button(app, pilot, "#no") else: await pilot.press("escape") await settle(app, pilot) assert getattr(fake, calls) == [] assert isinstance(app.screen, appmod.InfoScreen) and app.busy == set() async def test_R_failure_notifies_error_and_releases_busy(fake): fake.snap_error_op = cli_error("Error: rollback refused: snapshot is locked") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_confirm_on_row(app, pilot, "R") await press_button(app, pilot, "#yes") assert any("locked" in m for m in errors(app)) assert app.busy == set() and app.is_running async def test_R_reloads_info_after_rollback(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await open_confirm_on_row(app, pilot, "R") before = len(fake.snap_calls) await press_button(app, pilot, "#yes") assert len(fake.snap_calls) > before # --- delete ----------------------------------------------------------------- async def test_D_confirm_names_snapshot_then_deletes_it_busy_and_reloads(fake): # D: ConfirmScreen "Delete snapshot" naming it; yes -> busy guard, cli.delete_snapshot, notify, reload app = make_app() busy_seen = [] fake.snap_hook = lambda: busy_seen.append(set(app.busy)) async with app.run_test(size=SIZE) as pilot: await open_confirm_on_row(app, pilot, "D") assert "Delete snapshot" in str(app.screen.query_one("#dialog").border_title) text = text_of(app.screen) assert "older" in text and "newer" not in text assert fake.delete_calls == [] before = len(fake.snap_calls) await press_button(app, pilot, "#yes") assert fake.delete_calls == [(108, "older")] assert busy_seen and busy_seen[0] assert app.busy == set() and errors(app) == [] and app.seen assert len(fake.snap_calls) > before assert fake.rollback_calls == [] and fake.take_calls == [] async def test_D_targets_cursor_row_first_snapshot_for_ct(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) await pilot.press("D") await settle(app, pilot) assert "newer" in text_of(app.screen) await press_button(app, pilot, "#yes") assert fake.delete_calls == [(100, "newer")] async def test_D_failure_notifies_error_and_releases_busy(fake): fake.snap_error_op = cli_error("Error: snapshot 'older' does not exist") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_confirm_on_row(app, pilot, "D") await press_button(app, pilot, "#yes") assert any("does not exist" in m for m in errors(app)) assert app.busy == set() and app.is_running # --- no snapshots / busy ---------------------------------------------------- @pytest.mark.parametrize("key", ["R", "D"]) async def test_R_D_without_snapshots_give_info_notify_and_no_dialog(fake, key): # R/D with no snapshot -> info notify, no dialog fake.snap_list = [] app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await pilot.press(key) await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) assert [sev for _, sev in app.seen if sev != "information"] == [] assert app.seen assert fake.rollback_calls == [] and fake.delete_calls == [] @pytest.mark.parametrize( "key,calls", [("R", "rollback_calls"), ("D", "delete_calls"), ("p", "take_calls")] ) async def test_snapshot_keys_while_guest_busy_warn_and_make_no_second_call( fake, key, calls ): # busy guest -> warning, no call (busy held by a running take_snapshot) started, release = threading.Event(), threading.Event() fake.snap_hook = lambda: (started.set(), release.wait(10)) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) try: await open_snapshot_dialog(app, pilot) app.screen.query_one("#snapname", Input).value = "first" app.screen.query_one("#yes", Button).press() await wait_started(pilot, started) assert ( fake.take_calls == [(108, "first", "", False)] or len(fake.take_calls) == 1 ) app.screen.query_one("#snapshots", DataTable).move_cursor(row=0) await pilot.press(key) await pilot.pause() if isinstance(app.screen, (appmod.SnapshotScreen, ConfirmScreen)): app.screen.query_one( "#yes", Button ).press() # refusal may come at "yes" await pilot.pause() assert any(sev == "warning" for _, sev in app.seen) assert fake.rollback_calls == [] and fake.delete_calls == [] assert len(fake.take_calls) == 1 finally: release.set() await settle(app, pilot) assert ( fake.rollback_calls == [] and fake.delete_calls == [] and len(fake.take_calls) == 1 ) # --- #8 review findings --------------------------------------------------------------- async def test_rollback_start_flag_uses_status_at_rollback_time(fake): # opened while running, stopped before "yes": do not start it again app = make_app() async with app.run_test(size=SIZE) as pilot: await open_confirm_on_row(app, pilot, "R") fake.live_status = {108: "stopped"} await press_button(app, pilot, "#yes") assert fake.rollback_calls == [(108, "older", False)] async def test_rollback_aborts_when_status_cannot_be_read(fake, monkeypatch): # an unknown status must not silently leave a running guest stopped app = make_app() async with app.run_test(size=SIZE) as pilot: await open_confirm_on_row(app, pilot, "R") def broken(guest): raise cli.CliError("Error: status unavailable") monkeypatch.setattr(cli, "status", broken) await press_button(app, pilot, "#yes") assert fake.rollback_calls == [] assert errors(app) and app.busy == set() async def test_snapshot_description_starting_with_dash_is_refused(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await pilot.press("p") await settle(app, pilot) app.screen.query_one("#snapdesc", Input).value = "-y" await press_button(app, pilot, "#yes") assert fake.take_calls == [] and errors(app) assert isinstance(app.screen, appmod.SnapshotScreen) async def test_info_screen_scrolls_on_short_terminals(fake): from textual.containers import VerticalScroll app = make_app() async with app.run_test(size=(100, 24)) as pilot: await open_info(app, pilot) assert isinstance(app.screen.query_one("#info_body"), VerticalScroll) # --- live usage: CPU %, memory used, disk used (issue #12) --------------------------------- # spec: docs/plans/guest-tui-mvp.md, "Live usage" > app.py GIB, MIB = 2**30, 2**20 def column_labels(table): return [str(c.label) for c in table.columns.values()] async def table_cells(app, pilot, guests): """Open the app with the given guests; return {vmid: row cells as str}.""" fake_rows = {} await settle(app, pilot) table = app.query_one("#guests", DataTable) for key in table.rows: fake_rows[key.value] = [str(c) for c in table.get_row(key.value)] return fake_rows async def test_table_has_cpu_and_mem_percent_as_last_two_columns(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) assert column_labels(app.query_one("#guests", DataTable))[-2:] == [ "CPU %", "Mem %", ] async def test_table_running_guest_shows_cpu_and_mem_percent_one_decimal(fake): # cpu is a 0..1 fraction -> x100; mem 932048896 of 8 GiB = 10.85 % fake.guests = [dict(VM, cpu=0.0538, mem=932048896), CT] app = make_app() async with app.run_test() as pilot: rows = await table_cells(app, pilot, fake.guests) assert rows["108"][-2:] == ["5.4", "10.9"] async def test_table_idle_running_guest_shows_zero_not_dash(fake): fake.guests = [dict(VM, cpu=0.0, mem=0), CT] app = make_app() async with app.run_test() as pilot: rows = await table_cells(app, pilot, fake.guests) assert rows["108"][-2:] == ["0.0", "0.0"] async def test_table_stopped_guest_shows_dashes(fake): # stopped guests report 0s (or stale values); both columns are "-" fake.guests = [VM, dict(CT, cpu=0.5, mem=256 * MIB)] app = make_app() async with app.run_test() as pilot: rows = await table_cells(app, pilot, fake.guests) assert rows["100"][-2:] == ["-", "-"] async def test_table_running_guest_missing_fields_shows_dashes(fake): app = make_app() # VM 108 is running but has no cpu/mem in the fixture async with app.run_test() as pilot: rows = await table_cells(app, pilot, fake.guests) assert rows["108"][-2:] == ["-", "-"] async def test_table_running_guest_with_only_cpu_shows_dash_for_mem(fake): fake.guests = [dict(VM, cpu=0.25), CT] app = make_app() async with app.run_test() as pilot: rows = await table_cells(app, pilot, fake.guests) assert rows["108"][-2:] == ["25.0", "-"] def in_use_rows(app): t = app.screen.query_one("#resources", DataTable) return {cells(t, i)[0]: cells(t, i)[2] for i in range(t.row_count)} CT_RESOURCES = {"memory": 512, "swap": 256, "rootfs": "local-lvm:vm-100-disk-0,size=8G", "mp0": "local-lvm:vm-100-disk-1,mp=/data,size=32G"} # fmt: skip CT_USAGE = {"cpu": 0.0538, "cpus": 1, "mem": 256 * MIB, "maxmem": 512 * MIB, "swap": 64 * MIB, "maxswap": 256 * MIB, "disk": 2 * GIB, "maxdisk": 8 * GIB} # fmt: skip def running_ct(fake): fake.net_configs[100] = CT_RESOURCES fake.disk_map = {"rootfs": "8G", "mp0": "32G"} fake.live_status = {100: "running"} fake.usage[100] = CT_USAGE async def test_info_resources_table_has_header_and_three_columns(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) t = app.screen.query_one("#resources", DataTable) assert t.show_header is True assert column_labels(t) == ["Resource", "Configured", "In use"] async def test_info_configured_column_unchanged_with_usage(fake): running_ct(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) rows = resource_rows(app) assert rows["CPU"] == "1 core" and rows["Memory"] == "512 MiB" assert rows["Swap"] == "256 MiB" assert rows["Disk rootfs"] == "8G" and rows["Disk mp0"] == "32G" async def test_info_running_ct_in_use_column(fake): running_ct(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) use = in_use_rows(app) assert use["CPU"] == "5.4 %" assert use["Memory"] == "256 MiB (50 %)" assert use["Swap"] == "64 MiB" assert use["Disk rootfs"] == "2.0 GiB (25 %)" assert use["Disk mp0"] == "-" # only rootfs usage is known async def test_info_running_vm_cpu_memory_in_use_and_disks_dash(fake): # VMs have no guest-agent disk info: the disk In use is "-" even when the API sends a value fake.net_configs[108] = { "memory": 8192, "scsi0": "local-lvm:vm-108-disk-0,size=10G", } fake.usage[108] = {"cpu": 0.1, "cpus": 4, "mem": 2 * GIB, "maxmem": 8 * GIB, "disk": 3 * GIB, "maxdisk": 10 * GIB} # fmt: skip app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) use = in_use_rows(app) assert use["CPU"] == "10.0 %" assert use["Memory"] == "2048 MiB (25 %)" assert use["Disk scsi0"] == "-" async def test_info_stopped_guest_in_use_all_dashes(fake): fake.net_configs[100] = CT_RESOURCES fake.disk_map = {"rootfs": "8G", "mp0": "32G"} fake.usage[100] = CT_USAGE # stale non-zero values; the guest is stopped app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) use = in_use_rows(app) assert {"CPU", "Memory", "Swap", "Disk rootfs"} <= set(use) assert set(use.values()) == {"-"} async def test_info_load_uses_one_current_call_for_status_and_usage(fake): running_ct(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) assert fake.current_calls == [100] assert fake.iface_calls == [100] # live status from the same call -> running async def test_info_current_failure_is_inline_unavailable_and_in_use_dashes(fake): fake.current_error = cli_error("Error: usage-probe exploded") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) and app.is_running text = text_of(app.screen).lower() assert "unavailable" in text and "usage-probe exploded" in text assert set(in_use_rows(app).values()) == {"-"} # configured column still loads (a VM always shows sockets, see the #resources tests) assert resource_rows(app)["CPU"] == "4 cores × 1 socket" assert fake.iface_calls == [] # status unknown -> treated as not running async def test_table_usage_of_acted_guest_comes_from_live_status(fake): # the listing lags after an action: CPU %/Mem % of the acted guest must be live too fake.guests = [dict(CT, status="stopped", cpu=0, mem=0)] app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) fake.live_status = {100: "running"} fake.usage = {100: {"cpu": 0.5, "mem": 256 * 2**20, "maxmem": 512 * 2**20}} await pilot.press("s") # start CT 100; its listing still says stopped, 0, 0 await settle(app, pilot) row = [str(c) for c in app.query_one("#guests", DataTable).get_row("100")] assert "running" in row assert row[-2:] == ["50.0", "50.0"] # --- issue #10: task log ----------------------------------------------------------- # spec: docs/plans/guest-tui-mvp.md ("Task log (issue #10)" > app.py) TASK_COLUMNS = 8 # leading "!" column + Start, Duration, Node, Type, VMID, User, Status async def open_tasks(app, pilot): await pilot.press("l") await settle(app, pilot) assert isinstance(app.screen, appmod.TasksScreen) return app.screen.query_one("#tasks", DataTable) def log_text(widget): """Text of the #tasklog widget, whatever the widget class (the spec only says read-only plain text).""" if hasattr(widget, "text"): return widget.text if hasattr(widget, "lines"): return "\n".join(str(getattr(ln, "text", ln)) for ln in widget.lines) return str(widget.render()) def local_task_date(epoch): return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M") async def test_l_on_main_screen_lists_all_tasks_newest_first(fake): # `l` main screen app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) table = await open_tasks(app, pilot) assert table.row_count == 4 assert [cells(table, i)[4] for i in range(4)] == [ "qmstop", "qmstart", "aptupdate", "vzsnapshot", ] assert app.screen.query_one("#tasks_section").border_title == "Tasks" assert table.cursor_type == "row" async def test_tasks_columns_and_formatting( fake, ): # columns, duration, VMID "-", Status app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) table = await open_tasks(app, pilot) assert len(table.columns) == TASK_COLUMNS running, ok, node_task, failed = (cells(table, i) for i in range(4)) assert ok[1:] == [ local_task_date(T_TASK), "3m 12s", "mainframe", "qmstart", "108", "root@pam", "OK", ] assert running[1:] == [local_task_date(T_TASK + 1000), "running", "mainframe", "qmstop", "108", "root@pam", "running"] # fmt: skip assert node_task[2] == "1h 2m" assert node_task[5] == "-" # node task has no VMID assert failed[2] == "45s" assert failed[7] == "snapshot 'x' already exists" async def test_failed_task_gets_bang_in_leading_column_only(fake): # "!" for failed app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) table = await open_tasks(app, pilot) marks = [cells(table, i)[0].strip() for i in range(4)] assert marks == ["", "", "", "!"] # running, OK, OK, failed async def test_l_on_info_screen_shows_only_that_guests_tasks( fake, ): # `l` info screen, vmid filter app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) # VM 108 docker table = await open_tasks(app, pilot) assert [cells(table, i)[4] for i in range(table.row_count)] == [ "qmstop", "qmstart", ] assert ( app.screen.query_one("#tasks_section").border_title == "Tasks: docker (108)" ) async def test_info_tasks_filter_matches_vmid_exactly( fake, ): # id == str(vmid), not a prefix fake.task_list = [ dict(TASKS[1], id="1080"), dict(TASKS[1], id="10"), dict(TASKS[1], id="108"), ] app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) table = await open_tasks(app, pilot) assert [cells(table, i)[5] for i in range(table.row_count)] == ["108"] async def test_no_tasks_note_when_empty(fake): # Static#tasks_note "No tasks" fake.task_list = [] app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) table = await open_tasks(app, pilot) assert table.row_count == 0 assert "No tasks" in str(app.screen.query_one("#tasks_note", Static).render()) async def test_info_tasks_empty_for_guest_without_tasks( fake, ): # filter leaving nothing -> "No tasks" app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info( app, pilot, down=1 ) # CT 100; only a vzsnapshot task of 100 exists table = await open_tasks(app, pilot) assert table.row_count == 1 fake.task_list = [t for t in TASKS if t["id"] != "100"] await pilot.press("g") await settle(app, pilot) assert table.row_count == 0 assert "No tasks" in str(app.screen.query_one("#tasks_note", Static).render()) async def test_tasks_failure_notifies_error_and_app_keeps_running( fake, ): # failure -> error notify fake.tasks_error = cli_error("Error: tasks boom") app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await open_tasks(app, pilot) assert any("boom" in e for e in errors(app)) assert isinstance(app.screen, appmod.TasksScreen) and app.is_running async def test_enter_opens_log_of_the_cursor_tasks_upid(fake): # Enter -> TaskLogScreen fake.log_text = "starting\nTASK OK\n" app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await open_tasks(app, pilot) await pilot.press("down") # second row: qmstart await pilot.press("enter") await settle(app, pilot) assert isinstance(app.screen, appmod.TaskLogScreen) assert fake.log_calls == [TASKS[1]["upid"]] assert "TASK OK" in log_text(app.screen.query_one("#tasklog")) assert str(app.screen.query_one("#dialog").border_title) == "Log: qmstart 108" async def test_log_title_for_node_task_names_the_node( fake, ): # title " " app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await open_tasks(app, pilot) await pilot.press("down", "down", "enter") # aptupdate, no vmid await settle(app, pilot) assert ( str(app.screen.query_one("#dialog").border_title) == "Log: aptupdate mainframe" ) async def test_log_with_markup_like_text_is_shown_literally(fake): # no markup parsing fake.log_text = "error in [/b] and [bold]x[/bold] and [red\nTASK OK\n" app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await open_tasks(app, pilot) await pilot.press("enter") await settle(app, pilot) assert isinstance(app.screen, appmod.TaskLogScreen) and app.is_running shown = log_text(app.screen.query_one("#tasklog")) assert "error in [/b] and [bold]x[/bold] and [red" in shown async def test_task_log_failure_notifies_error( fake, ): # task_log failure -> error notify fake.log_error = cli_error("Error: log boom") app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await open_tasks(app, pilot) await pilot.press("enter") await settle(app, pilot) assert any("boom" in e for e in errors(app)) assert app.is_running async def test_g_reloads_tasks(fake): # `g` reload app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) table = await open_tasks(app, pilot) before = fake.tasks_calls fake.task_list = fake.task_list[:2] await pilot.press("g") await settle(app, pilot) assert fake.tasks_calls == before + 1 assert table.row_count == 2 async def test_escape_closes_log_then_tasks_then_returns_to_info( fake, ): # escape closes each screen app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await open_tasks(app, pilot) await pilot.press("enter") await settle(app, pilot) assert isinstance(app.screen, appmod.TaskLogScreen) await pilot.press("escape") await settle(app, pilot) assert isinstance(app.screen, appmod.TasksScreen) await pilot.press("escape") await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) async def test_escape_closes_tasks_screen_on_main(fake): # escape closes app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await open_tasks(app, pilot) await pilot.press("escape") await settle(app, pilot) assert not isinstance(app.screen, appmod.TasksScreen) assert app.is_running async def test_footer_on_main_screen_shows_l_tasks(fake): # footer `l Tasks` app = make_app() async with app.run_test() as pilot: await settle(app, pilot) shown = [ab.binding for ab in app.active_bindings.values() if ab.binding.show] assert any(b.key == "l" and b.description == "Tasks" for b in shown) # --- #10 review findings ---------------------------------------------------------------- async def test_task_with_endtime_but_no_starttime_does_not_crash(fake): fake.task_list = [ {"upid": "UPID:mainframe:a:vzdump::root@pam:", "type": "vzdump", "id": "", "node": "mainframe", "user": "root@pam", "endtime": T_TASK, "status": "OK"}, {"upid": "UPID:mainframe:b:vzdump::root@pam:", "type": "vzdump", "id": "", "node": "mainframe", "user": "root@pam", "starttime": None, "endtime": T_TASK, "status": "OK"}, ] # fmt: skip app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) table = await open_tasks(app, pilot) assert table.row_count == 2 and app.is_running assert [str(table.get_row_at(i)[2]) for i in range(2)] == [ "-", "-", ] # duration unknown async def test_finished_task_with_empty_status_is_not_shown_as_running(fake): fake.task_list = [ {"upid": "UPID:mainframe:c:qmstart:108:root@pam:", "type": "qmstart", "id": "108", "node": "mainframe", "user": "root@pam", "starttime": T_TASK, "endtime": T_TASK + 5, "status": ""}, ] # fmt: skip app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) table = await open_tasks(app, pilot) row = [str(c) for c in table.get_row_at(0)] assert "running" not in row assert row[0] == "!" # finished without OK: flagged # --- #13: node package updates ------------------------------------------------------------ # spec: docs/plans/guest-tui-mvp.md ("Node package updates (issue #13)") def pkg(name, old="1.0", new="2.0", origin="Debian", priority="optional"): return {"Package": name, "OldVersion": old, "Version": new, "Title": f"{name} title", "Priority": priority, "Origin": origin} # fmt: skip def many(n, prefix="pkg"): return [pkg(f"{prefix}{i:02d}") for i in range(n)] def frame_subtitle(app): return str(app.query_one("#guests_section").border_subtitle) async def open_updates(app, pilot): await pilot.press("U") await settle(app, pilot) assert isinstance(app.screen, appmod.UpdatesScreen) return app.screen.query_one("#updates", DataTable) def note_of(app): return str(app.screen.query_one("#updates_note", Static).render()) def two_nodes(fake): fake.updates = { "mainframe": [pkg("curl", "7.1", "7.2", "Debian", "optional"), pkg("pve-manager", "8.1", "8.2", "Proxmox", "important")], "edge": [pkg("bash", "5.1", "5.2", "Debian", "required")], } # fmt: skip async def test_u_lists_rows_across_nodes_with_all_columns( fake, ): # UpdatesScreen rows + columns two_nodes(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) table = await open_updates(app, pilot) assert [str(c.label) for c in table.columns.values()] == [ "Node", "Package", "Current", "New", "Origin", "Priority"] # fmt: skip assert table.row_count == 3 assert {tuple(cells(table, i)) for i in range(3)} == { ("mainframe", "curl", "7.1", "7.2", "Debian", "optional"), ("mainframe", "pve-manager", "8.1", "8.2", "Proxmox", "important"), ("edge", "bash", "5.1", "5.2", "Debian", "required"), } assert ( app.screen.query_one("#updates_section").border_title == "Package updates" ) async def test_updates_screen_shows_no_pending_updates_note_when_empty( fake, ): # "No pending updates" app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) table = await open_updates(app, pilot) assert table.row_count == 0 assert "No pending updates" in note_of(app) async def test_updates_screen_has_apt_update_freshness_hint( fake, ): # fixed hint: as of last apt update two_nodes(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await open_updates(app, pilot) assert "apt update" in note_of(app).lower() async def test_failing_node_is_unavailable_and_other_nodes_still_listed( fake, ): # failing node != hidden others two_nodes(fake) err = cli.CliError("Error: node edge timed out") fake.updates_errors = {"edge": err} app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) table = await open_updates(app, pilot) assert table.row_count == 2 assert {cells(table, i)[0] for i in range(2)} == {"mainframe"} note = note_of(app) assert "edge: unavailable" in note and err.summary in note assert "mainframe: unavailable" not in note async def test_g_in_updates_screen_reloads(fake): # `g` reloads two_nodes(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) table = await open_updates(app, pilot) before = len(fake.updates_calls) fake.updates["edge"].append(pkg("zsh")) await pilot.press("g") await settle(app, pilot) assert len(fake.updates_calls) > before assert table.row_count == 4 async def test_escape_closes_updates_screen(fake): # `escape` closes app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await open_updates(app, pilot) await pilot.press("escape") await settle(app, pilot) assert not isinstance(app.screen, appmod.UpdatesScreen) assert app.is_running async def test_frame_subtitle_shows_package_update_count_after_mount( fake, ): # "· N package updates" fake.updates = {"mainframe": many(23)} app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert "· 23 package updates" in frame_subtitle(app) assert "mainframe" in frame_subtitle(app) async def test_frame_subtitle_count_sums_nodes(fake): # count over all online nodes two_nodes(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert "· 3 package updates" in frame_subtitle(app) async def test_no_package_count_when_nothing_pending(fake): # N > 0 only app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert frame_subtitle(app) == "mainframe" async def test_package_count_follows_backup_count_in_subtitle( fake, ): # "after the backup count" fake.unprotected = {100} fake.updates = {"mainframe": many(23)} app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) sub = frame_subtitle(app) assert "1 without backup job" in sub and "· 23 package updates" in sub assert sub.index("without backup job") < sub.index("package updates") async def test_plain_guest_reload_does_not_refetch_package_count( fake, ): # not on every guest reload fake.updates = {"mainframe": many(23)} app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) nodes_before, pkg_before, list_before = ( fake.nodes_calls, len(fake.updates_calls), fake.list_calls, ) assert nodes_before >= 1 and pkg_before >= 1 await pilot.press("g") await settle(app, pilot) assert fake.list_calls > list_before assert (fake.nodes_calls, len(fake.updates_calls)) == (nodes_before, pkg_before) assert "· 23 package updates" in frame_subtitle(app) async def test_opening_updates_screen_refreshes_the_frame_count( fake, ): # count fetched on every screen load fake.updates = {"mainframe": many(23)} app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) fake.updates["mainframe"] = many(5) await open_updates(app, pilot) assert "· 5 package updates" in frame_subtitle(app) assert "23 package updates" not in frame_subtitle(app) async def test_context_switch_clears_old_count_then_shows_new_clusters_count( fake, ): # cleared at switch start fake.updates = {"mainframe": many(23)} fake.updates_by_context = {"mainframe-edge": {"edge": many(4)}} fake.guests_by_context = {"mainframe-edge": [EDGE]} started, release = threading.Event(), threading.Event() fake.use_hook = lambda: (started.set(), release.wait(10)) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert "23 package updates" in frame_subtitle(app) try: await switch_to_edge(app, pilot) await until(pilot, started.is_set) await pilot.pause() assert "package updates" not in frame_subtitle(app) finally: release.set() await settle(app, pilot) sub = frame_subtitle(app) assert "· 4 package updates" in sub and "23" not in sub async def test_count_fetched_before_a_switch_but_arriving_after_it_is_dropped( fake, ): # generation guard fake.updates = {"mainframe": many(23)} fake.updates_by_context = {"mainframe-edge": {"edge": many(4)}} fake.guests_by_context = {"mainframe-edge": [EDGE]} hook, started, release = blocking_once() fake.updates_hook = hook # the mount-time count reads mainframe, then stalls app = make_app() async with app.run_test(size=SIZE) as pilot: try: await until(pilot, started.is_set) await switch_to_edge(app, pilot) await until(pilot, lambda: rows(app) == ["300"]) finally: release.set() await settle(app, pilot) sub = frame_subtitle(app) assert "23" not in sub assert "· 4 package updates" in sub @pytest.mark.parametrize("failure", ["nodes", "node"]) async def test_count_failure_on_mount_shows_nothing_and_does_not_notify( fake, failure ): # unknown count fake.updates = {"mainframe": many(23)} if failure == "nodes": fake.nodes_error = cli.CliError("Error: no route") else: fake.updates_errors = {"mainframe": cli.CliError("Error: no route")} app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert "package updates" not in frame_subtitle(app) assert errors(app) == [] assert app.query_one("#guests", DataTable).row_count == 2 async def test_footer_on_main_screen_shows_u_updates(fake): # footer `U Updates` app = make_app() async with app.run_test() as pilot: await settle(app, pilot) shown = [ab.binding for ab in app.active_bindings.values() if ab.binding.show] assert any(b.key == "U" and b.description == "Updates" for b in shown) async def test_frame_subtitle_singular_for_one_package_update(fake): fake.updates = {"mainframe": many(1)} app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert frame_subtitle(app).endswith("· 1 package update") # --- #14: warn on an untested cv4pve-cli version ------------------------------------- async def test_no_warning_for_the_tested_cli_version(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert not any("tested with" in m for m, _ in app.seen) async def test_other_cli_version_warns_once(fake): fake.cli_version = "2.5.0" app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) warned = [m for m, sev in app.seen if sev == "warning" and "tested with" in m] assert ( len(warned) == 1 and "2.5.0" in warned[0] and cli.TESTED_VERSION in warned[0] ) async def test_cli_not_runnable_is_an_error_not_a_crash(fake): fake.version_error = cli.CliError( "[Errno 2] No such file or directory: 'cv4pve-cli'" ) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert any("cv4pve-cli" in m for m in errors(app)) and app.is_running # --- #16: search and filter in the guest table -------------------------------------------- # spec: docs/plans/guest-tui-mvp.md ("Search and filter in the guest table (issue #16)") TAGGED_VM = {**VM, "tags": "172.16.41.200;community-script;os"} TAGGED_CT = {**CT, "tags": "backup;Nightly"} def fguest(**over): return {**TAGGED_VM, **over} @pytest.mark.parametrize( ("query", "expected"), [ ("dock", True), # plain term: name substring ("DOCK", True), # case-insensitive ("108", True), # plain term: vmid ("10", True), # vmid substring ("community", True), # plain term: tag substring ("172.16.41.200", True), # a whole tag ("COMMUNITY-script", True), # tag, case-insensitive ("nomatch", False), ("999", False), ], ) def test_guest_matches_plain_term_on_name_vmid_tag(query, expected): # plain term assert appmod.guest_matches(fguest(), query) is expected def test_guest_matches_terms_are_anded(): # all terms must match g = fguest() assert appmod.guest_matches(g, "dock community") is True assert appmod.guest_matches(g, "dock nomatch") is False assert appmod.guest_matches(g, "nomatch community") is False assert appmod.guest_matches(g, "dock 108 os is:running type:vm") is True @pytest.mark.parametrize( ("status", "query", "expected"), [ ("running", "is:running", True), ("running", "is:stopped", False), ("stopped", "is:stopped", True), ("stopped", "is:running", False), ("running", "IS:Running", True), # case-insensitive ], ) def test_guest_matches_is_status(status, query, expected): # is:running / is:stopped assert appmod.guest_matches(fguest(status=status), query) is expected @pytest.mark.parametrize( ("kind", "query", "expected"), [ ("lxc", "type:lxc", True), ("lxc", "type:ct", True), ("lxc", "type:vm", False), ("lxc", "type:qemu", False), ("qemu", "type:vm", True), ("qemu", "type:qemu", True), ("qemu", "type:lxc", False), ("qemu", "type:ct", False), ("qemu", "TYPE:VM", True), # case-insensitive ], ) def test_guest_matches_type(kind, query, expected): # type:lxc|ct / type:vm|qemu assert appmod.guest_matches(fguest(type=kind), query) is expected @pytest.mark.parametrize("query", ["", " "]) def test_guest_matches_empty_query_matches_all(query): # empty filter = no filter assert appmod.guest_matches(fguest(), query) is True assert appmod.guest_matches(TAGGED_CT, query) is True def test_guest_matches_combines_keyword_and_plain_terms(): # AND across term kinds assert appmod.guest_matches(TAGGED_CT, "type:ct is:stopped back") is True assert appmod.guest_matches(TAGGED_CT, "type:ct is:running back") is False _text = st.text(alphabet="abcdXYZ0123;.-", max_size=8) _guests = st.fixed_dictionaries( { "vmid": st.integers(min_value=100, max_value=999), "name": _text, "type": st.sampled_from(["qemu", "lxc"]), "status": st.sampled_from(["running", "stopped"]), "tags": _text, } ) _terms = st.one_of( _text.filter(bool), st.sampled_from(["is:running", "is:stopped", "type:lxc", "type:vm", "type:ct"]), ) @given(g=_guests, terms=st.lists(_terms, max_size=4), extra=_terms) def test_guest_matches_property_empty_matches_and_more_terms_only_narrow( g, terms, extra ): # empty query matches all; AND is monotone assert appmod.guest_matches(g, "") is True base = " ".join(terms) if not appmod.guest_matches(g, base): assert not appmod.guest_matches(g, f"{base} {extra}") # --- UI ------------------------------------------------------------------------------------- def three_guests(fake): fake.guests = [ TAGGED_VM, TAGGED_CT, {**VM, "vmid": 200, "name": "web", "status": "stopped", "tags": "frontend"}, ] def filter_input(app): return app.query_one("#filter", Input) async def type_filter(app, pilot, text): await pilot.press("slash") await pilot.pause() for ch in text: await pilot.press("space" if ch == " " else ch) await settle(app, pilot) async def test_slash_shows_and_focuses_the_filter_input(fake): # '/' shows + focuses three_guests(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert not visible(app, filter_input(app)) # hidden until then await pilot.press("slash") await pilot.pause() assert visible(app, filter_input(app)) assert app.focused is filter_input(app) assert "is:running" in filter_input(app).placeholder # syntax hint async def test_typing_filters_rows_live(fake): # table filters as you type three_guests(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert sorted(rows(app)) == ["100", "108", "200"] await pilot.press("slash") await pilot.press("w", "e") await settle(app, pilot) assert rows(app) == ["200"] await pilot.press("b") await settle(app, pilot) assert rows(app) == ["200"] await pilot.press("backspace", "backspace", "backspace") await settle(app, pilot) assert sorted(rows(app)) == ["100", "108", "200"] async def test_filter_keywords_in_the_ui( fake, ): # is:/type: through the UI, AND of terms three_guests(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await type_filter(app, pilot, "is:stopped type:vm") assert rows(app) == ["200"] async def test_subtitle_shows_filter_text_and_n_of_m( fake, ): # "· filter: · N of M" three_guests(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) assert "filter" not in frame_subtitle(app) assert " of " not in frame_subtitle(app) await type_filter(app, pilot, "is:stopped") sub = frame_subtitle(app) assert "· filter: is:stopped · 2 of 3" in sub async def test_subtitle_order_context_filter_then_counts( fake, ): # context, filter, counts three_guests(fake) fake.unprotected = {100} fake.updates = {"mainframe": many(3)} app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await type_filter(app, pilot, "back") sub = frame_subtitle(app) assert "mainframe" in sub and "filter: back" in sub assert "without backup job" in sub and "package updates" in sub assert ( sub.index("mainframe") < sub.index("filter: back") < sub.index("without backup job") < sub.index("package updates") ) async def test_enter_keeps_the_filter_and_focuses_the_table( fake, ): # Enter in the filter three_guests(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await type_filter(app, pilot, "web") await pilot.press("enter") await settle(app, pilot) assert app.focused is app.query_one("#guests", DataTable) assert rows(app) == ["200"] assert "filter: web" in frame_subtitle(app) assert not isinstance(app.screen, ModalScreen) # Enter did not open guest info async def test_escape_clears_hides_and_shows_all_rows(fake): # Escape in the filter three_guests(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await type_filter(app, pilot, "web") assert rows(app) == ["200"] await pilot.press("escape") await settle(app, pilot) assert sorted(rows(app)) == ["100", "108", "200"] assert not visible(app, filter_input(app)) assert filter_input(app).value == "" assert app.focused is app.query_one("#guests", DataTable) assert "filter" not in frame_subtitle(app) assert " of " not in frame_subtitle(app) async def test_action_after_filtering_acts_on_the_visible_guest( fake, ): # actions: visible row three_guests(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await type_filter(app, pilot, "web") await pilot.press("enter") await settle(app, pilot) await pilot.press("s") await settle(app, pilot) assert fake.actions == [("start", 200)] # not the first, hidden guest (108) async def test_reload_keeps_the_filter(fake): # reload (g) keeps the filter applied three_guests(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await type_filter(app, pilot, "web") await pilot.press("enter") await settle(app, pilot) before = fake.list_calls await pilot.press("g") await settle(app, pilot) assert fake.list_calls > before assert rows(app) == ["200"] assert "· filter: web · 1 of 3" in frame_subtitle(app) async def test_action_refresh_keeps_the_filter( fake, ): # reload after actions keeps the filter three_guests(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await type_filter(app, pilot, "web") await pilot.press("enter") await pilot.press("s") await settle(app, pilot) assert rows(app) == ["200"] async def test_no_match_shows_empty_table_0_of_m_and_actions_do_nothing( fake, ): # no match three_guests(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await type_filter(app, pilot, "zzz") assert rows(app) == [] assert "0 of 3" in frame_subtitle(app) await pilot.press("enter") # leave the filter, table focused and empty await settle(app, pilot) await pilot.press("s", "S", "x", "r", "t") await settle(app, pilot) assert fake.actions == [] assert not isinstance(app.screen, ModalScreen) assert app.is_running async def test_context_switch_keeps_the_filter_text( fake, ): # a context switch keeps the filter text three_guests(fake) fake.guests_by_context = { "mainframe-edge": [{**VM, "vmid": 300, "name": "web-edge", "tags": ""}, {**VM, "vmid": 301, "name": "db-edge", "tags": ""}] } # fmt: skip app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await type_filter(app, pilot, "web") await pilot.press("enter") await settle(app, pilot) await switch_to_edge(app, pilot) await until(pilot, lambda: rows(app) == ["300"]) assert "filter: web" in frame_subtitle(app) assert "1 of 2" in frame_subtitle(app) # --- #16 review findings ---------------------------------------------------------------- @pytest.mark.parametrize("text", ["[/b]", "[/]", "[red]x"]) async def test_markup_in_the_filter_is_shown_literally_and_does_not_crash(fake, text): app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await pilot.press("slash") await pilot.pause() app.query_one("#filter").value = text await settle(app, pilot) assert app.is_running shown = Content.from_markup( app.query_one("#guests_section").border_subtitle ).plain assert f"filter: {text}" in shown async def test_escape_on_the_table_clears_an_applied_filter(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) await type_filter(app, pilot, "is:stopped") await pilot.press("enter") # filter applied, focus on the table await settle(app, pilot) await pilot.press("escape") await settle(app, pilot) assert "filter:" not in frame_subtitle(app) assert app.query_one("#guests", DataTable).row_count == len(fake.guests) def test_guest_matches_tolerates_none_status_and_type(): g = {"vmid": 1, "name": "x", "status": None, "type": None, "tags": None} assert appmod.guest_matches(g, "x") assert not appmod.guest_matches(g, "is:running") assert not appmod.guest_matches(g, "type:lxc") # --- #18: autostart, startup order and tags (spec: "Autostart, startup order and tags") --- CT_BOOT = {"onboot": 1, "startup": "order=2,up=10", "tags": "172.16.41.200;community-script;os"} # fmt: skip async def open_boot(app, pilot, down=1): await open_info(app, pilot, down=down) # down=1: CT 100 "backrest" await pilot.press("o") await settle(app, pilot) assert isinstance(app.screen, appmod.BootScreen) def setb(app, **values): for key, value in values.items(): w = app.screen.query_one(f"#{key}") w.value = value async def boot_to_confirm(app, pilot, **values): setb(app, **values) await press_button(app, pilot, "#apply") assert isinstance(app.screen, ConfirmScreen) return text_of(app.screen) async def boot_change(app, pilot, **values): await boot_to_confirm(app, pilot, **values) await press_button(app, pilot, "#yes") def any_text(screen, needle): titles = [str(w.border_title or "") for w in screen.query("*")] return needle in text_of(screen) or any(needle in t for t in titles) @pytest.mark.parametrize("onboot", [1, "1"]) async def test_info_resources_show_autostart_startup_tags( fake, onboot ): # Resources rows fake.net_configs[100] = {**CT_BOOT, "onboot": onboot} app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) rows = resource_rows(app) assert rows["Autostart"] == "yes" assert rows["Startup"] == "order 2, up 10 s" assert rows["Tags"] == "172.16.41.200, community-script, os" used = in_use_rows(app) assert used["Autostart"] == used["Startup"] == used["Tags"] == "-" async def test_info_shows_full_startup_with_down(fake): fake.net_configs[100] = {"startup": "order=2,up=10,down=30"} app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) assert resource_rows(app)["Startup"] == "order 2, up 10 s, down 30 s" async def test_info_shows_defaults_for_guest_without_boot_keys(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) rows = resource_rows(app) assert (rows["Autostart"], rows["Startup"], rows["Tags"]) == ("no", "-", "-") async def test_info_shows_boot_rows_for_vm_too(fake): # LXC and VM fake.net_configs[108] = {"onboot": 1, "startup": "up=5", "tags": "web"} app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=0) rows = resource_rows(app) assert rows["Autostart"] == "yes" assert rows["Startup"] == "up 5 s" assert rows["Tags"] == "web" async def test_o_opens_boot_screen_prefilled(fake): fake.net_configs[100] = dict(CT_BOOT) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) s = app.screen assert any_text(s, "Boot and tags: backrest (100)") assert s.query_one("#onboot", Checkbox).value is True assert s.query_one("#order", Input).value == "2" assert s.query_one("#up", Input).value == "10" assert s.query_one("#down", Input).value == "" # empty = not set assert s.query_one("#tags", Input).value == ( "172.16.41.200; community-script; os" ) s.query_one("#apply", Button) s.query_one("#cancel", Button) async def test_o_opens_boot_screen_empty_for_guest_without_keys(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) s = app.screen assert s.query_one("#onboot", Checkbox).value is False for sel in ("#order", "#up", "#down", "#tags"): assert s.query_one(sel, Input).value == "" async def test_change_only_onboot_confirms_then_sets_once(fake): fake.net_configs[100] = dict(CT_BOOT) app = make_app() busy_seen = [] fake.set_hook = lambda: busy_seen.append(set(app.busy)) async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) before = len(fake.backups_calls) text = await boot_to_confirm(app, pilot, onboot=False) assert any_text(app.screen, "Boot and tags") assert "onboot" in text.lower() or "autostart" in text.lower() assert fake.set_calls == [] # nothing before "yes" await press_button(app, pilot, "#yes") assert fake.set_calls == [(100, {"onboot": "0"})] assert busy_seen and busy_seen[0] # guest was busy during the call assert app.busy == set() and errors(app) == [] assert isinstance(app.screen, appmod.InfoScreen) assert len(fake.backups_calls) > before # info reloaded assert resource_rows(app)["Autostart"] == "no" async def test_enable_onboot_on_guest_without_keys_sends_string_one(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) await boot_change(app, pilot, onboot=True) assert fake.set_calls == [(100, {"onboot": "1"})] async def test_change_order_keeps_other_startup_parts(fake): fake.net_configs[100] = dict(CT_BOOT) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) await boot_change(app, pilot, order="5") assert fake.set_calls == [(100, {"startup": "order=5,up=10"})] assert resource_rows(app)["Startup"] == "order 5, up 10 s" async def test_set_startup_on_guest_without_keys(fake): app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) await boot_change(app, pilot, order="1", down="20") assert fake.set_calls == [(100, {"startup": "order=1,down=20"})] async def test_clearing_all_startup_fields_deletes_startup(fake): fake.net_configs[100] = dict(CT_BOOT) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) await boot_change(app, pilot, order="", up="", down="") assert fake.set_calls == [(100, {"startup": None})] assert resource_rows(app)["Startup"] == "-" async def test_adding_a_tag_sends_normalised_tag_string(fake): fake.net_configs[100] = dict(CT_BOOT) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) text = await boot_to_confirm( app, pilot, tags="172.16.41.200; community-script; os; tui-test" ) assert "tui-test" in text await press_button(app, pilot, "#yes") assert fake.set_calls == [ (100, {"tags": "172.16.41.200;community-script;os;tui-test"}) ] assert resource_rows(app)["Tags"].endswith("tui-test") async def test_clearing_tags_uses_set_config_delete_never_a_delete_command( fake, monkeypatch ): fake.net_configs[100] = dict(CT_BOOT) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) def forbidden(*args, **kwargs): # any CLI call outside the fake would land here raise AssertionError(f"unexpected CLI call: {args!r}") monkeypatch.setattr(cli, "run", forbidden) monkeypatch.setattr(cli.subprocess, "run", forbidden) await boot_change(app, pilot, tags="") assert fake.set_calls == [ (100, {"tags": None}) ] # only set_net, None -> --delete assert fake.delete_calls == [] and fake.actions == [] and fake.sets == [] assert errors(app) == [] assert resource_rows(app)["Tags"] == "-" async def test_combined_changes_go_in_one_set_net_call(fake): fake.net_configs[100] = dict(CT_BOOT) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) await boot_change(app, pilot, onboot=False, order="7", tags="") assert fake.set_calls == [ (100, {"onboot": "0", "startup": "order=7,up=10", "tags": None}) ] @pytest.mark.parametrize( "values", [ {"tags": "-x"}, {"tags": "a;b!"}, {"order": "-1"}, {"order": "abc"}, {"up": "-5"}, {"down": "1.5"}, ], ) async def test_invalid_boot_input_errors_without_call_and_dialog_stays(fake, values): fake.net_configs[100] = dict(CT_BOOT) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) setb(app, **values) await press_button(app, pilot, "#apply") assert errors(app) and fake.set_calls == [] assert isinstance(app.screen, appmod.BootScreen) assert app.busy == set() async def test_boot_no_change_gives_info_notify_no_confirm_no_call(fake): fake.net_configs[100] = dict(CT_BOOT) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) await press_button(app, pilot, "#apply") assert not isinstance(app.screen, ConfirmScreen) assert fake.set_calls == [] and errors(app) == [] assert any(sev == "information" for _, sev in app.seen) @pytest.mark.parametrize("how", ["cancel", "escape"]) async def test_cancel_or_escape_in_boot_screen_makes_no_call(fake, how): fake.net_configs[100] = dict(CT_BOOT) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) setb(app, onboot=False, tags="x") if how == "cancel": await press_button(app, pilot, "#cancel") else: await pilot.press("escape") await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) assert fake.set_calls == [] and app.busy == set() async def test_declining_boot_confirm_makes_no_call(fake): fake.net_configs[100] = dict(CT_BOOT) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) await boot_to_confirm(app, pilot, onboot=False) await press_button(app, pilot, "#no") assert fake.set_calls == [] and app.busy == set() async def test_boot_apply_while_guest_busy_warns_and_makes_no_second_call(fake): fake.net_configs[100] = dict(CT_BOOT) started, release = threading.Event(), threading.Event() fake.set_hook = lambda: (started.set(), release.wait(10)) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) try: await boot_to_confirm(app, pilot, onboot=False) app.screen.query_one("#yes", Button).press() for _ in range(200): if started.is_set(): break await pilot.pause(0.01) assert started.is_set() await to_info(app, pilot) await pilot.press("o") await pilot.pause() if isinstance( app.screen, appmod.BootScreen ): # refusal may come at apply/yes setb(app, tags="x") app.screen.query_one("#apply", Button).press() await pilot.pause() if isinstance(app.screen, ConfirmScreen): app.screen.query_one("#yes", Button).press() await pilot.pause() assert any(sev == "warning" for _, sev in app.seen) assert len(fake.set_calls) == 1 finally: release.set() await settle(app, pilot) assert len(fake.set_calls) == 1 async def test_boot_set_net_failure_notifies_error_and_releases_busy(fake): fake.net_configs[100] = dict(CT_BOOT) fake.set_error = cli_error("Error: unable to apply onboot") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_boot(app, pilot) await boot_change(app, pilot, onboot=False) assert any("unable to apply onboot" in m for m in errors(app)) assert app.busy == set() and app.is_running assert len(fake.set_calls) == 1 # --- issue #19: fstrim via the guest agent (spec: "fstrim in a VM via the guest agent") ----- def agent_vm(fake, config="enabled=1", status="running"): fake.guests = [dict(VM, status=status)] fake.live_status = {108: status} if config is not None: fake.net_configs[108] = {"agent": config} def notes(app): return " | ".join(m for m, _ in app.seen).lower() async def press_f(app, pilot): await pilot.press("f") await settle(app, pilot) @pytest.mark.parametrize("live", [None, "running"]) async def test_fstrim_on_container_notifies_pct_fstrim_and_runs_nothing(fake, live): # spec: container -> info notify (pct fstrim on the node instead); nothing runs if live: fake.live_status = {100: live} app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot, down=1) await press_f(app, pilot) assert any("pct fstrim" in m and sev == "information" for m, sev in app.seen) assert fake.events == [] assert isinstance(app.screen, appmod.InfoScreen) async def test_fstrim_on_stopped_vm_warns_and_runs_nothing(fake): # spec: VM not running -> warning notify saying why; nothing runs agent_vm(fake, status="stopped") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await press_f(app, pilot) assert any("running" in m.lower() and sev == "warning" for m, sev in app.seen) assert fake.events == [] assert isinstance(app.screen, appmod.InfoScreen) @pytest.mark.parametrize("config", [None, "enabled=0", "0"]) async def test_fstrim_on_vm_without_agent_warns_and_runs_nothing(fake, config): # spec: agent_enabled false -> warning notify saying why; nothing runs agent_vm(fake, config=config) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await press_f(app, pilot) assert any("agent" in m.lower() and sev == "warning" for m, sev in app.seen) assert fake.events == [] assert isinstance(app.screen, appmod.InfoScreen) async def test_fstrim_asks_confirm_titled_fstrim_naming_vm_before_any_call(fake): # spec: ConfirmScreen titled "fstrim" naming the VM; nothing runs before yes agent_vm(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await press_f(app, pilot) assert isinstance(app.screen, ConfirmScreen) assert app.screen.title_text == "fstrim" assert "docker" in text_of(app.screen) assert fake.events == [] async def test_fstrim_yes_pings_then_trims_once_busy_then_done_with_size_and_count( fake, ): # spec: yes -> busy guard, agent_ping then fstrim; done notify with GiB total and path count agent_vm(fake) fake.fstrim_paths = [ {"path": "/", "trimmed": 2**30, "minimum": 0}, {"path": "/boot", "error": "not supported"}, ] busy_seen = [] fake.fstrim_hook = lambda: busy_seen.append(set(app.busy)) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await press_f(app, pilot) await press_button(app, pilot, "#yes") assert fake.events == [("ping", 108), ("fstrim", 108)] assert busy_seen and busy_seen[0], "guest must be busy while fstrim runs" assert app.busy == set() done = [m for m, sev in app.seen if "1.0 GiB" in m] assert done, app.seen assert any(re.search(r"\b2\b", m) for m in done), "path count missing" assert "not supported" in notes(app), "per-path error must be mentioned" assert errors(app) == [] async def test_fstrim_small_total_is_shown_in_mib(fake): # spec: trimmed total in GiB/MiB agent_vm(fake) fake.fstrim_paths = [{"path": "/", "trimmed": 5 * 2**20, "minimum": 0}] app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await press_f(app, pilot) await press_button(app, pilot, "#yes") assert "mib" in notes(app) and "gib" not in notes(app) assert errors(app) == [] @pytest.mark.parametrize("how", ["no", "escape"]) async def test_fstrim_declined_makes_no_call(fake, how): # spec: ConfirmScreen; declining runs nothing agent_vm(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await press_f(app, pilot) assert isinstance(app.screen, ConfirmScreen) if how == "no": await press_button(app, pilot, "#no") else: await pilot.press("escape") await settle(app, pilot) assert fake.events == [] assert not isinstance(app.screen, ConfirmScreen) assert app.busy == set() async def test_fstrim_ping_failure_errors_agent_did_not_answer_and_skips_fstrim(fake): # spec: CliError -> error notify (ping failure says the agent did not answer) agent_vm(fake) fake.ping_error = cli_error("Error: VM 108 qmp command failed - timeout") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await press_f(app, pilot) await press_button(app, pilot, "#yes") assert fake.events == [("ping", 108)] assert any("agent" in m.lower() and "answer" in m.lower() for m in errors(app)) assert app.busy == set() and app.is_running async def test_fstrim_failure_notifies_error_and_releases_busy(fake): # spec: CliError -> error notify agent_vm(fake) fake.fstrim_error = cli_error("Error: fstrim command failed") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await press_f(app, pilot) await press_button(app, pilot, "#yes") assert fake.events == [("ping", 108), ("fstrim", 108)] assert any("fstrim command failed" in m for m in errors(app)) assert app.busy == set() and app.is_running assert not any("GiB" in m or "MiB" in m for m, _ in app.seen) async def test_fstrim_while_guest_busy_warns_and_makes_no_second_call(fake): # spec: busy guard agent_vm(fake) started, release = threading.Event(), threading.Event() fake.ping_hook = lambda: (started.set(), release.wait(10)) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) try: await press_f(app, pilot) app.screen.query_one("#yes", Button).press() for _ in range(200): if started.is_set(): break await pilot.pause(0.01) assert started.is_set() await pilot.press("f") await pilot.pause() if isinstance(app.screen, ConfirmScreen): # refusal may come at "yes" app.screen.query_one("#yes", Button).press() await pilot.pause() assert any(sev == "warning" for _, sev in app.seen) assert fake.events.count(("ping", 108)) == 1 finally: release.set() await settle(app, pilot) assert fake.events.count(("ping", 108)) == 1 assert fake.events.count(("fstrim", 108)) == 1 # --- #19 review findings ---------------------------------------------------------------- async def test_notifications_never_parse_markup(fake): # guest names, paths and agent errors are untrusted text; notify must show them literally app = make_app() async with app.run_test(size=SIZE) as pilot: await settle(app, pilot) app.notify("path [/b] failed") await pilot.pause() assert app.is_running assert all(n.markup is False for n in app._notifications) async def test_fstrim_without_results_says_so_and_singular_path(fake): agent_vm(fake) app = make_app() async with app.run_test(size=SIZE) as pilot: fake.fstrim_paths = [] await open_info(app, pilot) await press_f(app, pilot) await press_button(app, pilot, "#yes") assert any("no result" in m.lower() for m, _ in app.seen) fake.fstrim_paths = [{"path": "/", "trimmed": 2**20, "error": None}] await press_f(app, pilot) await press_button(app, pilot, "#yes") assert any("in 1 path" in m and "paths" not in m for m, _ in app.seen) async def test_fstrim_before_the_info_screen_loaded_says_still_loading( fake, monkeypatch ): agent_vm(fake) release = threading.Event() real = cli.guest_config monkeypatch.setattr(cli, "guest_config", lambda g: (release.wait(10), real(g))[1]) app = make_app() async with app.run_test(size=SIZE) as pilot: await pilot.pause() await pilot.press("enter") await pilot.pause() try: await pilot.press("f") await pilot.pause() assert any("loading" in m.lower() for m, _ in app.seen) assert fake.events == [] finally: release.set() await settle(app, pilot) # --- #15 restore a guest from a backup ---------------------------------------------------- # spec: docs/plans/guest-tui-mvp.md ("Restore a guest from a backup (issue #15)" > app.py) NEW_VOLID = BACKUPS[0]["volid"] OLD_VOLID = BACKUPS[1]["volid"] def vm_disk_on(fake, storage="zfs-pool"): # the guest's first disk lives on `storage` (config value ":,size=..") fake.net_configs[108] = {"scsi0": f"{storage}:vm-108-disk-0,size=10G"} async def open_restore(app, pilot, down=0, row=0): await open_info(app, pilot, down=down) app.screen.query_one("#backups", DataTable).move_cursor(row=row) await pilot.press("r") await settle(app, pilot) assert isinstance(app.screen, appmod.RestoreScreen) def set_value(app, selector, value): app.screen.query_one(selector).value = value async def apply_new(app, pilot, newid=None, start=False): if newid is not None: set_value(app, "#newid", newid) if start: set_value(app, "#start", True) await press_button(app, pilot, "#apply") async def apply_overwrite(app, pilot, confirm="108", start=False): app.screen.query_one("#overwrite", RadioButton).value = True set_value(app, "#confirm", confirm) if start: set_value(app, "#start", True) await press_button(app, pilot, "#apply") def title_of(app): return str(app.screen.query_one("#dialog").border_title) async def test_r_on_backup_opens_restore_screen_with_defaults(fake): # spec: title names date and guest; #new default; #newid from next_id; #storage preselected vm_disk_on(fake, "zfs-pool") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) title = title_of(app) assert "Restore" in title and "docker" in title and "108" in title assert local_date(T_NEW)[:10] in title assert app.screen.query_one("#new", RadioButton).value is True assert app.screen.query_one("#overwrite", RadioButton).value is False assert app.screen.query_one("#newid", Input).value == "105" assert app.screen.query_one("#storage", Select).value == "zfs-pool" assert app.screen.query_one("#start", Checkbox).value is False assert fake.restore_calls == [] async def test_restore_storage_falls_back_to_a_listed_one_when_disk_storage_unlisted( fake, ): # spec: preselected "if listed"; otherwise the select must still hold a listed storage vm_disk_on(fake, "not-offered") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) assert app.screen.query_one("#storage", Select).value in fake.restore_store async def test_restore_ct_preselects_rootfs_storage(fake): # spec: "storage of the guest's rootfs/first disk" fake.net_configs[100] = {"rootfs": "zfs-pool:subvol-100-disk-0,size=8G"} fake.disk_map = {"rootfs": "8G"} fake.backup_list = [ {**BACKUPS[0], "volid": "truenas-backup:backup/vzdump-lxc-100-new.tar.zst", "notes": "backrest (manual)"} # the CT's own backup (other notes = earlier guest, hidden) ] # fmt: skip app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot, down=1) assert "backrest" in title_of(app) assert app.screen.query_one("#storage", Select).value == "zfs-pool" async def test_r_without_backups_notifies_info_and_opens_nothing(fake): # spec: no backup -> info notify fake.backup_list = [] app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await pilot.press("r") await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) assert any(sev == "information" for _, sev in app.seen) assert fake.restore_calls == [] async def test_new_apply_asks_confirm_titled_restore_then_restores_busy_and_reloads( fake, ): # spec: apply new -> ConfirmScreen "Restore"; yes -> busy guard, restore(..., force=False, start=False) vm_disk_on(fake, "zfs-pool") app = make_app() busy_seen = [] fake.restore_hook = lambda: busy_seen.append(set(app.busy)) async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) await apply_new(app, pilot) assert isinstance(app.screen, ConfirmScreen) assert app.screen.title_text == "Restore" text = text_of(app.screen) assert "105" in text and "zfs-pool" in text assert fake.restore_calls == [] before = fake.list_calls await press_button(app, pilot, "#yes") assert fake.restore_calls == [(108, NEW_VOLID, 105, "zfs-pool", False, False)] assert busy_seen and busy_seen[0], ( "source guest must be busy during the restore" ) assert app.busy == set() and errors(app) == [] assert fake.list_calls > before, "main list must reload" async def test_new_with_edited_id_and_start_checked_passes_them(fake): # spec: Input#newid editable; Checkbox#start -> start=True vm_disk_on(fake, "zfs-pool") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) await apply_new(app, pilot, newid="777", start=True) await press_button(app, pilot, "#yes") assert fake.restore_calls == [(108, NEW_VOLID, 777, "zfs-pool", False, True)] async def test_new_with_another_storage_selected_passes_it(fake): vm_disk_on(fake, "zfs-pool") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) set_value(app, "#storage", "local-lvm") await apply_new(app, pilot) await press_button(app, pilot, "#yes") assert fake.restore_calls[0][3] == "local-lvm" @pytest.mark.parametrize("bad", ["108", "100", "abc", "0", "-5", "", "10.5", "1e3"]) async def test_new_with_unusable_id_errors_without_call(fake, bad): # spec: newid positive int not used by any guest in app.guests (108, 100 exist) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) await apply_new(app, pilot, newid=bad) assert errors(app) assert fake.restore_calls == [] assert not isinstance(app.screen, ConfirmScreen) @pytest.mark.parametrize("how", ["no", "escape"]) async def test_new_confirm_declined_makes_no_call(fake, how): app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) await apply_new(app, pilot) if how == "no": await press_button(app, pilot, "#no") else: await pilot.press("escape") await settle(app, pilot) assert fake.restore_calls == [] and app.busy == set() @pytest.mark.parametrize("how", ["cancel", "escape"]) async def test_restore_screen_cancel_or_escape_makes_no_call(fake, how): # spec: #cancel / escape = cancel app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) if how == "cancel": await press_button(app, pilot, "#cancel") else: await pilot.press("escape") await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) assert fake.restore_calls == [] and errors(app) == [] async def test_overwrite_on_running_guest_errors_without_call(fake): # spec: overwrite needs a stopped guest (live cli.status at apply time); listing says stopped, live says running fake.guests = [dict(VM, status="stopped"), CT] fake.live_status = {108: "running"} app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) await apply_overwrite(app, pilot) assert errors(app) assert fake.restore_calls == [] assert not isinstance(app.screen, ConfirmScreen) @pytest.mark.parametrize("wrong", ["", "100", "docker", "1080", " 108x"]) async def test_overwrite_with_wrong_typed_confirm_errors_without_call(fake, wrong): # spec: Input#confirm must equal the VMID fake.live_status = {108: "stopped"} app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) await apply_overwrite(app, pilot, confirm=wrong) assert errors(app) assert fake.restore_calls == [] assert not isinstance(app.screen, ConfirmScreen) async def test_overwrite_stopped_guest_right_confirm_asks_overwrite_then_restores_with_force( fake, ): # spec: ConfirmScreen "Overwrite" (disks and config replaced, cannot be undone); yes -> force=True on the guest's own vmid vm_disk_on(fake, "zfs-pool") fake.live_status = {108: "stopped"} app = make_app() busy_seen = [] fake.restore_hook = lambda: busy_seen.append(set(app.busy)) async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) await apply_overwrite(app, pilot, confirm="108", start=True) assert isinstance(app.screen, ConfirmScreen) assert app.screen.title_text == "Overwrite" text = text_of(app.screen).lower() assert "docker" in text and "108" in text and "undone" in text assert fake.restore_calls == [] before = fake.list_calls await press_button(app, pilot, "#yes") assert fake.restore_calls == [(108, NEW_VOLID, 108, "zfs-pool", True, True)] assert busy_seen and busy_seen[0] assert app.busy == set() and errors(app) == [] assert fake.list_calls > before async def test_overwrite_ignores_a_stale_newid_value(fake): # spec: #newid is "used for new" only; overwrite targets the guest's own vmid fake.live_status = {108: "stopped"} app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) set_value(app, "#newid", "999") await apply_overwrite(app, pilot) await press_button(app, pilot, "#yes") assert [(c[2], c[4]) for c in fake.restore_calls] == [(108, True)] @pytest.mark.parametrize("how", ["no", "escape"]) async def test_overwrite_confirm_declined_makes_no_call(fake, how): fake.live_status = {108: "stopped"} app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) await apply_overwrite(app, pilot) if how == "no": await press_button(app, pilot, "#no") else: await pilot.press("escape") await settle(app, pilot) assert fake.restore_calls == [] and app.busy == set() async def test_restore_uses_the_backup_under_the_cursor(fake): # spec: the backup under the cursor of #backups (2nd row = the old one) app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot, row=1) assert local_date(T_OLD)[:10] in title_of(app) await apply_new(app, pilot) await press_button(app, pilot, "#yes") assert [c[1] for c in fake.restore_calls] == [OLD_VOLID] async def test_hidden_earlier_guest_backup_is_not_restorable_by_default(fake): # spec: hidden earlier-guest backups are not restorable unless shown with `a` fake.backup_list = [*BACKUPS, EARLIER] app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot, row=99) # cursor clamps to the last visible row await apply_new(app, pilot) await press_button(app, pilot, "#yes") assert [c[1] for c in fake.restore_calls] == [OLD_VOLID] async def test_earlier_guest_backup_is_restorable_after_a(fake): fake.backup_list = [*BACKUPS, EARLIER] app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await pilot.press("a") await pilot.pause() app.screen.query_one("#backups", DataTable).move_cursor(row=2) await pilot.press("r") await settle(app, pilot) assert isinstance(app.screen, appmod.RestoreScreen) await apply_new(app, pilot) await press_button(app, pilot, "#yes") assert [c[1] for c in fake.restore_calls] == [EARLIER["volid"]] @pytest.mark.parametrize("overwrite", [False, True]) async def test_restore_failure_notifies_error_and_releases_busy(fake, overwrite): # spec: all CliErrors -> error notify (markup-safe), busy released fake.restore_error = cli_error("Error: restore failed: [bold]no space[/bold]") fake.live_status = {108: "stopped"} app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) if overwrite: await apply_overwrite(app, pilot) else: await apply_new(app, pilot) await press_button(app, pilot, "#yes") assert len(fake.restore_calls) == 1 assert any("no space" in m for m in errors(app)) assert app.busy == set() and app.is_running async def test_next_id_failure_is_notified_not_fatal(fake): # spec: all CliErrors -> error notify; the app keeps running fake.nextid_error = cli_error("Error: cluster not quorate") app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await pilot.press("r") await settle(app, pilot) assert any("quorate" in m for m in errors(app)) assert app.is_running and fake.restore_calls == [] async def test_r_on_busy_guest_makes_no_restore_call(fake): # spec: busy guard on the source guest app = make_app() async with app.run_test(size=SIZE) as pilot: await open_restore(app, pilot) app.busy.add(108) await apply_new(app, pilot) if isinstance(app.screen, ConfirmScreen): await press_button(app, pilot, "#yes") assert fake.restore_calls == [] assert any(sev == "warning" for _, sev in app.seen) app.busy.discard(108) # --- #15 review findings ---------------------------------------------------------------- async def test_restoring_an_earlier_guests_backup_says_so(fake): # shown only with "a": its notes name a deleted guest that had the same VMID fake.backup_list = [*BACKUPS, EARLIER] app = make_app() async with app.run_test(size=SIZE) as pilot: await open_info(app, pilot) await pilot.press("a") await pilot.pause() app.screen.query_one("#backups", DataTable).move_cursor(row=2) # the planka row await pilot.press("r") await settle(app, pilot) assert isinstance(app.screen, appmod.RestoreScreen) assert "planka" in title_of(app) and "earlier guest" in title_of(app) async def test_restore_before_the_info_screen_loaded_says_still_loading( fake, monkeypatch ): release = threading.Event() real = cli.guest_config monkeypatch.setattr(cli, "guest_config", lambda g: (release.wait(10), real(g))[1]) app = make_app() async with app.run_test(size=SIZE) as pilot: await pilot.pause() await pilot.press("enter") await pilot.pause() try: await pilot.press("r") await pilot.pause() assert any("loading" in m.lower() for m, _ in app.seen) assert not isinstance(app.screen, appmod.RestoreScreen) finally: release.set() await settle(app, pilot) # --- key bar wraps instead of overflowing --------------------------------------------- @given(st.lists(st.integers(1, 30), max_size=25), st.integers(1, 120)) def test_wrap_rows_keep_order_fit_width_and_pack_greedily(widths, width): rows = appmod.wrap_rows(widths, width) assert [w for row in rows for w in row] == widths for row in rows: assert row # no empty rows assert sum(row) <= width or len(row) == 1 # only a lone item may overflow for prev, nxt in itertools.pairwise(rows): assert sum(prev) + nxt[0] > width # greedy: the next item did not fit def keybar_text(app): bar = app.screen.query_one(appmod.KeyBar) return bar, "\n".join( bar.lines(bar.size.width)[i].plain for i in range(bar.size.height) ) async def test_keybar_wraps_on_a_narrow_terminal(fake): app = make_app() async with app.run_test(size=(50, 30)) as pilot: await settle(app, pilot) bar, text = keybar_text(app) assert bar.size.height > 1 for desc in ("Start", "Tasks", "Updates", "Quit"): assert desc in text async def test_keybar_is_one_line_on_a_wide_terminal(fake): app = make_app() async with app.run_test(size=(400, 30)) as pilot: await settle(app, pilot) bar, text = keybar_text(app) assert bar.size.height == 1 assert "Quit" in text async def test_keybar_follows_the_info_screen_bindings(fake): app = make_app() async with app.run_test(size=(50, 40)) as pilot: await open_info(app, pilot) _bar, text = keybar_text(app) assert "Restore backup" in text