diff --git a/docs/tui_v2_parity_checklist.md b/docs/tui_v2_parity_checklist.md index 4c226571..250367d4 100644 --- a/docs/tui_v2_parity_checklist.md +++ b/docs/tui_v2_parity_checklist.md @@ -44,7 +44,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. -- [ ] Add Textual interaction tests for list/search/edit/link/pagination flows. +- [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. - `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. diff --git a/memory-update.md b/memory-update.md index 164c4f2d..98d419c8 100644 --- a/memory-update.md +++ b/memory-update.md @@ -192,3 +192,5 @@ - 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. +- 2026-03-01: Added Textual interaction tests in `src/tests/test_tui_v2_textual_interactions.py` for pagination/search, document edit+save, link add/remove + neighbor open, and retry-after-failure. +- 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. diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index bed59a97..84a72650 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -73,6 +73,7 @@ def launch_tui2( *, fingerprint: str | None = None, entry_service_factory: Any | None = None, + app_hook: Any | None = None, ) -> bool: """Launch TUI v2 when runtime dependencies are available. @@ -1055,5 +1056,9 @@ def launch_tui2( if isinstance(item, EntryListItem): self._show_entry(item.entry_index) - SeedPassTuiV2().run() + app = SeedPassTuiV2() + if callable(app_hook): + app_hook(app) + return True + app.run() return True diff --git a/src/tests/test_tui_v2_textual_interactions.py b/src/tests/test_tui_v2_textual_interactions.py new file mode 100644 index 00000000..2e94eed8 --- /dev/null +++ b/src/tests/test_tui_v2_textual_interactions.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +pytest.importorskip("textual") +from textual.widgets import Input, ListView, Static + +from seedpass.tui_v2.app import launch_tui2 + + +class FakeEntryService: + def __init__(self, entries: list[dict], *, fail_search_times: int = 0) -> 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 + } + self.fail_search_times = fail_search_times + + def search_entries(self, query: str, kinds: list[str] | None = None): + if self.fail_search_times > 0: + self.fail_search_times -= 1 + raise RuntimeError("temporary search failure") + + 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), []) + kept = [] + for link in src: + if int(link.get("target", -1)) != int(target_id): + kept.append(link) + continue + if relation is not None and str(link.get("relation")) != relation: + kept.append(link) + self._links[int(entry_id)] = kept + return [dict(link) for link in kept] + + +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 + + +def _status_text(app) -> str: + return str(app.query_one("#status", Static).renderable) + + +def _filters_text(app) -> str: + return str(app.query_one("#filters", Static).renderable) + + +async def _run_palette(pilot, command: str) -> None: + await pilot.press("ctrl+p") + await pilot.type(command) + await pilot.press("enter") + await pilot.pause() + + +@pytest.mark.anyio +async def test_tui2_textual_pagination_and_search_flow() -> None: + entries = [ + {"id": i, "kind": "document", "label": f"Entry {i}", "content": "x"} + for i in range(1, 451) + ] + 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) == 200 + assert "Page: 1/3" in _filters_text(app) + + await pilot.press("n") + await pilot.pause() + assert "Page: 2/3" in _filters_text(app) + + await pilot.press("/") + await pilot.type("Entry 44") + await pilot.press("enter") + await pilot.pause() + assert len(list_view.children) == 11 + assert "Page: 1/1" in _filters_text(app) + + +@pytest.mark.anyio +async def test_tui2_textual_document_edit_save_flow() -> None: + service = FakeEntryService( + [ + { + "id": 1, + "kind": "document", + "label": "Doc A", + "content": "hello", + "file_type": "txt", + "tags": ["alpha"], + } + ] + ) + app = _build_app(service) + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("e") + await pilot.pause() + + app.query_one("#doc-edit-label", Input).value = "Doc A Updated" + app.query_one("#doc-edit-file-type", Input).value = "md" + app.query_one("#doc-edit-tags", Input).value = "alpha, beta" + if len(app.query("#doc-edit-content")) > 0: + area = app.query_one("#doc-edit-content") + if hasattr(area, "load_text"): + area.load_text("new content") + else: + area.text = "new content" + else: + app.query_one("#doc-edit-content-single", Input).value = "new content" + + await pilot.press("ctrl+s") + await pilot.pause() + + entry = service.retrieve_entry(1) + assert entry["label"] == "Doc A Updated" + assert entry["file_type"] == "md" + assert entry["tags"] == ["alpha", "beta"] + assert "Saved document 1" in _status_text(app) + + +@pytest.mark.anyio +async def test_tui2_textual_link_commands_and_neighbor_open() -> None: + service = FakeEntryService( + [ + {"id": 1, "kind": "document", "label": "Doc 1", "content": "a"}, + {"id": 2, "kind": "password", "label": "Login 2"}, + ] + ) + app = _build_app(service) + + async with app.run_test() as pilot: + await pilot.pause() + + await _run_palette(pilot, "link-add 2 references rel-note") + links_text = str(app.query_one("#link-detail", Static).renderable) + assert "references -> 2 (rel-note)" in links_text + + await pilot.press("o") + await pilot.pause() + assert "Opened linked entry 2" in _status_text(app) + + await _run_palette(pilot, "link-rm 2 references") + links_text = str(app.query_one("#link-detail", Static).renderable) + assert "No graph links" in links_text + + +@pytest.mark.anyio +async def test_tui2_textual_retry_after_search_failure() -> None: + service = FakeEntryService( + [{"id": 1, "kind": "document", "label": "Doc 1", "content": "a"}], + fail_search_times=1, + ) + app = _build_app(service) + + async with app.run_test() as pilot: + await pilot.pause() + assert "Failed to load entries" in _status_text(app) + + await pilot.press("x") + await pilot.pause() + + list_view = app.query_one("#entry-list", ListView) + assert len(list_view.children) == 1 + assert "retry" not in _status_text(app).lower()