From 7a1768f349d1b4eb5a83962cf6695573e054c9fc Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:24:55 -0500 Subject: [PATCH] Add testable TUI v2 helper layer and unit coverage --- docs/tui_v2_parity_checklist.md | 2 + memory-update.md | 2 + src/seedpass/tui_v2/app.py | 94 ++++++++++++++++++++------------ src/tests/test_tui_v2_helpers.py | 73 +++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 34 deletions(-) create mode 100644 src/tests/test_tui_v2_helpers.py diff --git a/docs/tui_v2_parity_checklist.md b/docs/tui_v2_parity_checklist.md index dc5dd3e0..4c226571 100644 --- a/docs/tui_v2_parity_checklist.md +++ b/docs/tui_v2_parity_checklist.md @@ -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. diff --git a/memory-update.md b/memory-update.md index 4de987eb..164c4f2d 100644 --- a/memory-update.md +++ b/memory-update.md @@ -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. diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index 5cbb72d9..bed59a97 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -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( diff --git a/src/tests/test_tui_v2_helpers.py b/src/tests/test_tui_v2_helpers.py new file mode 100644 index 00000000..09b692d6 --- /dev/null +++ b/src/tests/test_tui_v2_helpers.py @@ -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