mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-06 12:52:04 +00:00
Add testable TUI v2 helper layer and unit coverage
This commit is contained in:
1 parent
5e639b0d75
commit
7a1768f349
4 files changed
+137
-34
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user