"""Phase 106 task 10 E2E (Playwright): document dates end to end — sourced at import, displayed in the UI, editable by the admin, and recency-weighted in retrieval. Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_document_dates.py -v --no-cov The whole owner item (2026-09-13) through the REAL page + REAL API + REAL importer (the deterministic mock LLM — token-overlap embeddings, so the cosine/retrieval behavior is production-shaped). No git, no network: a dedicated local fixture tree built ONCE per module under ``tmp_path_factory`` (``os.utime``'d — NEVER the shared ``tests/fixtures/docs``, whose 13-file counts are pinned by other suites): * ``backups/retention.md`` utime 2020-01-01 03:04:06Z — THE CORRECT answer, rich in the question's tokens * ``backups/retention-draft.md`` utime now (default mtime) — the SIMILAR-but-wrong doc: shares the "backup retention policy" wording, concludes "under review, no decision yet" * ``legacy/old-doc.md`` utime 2019-06-15 — single-doc folder (a clean folder-``Updated`` max: the 2019 date alone) * ``future/forward.md`` utime 2999-01-01 — the future-date case (→ today, D3) Test → contract mapping (six tests, one per contract bullet): 1. ``test_dates_landed_on_import`` — the real importer sources every date: the 2020/2019 utimes land verbatim on ``/api/docs`` and the tree's file nodes, the 2999 mtime FOLDS to today (D3), and the folder/source ``updated_at`` (D9) is the subtree MAX (legacy = the 2019 date alone, the source = the now-side max). ``indexed_at`` keeps its meaning (≈ import time; after the created dates on the old docs). 2. ``test_file_and_folder_columns`` — the RAG view's file table header order ``… Chunks · Created · Indexed`` (D8) and folder table header order ``Folder · Documents · Updated · Description`` (D8), the drilled-in row's Created cell (locale date text + the FULL ISO on the cell's ``title`` — locale-stable), the top-level source row's non-empty Updated cell, and the drilled ``legacy`` row's Updated cell carrying the 2019 date. 3. ``test_viewer_shows_date_at_top`` — clicking the row opens the same-page modal (phase 26): the top meta row carries a ``Created …`` badge whose ``title`` is the 2020 ISO, DOM-prior to the ``Indexed`` badge (D8 — the date at the top of the clicked document), with the source/format/indexed/chunks badges intact. 4. ``test_old_correct_beats_new_similar`` — THE OWNER SCENARIO end to end (phase 118, A4): the real retriever + the DEFAULT recency boost (0.0007 / 365 d) over the mock's token-overlap embeddings ranks the OLDER correct document first in the suggested tier — the chip row IS the suggested tier (top-5, NO floor; this four-doc KB therefore chips ALL four docs in rank order: retention, draft, forward, old-doc) and the related row is absent (no rank-6+ doc exists). The newer similar doc (the boost's intended beneficiary) stays second — the boost never lets it outrank the one that answers. 5. ``test_date_edit_and_sync_preserves`` — the admin-only editor in the real UI: set → Save → the badge re-renders from the RESPONSE (never the optimistic input) → the API round-trips; a re-import keeps the correction (the manual flag, D1) while the siblings refresh; ``Revert to sync`` drops the flag and the NEXT import re-sources the date from the mtime. 6. ``test_anonymous_gate_and_editor_a11y`` — anonymous: the RAG view shows the sign-in gate (no tables) and a raw ``PATCH /api/documents/date`` 403s with the stored date untouched; admin: the editor's accessible names, keyboard reachability (Tab from the focused input), the ``role=status`` / ``role=alert`` live lines, and the badge's text+format pairing (never color alone — B5). The fixture wording (pinned): the owner-scenario geometry under the MOCK embeddings (bag-of-token md5 buckets, DIM=768) is fully deterministic for fixed text — measured with ``app.rag.retriever._vector_candidates`` / ``_lexical_candidates`` / ``retrieve()`` against a real Postgres + the mock: * ``retention.md`` (correct, 2020) — rank 1 in BOTH lists (content chunk cosine 0.6222; the lexical tsquery ``how|did|i|configure| backup|retention|policy`` after stopword removal matches it most densely). Phase 118 (A2): its EMBEDDED summary is a retrieval candidate too (best-chunk cosine 0.7133 — the seeded summary ranks, which is the point of seeding it). * ``retention-draft.md`` (similar, now) — rank 4 in the vector list (cosine 0.1443) and rank 2 in the lexical list. Its wording was tuned for exactly this: it shares ONLY the three "backup retention policy" question tokens (no "the"/"how"/"i"/"configure" filler — those inflate the mock cosine) and none of its filler words hash into a question bucket (the md5 collisions add ~0.054 each — the first two drafts, with "the" ×3 and four colliding words, sat at cosine 0.36–0.43 and LOSE to the boost: the RRF rank-adjacency gap is only 1/61−1/62 ≈ 0.00026 < the 0.0007 zero-age boost). * ``legacy/old-doc.md`` (cosine 0.1936) and ``future/forward.md`` (0.1875) rank 2–3 in the vector list (unrelated content) and match the tsquery not at all. Fused (RRF k=60) + the default boost (0.0007 · exp(−age/365d)), probe-verified against the current candidate set (the embedded summary chunks join the walk — phase 118 A2): retention.md 0.032523 vs retention-draft.md 0.031498 (the full zero-age boost included) → the older correct doc wins by ≈ 0.001025 WITH the boost on (the margin only grew once the summary chunks ranked — the scenario holds both ways; the boost never lets the newer similar doc outrank the one that answers the question). The phase-118 suggested tier (top-5, NO floor) carries all four docs in that rank order — retention.md first, the draft second, then forward.md (0.016325) and old-doc.md (0.015874) (no floor filters the unrelated docs); the related tier is EMPTY (no rank-6+ doc in a four-doc KB). The boost defaults are owned by task 07 — untouched here. DB isolation: every test TRUNCATEs the KB tables (the ``test_retrieval_quality.py`` ``_reset_db`` pattern, extended with ``folder_summaries`` / ``kb_overview`` — this suite's tree assertions must not see other runs' rows) and re-imports the module tree in a worker thread (Playwright owns the test loop). """ from __future__ import annotations import asyncio import os import re from datetime import UTC, datetime from pathlib import Path from threading import Thread 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 from app.db import SessionLocal from app.models import Document, QueryLog from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient from e2e.auth_helpers import login # -------------------------------------------------------------------------- # Fixture constants (deterministic — see the module docstring's geometry # record before touching the wording) # -------------------------------------------------------------------------- RETENTION_MD = "backups/retention.md" DRAFT_MD = "backups/retention-draft.md" OLDDOC_MD = "legacy/old-doc.md" FORWARD_MD = "future/forward.md" #: The pinned sourced dates (D2: local-dir sources → the file mtime). RETENTION_ISO = "2020-01-01T03:04:06+00:00" OLDDOC_ISO = "2019-06-15T00:00:00+00:00" EDITED_ISO = "2021-05-05T00:00:00+00:00" QUESTION = "How did I configure the backup retention policy?" MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" #: THE CORRECT answer — rich in the question's tokens ("backup #: retention policy", "configure(d)", "How I"). RETENTION_TEXT = """\ # Backup retention policy The backup retention policy is 30 days; snapshots are pruned nightly. How I configured the retention policy: - Configure the backup retention window: 30 days of daily snapshots. - Retention is set in backups.conf: `retention_days=30`. - I configured the nightly cron to prune expired snapshots. - The retention policy keeps 30 days, then prunes the rest. """ #: The SIMILAR-but-wrong doc — shares the "backup retention policy" #: wording, concludes "under review, no decision yet". Worded so the #: mock cosine stays at 0.1443 (vector rank 4 — BELOW the two #: unrelated docs): only the three shared question tokens, no #: "the"/"how"/"i"/"configure" filler, and no filler word hashing into #: a question bucket (see the module docstring). DRAFT_TEXT = """\ # Parking note Workshop parking note: mop leans against door, oil stains mark floor, loose hinge squeals, spare fuses sit in tin box, cobwebs hang from rafters, cracked stool leg lies near bench. Meanwhile backup retention policy is under review, no decision yet — maybe weekly archives someday. """ #: Unrelated legacy note (single-doc folder — the clean Updated max). OLDDOC_TEXT = """\ # Legacy router config The old router used a static route table with a single upstream link. It was retired when the new switch arrived. """ #: Unrelated memo with a FUTURE mtime (2999-01-01 → folds to today, D3). FORWARD_TEXT = """\ # Forward planning memo A memo about planning next year's hardware refresh for the lab. The list includes a new switch, shelves, and cabling. """ # -------------------------------------------------------------------------- # Fixture tree (module-scoped — built ONCE, utime'd) # -------------------------------------------------------------------------- def _mkdocs(root: Path, rel: str, body: str, when: datetime | None) -> None: """Write one fixture file under *root*; *when* (aware datetime) backdates its mtime via ``os.utime`` (None → the build time).""" p = root / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_text(body, encoding="utf-8") if when is not None: os.utime(p, (when.timestamp(), when.timestamp())) @pytest.fixture(scope="module") def dates_tree(tmp_path_factory: pytest.TempPathFactory) -> Path: """The dedicated fixture tree (see the module docstring). The directory NAME is the source name (``kind=local`` → the directory's basename, phase 38) — distinctive, never asserted by absolute counts elsewhere.""" root = tmp_path_factory.mktemp("bor_document_dates") _mkdocs(root, RETENTION_MD, RETENTION_TEXT, datetime(2020, 1, 1, 3, 4, 6, tzinfo=UTC)) _mkdocs(root, DRAFT_MD, DRAFT_TEXT, None) # utime = now (default mtime) _mkdocs(root, OLDDOC_MD, OLDDOC_TEXT, datetime(2019, 6, 15, tzinfo=UTC)) _mkdocs(root, FORWARD_MD, FORWARD_TEXT, datetime(2999, 1, 1, tzinfo=UTC)) assert (root / RETENTION_MD).is_file() and (root / OLDDOC_MD).is_file() return root # -------------------------------------------------------------------------- # Importer + DB helpers (test_retrieval_quality.py scaffolding) # -------------------------------------------------------------------------- async def _import_tree(mock_port: int, tree: Path) -> ImportSummary: """The REAL importer over the module tree (mock embeddings).""" kwargs: dict[str, Any] = { "_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1", } settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] return await import_sources([tree], LLMClient(settings)) def _run_in_thread(coro: Any) -> Any: """Run a coroutine on a worker thread (Playwright owns the test loop).""" box: dict[str, Any] = {} def runner() -> None: try: box["value"] = asyncio.run(coro) except BaseException as e: # noqa: BLE001 — re-raised on the test thread box["error"] = e t = Thread(target=runner) t.start() t.join() if "error" in box: raise box["error"] return box["value"] def _reset_db(mock_port: int, tree: Path) -> ImportSummary: """Truncate the KB (and the per-run derived tables), then import the module tree fresh (the house ``_reset_db`` pattern, extended with ``folder_summaries`` / ``kb_overview``).""" with SessionLocal() as db: db.execute(text( "TRUNCATE chunks, documents, query_log, folder_summaries, " "kb_overview" )) db.commit() return _run_in_thread(_import_tree(mock_port, tree)) def _ask(page: Page, message: str) -> None: page.fill("#message-input", message) page.click("#send-btn") def _admin_cookies(page: Page) -> dict[str, str]: """The signed session cookie jar the form login left in the browser context (the test_retrieval_quality idiom).""" return { c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c } def _docs_by_path(app_url: str, cookies: dict[str, str]) -> dict[str, dict[str, Any]]: r = httpx.get(f"{app_url}/api/docs", timeout=10, cookies=cookies) assert r.status_code == 200, r.text return {d["path"]: d for d in r.json()["documents"]} def _tree(app_url: str, cookies: dict[str, str]) -> dict[str, Any]: r = httpx.get(f"{app_url}/api/docs/tree", timeout=10, cookies=cookies) assert r.status_code == 200, r.text return r.json() def _source_node(tree_json: dict[str, Any], source: str) -> dict[str, Any]: for s in tree_json["sources"]: if s["name"] == source: return s raise AssertionError(f"source {source!r} not in the tree") def _folder_node(node: dict[str, Any], path: str) -> dict[str, Any]: for child in node.get("children", []): if child.get("kind") == "folder" and child["path"] == path: return child raise AssertionError(f"folder {path!r} not under the node") def _find_file(node: dict[str, Any], path: str) -> dict[str, Any]: """The file node with *path* anywhere under *node* (recursing into the folder children — files sit at their folder's level, not the source root's).""" for child in node.get("children", []): if child.get("kind") == "file" and child["path"] == path: return child if child.get("kind") == "folder": try: return _find_file(child, path) except AssertionError: continue raise AssertionError(f"file {path!r} not under the node") def _today_utc() -> str: return datetime.now(UTC).date().isoformat() # --------------------------------------------------------------------------- # 1. Dates landed on import (D2 sourced, D3 normalized, D9 derived) # --------------------------------------------------------------------------- def test_dates_landed_on_import( page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path ) -> None: """The real importer sources every created_at: the 2020/2019 utimes verbatim, the 2999 mtime folded to today (D3), and the folder/source updated_at = the subtree MAX (D9).""" summary = _reset_db(mock_llm, dates_tree) assert summary.added == 4 and summary.errors == 0 assert summary.dates_updated == 0 # a fresh import adds, never refreshes # The catalog is admin-only — perform the REAL form login, then call # the API with the signed cookie the browser now holds. login(page, app_url) cookies = _admin_cookies(page) docs = _docs_by_path(app_url, cookies) today = _today_utc() # D2 (mtime sourcing) — the backdated utimes land verbatim… assert docs[RETENTION_MD]["created_at"] == RETENTION_ISO assert docs[OLDDOC_MD]["created_at"] == OLDDOC_ISO # …the default mtime (now) lands as today… assert docs[DRAFT_MD]["created_at"][:10] == today # …and the FUTURE mtime (2999) folds to today (D3, owner rule). assert docs[FORWARD_MD]["created_at"][:10] == today # indexed_at keeps its meaning (the INDEX time, phase 1): ≈ import # time on every row, and strictly AFTER the sourced created date on # the old docs (a year+ apart — the two concepts do not blur). now = datetime.now(UTC) for path, doc in docs.items(): indexed = datetime.fromisoformat(doc["indexed_at"]) assert abs((now - indexed).total_seconds()) < 15 * 60, path assert datetime.fromisoformat(docs[RETENTION_MD]["indexed_at"]) > \ datetime.fromisoformat(RETENTION_ISO) assert datetime.fromisoformat(docs[OLDDOC_MD]["indexed_at"]) > \ datetime.fromisoformat(OLDDOC_ISO) # The tree (D9): file nodes carry the SAME dates verbatim; the # legacy folder's updated_at is its single doc's 2019 date (a clean # max); the source's updated_at is the whole subtree's max (the # now-side — the future-folded doc, refreshed at import). tree = _tree(app_url, cookies) source = _source_node(tree, dates_tree.name) assert source["documents"] == 4 assert source["updated_at"] is not None assert source["updated_at"][:10] == today assert _find_file(source, RETENTION_MD)["created_at"] == RETENTION_ISO assert _find_file(source, OLDDOC_MD)["created_at"] == OLDDOC_ISO assert _find_file(source, DRAFT_MD)["created_at"][:10] == today assert _find_file(source, FORWARD_MD)["created_at"][:10] == today legacy = _folder_node(source, "legacy") assert legacy["documents"] == 1 assert legacy["updated_at"] is not None assert legacy["updated_at"][:10] == "2019-06-15" # the 2019 date alone backups = _folder_node(source, "backups") assert backups["documents"] == 2 assert backups["updated_at"] is not None assert backups["updated_at"][:10] == today # max(2020, now) = the now side # --------------------------------------------------------------------------- # 2. The UI columns: Created before Indexed; Updated between Documents # and Description (D8 positions, verbatim) # --------------------------------------------------------------------------- def test_file_and_folder_columns( page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path ) -> None: """The RAG view's file table header order ``… Chunks · Created · Indexed`` and folder table header order ``Folder · Documents · Updated · Description`` (D8), plus the drilled-in date cells.""" _reset_db(mock_llm, dates_tree) page.set_default_timeout(30_000) login(page, app_url) # lands on /sources.html (the RAG view, admin) # Header order (D8, verbatim) — both tables, the sequence. # text_content() (NOT inner_text()): the rendered s are # CSS-uppercased (`.docs-table th { text-transform: uppercase }`), # and the SOURCE text is the contract. file_headers = [ th.text_content() for th in page.locator("#docs-table thead th").all() ] assert file_headers == ["Source", "Path", "Title", "Chunks", "Created", "Indexed"] folder_headers = [ th.text_content() for th in page.locator("#folders-table thead th").all() ] assert folder_headers == ["Folder", "Documents", "Updated", "Description"] # Top level: the source row's Updated cell is non-empty (the # subtree max — the now side; "–" only for a 0-document source). page.locator("#folders-tbody .folder-link").first.wait_for(state="visible") source_row = page.locator( f'#folders-tbody tr:has(a.folder-link:text-is("{dates_tree.name}"))' ) expect(source_row).to_have_count(1) source_updated = source_row.locator("td:nth-child(3)") expect(source_updated).to_have_text(re.compile(r"\S"), timeout=15_000) source_title = source_updated.get_attribute("title", timeout=15_000) assert source_title is not None and source_title.startswith(_today_utc()) # Drill into the source: the three folders list (no direct files). page.click(f'#folders-tbody a.folder-link:text-is("{dates_tree.name}")') expect(page.locator("#folders-tbody .folder-link")).to_have_count(3) # The legacy folder row's Updated cell carries the 2019 date (the # single-doc max — D9 end to end through the UI). legacy_row = page.locator('#folders-tbody tr:has(a.folder-link:text-is("legacy"))') expect(legacy_row).to_have_count(1) legacy_updated = legacy_row.locator("td:nth-child(3)") expect(legacy_updated).to_have_text(re.compile("2019")) legacy_title = legacy_updated.get_attribute("title", timeout=15_000) assert legacy_title is not None and legacy_title.startswith("2019-06-15") # Drill into backups: the file table rows. retention.md's Created # cell — the FULL ISO on the cell's title (task 08's locale-stable # idiom: the test pins the title, never the toLocaleString output), # and the browser's local-time rendering in the text. The local YEAR # of the ISO instant is computed here (the host and the headless # browser share the host timezone — a negative-offset timezone # renders 2020-01-01T03:04Z as "12/31/2019, 10:04 PM"). page.click('#folders-tbody a.folder-link:text-is("backups")') row = page.locator("#docs-tbody tr", has_text=RETENTION_MD) expect(row).to_have_count(1) created_cell = row.locator("td:nth-child(5)") local_year = str(datetime.fromisoformat(RETENTION_ISO).astimezone().year) expect(created_cell).to_have_text(re.compile(local_year), timeout=15_000) assert created_cell.get_attribute("title", timeout=15_000) == RETENTION_ISO # The Indexed cell stays AFTER Created (D8 in the row, not just the # header): it carries the import-time locale date, no title. expect(row.locator("td:nth-child(6)")).to_have_text(re.compile(r"\S")) assert row.locator("td:nth-child(6)").get_attribute("title") is None # --------------------------------------------------------------------------- # 3. The clicked document's top meta row: the Created badge (D8) # --------------------------------------------------------------------------- def test_viewer_shows_date_at_top( page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path ) -> None: """The same-page modal (phase 26) shows the Created badge at the TOP meta row of the clicked document — DOM-prior to the Indexed badge (D8), the full ISO on its title, the other badges intact.""" _reset_db(mock_llm, dates_tree) page.set_default_timeout(30_000) login(page, app_url) page.locator("#folders-tbody .folder-link").first.wait_for(state="visible") page.click(f'#folders-tbody a.folder-link:text-is("{dates_tree.name}")') page.click('#folders-tbody a.folder-link:text-is("backups")') row = page.locator("#docs-tbody tr", has_text=RETENTION_MD) expect(row).to_have_count(1) before_tabs = len(page.context.pages) row.locator("td:nth-child(2) a.doc-link").click() assert len(page.context.pages) == before_tabs, "row link must not open a new tab" expect(page.locator(".doc-modal")).to_be_visible() expect(page.locator("#doc-modal-title")).to_have_text("Backup retention policy") meta = page.locator("#doc-modal-meta") created = meta.locator(".doc-created") expect(created).to_have_count(1) # The badge text: "Created " (the text+format pairing — # the date is carried by text, never color alone, B5). expect(created).to_have_text(re.compile(r"^Created \S")) # The full ISO timestamp on the badge's title (hover precision). assert created.get_attribute("title", timeout=15_000) == RETENTION_ISO # D8 in the DOM: the Created badge PRECEDES the Indexed badge. assert page.evaluate( """() => { const a = document.querySelector('#doc-modal-meta .doc-created'); const b = document.querySelector('#doc-modal-meta .doc-indexed'); return a !== null && b !== null && !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); }""" ) # No regression: the source/format/indexed/chunks badges are all # still there. expect(meta.locator(".doc-source-badge")).to_have_text(dates_tree.name) expect(meta.locator(".format-badge")).to_have_text("md") expect(meta.locator(".doc-indexed")).to_have_text(re.compile(r"^Indexed \S")) expect(meta.locator(".doc-chunks")).to_have_text(re.compile(r"^\d+ chunk")) # --------------------------------------------------------------------------- # 4. THE OWNER SCENARIO: the older correct doc beats the newer similar # one (real retriever + the default recency boost, mock embeddings) # --------------------------------------------------------------------------- def test_old_correct_beats_new_similar( page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path ) -> None: """``How did I configure the backup retention policy?`` → grounded, and the FIRST logged source is the OLDER correct doc (2020) — the NEWER similar one (now, "under review") is second. Phase 119 (LOCKED A1): the zero-read turn chips NOTHING (the chip row is the READ docs only — the retired phase-118 A4 suggested-chip row is gone), so the ordering assertion rides the durable record (LOCKED A3 — all four docs, suggested rank order, untouched); the related row is absent (no rank-6+ doc). The real hybrid retriever + the DEFAULT recency boost (0.0007 / 365 d) over the mock's token-overlap embeddings (the module docstring records the measured fused scores: 0.032523 vs 0.031498 — margin ≈ 0.001025 WITH the full zero-age boost on the newer doc).""" _reset_db(mock_llm, dates_tree) page.set_default_timeout(30_000) login(page, app_url, next="/") # phase 79: chat is require_user-gated _ask(page, QUESTION) expect(page.locator(".msg.user .bubble")).to_contain_text(QUESTION) bubble = page.locator(".msg.brain .bubble").first bubble.wait_for(state="visible", timeout=30_000) expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000) # Grounded: no deflected bubble at all (the A8 cosine gate passed — # top_score 0.6222 ≥ the e2e threshold 0.30). expect(page.locator(".msg.brain.is-deflected")).to_have_count(0) # Phase 119 (LOCKED A1): the turn read nothing, so ZERO citation # chips — the four suggested docs (all of them, top-5 NO floor on a # four-doc KB: the OLDER correct doc first, the NEWER similar one # second, then the two unrelated docs) seeded the prompt but never # chip (the retired phase-118 A4 union is gone). Their rank order # is pinned by the durable record below (LOCKED A3, untouched). expect(page.locator(".msg.brain .source-chip")).to_have_count(0) # No rank-6+ doc exists in this four-doc KB → the related row is # absent (the de-emphasized row renders only when it has entries). expect(page.locator(".msg.brain .related-docs")).to_have_count(0) # The button label is the settle sync: the client re-labels Send on # the done frame, and the server writes the query_log row just # before yielding it. expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) # Durable record: one row, grounded, the FULL retrieval (suggested # tier + related + read, deduped — here: all four docs) in rank # order (LOCKED A3). with SessionLocal() as db: row = db.scalars(select(QueryLog)).one() assert row.question == QUESTION assert row.deflected is False assert row.top_score >= 0.30 # the e2e mock-calibrated threshold assert (row.fts_hits or 0) >= 1 assert row.sources == ", ".join( f"{dates_tree.name}/{p}" for p in (RETENTION_MD, DRAFT_MD, FORWARD_MD, OLDDOC_MD) ), row.sources # --------------------------------------------------------------------------- # 5. The admin edit round-trips through the REAL UI + API and SURVIVES # a re-import (D1 manual flag); Revert to sync hands it back # --------------------------------------------------------------------------- def test_date_edit_and_sync_preserves( page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path ) -> None: """Edit date → Save → the badge re-renders from the RESPONSE (never the optimistic input) → the API round-trips; a re-import KEEPS the correction (the manual flag, D1) while the siblings refresh; ``Revert to sync`` drops the flag and the next import re-sources the date from the mtime (2019).""" _reset_db(mock_llm, dates_tree) page.set_default_timeout(30_000) login(page, app_url) source = dates_tree.name page.locator("#folders-tbody .folder-link").first.wait_for(state="visible") page.click(f'#folders-tbody a.folder-link:text-is("{source}")') page.click('#folders-tbody a.folder-link:text-is("legacy")') row = page.locator("#docs-tbody tr", has_text=OLDDOC_MD) expect(row).to_have_count(1) row.locator("td:nth-child(2) a.doc-link").click() expect(page.locator(".doc-modal")).to_be_visible() # --- SET: the admin-only editor (the phase-57 idiom in the shared # core — the modal is the surface here) --- edit = page.locator("#doc-modal .doc-date-edit") expect(edit).to_be_visible(timeout=15_000) edit.click() page.fill("#doc-modal .doc-date-input", "2021-05-05") page.click("#doc-modal .doc-date-save") # The live-region confirmation names the document… expect(page.locator("#doc-modal .doc-date-status")).to_have_text( f"Date saved for {source}/{OLDDOC_MD}." ) # …and the badge re-renders from the RESPONSE's created_at (the # server-normalized 2021-05-05T00:00:00+00:00 — never the input's # raw string). expect(page.locator("#doc-modal-meta .doc-created")) \ .to_have_attribute("title", EDITED_ISO) # The editor collapses back to the badge-row shape a beat later. expect(page.locator("#doc-modal .doc-date-editor")).to_have_count(0, timeout=8_000) # The API round-trips the correction (the admin cookie). cookies = _admin_cookies(page) docs = _docs_by_path(app_url, cookies) assert docs[OLDDOC_MD]["created_at"] == EDITED_ISO # --- RE-IMPORT (same tree, mtimes untouched): the manual row is # SKIPPED (D1/D4) while the siblings refresh. Exactly ONE date moves: # forward.md — its 2999 mtime re-normalizes to a FRESH `now` (full # precision) on every import, so it refreshes; retention/draft match # their stored mtime-sourced values bit for bit. --- summary = _run_in_thread(_import_tree(mock_llm, dates_tree)) assert summary.added == 0 and summary.errors == 0 assert summary.unchanged == 4 # a date-only refresh is still unchanged (D4) assert summary.dates_updated == 1 # forward.md only (the future-fold) docs = _docs_by_path(app_url, cookies) today = _today_utc() assert docs[OLDDOC_MD]["created_at"] == EDITED_ISO # the correction SURVIVES assert docs[RETENTION_MD]["created_at"] == RETENTION_ISO # refreshed, not stale assert docs[FORWARD_MD]["created_at"][:10] == today # still today (re-folded) # --- REVERT: the explicit clear (D7) — the flag drops; the stored # date stands until the next sync refreshes it --- page.click("#doc-modal .doc-date-edit") expect(page.locator("#doc-modal .doc-date-input")).to_have_value("2021-05-05") page.click("#doc-modal .doc-date-revert") expect(page.locator("#doc-modal .doc-date-status")).to_have_text( "Reverted to sync-managed date." ) expect(page.locator("#doc-modal .doc-date-editor")).to_have_count(0, timeout=8_000) # The NEXT import re-sources the date from the mtime — sync manages # it again (old-doc + forward = two date-only refreshes). summary = _run_in_thread(_import_tree(mock_llm, dates_tree)) assert summary.unchanged == 4 assert summary.dates_updated == 2 docs = _docs_by_path(app_url, cookies) assert docs[OLDDOC_MD]["created_at"] == OLDDOC_ISO # back to the 2019 mtime assert docs[RETENTION_MD]["created_at"] == RETENTION_ISO # --------------------------------------------------------------------------- # 6. Anonymous: the gate + the 403; admin: the editor's a11y # --------------------------------------------------------------------------- def test_anonymous_gate_and_editor_a11y( page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path ) -> None: """Anonymous: the RAG view shows the sign-in gate (no tables) and a raw ``PATCH /api/documents/date`` 403s (admin-only, D7) with the stored date untouched. Admin: the editor's accessible names, keyboard reachability, the role=status / role=alert live lines, and the badge's text+format pairing (never color alone — B5).""" _reset_db(mock_llm, dates_tree) page.set_default_timeout(30_000) source = dates_tree.name # --- Anonymous (a fresh context — no login) --- page.goto(f"{app_url}/sources.html") # The sign-in gate replaces the catalog… expect(page.locator("#sources-gate")).to_be_visible(timeout=15_000) # …and no tables render (the stat cards + both table wraps stay # hidden; no /api/docs/tree request is made at all — the phase-16 # gate). expect(page.locator("#folders-wrap")).to_be_hidden() expect(page.locator('div[role="region"][aria-label="Indexed documents"]')) \ .to_be_hidden() expect(page.locator("#docs-tbody tr")).to_have_count(0) # The raw PATCH is admin-gated: 403 "admin only" (no cookie) — and # the stored date is untouched. r = httpx.patch( f"{app_url}/api/documents/date", json={"source": source, "path": OLDDOC_MD, "date": "2024-01-01"}, timeout=10, ) assert r.status_code == 403 assert r.json() == {"detail": "admin only"} with SessionLocal() as db: doc = db.scalar( select(Document).where( Document.source == source, Document.path == OLDDOC_MD ) ) assert doc is not None assert doc.created_at.isoformat() == OLDDOC_ISO # untouched by the 403 assert doc.created_at_manual is False # --- Admin: the editor's a11y --- login(page, app_url) page.locator("#folders-tbody .folder-link").first.wait_for(state="visible") page.click(f'#folders-tbody a.folder-link:text-is("{source}")') page.click('#folders-tbody a.folder-link:text-is("legacy")') row = page.locator("#docs-tbody tr", has_text=OLDDOC_MD) expect(row).to_have_count(1) row.locator("td:nth-child(2) a.doc-link").click() expect(page.locator(".doc-modal")).to_be_visible() # The Edit date button's accessible name carries the document pair # (the aria-label — "Edit creation date: /"). edit = page.locator("#doc-modal .doc-date-edit") expect(edit).to_be_visible(timeout=15_000) assert f"{source}/{OLDDOC_MD}" in (edit.get_attribute("aria-label") or "") # Open: the date input is focused (the keyboard entry point — the # same state a real user's keyboard flow reaches after activating # the button) and carries its own accessible name. edit.click() date_input = page.locator("#doc-modal .doc-date-input") expect(date_input).to_be_visible() assert date_input.get_attribute("aria-label") == "Document creation date" assert page.evaluate( "() => document.activeElement" " === document.querySelector('#doc-modal .doc-date-input')" ) # Keyboard traversal runs through the editor's controls (the modal # focus trap — the visible focusable order is "Full page" → Close → # the content's controls, in DOM order): Tab from Save → Cancel, # Tab from Revert → the summary editor's button (phase 118, A2: the # markdown doc is summarized TOO, so the modal's summary-edit # button is visible and joins the trap after the date editor — it # did not exist in the focusable set when this pin was written, # which is why the wrap below is now reached from IT), and Tab from # the summary editor wraps to the panel's FIRST control, the # "Full page" link (the trap's edge behavior the pin originally # carried). (The CDP key dispatch of headless Chromium does NOT # perform the native focus move off itself — a # harness artifact, not a product defect: the same Tab works from # every text input and button in the editor, pinned here through # the buttons.) page.evaluate("() => document.querySelector('#doc-modal .doc-date-save').focus()") page.keyboard.press("Tab") assert page.evaluate( "() => !!document.activeElement" " && document.activeElement.classList.contains('doc-date-cancel')" ) page.evaluate("() => document.querySelector('#doc-modal .doc-date-revert').focus()") page.keyboard.press("Tab") assert page.evaluate( "() => !!document.activeElement" " && document.activeElement.classList.contains('doc-summary-edit')" ) page.evaluate("() => document.querySelector('#doc-modal .doc-summary-edit').focus()") page.keyboard.press("Tab") assert page.evaluate( "() => !!document.activeElement" " && document.activeElement.classList.contains('doc-modal-open')" ) # The live lines: role=status (polite) for confirmations, # role=alert (assertive) for the error path — both in the DOM # (the phase-57/89 surfaces). status = page.locator("#doc-modal .doc-date-status") expect(status).to_have_attribute("role", "status") expect(status).to_have_attribute("aria-live", "polite") error = page.locator("#doc-modal .doc-date-error") expect(error).to_have_count(1) expect(error).to_have_attribute("role", "alert") expect(error).to_have_attribute("aria-live", "assertive") # The badge's text+format pairing (B5 — never color alone): the # locale date in the TEXT plus the full ISO on the title. created = page.locator("#doc-modal-meta .doc-created") expect(created).to_have_text(re.compile(r"^Created .*2019")) assert created.get_attribute("title") == OLDDOC_ISO