"""Phase 99 task 03 E2E (Playwright, mock-only): the browser Back/Forward buttons walk the directory breadcrumb — and a long description renders exactly one catalog row-line. The dedicated story suite for ``99_kb_tree_table_and_back_nav`` (owner request, 2026-09-12): both owner-visible contracts, pinned in a browser against the real app + the deterministic mock: * **the back button walks the breadcrumb** (D2) — every drill (source-row click, folder-link click, breadcrumb-segment click) is a STATE-ONLY history entry (``history.pushState({ view: "rag", kb: target }, "")`` — the URL stays the shell's pathname, the phase-76 deep-link surface untouched), so ``page.go_back()`` pops exactly one level at a time (``one/two`` → ``one`` → the source root → the top level), ``page.go_forward()`` re-descends the whole chain, and Back at the top level pops the router's own view entry — the CHAT view becomes visible (the phase-76 router contract, unchanged). A breadcrumb jump (clicking ``Knowledge base`` from ``one/two``) pushes a top-level entry, and Back returns to the jump's ORIGIN (standard history semantics). A fresh nav visit to RAG (a router entry carrying no ``kb``) starts at the top level (the D2 refresh alignment), while a re-click of the ACTIVE RAG nav link (no pushState — the drilled entry is still on top) KEEPS the drill. * **the one-line clamp** (D1) — a long stored description renders ONE line in the catalog row: the Description ````'s bounding-box height equals a short-description row's height (± 4 px), the ``.kb-desc-text`` span's computed style is ``text-overflow: ellipsis`` + ``white-space: nowrap``, the span's ``title`` carries the FULL text (hover escape hatch) and the full text stays in the DOM, the Edit button is visible IN the same cell (its bounding box shares the cell's row — not wrapped below), and clicking the folder shows the FULL unclamped text in the level block (``#kb-level- summary`` — the owner's escape hatch at the top). Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_kb_tree_nav.py -v --no-cov MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the clamp test's level-block assertion keys on a MANUAL description (LLM-free), but the seeded KB's stored descriptions at the other levels are the mock's canned ``FOLDER_SUMMARY_MODE`` one-liner ``Fixture folder summary for .`` (the phase-94 convention — byte-exact only against the mock). KB fixture — a host temp dir (``tmp_path_factory``; the app runs on the same host) with ONE registered local source (the ``test_ls_tree_drilldown.py`` / phase-94 API-registration + real ``POST /api/sync`` pattern; no git anywhere): ``alpha/`` with the NESTED folder chain ``one/`` → ``two/`` (2 docs each, so the mock stores a description at every level) plus the sibling folder ``three/`` (2 docs — the short-description row the clamp height is measured against). Total: 6 documents; the mock stores exactly four rows (the recursive-subtree ≥ 2 rule): the source root ``""``, ``one``, the nested ``one/two``, and ``three``. The long manual description (test 4) is set through ``PATCH /api/folders/summary`` with the admin cookie (the phase-97 endpoint — pure DB write, no LLM call) BEFORE the browser visit, so the view's first tree fetch already carries it. Autouse cleanup (the phase-96 pattern, module-scoped — the KB is module-scoped: all four tests drill the same tree): after the module's tests, the suite removes its OWN rows — this temp source's KB rows (documents + their chunks), its ``folder_summaries`` rows, and its ``git_sources`` registration — leaving the shared E2E Postgres clean (the deterministic start is the fresh TRUNCATE in ``seeded_kb``). Test → observable mapping (Playwright Mapping Rule): 1. ``test_back_button_walks_the_breadcrumb`` — the full chain: chat (``/``) → RAG nav link (the router's pushState entry) → source row (push #1) → ``one`` (push #2) → ``two`` (push #3); the breadcrumb renders ``Knowledge base / alpha / one / two`` (last segment ``aria-current="page"``). Then one level per press: Back → ``one`` (title ``alpha/one``, ``two`` listed as a subfolder, current segment ``one``), Back → the source root (title ``alpha``, current segment ``alpha``), Back → the TOP level (the source row, breadcrumb hidden — the URL is STILL ``/sources.html``: state-only entries never change it), Back → the CHAT view visible + the RAG view hidden (Back at the top leaves the view — the router contract). Forward replays the whole chain: top → ``alpha`` → ``one`` → ``two``, the current segment correct at each step. 2. ``test_breadcrumb_jump_then_back`` — at ``one/two``: clicking the ``Knowledge base`` breadcrumb segment is a JUMP (it pushes a top-level entry) → the top level renders; Back → ``one/two`` again (the jump's origin — standard history semantics, pinned). 3. ``test_fresh_nav_visit_starts_at_the_top`` — drill to ``one/two``; leave via the Tuning nav link; click the RAG nav link again (a FRESH router entry, no ``kb`` state) → the RAG view shows the TOP level after the refresh re-render (the D2 alignment). Then drill to ``one`` and re-click the ACTIVE RAG link (no pushState — ``history.length`` unchanged) → the refresh re-render KEEPS the drill (still at ``one`` — the active re-click contract). 4. ``test_description_cell_clamps_to_one_line`` — a 400+ char manual description on ``one`` (via the admin API): at the source level the ``one`` row's Description ```` bounding-box height equals the ``three`` row's (± 4 px — both one line, the row height is independent of the description's length), the ``.kb-desc-text`` span computes to ``text-overflow: ellipsis`` + ``white-space: nowrap``, its ``title`` attribute carries the FULL text, the full text is in the DOM, and the Edit button is visible IN the same cell (its box shares the cell's row — vertically centered with the text, not wrapped below). Clicking ``one`` → the level block's ``#kb-level-summary`` textContent is the FULL long text (unclamped at the top — the owner's escape hatch). """ from __future__ import annotations import json import os import subprocess import sys import time from collections.abc import Iterator from pathlib import Path from typing import Any import httpx import pytest from playwright.sync_api import Page, expect from sqlalchemy import select, text from app.config import Settings as _Settings from app.db import SessionLocal from app.models import FolderSummary from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http, ) REPO = Path(__file__).resolve().parents[2] # Phase 79 (task 04, full inventory): the conftest session app owns its # port in a combined run — this module app binds its own port instead # (a same-port second uvicorn dies on bind and would drive the wrong # server). Env-overridable. APP_PORT = int(os.environ.get("E2E_APP_PORT_KBTRENAV", "8144")) APP_URL = f"http://127.0.0.1:{APP_PORT}" # -------------------------------------------------------------------------- # Fixture constants (deterministic, token-controlled) # -------------------------------------------------------------------------- ALPHA = "alpha" ONE_A = "one/one-a.md" ONE_B = "one/one-b.md" TWO_A = "one/two/two-a.md" TWO_B = "one/two/two-b.md" THREE_A = "three/three-a.md" THREE_B = "three/three-b.md" TOTAL_DOCS = 6 # 2 one/ + 2 one/two/ + 2 three/ (no root-level files) #: The sync-time folder descriptions the mock's canned #: ``FOLDER_SUMMARY_MODE`` branch stores (the phase-94 byte-stable #: template — the one-liner names the folder), in ``(source, #: folder_path)`` order: one row per ≥ 2-doc folder (the #: recursive-subtree rule — ``one/two`` is a distinct nested folder) — #: the ``""`` row is the source root. SUMMARY_FOR = "Fixture folder summary for {}." ALPHA_ROOT_SUM = SUMMARY_FOR.format(ALPHA) ONE_SUM = SUMMARY_FOR.format(f"{ALPHA}/one") TWO_SUM = SUMMARY_FOR.format(f"{ALPHA}/one/two") THREE_SUM = SUMMARY_FOR.format(f"{ALPHA}/three") EXPECTED_SUMMARIES: list[tuple[str, str, str]] = [ (ALPHA, "", ALPHA_ROOT_SUM), (ALPHA, "one", ONE_SUM), (ALPHA, "one/two", TWO_SUM), (ALPHA, "three", THREE_SUM), ] assert [ (source, folder) for source, folder, _s in EXPECTED_SUMMARIES ] == sorted((source, folder) for source, folder, _s in EXPECTED_SUMMARIES) #: The LONG manual description test 4 sets on ``one`` (via #: ``PATCH /api/folders/summary``): ≥ 300 chars — at the column's #: clamped width (0.88rem text, ≤ 44rem) that is comfortably ≥ 4 #: WRAPPED lines' worth without the clamp (the owner's "rows grow way #: too much in height" — the pre-fix failure mode) — single line (no #: newlines: the API stores it as one text node) and distinctive #: (no part of it occurs in the fixture or the canned template). LONG_DESC = ( "Alpha one is the deep fixture folder of this navigation suite: it " "holds the one-a and one-b fixture notes plus the nested two folder " "with its two-a and two-b notes, and every one of those documents " "exists to prove the point of this very cell, namely that no matter " "how many words the owner writes into a catalog description, the " "row stays a single clamped line with an ellipsis instead of " "growing to four wrapped lines and stretching the whole table. " "(RESE-KBTRENAV-01)" ) assert len(LONG_DESC) >= 300 and "\n" not in LONG_DESC def _md(title: str, body: str) -> str: return f"# {title}\n\n{body}\n" # -------------------------------------------------------------------------- # Fixtures # -------------------------------------------------------------------------- @pytest.fixture(scope="module") def nav_dirs(tmp_path_factory: pytest.TempPathFactory) -> Path: """The ONE-source temp tree (see the module docstring): the app server runs on the same host, so the path is visible to it. The directory NAME is the source name (``kind=local`` → the directory's basename, phase 38).""" root = tmp_path_factory.mktemp("bor_kb_tree_nav") alpha = root / ALPHA (alpha / "one" / "two").mkdir(parents=True) (alpha / "three").mkdir(parents=True) (alpha / ONE_A).write_text( _md("Alpha One A", "Alpha one fixture note A: covers topic A of " "the alpha source tree."), encoding="utf-8", ) (alpha / ONE_B).write_text( _md("Alpha One B", "Alpha one fixture note B: covers topic B of " "the alpha source tree."), encoding="utf-8", ) (alpha / TWO_A).write_text( _md("Alpha Two A", "Alpha one/two fixture note A: covers topic A " "of the nested folder."), encoding="utf-8", ) (alpha / TWO_B).write_text( _md("Alpha Two B", "Alpha one/two fixture note B: covers topic B " "of the nested folder."), encoding="utf-8", ) (alpha / THREE_A).write_text( _md("Alpha Three A", "Alpha three fixture note A: covers topic A " "of the alpha source tree."), encoding="utf-8", ) (alpha / THREE_B).write_text( _md("Alpha Three B", "Alpha three fixture note B: covers topic B " "of the alpha source tree."), encoding="utf-8", ) assert (alpha / TWO_B).is_file() and (alpha / THREE_B).is_file() return alpha @pytest.fixture(scope="module") def app_server(mock_llm: int, nav_dirs: Path) -> Iterator[str]: """The real app under test — per-module app (the conftest pattern, cf. ``test_ls_tree_drilldown.py`` / ``test_kb_tree.py``): NO ``BOR_GIT_SOURCES`` (the env fallback is git-only — the source here is a DB-registered local directory), the mock LLM, the mock-calibrated threshold, and the leak-guarded code defaults. The session app is never started in this isolated run, so no port clash. ``nav_dirs`` is a dependency only for the fixture ordering (the temp tree exists before the app boots — the sync reads it).""" env = dict(os.environ) env.pop("DEBUGPY", None) env["BOR_ENVIRONMENT"] = "e2e" env["BOR_STATIC_DIR"] = str(REPO / "frontend") env["BOR_LLM_BASE_URL"] = ( "https://aipi.reeseapps.com/v1" if USE_REAL_LLM else f"http://127.0.0.1:{mock_llm}/v1" ) # Mock-calibrated threshold (conftest pattern): this suite never # asks the chat model anything — the gate is never on a path. env["BOR_RELEVANCE_THRESHOLD"] = "0.30" # Phase 67: instant retry waits + the code-default budget (the # conftest leak-guard pattern). env["BOR_LLM_RETRY_DELAY"] = "0" env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default) env.setdefault( "BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", ) # Phase 16: admin auth must be set or create_app() refuses to boot. env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD env["BOR_SESSION_SECRET"] = SESSION_SECRET # The repo's .env file carries the owner's BOR_GIT_SOURCES (the app # reads it from cwd) — override it with an EMPTY value (the env var # beats the .env file): the registry must hold EXACTLY the one # local directory this suite registers (a leftover env git list # would pollute the top-level rows the whole story asserts on). env["BOR_GIT_SOURCES"] = "" # Leak guards (conftest pattern): an operator's local (gitignored) # .env cannot leak corpus-specific settings into the app under test. env["BOR_DOCS_REPO"] = "" env["BOR_SUGGESTIONS"] = json.dumps( _Settings.model_fields["suggestions"].default ) env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default proc = subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"], cwd=REPO, env=env, ) try: _wait_http(f"{APP_URL}/api/health") yield APP_URL finally: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() @pytest.fixture(scope="module") def app_url(app_server: str) -> str: return app_server def _truncate_all() -> None: """Fresh registry + KB (the E2E isolation pattern): the E2E suites share one Postgres, so a leftover git_sources row would pollute the top-level rows and a leftover document would show up in the level file tables and the stat cards the suite asserts on exactly.""" with SessionLocal() as db: db.execute( text( "TRUNCATE chunks, documents, query_log, steering_notes, " "kb_overview, git_sources, folder_summaries" ) ) db.commit() # without the commit the TRUNCATE rolls back (the house pattern) def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]: """Poll the (cookie-authenticated) status endpoint until the run reaches a terminal state (the test_local_directory_sources pattern, over plain httpx — this fixture has no browser page yet).""" deadline = time.monotonic() + timeout_s body: dict[str, Any] = {} while time.monotonic() < deadline: r = client.get("/api/sync/status") assert r.status_code == 200, r.text body = r.json() if body["state"] in ("success", "failed"): return body time.sleep(0.5) raise AssertionError(f"sync did not reach a terminal state: {body}") @pytest.fixture(scope="module") def seeded_kb(app_server: str, nav_dirs: Path) -> None: """The story's precondition: the nested-folder KB synced under the deterministic mock. Registers the temp directory through the authenticated API (the ``test_local_directory_sources.py`` / phase-94 pattern), runs the REAL in-process sync (``POST /api/sync`` — walk → chunk → embed → overview → folder summaries → version bump), and pins the stored folder descriptions: the mock's canned ``FOLDER_SUMMARY_MODE`` branch (phase 94) makes the sync store one deterministic row per ≥ 2-doc folder — the level-block assertions below key on that exact text at every level of the chain. """ alpha = nav_dirs _truncate_all() with httpx.Client(base_url=app_server, timeout=30.0) as client: r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, r.text r = client.post( "/api/git-sources", json={"kind": "local", "path": str(alpha)} ) assert r.status_code == 201, r.text r = client.post("/api/sync") assert r.status_code == 202, r.text body = _wait_sync_done_http(client) assert body["state"] == "success", body detail = body["detail"] assert detail["added"] == TOTAL_DOCS, detail assert detail["pruned"] == 0, detail assert detail["overview"] is True, detail # The change-gated folder descriptions (phase 94) landed: one row # per ≥ 2-doc folder (the nested ``one/two`` included), the mock's # byte-stable text — and every row AI-written (the phase-97 # ``manually_edited`` flag starts false). with SessionLocal() as db: rows = db.execute( select( FolderSummary.source, FolderSummary.folder_path, FolderSummary.summary, FolderSummary.manually_edited, ).order_by(FolderSummary.source, FolderSummary.folder_path) ).all() assert [ (source, folder, summary, False) for source, folder, summary in EXPECTED_SUMMARIES ] == [(s, f, t, m) for s, f, t, m in rows], rows @pytest.fixture(autouse=True, scope="module") def _clean(nav_dirs: Path, seeded_kb: None) -> Iterator[None]: """Autouse cleanup (the phase-96 pattern, module-scoped — the KB is module-scoped: all four tests drill the same tree): AFTER the module's tests, remove this suite's OWN rows — the temp source's KB rows (documents + their chunks), its ``folder_summaries`` rows (test 4's manual description included), and its ``git_sources`` registration (the local row's ``url`` column holds the expanded path) — leaving the shared E2E Postgres the way the house isolation pattern found it. The deterministic START is the fresh TRUNCATE in ``seeded_kb``.""" yield with SessionLocal() as db: db.execute( text( "DELETE FROM chunks WHERE document_id IN " "(SELECT id FROM documents WHERE source = :s)" ), {"s": ALPHA}, ) db.execute(text("DELETE FROM documents WHERE source = :s"), {"s": ALPHA}) db.execute(text("DELETE FROM folder_summaries WHERE source = :s"), {"s": ALPHA}) db.execute(text("DELETE FROM git_sources WHERE url = :p"), {"p": str(nav_dirs)}) db.execute(text("TRUNCATE query_log")) db.commit() # -------------------------------------------------------------------------- # Page helpers # -------------------------------------------------------------------------- def _wait_top_level(page: Page) -> None: """The TOP level has rendered: the source rows (this suite: exactly one — ``alpha``), the breadcrumb + the level block hidden, the file table empty (files are per-source — always hidden at the top), and the stat cards' document total (the tree walk).""" expect(page.locator("#folders-tbody tr")).to_have_count(1, timeout=30_000) expect(page.locator("#kb-crumb")).to_be_hidden() expect(page.locator("#kb-level")).to_be_hidden() expect(page.locator("#docs-tbody tr")).to_have_count(0) expect(page.locator("#stat-docs")).to_have_text(str(TOTAL_DOCS)) def _drill(page: Page, *names: str) -> None: """Drill one level at a time (client-side — no fetch, NO URL change): each name is the EXACT text of the source/folder link at the current level (the row builders' link text: the source name, or the folder's last path segment). Phase 99 (D2): every drill click is a STATE-ONLY history entry (``goTo`` → ``applyTarget(target, true)``).""" for name in names: page.click(f'#folders-tbody a.folder-link:text-is("{name}")') def _assert_crumb(page: Page, links: list[str], current: str) -> None: """The breadcrumb (``#kb-crumb``) renders the drilled chain: one ``.kb-crumb-link`` per ancestor (in order — the first is always the ``Knowledge base`` jump-to-top segment), then the last segment as the ``.kb-crumb-current`` span with ``aria-current="page"``.""" expect(page.locator("#kb-crumb")).to_be_visible() texts = page.eval_on_selector_all( "#kb-crumb :is(.kb-crumb-link, .kb-crumb-current)", "(els) => els.map((el) => el.textContent)", ) assert texts == [*links, current], texts cur = page.locator("#kb-crumb .kb-crumb-current") expect(cur).to_have_count(1) expect(cur).to_have_attribute("aria-current", "page") def _assert_level(page: Page, title: str, summary: str) -> None: """The level block (``#kb-level``) shows the current directory's stored description: the full source-relative path as the title, the description as the text (UNCLAMPED at the top — the D1 escape hatch).""" expect(page.locator("#kb-level")).to_be_visible() expect(page.locator("#kb-level-title")).to_have_text(title) expect(page.locator("#kb-level-summary")).to_have_text(summary) def _open_rag_from_chat(page: Page, app_url: str) -> None: """The phase-76 router entry: from the chat view (``/``) click the RAG nav link — a client-side view switch (pushState ``{ view: "rag" }`` — NO ``kb``) that mounts the RAG view on its first show and lands on the top level.""" page.click("#nav-sources") expect(page.locator("#view-rag")).to_be_visible() expect(page).to_have_url(app_url + "/sources.html") _wait_top_level(page) def _drill_to_two(page: Page) -> None: """The full drill chain, asserted at every level (the chain every test in this suite starts with): source row → ``one`` → ``two`` — the level block + the breadcrumb's exact segment chain at each step (the last segment ``aria-current``).""" page.click(f'#folders-tbody a.folder-link:text-is("{ALPHA}")') _assert_level(page, ALPHA, ALPHA_ROOT_SUM) _assert_crumb(page, ["Knowledge base"], ALPHA) expect(page.locator("#folders-tbody a.folder-link")).to_have_count(2) page.click('#folders-tbody a.folder-link:text-is("one")') _assert_level(page, f"{ALPHA}/one", ONE_SUM) _assert_crumb(page, ["Knowledge base", ALPHA], "one") expect(page.locator('#folders-tbody a.folder-link:text-is("two")')).to_be_visible() page.click('#folders-tbody a.folder-link:text-is("two")') _assert_level(page, f"{ALPHA}/one/two", TWO_SUM) _assert_crumb(page, ["Knowledge base", ALPHA, "one"], "two") expect(page.locator("#folders-wrap")).to_be_hidden() # a leaf folder: no subfolders # -------------------------------------------------------------------------- # 1. The back button walks the breadcrumb — one level per press, the # router contract at the top, the whole chain replays on Forward # -------------------------------------------------------------------------- def test_back_button_walks_the_breadcrumb( page: Page, app_url: str, seeded_kb: None, db_ready: None ) -> None: page.set_default_timeout(30_000) login(page, app_url, next="/") _open_rag_from_chat(page, app_url) # The full drill chain (three STATE-ONLY pushes — the URL is # /sources.html at every level; the phase-76 deep-link surface is # untouched). _drill_to_two(page) expect(page).to_have_url(app_url + "/sources.html") # --- Back: one level per press -------------------------------------- # popstate adopts the popped entry's kb (no push) — the level is # `one`: the title, `two` listed as a subfolder, the breadcrumb's # current segment = `one`. page.go_back() expect(page.locator("#view-rag")).to_be_visible() expect(page).to_have_url(app_url + "/sources.html") # state-only: no URL change _assert_level(page, f"{ALPHA}/one", ONE_SUM) _assert_crumb(page, ["Knowledge base", ALPHA], "one") expect(page.locator('#folders-tbody a.folder-link:text-is("two")')).to_be_visible() # Back: the source root — title `alpha`, current segment the source. page.go_back() _assert_level(page, ALPHA, ALPHA_ROOT_SUM) _assert_crumb(page, ["Knowledge base"], ALPHA) expect(page.locator("#folders-tbody a.folder-link")).to_have_count(2) # Back: the TOP level — the source row, the breadcrumb + level # block hidden. The URL is STILL the RAG path (the entry popped is # the router's view entry — same-document, state-only pushes never # changed the pathname). page.go_back() expect(page).to_have_url(app_url + "/sources.html") _wait_top_level(page) # Back at the top level: the router's OWN popstate (pathname `/`) # switches the view — the CHAT view is visible, the RAG view hidden # (Back at the top leaves the view, exactly as pre-phase-99). page.go_back() expect(page.locator("#view-chat")).to_be_visible() expect(page.locator("#view-rag")).to_be_hidden() expect(page).to_have_url(app_url + "/") # --- Forward: the whole chain replays ------------------------------- # Forward: the router's view entry — the RAG view re-shows on the # top level (the kb-less entry — the D2 alignment, via the # refresh re-render). page.go_forward() expect(page.locator("#view-rag")).to_be_visible() _wait_top_level(page) # Forward: the source root. page.go_forward() _assert_level(page, ALPHA, ALPHA_ROOT_SUM) _assert_crumb(page, ["Knowledge base"], ALPHA) # Forward: `one`. page.go_forward() _assert_level(page, f"{ALPHA}/one", ONE_SUM) _assert_crumb(page, ["Knowledge base", ALPHA], "one") # Forward: `two` — the chain's end again, current segment correct. page.go_forward() _assert_level(page, f"{ALPHA}/one/two", TWO_SUM) _assert_crumb(page, ["Knowledge base", ALPHA, "one"], "two") # -------------------------------------------------------------------------- # 2. A breadcrumb jump pushes an entry — Back returns to the jump's # origin (standard history semantics) # -------------------------------------------------------------------------- def test_breadcrumb_jump_then_back( page: Page, app_url: str, seeded_kb: None, db_ready: None ) -> None: page.set_default_timeout(30_000) login(page, app_url, next="/") _open_rag_from_chat(page, app_url) _drill_to_two(page) # The jump: the `Knowledge base` breadcrumb segment (a real drill # target — goTo pushes a top-level entry). page.click('#kb-crumb a.kb-crumb-link:text-is("Knowledge base")') expect(page).to_have_url(app_url + "/sources.html") # state-only: no URL change _wait_top_level(page) # Back: the jump's ORIGIN — `one/two` again (standard history # semantics: the jump was an entry, not a reset). page.go_back() _assert_level(page, f"{ALPHA}/one/two", TWO_SUM) _assert_crumb(page, ["Knowledge base", ALPHA, "one"], "two") # -------------------------------------------------------------------------- # 3. The D2 alignment: a fresh nav visit starts at the top; an # active-link re-click keeps the drill # -------------------------------------------------------------------------- def test_fresh_nav_visit_starts_at_the_top( page: Page, app_url: str, seeded_kb: None, db_ready: None ) -> None: page.set_default_timeout(30_000) login(page, app_url, next="/") _open_rag_from_chat(page, app_url) _drill_to_two(page) # Leave via the Tuning nav link (the router's view entry). page.click("#nav-tuning") expect(page.locator("#view-tuning")).to_be_visible() expect(page.locator("#view-rag")).to_be_hidden() expect(page).to_have_url(app_url + "/tuning.html") # A FRESH nav visit to RAG: the router's entry carries no `kb` # state — the D2 alignment resets the drill to the TOP level (after # the refresh re-render). page.click("#nav-sources") expect(page.locator("#view-rag")).to_be_visible() expect(page).to_have_url(app_url + "/sources.html") _wait_top_level(page) # Drill to `one`… page.click(f'#folders-tbody a.folder-link:text-is("{ALPHA}")') page.click('#folders-tbody a.folder-link:text-is("one")') _assert_level(page, f"{ALPHA}/one", ONE_SUM) # …and re-click the ACTIVE RAG link: NO pushState (the router's # re-click contract — `history.length` unchanged), the drilled # entry is still on top, and the refresh alignment KEEPS the drill. length_before = page.evaluate("() => history.length") page.click("#nav-sources") expect(page.locator("#view-rag")).to_be_visible() assert page.evaluate("() => history.length") == length_before # no pushState _assert_level(page, f"{ALPHA}/one", ONE_SUM) _assert_crumb(page, ["Knowledge base", ALPHA], "one") expect(page).to_have_url(app_url + "/sources.html") # -------------------------------------------------------------------------- # 4. The measured one-line clamp: row height independent of the # description's length, ellipsis + hover title, Edit in-cell, the # full text at the top # -------------------------------------------------------------------------- def test_description_cell_clamps_to_one_line( page: Page, app_url: str, seeded_kb: None, db_ready: None ) -> None: page.set_default_timeout(30_000) # The long manual description on `one` (the phase-97 endpoint — a # pure DB write, the admin cookie via the real /api/login), set # BEFORE the browser visit: the view's first tree fetch already # carries it (manually_edited — the sync's generator would have # skipped it). with httpx.Client(base_url=app_url, timeout=30.0) as client: r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, r.text r = client.patch( "/api/folders/summary", json={"source": ALPHA, "folder_path": "one", "summary": LONG_DESC}, ) assert r.status_code == 200, r.text assert r.json()["summary"] == LONG_DESC, r.json() login(page, app_url, next="/sources.html") _wait_top_level(page) # The source level: the folder rows `one` (the long manual # description) + `three` (the short canned one). page.click(f'#folders-tbody a.folder-link:text-is("{ALPHA}")') _assert_level(page, ALPHA, ALPHA_ROOT_SUM) one_row = page.locator( "#folders-tbody tr", has=page.locator('a.folder-link:text-is("one")') ) three_row = page.locator( "#folders-tbody tr", has=page.locator('a.folder-link:text-is("three")') ) expect(one_row).to_have_count(1) expect(three_row).to_have_count(1) # The measured clamp: the long-description row's Description # is ONE line — its bounding-box height equals the short row's (± # 4 px). Without the clamp, ≥ 4 wrapped lines would stretch it. one_desc = one_row.locator("td:nth-child(3)") three_desc = three_row.locator("td:nth-child(3)") one_box = one_desc.bounding_box() three_box = three_desc.bounding_box() assert one_box is not None and three_box is not None assert abs(one_box["height"] - three_box["height"]) <= 4, (one_box, three_box) # The span: the full text stays in the DOM (the accessible name is # unchanged — the ellipsis is CSS-only) and rides the `title` (the # hover escape hatch, D1); the computed style is the ellipsis # triad's visible half. span = one_row.locator("span.kb-desc-text") expect(span).to_have_text(LONG_DESC) expect(span).to_have_attribute("title", LONG_DESC) style = span.evaluate( """(el) => { const s = getComputedStyle(el); return { textOverflow: s.textOverflow, whiteSpace: s.whiteSpace }; }""" ) assert style["textOverflow"] == "ellipsis", style assert style["whiteSpace"] == "nowrap", style # The Edit button stays IN the same cell (D1 — the owner's "nice # touch"): visible, its bounding box inside the cell's, sharing the # cell's row (vertically centered with the text — NOT wrapped # below). btn = one_row.locator("button.kb-summary-edit") expect(btn).to_be_visible() btn_box = btn.bounding_box() span_box = span.bounding_box() assert btn_box is not None and span_box is not None assert btn_box["y"] >= one_box["y"] - 1, (btn_box, one_box) assert btn_box["y"] + btn_box["height"] <= one_box["y"] + one_box["height"] + 1 assert btn_box["x"] >= one_box["x"] - 1, (btn_box, one_box) assert btn_box["x"] + btn_box["width"] <= one_box["x"] + one_box["width"] + 1 assert ( abs( (span_box["y"] + span_box["height"] / 2) - (btn_box["y"] + btn_box["height"] / 2) ) <= 4 ), (span_box, btn_box) # The escape hatch at the top: clicking `one` shows the FULL long # text in the level block (unclamped — `.kb-level p` is untouched). one_row.locator("a.folder-link").click() _assert_level(page, f"{ALPHA}/one", LONG_DESC)