Files
brain-of-reese/tests/fakes.py
T
ducoterra 6cf1df9bf2 feat(sync): fail fast with a modal when a model is unavailable
TODO.md L4: with a dead model endpoint the sync discovered it only
mid-import, after slow clones — and a tooltip on the button is not a
readable error.

- app/rag/llm.py: ModelUnavailableError + check_models(llm) — a tiny
  pre-sync probe (one short embedding + one 1-token-scale completion)
  that fails naming the unavailable model (embed first, then the
  summary model); the sync sanitizer still masks credentials.
- app/api/sync.py: the probe is step 1 of _run_sync — before source
  resolution and before any clone_or_pull; a model failure is just
  another 'failed' state (no new endpoint, A10/A12 untouched).
- frontend/assets/header.js: applySyncFailure now also opens the
  module-owned error modal (every page carrying #sync-btn, zero
  page-markup changes): lazily built backdrop + role=alertdialog
  panel, error text via textContent, close via button / Esc /
  backdrop, focus in-and-out to #sync-btn (with a body→#sync-btn
  fallback — the run's disabled button drops focus to <body>).
- frontend/assets/styles.css: the modal on the phase-08 error palette
  (z-index above the header, .is-open open/close, reduced-motion
  stilling, 44px close target).
- Tests: probe unit tests (both up / embed down / summary down /
  custom model names), sync integration (fail-fast before any clone,
  probe-before-effective_sources ordering, credential masking,
  healthy regression), the phase-41 source pins, and the story E2E
  (two module apps on distinct ports — dead endpoint on a closed
  loopback port vs session mock: ≤10 s fail-fast + modal contract,
  all three dismissal paths with focus out to #sync-btn, button
  title/.is-error + Sources banner untouched, healthy phase-32
  lifecycle regression to 'Synced HH:MM').

E2E (isolation): test_sync_model_down.py 4/4, test_sync_button.py
3/3, test_git_sources_admin.py 6/6, test_local_directory_sources.py
3/3; unit+integration 721 passed, app/ coverage 99%; ruff + pyright
clean.
2026-08-27 23:44:35 -04:00

47 lines
1.9 KiB
Python

"""Shared test fakes (no network, deterministic)."""
from __future__ import annotations
from app.config import Settings
from app.rag.llm import LLMError
class FakeEmbedder:
"""Duck-typed stand-in for :class:`app.rag.llm.LLMClient` (see the
``Embedder`` protocol in :mod:`app.rag.importer`).
Returns deterministic vectors of *dim* dimensions; records every call
so tests can assert batching behaviour. ``chat`` is the deterministic
``lite``-model stand-in (phase 30): it returns
``"Summary of <first token of the user content>"`` and raises
:class:`LLMError` when the content contains the sentinel word
``SUMMARY-BLOWUP`` (drives the importer's fail-soft summary path).
"""
def __init__(self, dim: int = 768) -> None:
self.dim = dim
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
self.embed_batches = 0
self.calls: list[list[str]] = []
self.chat_calls: list[list[dict[str, str]]] = []
async def embed(self, texts: list[str]) -> list[list[float]]:
self.calls.append(list(texts))
self.embed_batches += 1
return [[0.01 * (i % 97) for i in range(self.dim)] for _ in texts]
async def embed_one(self, text: str) -> list[float]:
"""The retrieval/probe convenience path — delegates to :meth:`embed`
(satisfies the phase-41 pre-sync model probe)."""
(vec,) = await self.embed([text])
return vec
async def chat(
self, messages: list[dict[str, str]], model: str | None = None
) -> str:
self.chat_calls.append(list(messages))
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
if "SUMMARY-BLOWUP" in user:
raise LLMError("simulated lite-model failure (SUMMARY-BLOWUP sentinel)")
first = user.split()
return "Summary of " + (first[0] if first else "<empty>")