Add search and filter to the guest table
CI / pytest and ruff (pull_request) Successful in 4m48s
CI / pytest and ruff (push) Has been cancelled

/ opens a filter box under the guest table, and the table filters as
you type. Terms are separated by spaces, all must match, and case is
ignored:

- a plain term matches part of the name, the VMID or a tag
- is:running and is:stopped match the status
- type:lxc/ct and type:vm/qemu match the guest type

Enter keeps the filter and goes back to the table. Escape clears it,
from the box or from the table. The frame shows
"· filter: <text> · N of M". Actions only ever see the visible rows,
and the filter survives reloads and context switches.

Review fixes:
- Typing "[/b]" or "[/]" crashed the app, because the filter text went
  into the border subtitle, which Textual parses as markup. The
  subtitle is now plain text, so "[red]" is also shown literally.
- Escape on the table now clears a filter that was applied with Enter.
- guest_matches tolerates a None status or type.

Read-only live check on mainframe: "debian" gives 104, "type:vm" gives
108, "community-script is:running" gives 14 of 19, and "is:stopped"
gives 0 of 19.

Closes #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #29.
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-03 00:42:13 +01:00
1 parent ff6345c80f
commit d97ad22c78
4 files changed
+481 -7

No files matched your search

+364
View File
@@ -9,6 +9,8 @@ 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,
@@ -3511,3 +3513,365 @@ async def test_cli_not_runnable_is_an_error_not_a_crash(fake):
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")