189 lines
5.9 KiB
Python
189 lines
5.9 KiB
Python
"""Shared fixtures for the corpus tests: a throwaway content tree and a fake Qdrant."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
from unittest import mock
|
||
|
||
from corpus.config import Config
|
||
from corpus.tests.fake_qdrant import FakeQdrant
|
||
|
||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
DOC = """---
|
||
id: {id}
|
||
area: {area}
|
||
level: {level}
|
||
title: "{title}"
|
||
claim: "{claim}"
|
||
confidence: {confidence}
|
||
period: "2023–"
|
||
tech: [Go, SQLite]
|
||
metrics: [{{label: endpoints, value: 8}}]
|
||
artefacts: []
|
||
public: true
|
||
last_verified: {last_verified}
|
||
tags: [{tags}]
|
||
---
|
||
|
||
# {title}
|
||
|
||
{body}
|
||
"""
|
||
|
||
FENCE = """A paragraph before the code block explains the constraint.
|
||
|
||
```go
|
||
package main
|
||
|
||
func main() {{
|
||
println("chunk boundary must never land inside this fence")
|
||
}}
|
||
```
|
||
|
||
A paragraph after the code block.
|
||
"""
|
||
|
||
TABLE = """Measured results, one row per endpoint:
|
||
|
||
| endpoint | latency | loss |
|
||
|---|---|---|
|
||
| alpha | 12ms | 0.0% |
|
||
| beta | 14ms | 0.1% |
|
||
| gamma | 9ms | 0.0% |
|
||
|
||
And a closing paragraph.
|
||
"""
|
||
|
||
PROSE = "\n\n".join(
|
||
f"Paragraph {index} describes a delivery constraint in corporate language, "
|
||
"with enough words that the chunker has to think about where to break, and "
|
||
"never inside a fenced block, a table or a front-matter block."
|
||
for index in range(1, 40)
|
||
)
|
||
|
||
DEFAULT_DOCS = {
|
||
"ai/ai-overview.md": dict(
|
||
id="ai-overview", area="ai", level=1, title="Agent Systems",
|
||
claim="Harnesses, memory and routing.", confidence="running",
|
||
last_verified="2026-10-01", tags="agents, llm", body=PROSE,
|
||
),
|
||
"edge/l3/edge-zigbee.md": dict(
|
||
id="edge-zigbee", area="edge", level=3, title="Low Power Mesh Sensing",
|
||
claim="A constrained mesh that survives power loss.", confidence="delivered",
|
||
last_verified="2026-09-15", tags="zigbee, mesh", body=FENCE,
|
||
),
|
||
"platform/l3/platform-containers.md": dict(
|
||
id="platform-containers", area="platform", level=3, title="Container Estate",
|
||
claim="Fifty containers with measured resource limits.", confidence="in-progress",
|
||
last_verified="2026-08-01", tags="docker", body=TABLE,
|
||
),
|
||
}
|
||
|
||
|
||
def temp_repo(docs: dict = None) -> str:
|
||
"""Create a throwaway repository root with tools/ and content/."""
|
||
root = tempfile.mkdtemp(prefix="corpus-test-")
|
||
os.makedirs(os.path.join(root, "tools"), exist_ok=True)
|
||
shutil.copy(
|
||
os.path.join(REPO_ROOT, "tools", "validate_content.py"),
|
||
os.path.join(root, "tools", "validate_content.py"),
|
||
)
|
||
write_docs(root, docs or DEFAULT_DOCS)
|
||
return root
|
||
|
||
|
||
def write_docs(root: str, docs: dict) -> None:
|
||
for rel, fields in docs.items():
|
||
path = os.path.join(root, "content", rel)
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
with open(path, "w", encoding="utf-8") as handle:
|
||
handle.write(DOC.format(**fields))
|
||
|
||
|
||
def write_doc(root: str, rel: str, **fields) -> None:
|
||
path = os.path.join(root, "content", rel)
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
with open(path, "w", encoding="utf-8") as handle:
|
||
handle.write(DOC.format(**fields))
|
||
# A fresh interpreter's import cache is fine; the loader re-reads the file.
|
||
|
||
|
||
def make_config(root: str, **overrides) -> Config:
|
||
values = dict(
|
||
provider="local-hashing",
|
||
model="local-hashing-v1",
|
||
dim=32,
|
||
qdrant_url="fake://qdrant",
|
||
collection="test_corpus",
|
||
graph_collection="test_graph",
|
||
offline=True,
|
||
)
|
||
values.update(overrides)
|
||
return Config.from_env(root=root, **values)
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def fake_qdrant(cfg, fake: FakeQdrant = None):
|
||
"""Point every module that builds a Qdrant client at one in-memory fake."""
|
||
fake = fake or FakeQdrant(cfg.dim)
|
||
with mock.patch("corpus.pipeline.Qdrant", return_value=fake), mock.patch(
|
||
"corpus.freshness.Qdrant", return_value=fake
|
||
):
|
||
yield fake
|
||
|
||
|
||
class StubServer:
|
||
"""A throwaway HTTP server on a loopback ephemeral port, for provider tests.
|
||
|
||
`responder(method, path, body)` returns `(status, payload)`; every request is
|
||
recorded with its parsed body so a test can assert the wire shape without a
|
||
real provider or a real Qdrant.
|
||
"""
|
||
|
||
def __init__(self, responder):
|
||
import json
|
||
import threading
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
||
self.requests = []
|
||
self._responder = responder
|
||
outer = self
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
def _handle(self):
|
||
length = int(self.headers.get("Content-Length") or 0)
|
||
raw = self.rfile.read(length).decode("utf-8") if length else ""
|
||
body = json.loads(raw) if raw.strip() else None
|
||
outer.requests.append(
|
||
{"method": self.command, "path": self.path, "body": body,
|
||
"headers": dict(self.headers)}
|
||
)
|
||
status, payload = outer._responder(self.command, self.path, body)
|
||
data = payload.encode("utf-8") if isinstance(payload, str) else json.dumps(payload).encode("utf-8")
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.send_header("Content-Length", str(len(data)))
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
|
||
do_GET = do_POST = do_PUT = do_DELETE = _handle
|
||
|
||
def log_message(self, *_args):
|
||
pass
|
||
|
||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||
self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
|
||
self._thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||
|
||
def __enter__(self):
|
||
self._thread.start()
|
||
return self
|
||
|
||
def __exit__(self, *_exc):
|
||
self.server.shutdown()
|
||
self.server.server_close()
|