All verification is complete — the whole project is 100% green. Final report: **Phase 155 final verification pass — complete.** **Verified (all tasks 01–04 done in `complete/`):** - Instruction nudge in `app/rag/folder_summaries.py` (forbidden openers as the pin, 4 constraints preserved, docstring cites the phase) - `scripts/regen_folder_summaries.py` (import_docs idiom, full mode, cron-safe summary line, fail-loud boot) - Unit pins, integration suite, mock voice update, dedicated E2E suite **Test / lint / coverage:** - `uv run pytest --cov=app -q` → 100% passed (exit 0, zero failures); coverage **99%** on `app/` (>90% ✓) - `uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov` → **3 passed** in isolation - `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings **Completion criteria:** 1. Marker + header prefix byte-identical — ✅ (git vs HEAD + unit pins verbatim, green) 2. Regen script full regeneration — ✅ (live smoke: exit 0, `folders=… limit=7` summary line; misconfig boot → named stderr, exit 1; `updated_at`-advance/no-prune/canned-text semantics proven by the integration suite, green) 3. Sync path untouched — ✅ (0 diffs to `app/api/sync.py` / `app/rag/importer.py`; phase-127 pins green) 4. E2E stored + rendered without forbidden openers — ✅ (3/3, exact canned-string assertions + phase-127 regression guard) 5. Full suite green, E2E isolated green, ruff + pyright clean, coverage >90% — ✅ 6. Atomic commit — deferred to the harness (working tree left intact per protocol) **Documentation:** README.md and AGENTS.md reviewed in full — verified consistent, no changes needed (README already documents the new script in the update workflow; all `.env.example` vars documented; pages table matches routes; 17 colors = 9+8; no stale mock-voice references). **Decisions:** No live-infrastructure changes (dev-DB read + a 0-row script smoke only). The `TODO.md` working-tree edit is the owner's own, left untouched. **Next pending phase:** `156_table_ux_polish`.
698 lines
31 KiB
Python
698 lines
31 KiB
Python
"""Phase 155 task 04 E2E (Playwright, mock-only): the new voice, end to end.
|
||
|
||
The dedicated phase suite for ``155_folder_summary_voice`` (A16 — one
|
||
Playwright file per phase, run in isolation): the owner's audit found
|
||
that every ``lite`` folder summary opened with the formulaic "This
|
||
folder contains…" catalogue. The fix under test, proven here end to
|
||
end: a KB change triggers the sync's folder regeneration (the
|
||
change-gated path, phase 127) under the deterministic mock, the
|
||
STORED ``folder_summaries`` row for the regenerated folder carries the
|
||
mock's new-voice canned text — ``Documentation for <folder>.`` (a
|
||
natural opener naming the folder, NEVER the two forbidden "This folder
|
||
contains" / "The <folder> folder contains" openers — phase 155's pin)
|
||
— and the RAG catalogue (the phase-97 view) renders that stored voice
|
||
in its Description cells.
|
||
|
||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||
|
||
uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov
|
||
|
||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
|
||
deterministic mock (the ``FOLDER_SUMMARY_MODE`` branch's canned
|
||
``Documentation for <folder>.`` line, ``tests/e2e/mock_llm.py``): the
|
||
stored rows are a pure function of the request (the label is the
|
||
``Folder: …`` header tail — ``<source>`` for the source root,
|
||
``<source>/<folder>`` for a folder), so the exact-text and the
|
||
forbidden-opener assertions are deterministic only against the mock.
|
||
|
||
No slow-LLM proxy: the assertions are on the POST-terminal DB state
|
||
(the rows' ``summary`` AND ``updated_at``) plus the terminal status
|
||
(the keep-final-counts convention — the terminal keeps
|
||
``summaries_done == summaries_total == <affected count>``) and the
|
||
rendered RAG view, never on in-flight sampling, so the fast mock is
|
||
dense enough by construction.
|
||
|
||
KB fixture — one host temp dir (``tmp_path_factory``; the app runs on
|
||
the same host) registered as a ``kind=local`` source (the
|
||
``test_local_directory_sources.py`` / phase-94 API-registration
|
||
pattern; no git anywhere): ``voice/`` with TWO 2-doc folders —
|
||
``alpha/`` (the folder every test changes) and ``bravo/`` (the
|
||
unchanged sibling — the phase-127 regression guard) — plus ONE
|
||
root-level doc (5 documents total). The generator's candidate set is
|
||
therefore exactly 3: the source root (``folder_path ""``) + the two
|
||
folders (the recursive-subtree ≥ 1 rule).
|
||
|
||
Autouse cleanup (the phase-96/127 pattern): before each test wait for
|
||
no running sync on the module app, then truncate the shared registry +
|
||
KB tables (documents/chunks/query_log/steering_notes/kb_overview/
|
||
git_sources/folder_summaries) — each test seeds its own source and
|
||
runs its own syncs; after each test the same truncate. (The temp tree
|
||
itself persists for the module; the tests' disk mutations are content
|
||
revisions of existing fixture files — no file is ever added or
|
||
removed — so every test's syncs are deltas against its own truncated
|
||
KB and the counts below hold independently of the tree state the
|
||
previous test left behind.)
|
||
|
||
Test → observable mapping (Playwright Mapping Rule):
|
||
1. ``test_regen_summary_avoids_forbidden_openers`` — a first (full)
|
||
sync settles ``success``; ONE doc inside ``alpha/`` is content-
|
||
revised and the second sync (the change-gated path, phase 127)
|
||
settles ``success`` with the single-file detail and
|
||
``summaries_done == summaries_total == 2`` (the AFFECTED count —
|
||
alpha + the source root): the stored ``folder_summaries`` row for
|
||
``alpha`` EQUALS the mock's canned reply for that folder
|
||
(``Documentation for voice/alpha.`` — the exact string, a pure
|
||
function of the request) AND does not start with "This folder
|
||
contains" or "The voice/alpha folder contains" (the phase-155
|
||
shape pin — the regenerated voice, not the formulaic catalogue),
|
||
while the source-root row — the other affected row — likewise
|
||
carries its exact canned line.
|
||
2. ``test_rag_table_renders_the_stored_voice`` — the same
|
||
sync-#1 + alpha-revision + sync-#2 sequence (the regenerated
|
||
folder's row is re-stamped under the new instruction), then the
|
||
REAL admin login (the ``auth_helpers`` form pattern) opens the RAG
|
||
view: the top-level source row's Description cell renders the
|
||
stored source-root voice (the cell text AND its ``title`` — the
|
||
phase-99 one-line clamp keeps the FULL text in the DOM and on the
|
||
hover escape hatch, so the assertion holds whether or not the
|
||
display ellipsizes); drilling into the source, the ``alpha`` row's
|
||
Description cell renders ``Documentation for voice/alpha.`` (text +
|
||
``title``) and the ``bravo`` row its own canned line, and the
|
||
level block shows the stored source-root voice — the stored voice
|
||
is what the catalogue renders, end to end.
|
||
3. ``test_unchanged_folders_keep_their_rows`` (the phase-127
|
||
regression guard) — sync #1 (full), capturing every row's
|
||
``(summary, manually_edited, updated_at)``; ONE doc inside
|
||
``alpha/`` is content-revised and sync #2 settles ``success`` with
|
||
``summaries_done == summaries_total == 2`` (the AFFECTED count):
|
||
the ``bravo`` row is BYTE-IDENTICAL — same ``summary`` AND same
|
||
``updated_at`` (a KB change in folder A does not touch folder B's
|
||
stored row), while the ``alpha`` AND the source-root rows are
|
||
re-stamped (``updated_at`` strictly after the captured stamp, the
|
||
canned text, still AI-written) — asserted through the DB the way
|
||
the phase-127 suite does.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from collections.abc import Iterator
|
||
from datetime import datetime
|
||
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's 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_FVOICE", "8158"))
|
||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Fixture constants (deterministic, token-controlled)
|
||
# --------------------------------------------------------------------------
|
||
|
||
#: The local source — the temp directory's basename (``kind=local`` →
|
||
#: the directory's basename is the source name, phase 38).
|
||
SOURCE = "voice"
|
||
|
||
#: The folder every test changes (A) and the unchanged sibling (B —
|
||
#: the phase-127 regression guard's keeper).
|
||
FOLDER_A = "alpha"
|
||
FOLDER_B = "bravo"
|
||
|
||
ROOT_NOTE = "root-note.md" # the one root-level doc
|
||
|
||
N_FILES = 5 # two folders × 2 docs + the root-level doc
|
||
N_CANDIDATES = 3 # the source root + the two folders (the ≥ 1-doc rule)
|
||
|
||
#: The mock's byte-stable ``FOLDER_SUMMARY_MODE`` lines for this fixture
|
||
#: (the phase-94 template — the label is the ``FOLDER_HEADER_PREFIX``
|
||
#: tail: ``<source>`` for the root, ``<source>/<folder>`` for a folder;
|
||
#: the phase-155 voice: a natural opener naming the folder, never the
|
||
#: forbidden "This folder contains…" openers).
|
||
SUMMARY_FOR = "Documentation for {}."
|
||
EXPECTED: dict[str, str] = {
|
||
"": SUMMARY_FOR.format(SOURCE),
|
||
FOLDER_A: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_A}"),
|
||
FOLDER_B: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_B}"),
|
||
}
|
||
|
||
#: The phase-155 shape pin (test 1): the forbidden openers the new
|
||
#: instruction bans — the generic "This folder contains" opener and the
|
||
#: "The <folder name> folder contains" form with <folder name> bound to
|
||
#: the mock's own label (``voice/alpha``) and to the bare folder name
|
||
#: (``alpha``) — the stored voice must open with none of them.
|
||
FORBIDDEN_PREFIXES: tuple[str, ...] = (
|
||
"This folder contains",
|
||
f"The {SOURCE}/{FOLDER_A} folder contains",
|
||
f"The {FOLDER_A} folder contains",
|
||
)
|
||
|
||
|
||
def _forbidden_hits(summary: str) -> list[str]:
|
||
"""The forbidden openers the text starts with (``[]`` = the shape
|
||
pin holds — the summary opens in the varied voice)."""
|
||
return [p for p in FORBIDDEN_PREFIXES if summary.startswith(p)]
|
||
|
||
|
||
#: Test 1's single-file revision — a revised body whose hash differs
|
||
#: from the fixture's (the importer's sha256 delta reports ``updated``)
|
||
#: and which no other fixture text contains.
|
||
ALPHA_A_REVISED_1 = (
|
||
"Voice alpha fixture note A, REVISED-1: the only file test 1 changed "
|
||
"— the change-gated regeneration must re-voice exactly this file's "
|
||
"chain (alpha + the source root) under the new instruction. "
|
||
"(RESE-FVOICE-01)"
|
||
)
|
||
|
||
#: Test 2's single-file revision (a fresh distinctive body — the temp
|
||
#: tree persists across the module, so the second revision is a delta
|
||
#: against the state test 1's revision left behind).
|
||
ALPHA_A_REVISED_2 = (
|
||
"Voice alpha fixture note A, REVISED-2: the one changed file of "
|
||
"test 2's sync — the regenerated folder whose stored voice the RAG "
|
||
"catalogue must render. (RESE-FVOICE-02)"
|
||
)
|
||
|
||
#: Test 3's single-file revision (``alpha-b.md`` — a different file, so
|
||
#: the revision is a delta against test 3's own truncated KB).
|
||
ALPHA_B_REVISED_3 = (
|
||
"Voice alpha fixture note B, REVISED-3: the one changed file of "
|
||
"test 3's sync — bravo's row must stay byte-identical through it. "
|
||
"(RESE-FVOICE-03)"
|
||
)
|
||
|
||
|
||
def _md(title: str, body: str) -> str:
|
||
return f"# {title}\n\n{body}\n"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Fixtures
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def fvoice_dir(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 paths are visible to it. The
|
||
directory NAME is the source name (``kind=local``, phase 38)."""
|
||
root = tmp_path_factory.mktemp("bor_fvoice")
|
||
src = root / SOURCE
|
||
for folder in (FOLDER_A, FOLDER_B):
|
||
(src / folder).mkdir(parents=True)
|
||
for letter in ("a", "b"):
|
||
(src / folder / f"{folder}-{letter}.md").write_text(
|
||
_md(
|
||
f"Voice {folder.title()} {letter.upper()}",
|
||
f"Voice {folder} fixture note {letter.upper()}: covers "
|
||
f"topic {letter.upper()} of the {SOURCE} source tree.",
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
(src / ROOT_NOTE).write_text(
|
||
_md(
|
||
"Voice Root Note",
|
||
f"This file sits directly under the {SOURCE} source, not in "
|
||
"any folder.",
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
assert len(list(src.rglob("*.md"))) == N_FILES
|
||
return src
|
||
|
||
|
||
def _app_env(
|
||
base_url: str, tmp_path_factory: pytest.TempPathFactory
|
||
) -> dict[str, str]:
|
||
"""The per-module app env (the conftest pattern, cf.
|
||
``test_incremental_folder_summaries.py``): the ``base_url`` is the
|
||
mock LLM directly (the fast leg is the only leg — see the module
|
||
docstring), ``BOR_GIT_SOURCES`` forced empty (the sync source is
|
||
this suite's own DB-registered local row), and the leak-guarded
|
||
code defaults."""
|
||
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 base_url
|
||
)
|
||
# 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 local
|
||
# directory this suite registers.
|
||
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
|
||
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts"))
|
||
return env
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def app_server(
|
||
mock_llm: int,
|
||
fvoice_dir: Path,
|
||
tmp_path_factory: pytest.TempPathFactory,
|
||
) -> Iterator[str]:
|
||
"""The app under test (all three tests): ``BOR_LLM_BASE_URL`` is
|
||
the mock directly — the canned ``FOLDER_SUMMARY_MODE`` lines are
|
||
byte-stable there, and the exact-text and shape assertions key on
|
||
that exact text (no slow proxy: the assertions are post-terminal,
|
||
see the module docstring). ``fvoice_dir`` is a dependency only for
|
||
the fixture ordering (the temp tree exists before the app boots —
|
||
the sync reads it)."""
|
||
env = _app_env(f"http://127.0.0.1:{mock_llm}/v1", tmp_path_factory)
|
||
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 or document would
|
||
pollute the candidate count and the counts the syncs and the
|
||
folder-rows assertions pin 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_settled(base_url: str, timeout_s: float = 180.0) -> None:
|
||
"""No background sync may leak across tests (the run state lives in
|
||
the app's memory, and a still-running run would keep importing into
|
||
the NEXT test's truncated KB): wait for the app's sync status to
|
||
be non-running BEFORE the truncate. Own admin session (the
|
||
endpoint is admin-only) — usually a no-op: the tests settle only
|
||
after their run's terminal state."""
|
||
with httpx.Client(base_url=base_url, timeout=5.0) as client:
|
||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||
assert r.status_code == 204, r.text
|
||
deadline = time.monotonic() + timeout_s
|
||
while time.monotonic() < deadline:
|
||
r = client.get("/api/sync/status")
|
||
assert r.status_code == 200, r.text
|
||
if r.json()["state"] != "running":
|
||
return
|
||
time.sleep(0.2)
|
||
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clean(app_url: str, db_ready: None) -> Iterator[None]:
|
||
"""Per-test KB isolation (the phase-96/127 cleanup pattern): the
|
||
shared E2E Postgres is truncated before AND after every test —
|
||
each test seeds its own source and runs its own syncs, so the KB
|
||
rows + folder_summaries rows of the temp source are exactly this
|
||
test's own. The pre-wait keeps a leaked run from the previous test
|
||
(the module app is shared) from racing the truncate."""
|
||
_wait_sync_settled(app_url)
|
||
_truncate_all()
|
||
yield
|
||
_truncate_all()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Helpers
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _register_source(base_url: str, path: str) -> None:
|
||
"""Register the temp directory through the authenticated API (the
|
||
``test_local_directory_sources.py`` / phase-94 pattern — a plain
|
||
directory is an API/DB-only operation)."""
|
||
with httpx.Client(base_url=base_url, 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": path}
|
||
)
|
||
assert r.status_code == 201, r.text
|
||
|
||
|
||
def _run_sync(base_url: str, timeout_s: float = 120.0) -> dict[str, Any]:
|
||
"""Login + ``POST /api/sync`` + poll the status endpoint until THIS
|
||
run reaches a terminal state (the ``test_local_directory_sources``
|
||
pattern, over plain httpx — with the stale-terminal freshness gate:
|
||
the module app is shared, so the PREVIOUS run's terminal lives in
|
||
the app's memory until this run supersedes it; a terminal is
|
||
accepted only after a running tick was observed or its
|
||
``started_at`` post-dates this POST)."""
|
||
with httpx.Client(base_url=base_url, timeout=30.0) as client:
|
||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||
assert r.status_code == 204, r.text
|
||
t0 = time.time()
|
||
r = client.post("/api/sync")
|
||
assert r.status_code == 202, r.text
|
||
saw_running = False
|
||
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()
|
||
state = body["state"]
|
||
if state == "running":
|
||
saw_running = True
|
||
elif state in ("success", "failed") and (
|
||
saw_running
|
||
or _started_at(body) >= t0 - 1.0 # same-host clock; 1 s tolerance
|
||
):
|
||
return body
|
||
time.sleep(0.1)
|
||
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
||
|
||
|
||
def _started_at(body: dict[str, Any]) -> float:
|
||
"""The status body's ``started_at`` as a wall-clock epoch (the
|
||
server stamps it in UTC; this process runs on the same host —
|
||
same clock, the phase-64 recorder's freshness convention)."""
|
||
started = body.get("started_at")
|
||
if not started:
|
||
return 0.0
|
||
try:
|
||
return datetime.fromisoformat(str(started)).timestamp()
|
||
except ValueError:
|
||
return 0.0
|
||
|
||
|
||
def _folder_rows() -> dict[str, tuple[str, bool, datetime]]:
|
||
"""The source's stored folder summaries
|
||
``{folder_path: (summary, manually_edited, updated_at)}``
|
||
(``""`` = the source root) — the test process's direct DB access
|
||
(the E2E's other established lens)."""
|
||
with SessionLocal() as db:
|
||
rows = db.execute(
|
||
select(
|
||
FolderSummary.folder_path,
|
||
FolderSummary.summary,
|
||
FolderSummary.manually_edited,
|
||
FolderSummary.updated_at,
|
||
).where(FolderSummary.source == SOURCE)
|
||
).all()
|
||
return {
|
||
folder: (summary, manually, updated)
|
||
for folder, summary, manually, updated in rows
|
||
}
|
||
|
||
|
||
def _first_sync(app_url: str, fvoice_dir: Path) -> dict[str, Any]:
|
||
"""The suite's precondition (every test): register the temp source
|
||
and run the first (full) sync under the deterministic mock —
|
||
settled ``success`` with all 5 docs added and the 3 candidate rows
|
||
generated (the phase-127 suite's sequence)."""
|
||
_register_source(app_url, str(fvoice_dir))
|
||
body = _run_sync(app_url)
|
||
assert body["state"] == "success", body
|
||
detail = body["detail"]
|
||
assert detail["added"] == N_FILES, detail
|
||
assert detail["updated"] == 0 and detail["unchanged"] == 0, detail
|
||
assert detail["pruned"] == 0, detail
|
||
assert detail["overview"] is True, detail
|
||
return body
|
||
|
||
|
||
def _revise_alpha_a(fvoice_dir: Path, body: str) -> None:
|
||
"""Test 1's / test 2's KB change: ONE doc inside ``alpha/`` gets
|
||
new content (the importer's sha256 delta reports ``updated`` — no
|
||
add, no prune)."""
|
||
target = fvoice_dir / FOLDER_A / f"{FOLDER_A}-a.md"
|
||
target.write_text(_md(f"Voice {FOLDER_A.title()} A (revised)", body), encoding="utf-8")
|
||
|
||
|
||
def _assert_change_terminal(body: dict[str, Any]) -> None:
|
||
"""The second sync's terminal (the keep-final-counts convention):
|
||
the AFFECTED count — alpha + the source root — the phase-127
|
||
observable at the wire, plus the single-file detail."""
|
||
assert body["state"] == "success", body
|
||
detail = body["detail"]
|
||
assert detail["added"] == 0, detail
|
||
assert detail["updated"] == 1, detail
|
||
assert detail["unchanged"] == N_FILES - 1, detail
|
||
assert detail["pruned"] == 0, detail
|
||
assert detail["overview"] is True, detail
|
||
assert body["summaries_done"] == 2, body
|
||
assert body["summaries_total"] == 2, body
|
||
assert body["phase"] is None, body
|
||
assert body["current_summary"] is None, body
|
||
|
||
|
||
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)."""
|
||
for name in names:
|
||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||
|
||
|
||
def _expect_desc_cell(
|
||
page: Page, row_index: int, full_text: str
|
||
) -> None:
|
||
"""The RAG view's Description cell (the phase-97 catalogue idiom —
|
||
``td:nth-child(4)``; the phase-99 one-line clamp): the cell's text
|
||
span carries the STORED text (``textContent`` only — the house
|
||
rule; the clamp is VISUAL only, so the full text stays in the DOM)
|
||
and its ``title`` carries the FULL unclamped string (the hover
|
||
escape hatch, ``makeDescCell`` — set whenever the description is
|
||
non-empty, whether or not the display ellipsizes)."""
|
||
span = page.locator(f"#folders-tbody tr:nth-child({row_index + 1}) "
|
||
"td:nth-child(4) span")
|
||
expect(span).to_have_text(full_text)
|
||
expect(span).to_have_attribute("title", full_text)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 1. The change-gated regeneration (phase 127's path) stores the mock's
|
||
# new-voice canned text — exact match, and the shape pin: none of
|
||
# the forbidden openers
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_regen_summary_avoids_forbidden_openers(
|
||
app_url: str, fvoice_dir: Path, db_ready: None
|
||
) -> None:
|
||
"""A first sync of the temp source settles ``success`` with all 5
|
||
docs added; ONE doc inside ``alpha/`` is content-revised and the
|
||
second sync (the CHANGE-GATED path — phase 127: the run's changed
|
||
document set reaches ``generate_folder_summaries``) settles
|
||
``success`` with the single-file detail and
|
||
``summaries_done == summaries_total == 2`` (the AFFECTED count —
|
||
alpha + the source root): the stored ``folder_summaries`` row for
|
||
``alpha`` EQUALS the mock's canned reply for that folder (the
|
||
EXACT string — the mock's line is a pure function of the request,
|
||
so the match is byte-exact) AND does not start with "This folder
|
||
contains" or "The voice/alpha folder contains" (the phase-155
|
||
shape pin); the source-root row — the other affected row —
|
||
likewise carries its exact canned line."""
|
||
_first_sync(app_url, fvoice_dir)
|
||
_revise_alpha_a(fvoice_dir, ALPHA_A_REVISED_1)
|
||
|
||
body = _run_sync(app_url)
|
||
_assert_change_terminal(body)
|
||
|
||
rows = _folder_rows()
|
||
assert set(rows) == {""} | {FOLDER_A, FOLDER_B}, rows
|
||
# The regenerated folder: the EXACT canned string (deterministic) +
|
||
# the shape pin (no forbidden opener).
|
||
summary, manually, _updated_at = rows[FOLDER_A]
|
||
assert summary == EXPECTED[FOLDER_A], (FOLDER_A, summary)
|
||
assert _forbidden_hits(summary) == [], (FOLDER_A, summary)
|
||
assert manually is False, (FOLDER_A, manually)
|
||
# The other affected row (the source root) — the new voice there too.
|
||
assert rows[""][0] == EXPECTED[""], rows[""]
|
||
assert _forbidden_hits(rows[""][0]) == [], rows[""]
|
||
# The unaffected sibling keeps its canned line (phase 127 — full
|
||
# guard in test 3; here only the text, the shape pin's context).
|
||
assert rows[FOLDER_B][0] == EXPECTED[FOLDER_B], rows[FOLDER_B]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 2. The RAG view (the phase-97 catalogue) renders the stored voice in
|
||
# the Description cells — the exact canned text, title attribute
|
||
# carrying the full string
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_rag_table_renders_the_stored_voice(
|
||
page: Page,
|
||
app_url: str,
|
||
fvoice_dir: Path,
|
||
db_ready: None,
|
||
) -> None:
|
||
"""The same sync-#1 + alpha-revision + sync-#2 sequence (the
|
||
regenerated folder's row is re-stamped under the new instruction —
|
||
the DB state test 1 pins), then the REAL admin login opens the
|
||
RAG view (the phase-97 catalogue): the top-level source row's
|
||
Description cell renders the stored source-root voice (the cell
|
||
text AND its ``title`` — the phase-99 clamp keeps the full text in
|
||
the DOM and on the hover escape hatch, so the assertion holds
|
||
whether or not the display ellipsizes); drilling into the source,
|
||
the ``alpha`` row's Description cell renders the regenerated
|
||
folder's exact canned line (text + ``title``) and the ``bravo``
|
||
row its own canned line, and the level block shows the stored
|
||
source-root voice — the stored voice is what the catalogue
|
||
renders, end to end."""
|
||
page.set_default_timeout(30_000)
|
||
_first_sync(app_url, fvoice_dir)
|
||
_revise_alpha_a(fvoice_dir, ALPHA_A_REVISED_2)
|
||
_assert_change_terminal(_run_sync(app_url))
|
||
# The regenerated folder's DB state (the precondition the view
|
||
# renders — test 1's exact-text pin, re-asserted here so the view
|
||
# assertion stands on a verified store).
|
||
rows = _folder_rows()
|
||
assert rows[FOLDER_A][0] == EXPECTED[FOLDER_A], rows[FOLDER_A]
|
||
|
||
login(page, app_url) # → /sources.html (the RAG view)
|
||
# The top level: the one source row (the ls() equivalence — the
|
||
# tree's single fetch settled) and its stored source-root voice in
|
||
# the Description cell (the exact canned line, text + title).
|
||
page.wait_for_selector("#folders-tbody tr", timeout=30_000)
|
||
expect(page.locator("#folders-tbody tr")).to_have_count(1)
|
||
expect(page.locator("#folders-tbody tr td:nth-child(1) a.folder-link")
|
||
).to_have_text(SOURCE)
|
||
_expect_desc_cell(page, 0, EXPECTED[""])
|
||
|
||
# Drill into the source: the two folder rows (path order) and the
|
||
# regenerated folder's stored voice in its Description cell — the
|
||
# exact canned line (the DB state above is what the cell renders),
|
||
# plus the sibling's own canned line; the level block carries the
|
||
# source root's stored voice (the phase-97 ``#kb-level`` idiom).
|
||
_drill(page, SOURCE)
|
||
rows_visible = page.locator("#folders-tbody tr")
|
||
expect(rows_visible).to_have_count(2)
|
||
expect(rows_visible.nth(0).locator("a.folder-link")).to_have_text(FOLDER_A)
|
||
_expect_desc_cell(page, 0, EXPECTED[FOLDER_A])
|
||
expect(rows_visible.nth(1).locator("a.folder-link")).to_have_text(FOLDER_B)
|
||
_expect_desc_cell(page, 1, EXPECTED[FOLDER_B])
|
||
expect(page.locator("#kb-level")).to_be_visible()
|
||
expect(page.locator("#kb-level-summary")).to_have_text(EXPECTED[""])
|
||
# The rendered voice is the new one: no Description cell in the
|
||
# level shows a forbidden opener (the shape pin, view-side).
|
||
for cell in page.locator("#folders-tbody td:nth-child(4) span").all():
|
||
rendered = cell.text_content() or ""
|
||
for prefix in FORBIDDEN_PREFIXES:
|
||
assert not rendered.startswith(prefix), rendered
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 3. The phase-127 regression guard: a KB change in folder A does not
|
||
# touch folder B's stored row (text AND updated_at byte-identical)
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_unchanged_folders_keep_their_rows(
|
||
app_url: str, fvoice_dir: Path, db_ready: None
|
||
) -> None:
|
||
"""Re-seed (the autouse truncate) + sync #1 (full), capturing every
|
||
row's ``(summary, manually_edited, updated_at)``; then ONE doc
|
||
inside ``alpha/`` is content-revised (``alpha-b.md`` — a different
|
||
file, so the revision is a delta against this test's own truncated
|
||
KB) and sync #2 settles ``success`` with the single-file detail
|
||
and ``summaries_done == summaries_total == 2`` (the AFFECTED
|
||
count — alpha + the source root): the ``bravo`` row is
|
||
BYTE-IDENTICAL — same ``summary`` AND same ``updated_at`` (a KB
|
||
change in folder A does not touch folder B's stored row — the
|
||
phase-127 contract), while the ``alpha`` AND the source-root rows
|
||
are re-stamped (``updated_at`` strictly after the captured stamp,
|
||
the canned text, still AI-written)."""
|
||
_first_sync(app_url, fvoice_dir)
|
||
before = _folder_rows()
|
||
assert set(before) == {""} | {FOLDER_A, FOLDER_B}, before
|
||
|
||
target = fvoice_dir / FOLDER_A / f"{FOLDER_A}-b.md"
|
||
target.write_text(
|
||
_md(f"Voice {FOLDER_A.title()} B (revised)", ALPHA_B_REVISED_3),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
body = _run_sync(app_url)
|
||
_assert_change_terminal(body)
|
||
|
||
after = _folder_rows()
|
||
assert set(after) == {""} | {FOLDER_A, FOLDER_B}, after
|
||
# The unchanged sibling: BYTE-IDENTICAL — same summary text AND
|
||
# same updated_at (never re-stamped, never re-generated).
|
||
assert after[FOLDER_B] == before[FOLDER_B], (
|
||
FOLDER_B,
|
||
before[FOLDER_B],
|
||
after[FOLDER_B],
|
||
)
|
||
# The changed chain (alpha + the source root): RE-STAMPED — a new
|
||
# stamp strictly after the captured one, the canned text, still
|
||
# AI-written (the mock labels from the folder header only, so the
|
||
# text is the same deterministic line).
|
||
for folder in ("", FOLDER_A):
|
||
summary, manually, updated_at = after[folder]
|
||
assert summary == EXPECTED[folder], (folder, summary)
|
||
assert manually is False, (folder, manually)
|
||
assert updated_at > before[folder][2], (
|
||
folder,
|
||
before[folder],
|
||
after[folder],
|
||
)
|