"""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 .`` (a natural opener naming the folder, NEVER the two forbidden "This folder contains" / "The 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 .`` line, ``tests/e2e/mock_llm.py``): the stored rows are a pure function of the request (the label is the ``Folder: …`` header tail — ```` for the source root, ``/`` 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 == ``) 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: ```` for the root, ``/`` 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 contains" form with 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 (no URL change; phase 167: the first open of a level the depth-1 payload only NAMES costs one /api/docs/tree/node fetch, a level already held costs nothing): 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.kb-desc-text") 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 ONE depth-1 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.kb-desc-text").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], )