All verification complete. Final report: **Phase 119 final verification pass — all criteria verified, one stale pin fixed.** - Verified implementation of all 6 tasks: D1 component name-hit rule (`name_hit` flag, titles never matched, retired length tie-break), D2 `BOR_NAME_HIT_BONUS` (0.005 default, 0 = byte-identical kill switch, negative fails startup, selection-layer only, `eval_retrieval` `suggested:` line), D3 suggested-folder lines (after `SUGGEST_INTRO`, before first block), D4 cite-discipline `SUGGEST_INTRO` sentence (PERSONA/LOW/`TOOLS_SECTION` byte-pins intact), D5 `done.sources` = read docs only (frontend no-op on empty confirmed), D6 mock `repeat your folder map` echo + new suite + telemetry. - Battery (replica restored per skill, fingerprint docs=1000/chunks=8866 verified, `eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` re-run): **GATE PASS** — gitea README #4 in suggested top-5, forgejo 5/5 (README #1), gateway README in top-5 (#4), qwen3.8-27b quadlets top-5, Mongolia HIGH/fts=5 unchanged. - New E2E in isolation: `4 passed` ×2 (deterministic). All 27 modified E2E suites in isolation: 26 green; **1 stale pin fixed** — `test_source_chip_quality.py` durable-record order pin pre-dated the D1 re-rank (`aliases` stem sub-component name-hits `ssh_aliases.txt`, deterministically lifting `backups.md` over `kubernetes.md`; probe-verified 0.016277 vs 0.016036, 4/4 stable) — re-pinned with the phase-119 rationale; suite green ×2. - Gates: `uv run pytest --cov=app --cov-report=term-missing` → **2547 passed, app coverage 99%** (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. - Completion criteria: 1 ✅ (battery, recorded), 2 ✅ (folder lines; block/LOW byte-identical pins green), 3 ✅ (read-only chips, zero-read chips nothing, related row + durable record untouched — unit+E2E agree), 4 ✅ (all green), 5 → commit/phase-move left to the harness per pass rules (nothing committed). - Deviations: battery output + real-model telemetry recorded in `.agents/reports/119_name_signal_read_chips/task06_battery_and_e2e.md` and `TOOL_CALLING_TESTING.md` §11 (task files in `complete/` are immutable to this pass); gateway canonical doc at #4 vs overview's #3 was already documented at task 06 (containment gate met). - Next pending phase: **none** — `todo/` holds only phase 119.
808 lines
37 KiB
Python
808 lines
37 KiB
Python
"""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 <th> sequence.
|
||
# text_content() (NOT inner_text()): the rendered <th>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 <locale date>" (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: <source>/<path>").
|
||
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 <input type="date"> 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
|