Add testable TUI v2 helper layer and unit coverage

This commit is contained in:
thePR0M3TH3AN committed 2026-03-01 16:24:55 -05:00
1 parent 5e639b0d75
commit 7a1768f349
4 files changed
+137 -34

No files matched your search

+2
View File
@@ -45,5 +45,7 @@ This checklist tracks readiness to switch default interactive mode from legacy T
- [ ] Define and run deterministic parity scenarios across entry kinds.
- [ ] Add Textual interaction tests for list/search/edit/link/pagination flows.
- [x] Add non-UI unit tests for TUI v2 helper logic.
- `src/tests/test_tui_v2_helpers.py` covers command parsing, pagination, and detail truncation.
- [ ] Verify large vault behavior with realistic data volume in CI-like environment.
- [ ] Confirm help text and docs match shipped keybindings and palette commands.
+2
View File
@@ -190,3 +190,5 @@
- Recovery hints are now included in status messages (for example, "Press 'x' to retry") and Phase 4 docs mark error/recovery flow work complete.
- 2026-03-01: Added `docs/tui_v2_cutover_decision.md` with explicit recommendation to keep legacy default for next release, required cutover gates, staged rollout, and rollback plan for switching `seedpass` default to TUI v2.
- Updated phase tracking docs to mark the cutover timing decision item complete and linked the memo in docs indexes.
- 2026-03-01: Began cutover-gate automation by extracting testable TUI v2 helpers (`parse_palette_command`, `pagination_window`, `truncate_entry_for_display`) from `tui_v2/app.py`.
- Added `src/tests/test_tui_v2_helpers.py` with coverage for palette parsing edge cases, pagination normalization, and large-vault pagination smoke sizes up to 50k rows.
+60 -34
View File
@@ -21,6 +21,54 @@ def check_tui2_runtime() -> dict[str, Any]:
}
def parse_palette_command(command: str) -> tuple[str, list[str]]:
"""Parse a palette command into ``(cmd, args)``."""
raw = command.strip()
if not raw:
raise ValueError("Palette: command required")
try:
parts = shlex.split(raw)
except ValueError as exc:
raise ValueError(f"Palette parse error: {exc}") from exc
if not parts:
raise ValueError("Palette: command required")
return parts[0].lower(), parts[1:]
def pagination_window(
total_rows: int, page_size: int, page_index: int
) -> tuple[int, int, int, int]:
"""Return normalized pagination tuple.
Returns ``(normalized_page_index, start, end, total_pages)``.
"""
if page_size <= 0:
raise ValueError("page_size must be > 0")
total = max(0, int(total_rows))
total_pages = 1 if total == 0 else (total + page_size - 1) // page_size
page = min(max(0, int(page_index)), total_pages - 1)
start = page * page_size
end = min(total, start + page_size)
return page, start, end, total_pages
def truncate_entry_for_display(
entry: dict[str, Any], content_limit: int
) -> dict[str, Any]:
"""Return an entry payload suitable for responsive display in TUI details."""
payload = dict(entry)
content = payload.get("content")
if content_limit <= 0:
return payload
if isinstance(content, str) and len(content) > content_limit:
head = content[:content_limit]
payload["content"] = (
f"{head}\n\n...[truncated {len(content) - len(head)} chars]"
)
payload["content_truncated"] = True
return payload
def launch_tui2(
*,
fingerprint: str | None = None,
@@ -278,10 +326,10 @@ def launch_tui2(
return f"{idx:>4} {etype:<15} {label}{arch}"
def _total_pages(self) -> int:
total = len(self._all_results)
if total <= 0:
return 1
return (total + self.RESULT_PAGE_SIZE - 1) // self.RESULT_PAGE_SIZE
_page, _start, _end, total_pages = pagination_window(
len(self._all_results), self.RESULT_PAGE_SIZE, self._result_page
)
return total_pages
def _render_current_page(self, *, preserve_selected: bool = True) -> None:
self._entry_ids_in_view = []
@@ -301,14 +349,9 @@ def launch_tui2(
self._update_filters_panel()
return
max_page = max(0, self._total_pages() - 1)
if self._result_page > max_page:
self._result_page = max_page
if self._result_page < 0:
self._result_page = 0
start = self._result_page * self.RESULT_PAGE_SIZE
end = min(total, start + self.RESULT_PAGE_SIZE)
self._result_page, start, end, _total_pages = pagination_window(
total, self.RESULT_PAGE_SIZE, self._result_page
)
page_rows = self._all_results[start:end]
for idx, label, _username, _url, archived, etype in page_rows:
kind = getattr(etype, "value", str(etype))
@@ -329,17 +372,9 @@ def launch_tui2(
self._show_entry(chosen_id)
def _entry_detail_text(self, entry: dict[str, Any]) -> str:
payload = dict(entry)
content = payload.get("content")
if (
isinstance(content, str)
and len(content) > self.DETAIL_CONTENT_PREVIEW_LIMIT
):
head = content[: self.DETAIL_CONTENT_PREVIEW_LIMIT]
payload["content"] = (
f"{head}\n\n...[truncated {len(content) - len(head)} chars]"
)
payload["content_truncated"] = True
payload = truncate_entry_for_display(
entry, self.DETAIL_CONTENT_PREVIEW_LIMIT
)
return json.dumps(payload, indent=2, sort_keys=True)
def _load_entries(self, query: str = "", *, reset_page: bool = False) -> None:
@@ -544,20 +579,11 @@ def launch_tui2(
def _run_palette_command(self, command: str) -> None:
raw = command.strip()
if not raw:
self._set_status("Palette: command required")
return
try:
parts = shlex.split(raw)
cmd, args = parse_palette_command(raw)
except ValueError as exc:
self._set_status(f"Palette parse error: {exc}")
self._set_status(str(exc))
return
if not parts:
self._set_status("Palette: command required")
return
cmd = parts[0].lower()
args = parts[1:]
if cmd == "help":
self._set_status(
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import pytest
from seedpass.tui_v2.app import (
pagination_window,
parse_palette_command,
truncate_entry_for_display,
)
def test_parse_palette_command_ok() -> None:
cmd, args = parse_palette_command('link-add 42 related_to "note text"')
assert cmd == "link-add"
assert args == ["42", "related_to", "note text"]
def test_parse_palette_command_empty() -> None:
with pytest.raises(ValueError, match="command required"):
parse_palette_command(" ")
def test_parse_palette_command_unbalanced_quotes() -> None:
with pytest.raises(ValueError, match="Palette parse error"):
parse_palette_command('search "oops')
def test_pagination_window_normalizes_page() -> None:
page, start, end, total_pages = pagination_window(
total_rows=501, page_size=200, page_index=99
)
assert total_pages == 3
assert page == 2
assert start == 400
assert end == 501
def test_pagination_window_empty() -> None:
page, start, end, total_pages = pagination_window(
total_rows=0, page_size=200, page_index=5
)
assert total_pages == 1
assert page == 0
assert start == 0
assert end == 0
def test_pagination_window_rejects_nonpositive_page_size() -> None:
with pytest.raises(ValueError, match="page_size"):
pagination_window(total_rows=10, page_size=0, page_index=0)
def test_truncate_entry_for_display_no_content() -> None:
payload = truncate_entry_for_display({"kind": "password", "label": "x"}, 10)
assert payload == {"kind": "password", "label": "x"}
def test_truncate_entry_for_display_truncates_content() -> None:
payload = truncate_entry_for_display({"content": "A" * 25, "kind": "document"}, 10)
assert payload["content"].startswith("A" * 10)
assert payload["content_truncated"] is True
@pytest.mark.parametrize("rows", [1000, 10000, 50000])
def test_pagination_window_large_vault_sizes(rows: int) -> None:
# Large-vault smoke: pagination math remains stable at high row counts.
last_page = (rows - 1) // 200 if rows else 0
page, start, end, total_pages = pagination_window(
total_rows=rows, page_size=200, page_index=last_page
)
assert page == last_page
assert total_pages >= 1
assert 0 <= start <= end <= rows