The info screen's Resources table shows Autostart (yes/no), Startup (order, up and down delays) and Tags. o opens a dialog to change them. Only keys that differ are sent, in one `set <kind> config` call through the existing set_net. A confirm lists the old and new value of each key. Tags are removed with `--delete tags` on set config. The CLI also has a `delete ct|vm ... tags` command, but it shares the form that destroys the whole guest, so the app never uses it; nothing in the code builds a delete ct|vm argv. Inputs are validated before sending: startup values must be ints >= 0, and tags must match [A-Za-z0-9_][A-Za-z0-9_+.-]*. They are split on ; , or whitespace and deduplicated. Live test on CT 104: onboot 1 -> 0, startup order=5,up=10, tag tui-test added, then all set back through the dialog. The config ended identical to the baseline. Closes #18 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4198 lines
151 KiB
Python
4198 lines
151 KiB
Python
"""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 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,
|
||
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
|
||
|
||
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 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",
|
||
):
|
||
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: <summary>" 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: <name> (<vmid>)", 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 "<type> <vmid or node>"
|
||
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: <text> · 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
|