"""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(4)")
three_desc = three_row.locator("td:nth-child(4)")
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)
| |