Add search and filter to the guest table
/ 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:
1 parent
ff6345c80f
commit
d97ad22c78
4 files changed
+481
-7
No files matched your search
@@ -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")
|
||||
Reference in new issue
Block a user