phase: 99_kb_tree_table_and_back_nav
All verification is complete and green. Final report: **Phase 99 — final verification pass (all 3 tasks already in `complete/`)** - Verified the one-line Description clamp: `.kb-desc-cell` flex wrapper + `.kb-desc-text` ellipsis triad + `nowrap` column + in-cell Edit button + hover `title` + untouched level block (source diffs match D1; unit pins present) - Verified the back-button breadcrumb nav: `applyTarget(target, push)` state-only `pushState`, no-duplicate gate, popstate adopt/reset, `bor:view-refresh` alignment before `loadTree()`, anonymous gate listener-free, router.js untouched (D2) - `uv run pytest --cov=app`: **2180 passed**, coverage **99%** (>90% gate) - `uv run ruff check .`: clean; `uv run pyright`: **0 errors, 0 warnings** - `uv run pytest tests/e2e/test_kb_tree_nav.py -v --no-cov` (isolated, DB up): **4/4 passed** (back-walk, jump-then-back, fresh-nav/active-reclick, measured clamp incl. row-height parity ±4px, computed styles, full text in DOM/title/level block) - Regression suites isolated: `test_kb_tree.py` 8✓, `test_ls_tree_drilldown.py` 3✓, `test_navbar_refresh.py` 7✓, plus `test_sync_summary_visibility.py` 3✓ (marker pin updated to `kb-desc-text kb-summary-pending` — the sanctioned class-pair change) and `test_edit_summaries.py` 4✓ - Criteria 1–4: **met** (each as above). Criterion 5 (atomic commit): left to the harness per executor rules — no `git add`/`commit` run; all changes left in the working tree - No defects found in prior phases; no deviations. Stray `__pycache__/test_zz_smoke_tree_ui.*.pyc` is a leftover (no `.py` on disk) — inert, not touched - Next pending phase: **none** — `todo/` will be empty once the harness moves this phase
This commit is contained in:
@@ -0,0 +1,759 @@
|
||||
"""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 ``<td>``'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
|
||||
<folder>.`` (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 ``<td>`` 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 <td>
|
||||
# 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)
|
||||
@@ -865,7 +865,9 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
src_row = page.locator("#folders-tbody tr")
|
||||
s_span = src_row.locator("td:nth-child(3) span")
|
||||
expect(s_span).to_have_text(PENDING_COPY)
|
||||
expect(s_span).to_have_class("kb-summary-pending")
|
||||
# Phase 99 (task 01): the marker toggles onto the cell's base
|
||||
# .kb-desc-text span (the one-line clamp) — the class pair, exact.
|
||||
expect(s_span).to_have_class("kb-desc-text kb-summary-pending")
|
||||
expect(s_span).to_have_attribute("title", PENDING_TITLE)
|
||||
# The Edit button is KEPT with the marker (a manual save creates
|
||||
# the row and clears the marker in place).
|
||||
@@ -888,10 +890,11 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
expect(alpha_row.locator("td:nth-child(3) span")).not_to_have_class(
|
||||
"kb-summary-pending"
|
||||
)
|
||||
# The affected folder: the marker (copy + class + title).
|
||||
# The affected folder: the marker (copy + class + title) — the
|
||||
# class pair with the phase-99 .kb-desc-text base span, exact.
|
||||
b_span = bravo_row.locator("td:nth-child(3) span")
|
||||
expect(b_span).to_have_text(PENDING_COPY)
|
||||
expect(b_span).to_have_class("kb-summary-pending")
|
||||
expect(b_span).to_have_class("kb-desc-text kb-summary-pending")
|
||||
expect(b_span).to_have_attribute("title", PENDING_TITLE)
|
||||
|
||||
# Drill into the affected folder: ITS level block shows the D4
|
||||
|
||||
@@ -548,23 +548,34 @@ def _pin_refresh_listener(js: str, gate: str, listener_call: str, name: str) ->
|
||||
"""Shared shape of the task-02 pin: the view module listens for
|
||||
``bor:view-refresh`` on its own root, the listener re-runs the
|
||||
view's existing load, and the listener is armed ONLY in the ADMIN
|
||||
branch — after the whoami gate (anonymous never fetches)."""
|
||||
branch — after the whoami gate (anonymous never fetches). The
|
||||
body is sliced to the listener's close: the block-arrow forms
|
||||
(phase 99 task 02: the rag view's history-alignment step runs
|
||||
before the load) close with ``});``, the inline-arrow forms close
|
||||
with ``());`` (the slice then runs to the NEXT ``});`` in the file
|
||||
— or to its end — harmless, the inline call sits at the slice's
|
||||
start)."""
|
||||
listener = js.find('addEventListener("bor:view-refresh"')
|
||||
assert listener != -1, f"{name} must listen for the refresh event on the view root"
|
||||
gate_i = js.find(gate)
|
||||
assert 0 <= gate_i < listener, (
|
||||
f"{name}: the listener must be armed in the ADMIN branch (after {gate!r})"
|
||||
)
|
||||
assert listener_call in js[listener : listener + 120], (
|
||||
end = js.find("});", listener)
|
||||
body = js[listener:] if end == -1 else js[listener : end + 3]
|
||||
assert listener_call in body, (
|
||||
f"{name}: the listener must re-run the view's load ({listener_call!r})"
|
||||
)
|
||||
|
||||
|
||||
def test_rag_view_refetches_on_reshow() -> None:
|
||||
"""Phase 77 task 02 (+ phase 97 task 04): the RAG (knowledge base)
|
||||
view re-fetches on a user-initiated re-show — sources.js listens
|
||||
and re-runs ``loadTree()`` (the phase-97 catalog load: ONE fetch of
|
||||
``GET /api/docs/tree``). The load is race-tokened (phase 79) and
|
||||
"""Phase 77 task 02 (+ phase 97 task 04, + phase 99 task 02):
|
||||
the RAG (knowledge base) view re-fetches on a user-initiated
|
||||
re-show — sources.js listens and re-runs ``loadTree()`` (the
|
||||
phase-97 catalog load: ONE fetch of ``GET /api/docs/tree``), now
|
||||
AFTER the phase-99 history alignment (adopt the entry's kb / reset
|
||||
to the top — a block-arrow listener, hence the ``});`` close in
|
||||
the pin helper below). The load is race-tokened (phase 79) and
|
||||
the RE-ENTRANT render — ``renderLevel`` clears BOTH row containers
|
||||
at the TOP before filling them (the History pattern from task 01,
|
||||
extended to the folders table) — so a refresh from a populated
|
||||
@@ -572,7 +583,7 @@ def test_rag_view_refetches_on_reshow() -> None:
|
||||
of leaving ghost rows."""
|
||||
js = _asset("sources.js")
|
||||
_pin_refresh_listener(
|
||||
js, "const admin = await fetchIsAdmin();", "() => loadTree()", "sources.js"
|
||||
js, "const admin = await fetchIsAdmin();", "loadTree()", "sources.js"
|
||||
)
|
||||
load = js.find("async function loadTree()")
|
||||
assert load != -1, "loadTree must exist"
|
||||
|
||||
+326
-20
@@ -357,16 +357,40 @@ def test_breadcrumb_links_go_up_and_the_top_reset() -> None:
|
||||
assert "span.textContent = label" in cur
|
||||
|
||||
|
||||
def test_navigation_is_client_side_only() -> None:
|
||||
"""goTo() is the ONLY navigation: set `current`, re-render. No
|
||||
fetch (the tree is already in memory), no URL change (history is
|
||||
untouched — the deep-link contract of the shell is unchanged)."""
|
||||
def test_drill_navigation_pushes_state_only_entries() -> None:
|
||||
"""Phase 99 (task 02, D2): every user-initiated drill is a history
|
||||
entry — ``goTo`` is the push variant of ``applyTarget``: when the
|
||||
target DIFFERS from the current level (a re-click of the current
|
||||
row/segment pushes NO duplicate entry), it FIRST records a
|
||||
STATE-ONLY entry — ``history.pushState({ view: "rag", kb: target },
|
||||
"")`` — the second arg is ``""`` and there is no third (the URL
|
||||
stays the shell's pathname: no new route, the phase-76 deep-link
|
||||
surface is untouched) — then sets ``current`` + re-renders. The
|
||||
drill still never fetches (the tree is in memory) and the module
|
||||
never ``replaceState``s."""
|
||||
js = _js()
|
||||
body = js[js.find("function goTo(") : js.find("function makeSourceRow(")]
|
||||
assert "current = { source: target.source, folder: target.folder };" in body
|
||||
assert "renderLevel();" in body
|
||||
start = js.find("function applyTarget(")
|
||||
assert start != -1, "applyTarget must exist (the two-step drill)"
|
||||
body = js[start : js.find("function makeSourceRow(")]
|
||||
gate_i = body.find(
|
||||
"if (push && (target.source !== current.source || target.folder !== current.folder)) {"
|
||||
)
|
||||
push_i = body.find('history.pushState({ view: "rag", kb: target }, "")')
|
||||
set_i = body.find("current = { source: target.source, folder: target.folder };")
|
||||
render_i = body.find("renderLevel();")
|
||||
assert -1 < gate_i < push_i < set_i < render_i, (
|
||||
"push gate (target differs from current) → pushState → set current → render"
|
||||
)
|
||||
# STATE-ONLY: exactly ONE pushState call site — the "" second arg,
|
||||
# no third (no URL change).
|
||||
assert _code(body).count("pushState") == 1, (
|
||||
"ONE pushState call site — the state-only drill entry"
|
||||
)
|
||||
assert "replaceState" not in _code(js), "no replaceState anywhere in the module"
|
||||
# goTo is the push variant — every drill call site keeps calling it.
|
||||
go = body[body.find("function goTo(") :]
|
||||
assert "applyTarget(target, true)" in go, "goTo pushes (the user-initiated drill)"
|
||||
assert "fetch(" not in body, "the drill never fetches (client-side only)"
|
||||
assert "pushState" not in body and "replaceState" not in body, "no URL change"
|
||||
|
||||
|
||||
# ---------- the level rendering ----------
|
||||
@@ -577,16 +601,30 @@ def test_load_tree_wired_at_exactly_the_three_refresh_points_plus_boot() -> None
|
||||
assert _code(js).count("loadTree()") == 5, (
|
||||
"the definition + the 4 call sites, no others (code, not comments)"
|
||||
)
|
||||
# 1. the view-refresh listener, armed in the admin branch.
|
||||
# 1. the view-refresh listener, armed in the admin branch — since
|
||||
# Phase 99 (task 02, D2) its body is the ALIGNMENT step (a
|
||||
# kb-carrying history.state entry keeps the drill — the
|
||||
# active-link re-click; a kb-less entry starts at the top — a
|
||||
# fresh nav visit) BEFORE the re-fetch.
|
||||
listener = js.find('addEventListener("bor:view-refresh"')
|
||||
assert listener != -1
|
||||
gate = js.find("const admin = await fetchIsAdmin();")
|
||||
assert -1 < gate < listener, "armed after the whoami gate (the phase-77 pin)"
|
||||
assert "() => loadTree()" in js[listener : listener + 120]
|
||||
listener_body = js[listener : js.find('window.addEventListener("popstate"')]
|
||||
state_i = listener_body.find("history.state && history.state.kb")
|
||||
adopt_i = listener_body.find("applyTarget(kb, false)")
|
||||
reset_i = listener_body.find("applyTarget({ source: null, folder: null }, false)")
|
||||
load_i = listener_body.find("loadTree();")
|
||||
assert -1 < state_i < adopt_i < reset_i < load_i, (
|
||||
"align with history.state (adopt kb / reset to top) BEFORE the re-fetch"
|
||||
)
|
||||
assert "pushState" not in _code(listener_body), ("the alignment adopts only — no push")
|
||||
# 2. the boot load — the last statement of mount, right after the
|
||||
# listener is armed (the mount's own load is the first fetch).
|
||||
assert 'root.addEventListener("bor:view-refresh", () => loadTree());\n loadTree();' in js, (
|
||||
"the boot load follows the listener"
|
||||
# listeners are armed (the mount's own load is the first fetch).
|
||||
pop = js.find('window.addEventListener("popstate"')
|
||||
boot = js[js.find("});", pop) + 3 :] # past the popstate listener's close
|
||||
assert boot.strip().startswith("loadTree();"), (
|
||||
"the boot load follows the listeners (no pushState on boot)"
|
||||
)
|
||||
# 3. applySyncSuccess (the sync's terminal — the KB just changed).
|
||||
success = js[js.find("function applySyncSuccess(") : js.find("function applySyncFailure(")]
|
||||
@@ -746,13 +784,16 @@ def test_row_description_cell_builds_text_and_always_present_edit() -> None:
|
||||
the text gates on the node state, the button never does). The
|
||||
button is a real type=button with a human aria-label, and the
|
||||
shared editor is wired with a CONSTANT target { node, source,
|
||||
folder }."""
|
||||
folder }. Phase 99 (task 01, D1): text + button live in ONE
|
||||
`div.kb-desc-cell` flex wrapper (the <td> holds only the wrapper),
|
||||
and the editor's container IS the wrapper (the open/close swaps
|
||||
fill it — the cell layout survives the editor swap)."""
|
||||
body = _fn(_js(), "makeDescCell")
|
||||
# The Edit button is built unconditionally — the whole button
|
||||
# block (from its creation to the append) carries no `if` gate on
|
||||
# the stored description or the pending flag.
|
||||
btn_block = body[
|
||||
body.find("const btn = document.createElement") : body.find("td.append(text, btn)")
|
||||
body.find("const btn = document.createElement") : body.find("wrap.append(text, btn)")
|
||||
]
|
||||
assert "if (" not in btn_block, (
|
||||
"always present: no gate on the stored description"
|
||||
@@ -761,10 +802,20 @@ def test_row_description_cell_builds_text_and_always_present_edit() -> None:
|
||||
assert 'btn.className = "kb-summary-edit"' in body
|
||||
assert 'btn.textContent = "Edit"' in body
|
||||
assert 'btn.setAttribute("aria-label", `Edit description: ${label}`)' in body
|
||||
assert "td.append(text, btn)" in body
|
||||
assert 'wrap.className = "kb-desc-cell"' in body, "the ONE flex wrapper"
|
||||
assert "wrap.append(text, btn)" in body, "text + button, in order, in the wrapper"
|
||||
assert "td.append(wrap)" in body, "the <td> holds the wrapper (no new td class)"
|
||||
assert "container: wrap" in body, (
|
||||
"the editor's container is the WRAPPER (Phase 99 — the swap fills it)"
|
||||
)
|
||||
assert "getTarget: () => ({ node, source, folder })" in body, (
|
||||
"a row's target is a constant (its own node)"
|
||||
)
|
||||
# The level block's editor is UNTOUCHED — its container is still
|
||||
# .kb-level-body (the D1 escape hatch keeps its full text).
|
||||
js = _js()
|
||||
wire = js[js.find("levelEditor = wireDescriptionEdit({") : js.find("/* ---------- view boot")]
|
||||
assert "container: levelBody" in wire
|
||||
|
||||
|
||||
def test_desc_cell_pending_marker_branch() -> None:
|
||||
@@ -786,7 +837,12 @@ def test_desc_cell_pending_marker_branch() -> None:
|
||||
assert "kb-summary-pending" not in stored, "a stored summary is NEVER the marker"
|
||||
assert "Summary pending" not in stored
|
||||
pending = body[pending_i:empty_i]
|
||||
assert 'text.className = "kb-summary-pending"' in pending
|
||||
assert 'text.classList.add("kb-summary-pending")' in pending, (
|
||||
"Phase 99: the marker TOGGLES onto the .kb-desc-text base class"
|
||||
)
|
||||
assert 'text.className = "kb-summary-pending"' not in body, (
|
||||
"a bare className re-assignment would drop the clamp's base class"
|
||||
)
|
||||
assert 'text.textContent = "Summary pending"' in pending, "the D4 marker copy"
|
||||
assert (
|
||||
'text.title = "No stored description yet — the next sync will generate one."'
|
||||
@@ -841,13 +897,23 @@ def test_close_editor_rederives_the_pending_display() -> None:
|
||||
close = body[body.find("function closeEditor(") : body.find("function openEditor()")]
|
||||
flag_i = close.find('const pending = node !== null && stored === "" && node.summary_pending;')
|
||||
value_i = close.find("const value = pending && pendingText ? pendingText : stored;")
|
||||
class_i = close.find('textEl.className = pending ? "kb-summary-pending" : "";')
|
||||
class_i = close.find('textEl.classList.toggle("kb-summary-pending", pending);')
|
||||
title_i = close.find("if (pendingTitle) textEl.title = pendingTitle;")
|
||||
hover_i = close.find('} else if (stored && textEl.classList.contains("kb-desc-text")) {')
|
||||
full_i = close.find("textEl.title = stored;")
|
||||
clear_title_i = close.find('textEl.removeAttribute("title")')
|
||||
render_i = close.find("textEl.textContent = value")
|
||||
assert -1 < flag_i < value_i < class_i < title_i < clear_title_i < render_i, (
|
||||
"flag → value → class → title (set/clear) → text, in order"
|
||||
assert -1 < flag_i < value_i < class_i < title_i < hover_i < full_i, (
|
||||
"flag → value → class toggle → title (pending / hover-full), in order"
|
||||
)
|
||||
assert full_i < clear_title_i < render_i, "title-set → title-clear → text, in order"
|
||||
# Phase 99 (task 01): the class line is a TOGGLE (the base classes
|
||||
# survive — the row's span keeps .kb-desc-text, the clamp; the
|
||||
# level's <p> keeps none), and the CLAMPED row span's title is
|
||||
# re-derived from the stored text (the D1 hover escape hatch — a
|
||||
# stale pre-edit title cannot survive a save; the unclamped
|
||||
# level's <p> is gated out by the kb-desc-text check).
|
||||
assert 'textEl.className' not in close, "no bare className re-assignment"
|
||||
# The row passes its marker copy + the EXACT D4 tooltip…
|
||||
cell = _fn(_js(), "makeDescCell")
|
||||
assert 'pendingText: "Summary pending"' in cell
|
||||
@@ -1212,3 +1278,243 @@ def test_module_docstring_documents_the_pending_markers() -> None:
|
||||
in header
|
||||
), "the level block's pending note"
|
||||
assert "node.summary_pending = false" in header, "the in-place clear"
|
||||
|
||||
|
||||
# ---------- the one-line Description clamp (phase 99, task 01) ----------
|
||||
# D1: the clamp is VISUAL only — the row's Description cell is ONE flex
|
||||
# row (text flexes + ellipsizes, the Edit button stays fixed), the full
|
||||
# text stays in the DOM + on the span's hover title, and the level
|
||||
# block keeps the full unclamped description. The measured-height proof
|
||||
# lands in the phase's E2E (task 03); this module pins the structure
|
||||
# and the CSS.
|
||||
|
||||
|
||||
def _css_rule(css_text: str, selector: str) -> str:
|
||||
"""The declarations of the rule with exactly this selector (the
|
||||
task-named classes own their base rule — no compound matches)."""
|
||||
m = re.search(r"(?<![\w-])" + re.escape(selector) + r"\s*\{([^}]*)\}", css_text)
|
||||
assert m, f"styles.css must define {selector}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_desc_cell_hover_title_covers_real_description_text_only() -> None:
|
||||
"""Phase 99 (task 01, D1): the text span (`.kb-desc-text` — the
|
||||
base class, set UNCONDITIONALLY before the three-state branch) AL-
|
||||
WAYS carries `title` = the FULL description text for real
|
||||
(non-empty) stored text — the hover escape hatch of the visual
|
||||
clamp; the phase-98 marker keeps its OWN D4 title (never over-
|
||||
ridden by the hover rule); the empty cell carries no title at all.
|
||||
textContent/title only — the house rule (no innerHTML)."""
|
||||
body = _fn(_js(), "makeDescCell")
|
||||
base_i = body.find('text.className = "kb-desc-text"')
|
||||
stored_i = body.find("if (node && node.summary) {")
|
||||
pending_i = body.find("else if (node && node.summary_pending) {")
|
||||
empty_i = body.find("} else {")
|
||||
assert -1 < base_i < stored_i, "the base clamp class is set before the state branch"
|
||||
stored = body[stored_i:pending_i]
|
||||
assert "text.title = node.summary;" in stored, "stored → title = the full text (hover)"
|
||||
assert "Summary pending" not in stored, "the stored branch never touches the marker"
|
||||
pending = body[pending_i:empty_i]
|
||||
assert (
|
||||
'text.title = "No stored description yet — the next sync will generate one."'
|
||||
in pending
|
||||
), "the marker keeps its OWN D4 title"
|
||||
empty = body[empty_i : empty_i + 200]
|
||||
assert "text.title" not in empty, "the empty cell carries no title"
|
||||
code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M)
|
||||
assert "innerHTML" not in code, "the house rule: no innerHTML in the cell builder"
|
||||
|
||||
|
||||
def test_close_editor_rederives_the_hover_title_from_the_stored_text() -> None:
|
||||
"""Phase 99 (task 01, D1): the hover title is the escape hatch for
|
||||
the CLAMPED text, so a Save/Cancel must re-derive it — a stale
|
||||
pre-edit title cannot survive a save (the span is re-inserted
|
||||
into the wrapper by the swap, attributes included). The re-
|
||||
derivation is gated on the kb-desc-text class: only the clamped
|
||||
row span gets the full-text title; the unclamped level's <p>
|
||||
(full text visible) keeps its no-title behavior, and an emptied
|
||||
(cleared) cell loses its title."""
|
||||
body = _fn(_js(), "wireDescriptionEdit")
|
||||
close = body[body.find("function closeEditor(") : body.find("function openEditor()")]
|
||||
gate_i = close.find('} else if (stored && textEl.classList.contains("kb-desc-text")) {')
|
||||
set_i = close.find("textEl.title = stored;")
|
||||
clear_i = close.find('textEl.removeAttribute("title")')
|
||||
assert -1 < gate_i < set_i < clear_i, "gated set (stored + clamped span) → clear, in order"
|
||||
assert close.count("textEl.title =") == 2, (
|
||||
"exactly: pendingTitle (the marker's own) + stored (the hover full text) — nothing else"
|
||||
)
|
||||
|
||||
|
||||
def test_styles_carry_the_one_line_clamp() -> None:
|
||||
"""Phase 99 (task 01, D1): the Description cell is one flex row —
|
||||
.kb-desc-cell (flex + center + the 0.4rem gap that replaces the
|
||||
button's old margin-left + min-width: 0) and .kb-desc-text (flex:
|
||||
1 1 auto + min-width: 0 — the flex item may shrink — + the
|
||||
ellipsis triad). The column itself is white-space: nowrap (was
|
||||
normal — the column width is the clamp's budget; the min/max width
|
||||
+ font-size stay). The button's margin-left rule is GONE, the
|
||||
level block's margin-top rule is untouched, and the open editor
|
||||
fills the flex wrapper (flex: 1 1 auto + min-width: 0). .kb-level
|
||||
p is UNTOUCHED (the full unclamped description — D1), and the
|
||||
phase-97 block carries no new hue (the phase-92 invariant)."""
|
||||
css = _text(STYLES_CSS)
|
||||
cell = _css_rule(css, ".kb-desc-cell")
|
||||
for prop in ("display: flex", "align-items: center", "gap: 0.4rem", "min-width: 0"):
|
||||
assert prop in cell, f".kb-desc-cell must carry {prop!r}"
|
||||
text = _css_rule(css, ".kb-desc-text")
|
||||
for prop in (
|
||||
"flex: 1 1 auto",
|
||||
"min-width: 0",
|
||||
"overflow: hidden",
|
||||
"text-overflow: ellipsis",
|
||||
"white-space: nowrap",
|
||||
):
|
||||
assert prop in text, f".kb-desc-text must carry {prop!r}"
|
||||
col_i = css.find(".kb-folders-table td:nth-child(3) {")
|
||||
assert col_i != -1
|
||||
col = css[col_i : col_i + 400]
|
||||
col = col[: col.find("\n}")]
|
||||
assert "white-space: nowrap" in col, "the column is one line (was: normal)"
|
||||
assert "white-space: normal" not in col
|
||||
for prop in ("min-width: 18rem", "max-width: 44rem", "font-size: 0.88rem"):
|
||||
assert prop in col, f"the column keeps {prop!r} (the clamp's budget)"
|
||||
assert ".kb-folders-table .kb-summary-edit" not in css, (
|
||||
"the button's margin-left is gone — the wrapper's gap replaces it"
|
||||
)
|
||||
lvl_i = css.find(".kb-level-body .kb-summary-edit")
|
||||
assert lvl_i != -1
|
||||
assert "margin-top: 0.5rem" in css[lvl_i : lvl_i + 80], (
|
||||
"the level block's spacing rule is untouched"
|
||||
)
|
||||
fill_i = css.find(".kb-desc-cell .kb-summary-editor")
|
||||
assert fill_i != -1
|
||||
fill = css[fill_i : fill_i + 80]
|
||||
assert "flex: 1 1 auto" in fill and "min-width: 0" in fill, (
|
||||
"the open editor fills the flex wrapper"
|
||||
)
|
||||
# The level block keeps the FULL unclamped description (D1) — its
|
||||
# rule is unchanged (no white-space/ellipsis additions).
|
||||
m = re.search(r"(?<![\w-])\.kb-level p\s*\{([^}]*)\}", css)
|
||||
assert m, "the level block's <p> rule must exist"
|
||||
assert "white-space" not in m.group(1) and "text-overflow" not in m.group(1)
|
||||
block = _css_block(css, "KB drill-down tree (phase 97", "Git sources page (phase 35)")
|
||||
assert ".kb-desc-cell" in block and ".kb-desc-text" in block, (
|
||||
"the clamp lives in the phase-97 tree region"
|
||||
)
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), "no new hue (phase-92 invariant)"
|
||||
assert not re.search(r"rgba?\(", block), "no new hue (phase-92 invariant)"
|
||||
|
||||
|
||||
def test_module_docstring_documents_the_one_line_clamp() -> None:
|
||||
"""The house per-phase module-note convention: the phase-99
|
||||
task-01 section records the flex wrapper structure (text flexes +
|
||||
ellipsizes, button fixed), the VISUAL-only clamp (full text in the
|
||||
DOM + on the hover title), the marker's own title, and the wrapper
|
||||
as the editor's container (the level block's container stays
|
||||
.kb-level-body)."""
|
||||
js = _js()
|
||||
header = js[: js.find("import { fetchIsAdmin }")]
|
||||
assert "Phase 99 (task 01, D1)" in header
|
||||
assert "kb-desc-cell" in header and "kb-desc-text" in header
|
||||
assert "VISUAL only" in header, "the clamp is documented as visual only"
|
||||
assert "title" in header, "the hover escape hatch is documented"
|
||||
assert "kb-level-body" in header, "the level block's container is documented as kept"
|
||||
|
||||
|
||||
# ---------- the history integration (phase 99, task 02, D2) ----------
|
||||
# The drill state IS the history state: every drill pushes a state-
|
||||
# only entry (no URL change), Back/Forward adopt the entry's kb (or
|
||||
# reset to the top), the re-show aligns before the re-fetch, and the
|
||||
# anonymous gate installs no listeners. The browser proof lands in
|
||||
# the phase's E2E (task 03); this module pins the wiring.
|
||||
|
||||
|
||||
def test_popstate_adopts_the_entry_kb_or_resets_to_top() -> None:
|
||||
"""Phase 99 (task 02, D2): the window popstate — armed exactly
|
||||
ONCE, in the ADMIN branch (after the whoami gate, next to the
|
||||
refresh listener) — ADOPTS the popped entry's ``event.state.kb``
|
||||
via the no-push variant (the browser owns its entries —
|
||||
adopt/reset never push), and an entry WITHOUT a kb (the boot
|
||||
entry, the router's view entries, any foreign state) RESETS the
|
||||
drill to the TOP level. Rendering a currently-hidden view is
|
||||
harmless: the router's own popstate (registered earlier, at shell
|
||||
boot) owns the view switch for foreign entries; rag-internal
|
||||
entries never change the pathname, so this listener's render is
|
||||
the visible one."""
|
||||
js = _js()
|
||||
assert js.count('window.addEventListener("popstate"') == 1, (
|
||||
"armed exactly once (mount-once — the router mounts a view ONCE)"
|
||||
)
|
||||
pop = js.find('window.addEventListener("popstate"')
|
||||
gate = js.find("const admin = await fetchIsAdmin();")
|
||||
refresh = js.find('root.addEventListener("bor:view-refresh"')
|
||||
assert -1 < gate < refresh < pop, (
|
||||
"armed in the admin branch, after the gate, next to the refresh listener"
|
||||
)
|
||||
body = js[pop : js.find("});", pop)]
|
||||
kb_i = body.find("event.state && event.state.kb")
|
||||
adopt_i = body.find("applyTarget(kb, false)")
|
||||
reset_i = body.find("applyTarget({ source: null, folder: null }, false)")
|
||||
assert -1 < kb_i < adopt_i < reset_i, (
|
||||
"read event.state.kb → adopt (no push) / reset to the top"
|
||||
)
|
||||
assert "pushState" not in _code(body), ("adopt/reset never push (the browser owns its entries)")
|
||||
|
||||
|
||||
def test_refresh_alignment_adopts_history_state_before_the_refetch() -> None:
|
||||
"""Phase 99 (task 02, D2): the bor:view-refresh listener (the
|
||||
phase-77 re-show) aligns ``current`` with ``history.state``
|
||||
BEFORE ``loadTree()`` — a kb-carrying top entry (the active-link
|
||||
re-click pushed NOTHING — the drilled entry is still on top)
|
||||
KEEPS the drill (``applyTarget(kb, false)``); a kb-less entry (a
|
||||
fresh nav visit) RESETS to the top level. The alignment adopts —
|
||||
it never pushes."""
|
||||
js = _js()
|
||||
listener = js.find('root.addEventListener("bor:view-refresh"')
|
||||
assert listener != -1
|
||||
body = js[listener : js.find("});", listener)]
|
||||
state_i = body.find("history.state && history.state.kb")
|
||||
adopt_i = body.find("applyTarget(kb, false)")
|
||||
reset_i = body.find("applyTarget({ source: null, folder: null }, false)")
|
||||
load_i = body.find("loadTree();")
|
||||
assert -1 < state_i < adopt_i < reset_i < load_i, (
|
||||
"align (adopt kb / reset to top) BEFORE the re-fetch"
|
||||
)
|
||||
assert "pushState" not in _code(body), "the alignment never pushes"
|
||||
|
||||
|
||||
def test_anonymous_branch_installs_no_navigation_listeners() -> None:
|
||||
"""Phase 99 (task 02): the anonymous gate installs NO listeners at
|
||||
all — no popstate, no refresh (the phase-16 rule: anonymous shows
|
||||
the gate, fetches nothing, and owns no drill state — both
|
||||
listeners are armed only in the admin branch)."""
|
||||
js = _js()
|
||||
start = js.find("if (!admin) {")
|
||||
end = js.find("if (gateEl) gateEl.hidden = true;")
|
||||
assert -1 < start < end, "the anonymous branch must exist"
|
||||
branch = js[start:end]
|
||||
assert "addEventListener" not in branch, (
|
||||
"the anonymous branch arms no listeners (no popstate, no refresh)"
|
||||
)
|
||||
|
||||
|
||||
def test_module_docstring_documents_the_history_integration() -> None:
|
||||
"""The house per-phase module-note convention: the phase-99
|
||||
task-02 section records the push/adopt/reset rules — the
|
||||
STATE-ONLY push (the URL STAYS PUT, no new route — the phase-76
|
||||
deep-link surface untouched), the duplicate-push skip, the
|
||||
popstate adopt/reset, the refresh alignment before the re-fetch,
|
||||
the no-boot-push contract, the UNCHANGED (no-push)
|
||||
resetVanishedLocation, and the UNTOUCHED router module."""
|
||||
js = _js()
|
||||
header = js[: js.find("import { fetchIsAdmin }")]
|
||||
assert "Phase 99 (task 02, D2)" in header
|
||||
assert "STATE-ONLY" in header, "the state-only push is documented"
|
||||
assert "STAYS PUT" in header, "the URL-stays-put note"
|
||||
assert "no duplicate history" in header, "the duplicate-push skip"
|
||||
assert "ADOPTS" in header and "popstate" in header, "the popstate adopt"
|
||||
assert "RESETS the drill to the TOP level" in header, "the reset branch"
|
||||
assert "BEFORE the re-fetch" in header, "the refresh alignment"
|
||||
assert "Boot pushes NOTHING" in header, "no pushState on boot"
|
||||
assert "resetVanishedLocation is UNCHANGED" in header
|
||||
assert "module is UNTOUCHED" in header, "the router module is untouched"
|
||||
|
||||
Reference in New Issue
Block a user