From 5f0dbe82afc05991b763ae8024662f46b36010d5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:44:07 -0500 Subject: [PATCH] Add TUI v2 parity scenarios and align help hints --- docs/README.md | 2 + docs/tui_v2_parity_checklist.md | 6 +- memory-update.md | 2 + src/seedpass/cli/__init__.py | 2 +- src/seedpass/cli/capabilities.py | 1 + src/tests/test_tui_v2_parity_scenarios.py | 174 ++++++++++++++++++++++ 6 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 src/tests/test_tui_v2_parity_scenarios.py diff --git a/docs/README.md b/docs/README.md index b560f1e0..f86c4945 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,5 +44,7 @@ This directory contains SeedPass project documentation. - `seedpass --help` - `seedpass --help` +- `seedpass tui2 --help` +- `seedpass tui2 --check` - `seedpass capabilities --format json` - `seedpass agent bootstrap-context` diff --git a/docs/tui_v2_parity_checklist.md b/docs/tui_v2_parity_checklist.md index e68f9f43..92393d83 100644 --- a/docs/tui_v2_parity_checklist.md +++ b/docs/tui_v2_parity_checklist.md @@ -43,7 +43,8 @@ This checklist tracks readiness to switch default interactive mode from legacy T ## Validation Checklist Before Cutover -- [ ] Define and run deterministic parity scenarios across entry kinds. +- [x] Define and run deterministic parity scenarios across entry kinds. + - `src/tests/test_tui_v2_parity_scenarios.py` - [x] Add Textual interaction tests for list/search/edit/link/pagination flows. - `src/tests/test_tui_v2_textual_interactions.py` (skips when `textual` is unavailable). - [x] Add non-UI unit tests for TUI v2 helper logic. @@ -51,4 +52,5 @@ This checklist tracks readiness to switch default interactive mode from legacy T - [x] Verify large vault behavior with realistic data volume in CI-like environment. - `src/tests/test_tui_v2_large_vault_validation.py` - standard: `10,000` rows, stress: `50,000` rows (`--stress`) -- [ ] Confirm help text and docs match shipped keybindings and palette commands. +- [x] Confirm help text and docs match shipped keybindings and palette commands. + - Updated `seedpass tui2 --help`, capabilities hints, and docs discoverability commands. diff --git a/memory-update.md b/memory-update.md index 7c0bac65..f436667d 100644 --- a/memory-update.md +++ b/memory-update.md @@ -196,3 +196,5 @@ - Introduced a test hook in `launch_tui2(..., app_hook=...)` so tests can capture and pilot the Textual app instance without starting a live terminal run. - 2026-03-01: Added CI-like large-vault validation with explicit thresholds in `src/tests/test_tui_v2_large_vault_validation.py` (10k rows standard, 50k rows under `--stress`). - Added docs `docs/tui_v2_large_vault_validation.md` with exact commands and budgets; parity checklist now marks large-vault CI-like validation complete. +- 2026-03-01: Added deterministic TUI v2 parity scenarios across entry kinds in `src/tests/test_tui_v2_parity_scenarios.py` (kind filters, archive/restore roundtrip, non-document edit guard). +- Completed help/docs alignment gate: updated `seedpass tui2` help text, capabilities hints for keybindings/palette (`Ctrl+P`, `p/n`, `x`), and docs discoverability commands (`seedpass tui2 --help/--check`). diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index e7ecfae8..e6cea13b 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -121,7 +121,7 @@ def tui2( help="Fall back to legacy TUI when TUI v2 runtime is unavailable", ), ) -> None: - """Launch experimental TUI v2 scaffold.""" + """Launch the Textual-based TUI v2 (search/edit/graph workflows).""" if check: typer.echo(json.dumps(check_tui2_runtime(), indent=2, sort_keys=True)) return diff --git a/src/seedpass/cli/capabilities.py b/src/seedpass/cli/capabilities.py index 9f46c337..9bb027e3 100644 --- a/src/seedpass/cli/capabilities.py +++ b/src/seedpass/cli/capabilities.py @@ -241,6 +241,7 @@ def build_capabilities() -> dict[str, Any]: "Run `seedpass --help` for detailed options.", "Use `seedpass capabilities --format json` for machine-readable discovery.", "Run `seedpass tui2 --check` before adopting Textual-based TUI v2.", + "In TUI v2, use `Ctrl+P` for the command palette, `p/n` for paging, and `x` to retry the last failed action.", "For graph workflows, start with `seedpass entry link-add --help` and `seedpass entry links --help`.", "For document workflows, run `seedpass entry import-document --help` and `seedpass agent document-import --help`.", "After unlock/login, run `seedpass capabilities` before autonomous runs.", diff --git a/src/tests/test_tui_v2_parity_scenarios.py b/src/tests/test_tui_v2_parity_scenarios.py new file mode 100644 index 00000000..a3615720 --- /dev/null +++ b/src/tests/test_tui_v2_parity_scenarios.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +pytest.importorskip("textual") +from textual.widgets import ListView, Static + +from seedpass.tui_v2.app import launch_tui2 + + +class FakeEntryService: + def __init__(self, entries: list[dict]) -> None: + self._entries = {int(entry["id"]): dict(entry) for entry in entries} + self._links = { + int(entry["id"]): [dict(link) for link in entry.get("links", [])] + for entry in entries + } + + def search_entries(self, query: str, kinds: list[str] | None = None): + q = (query or "").strip().lower() + out = [] + for entry_id in sorted(self._entries.keys()): + entry = self._entries[entry_id] + kind = str(entry.get("kind", "password")) + if kinds and kind not in kinds: + continue + label = str(entry.get("label", "")) + if q and q not in label.lower(): + continue + out.append( + ( + entry_id, + label, + None, + None, + bool(entry.get("archived", False)), + SimpleNamespace(value=kind), + ) + ) + return out + + def retrieve_entry(self, entry_id: int): + return dict(self._entries.get(int(entry_id), {})) + + def modify_entry(self, entry_id: int, **kwargs) -> None: + entry = self._entries[int(entry_id)] + for key, value in kwargs.items(): + if value is None: + continue + entry[key] = value + + def archive_entry(self, entry_id: int) -> None: + self._entries[int(entry_id)]["archived"] = True + + def restore_entry(self, entry_id: int) -> None: + self._entries[int(entry_id)]["archived"] = False + + def get_links(self, entry_id: int): + return [dict(link) for link in self._links.get(int(entry_id), [])] + + def add_link( + self, + entry_id: int, + target_id: int, + *, + relation: str = "related_to", + note: str = "", + ): + links = self._links.setdefault(int(entry_id), []) + links.append({"target": int(target_id), "relation": relation, "note": note}) + return [dict(link) for link in links] + + def remove_link( + self, entry_id: int, target_id: int, *, relation: str | None = None + ): + src = self._links.setdefault(int(entry_id), []) + keep = [] + for link in src: + if int(link.get("target", -1)) != int(target_id): + keep.append(link) + continue + if relation is not None and str(link.get("relation")) != relation: + keep.append(link) + self._links[int(entry_id)] = keep + return [dict(link) for link in keep] + + +def _build_app(service: FakeEntryService): + holder: dict[str, object] = {} + + def _hook(app): + holder["app"] = app + + launched = launch_tui2(entry_service_factory=lambda: service, app_hook=_hook) + assert launched is True + app = holder.get("app") + assert app is not None + return app + + +async def _run_palette(pilot, command: str) -> None: + await pilot.press("ctrl+p") + await pilot.type(command) + await pilot.press("enter") + await pilot.pause() + + +def _status_text(app) -> str: + return str(app.query_one("#status", Static).renderable) + + +@pytest.mark.anyio +async def test_tui2_parity_filters_cover_all_entry_kinds() -> None: + kinds = [ + "password", + "totp", + "document", + "key_value", + "ssh", + "pgp", + "nostr", + "seed", + "managed_account", + ] + entries = [ + {"id": i + 1, "kind": kind, "label": f"{kind}-entry", "content": "x"} + for i, kind in enumerate(kinds) + ] + app = _build_app(FakeEntryService(entries)) + + async with app.run_test() as pilot: + await pilot.pause() + list_view = app.query_one("#entry-list", ListView) + assert len(list_view.children) == len(kinds) + + for kind in kinds: + await _run_palette(pilot, f"filter {kind}") + assert len(list_view.children) == 1 + detail_text = str(app.query_one("#entry-detail", Static).renderable) + assert f'"kind": "{kind}"' in detail_text + + await _run_palette(pilot, "filter all") + assert len(list_view.children) == len(kinds) + + +@pytest.mark.anyio +async def test_tui2_parity_archive_restore_roundtrip_non_document() -> None: + service = FakeEntryService( + [{"id": 1, "kind": "password", "label": "pw-entry", "archived": False}] + ) + app = _build_app(service) + + async with app.run_test() as pilot: + await pilot.pause() + await _run_palette(pilot, "archive") + assert service.retrieve_entry(1)["archived"] is True + assert "archived" in _status_text(app) + + await _run_palette(pilot, "restore") + assert service.retrieve_entry(1)["archived"] is False + assert "restored" in _status_text(app) + + +@pytest.mark.anyio +async def test_tui2_parity_edit_document_guard_for_non_document() -> None: + app = _build_app(FakeEntryService([{"id": 1, "kind": "password", "label": "pw"}])) + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("e") + await pilot.pause() + assert "Select a document entry to edit" in _status_text(app)