feat(test-runner): lazy catalog — resolve nested @{uuid} refs

Wire walk_transitive_refs into _build_ctx_with_catalog so the SPA Test
Runner populates __wp_catalog__ from the live DB before resolution. Also
fixes _resolve_ref to support SnapshotEntry payload.options shape alongside
the legacy flat options shape, and updates db_session to accept an injected
connection from request.app["wp_db"] for test isolation.
This commit is contained in:
DumiFlex
2026-04-28 13:23:03 +03:00
parent 339a3f38f2
commit 0f6fe72182
5 changed files with 175 additions and 19 deletions
+8 -2
View File
@@ -297,8 +297,14 @@ def _resolve_ref(
)
return ""
# Pick weighted option
options = module.get("options", [])
# Pick weighted option.
# Support both SnapshotEntry shape (payload.options, spec §2.4) and the
# legacy flat shape used by existing unit tests (options at top level).
payload_dict = module.get("payload")
if isinstance(payload_dict, dict):
options = payload_dict.get("options", [])
else:
options = module.get("options", [])
chosen = _pick_weighted(options, ctx.rng)
if chosen is None:
return ""
+7 -5
View File
@@ -13,9 +13,10 @@ async def test_run_wildcard(wp_client):
})
assert resp.status == 200
body = await resp.json()
assert len(body["results"]) == 5
assert all(r == {"$x": "alpha"} for r in body["results"])
assert body["histogram"] == {"alpha": 5}
data = body["data"]
assert len(data["results"]) == 5
assert all(r == {"$x": "alpha"} for r in data["results"])
assert data["histogram"] == {"alpha": 5}
async def test_run_fixed_values(wp_client):
@@ -27,8 +28,9 @@ async def test_run_fixed_values(wp_client):
})
assert resp.status == 200
body = await resp.json()
assert body["results"][0] == {"$lens": "85mm"}
assert body["histogram"] == {"85mm": 1}
data = body["data"]
assert data["results"][0] == {"$lens": "85mm"}
assert data["histogram"] == {"85mm": 1}
async def test_run_unknown_type_400(wp_client):
+92
View File
@@ -0,0 +1,92 @@
"""End-to-end Test Runner tests. Spec §6 / 5.5.1.
These run the real aiohttp handler against a real in-memory DB. After
this task, posting a wildcard whose option references @{B_uuid} resolves
B and produces the expanded value, instead of leaking raw @{B} text."""
from __future__ import annotations
import sqlite3
import pytest
from aiohttp import web
from engine.db.migrations import migrate
from engine.db.repositories import ModuleRepository
from wp_api import test_runner as test_runner_mod
@pytest.fixture
def app_with_db(tmp_path):
"""Build an aiohttp app with a freshly-migrated in-memory DB and the
test_runner endpoint wired up."""
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
migrate(conn)
app = web.Application()
app["wp_db"] = conn
app.router.add_post("/wp/api/test", test_runner_mod.run_test)
return app, conn
async def test_nested_wildcard_ref_resolves_via_catalog(aiohttp_client, app_with_db):
"""Library has wildcards 'color' (UUID c0000001) and 'outfit' (UUID
o0000001) where outfit.options[0].value = "@{c0000001} dress". Test
Runner must load color into the catalog and resolve the ref so the
sample is "red dress" or "blue dress", never raw "@{c0000001} dress"."""
app, conn = app_with_db
repo = ModuleRepository(conn)
color = repo.create(
type="wildcard", name="color", description="",
category_id=None, tags=[],
payload={"options": [
{"value": "red", "weight": 1},
{"value": "blue", "weight": 1},
]},
)
outfit_payload = {
"options": [
{"value": f"@{{{color['uuid']}}} dress", "weight": 1},
],
}
repo.create(
type="wildcard", name="outfit", description="",
category_id=None, tags=[], payload=outfit_payload,
)
client = await aiohttp_client(app)
resp = await client.post("/wp/api/test", json={
"type": "wildcard",
"payload": outfit_payload,
"instance": {"variable_binding": "outfit"},
"samples": 5,
})
assert resp.status == 200
body = await resp.json()
samples = body["data"]["samples"]
assert len(samples) == 5
for s in samples:
assert s in ("red dress", "blue dress"), (
f"@{{{color['uuid']}}} did not resolve — got raw text {s!r}"
)
async def test_unknown_ref_emits_warning_does_not_crash(aiohttp_client, app_with_db):
"""A missing target produces a graceful warning + raw token in the
output (lenient mode) — Test Runner never strict-raises."""
app, conn = app_with_db
payload = {
"options": [{"value": "@{deadbeef} thing", "weight": 1}],
}
client = await aiohttp_client(app)
resp = await client.post("/wp/api/test", json={
"type": "wildcard",
"payload": payload,
"instance": {"variable_binding": "x"},
"samples": 1,
})
assert resp.status == 200
body = await resp.json()
warnings = body["data"].get("warnings", [])
assert any("deadbeef" in str(w) for w in warnings), (
f"expected 'Unknown wildcard ref' warning for deadbeef, got: {warnings}"
)
+12 -2
View File
@@ -12,11 +12,21 @@ from engine.syntax.types import TokenKind
@contextmanager
def db_session(_request: web.Request):
"""Yield a fresh SQLite connection that is auto-closed on exit.
def db_session(request: web.Request):
"""Yield a SQLite connection that is auto-closed on exit.
If the aiohttp app has a ``wp_db`` key (injected by tests or the
application startup), that connection is yielded directly and is NOT
closed on exit (lifetime owned by the injector). Otherwise, a fresh
file-based connection is opened via ``get_connection()`` and closed
when the context manager exits.
SQLite WAL mode + per-request connections avoid cross-task locking.
"""
injected = request.app.get("wp_db") if hasattr(request, "app") else None
if injected is not None:
yield injected
return
conn = get_connection()
try:
yield conn
+56 -10
View File
@@ -6,23 +6,41 @@ from collections import Counter
from aiohttp import web
from engine.db.repositories import ModuleRepository
from engine.modules.dispatcher import UnknownModuleType, resolve_module
from wp_api._helpers import json_error, json_ok
from engine.modules.snapshot import walk_transitive_refs
from wp_api._helpers import db_session, extract_referenced_uuids, json_error, json_ok
_MAX_SAMPLES = 10000
def _make_test_ctx() -> dict:
"""Build a minimal pipeline ctx for the test-runner endpoint.
def _build_ctx_with_catalog(request: web.Request, body: dict) -> dict:
"""Build the runtime ctx for one Test Runner invocation.
Walks the request payload + instance for `@{8hex}` refs, loads only
those modules (and their transitive deps) from the DB, and seeds the
catalog. Spec §2.10 — lazy walk, never the full library."""
roots = extract_referenced_uuids(body.get("payload"))
roots |= extract_referenced_uuids(body.get("instance"))
catalog: dict[str, dict] = {}
if roots:
with db_session(request) as conn:
repo = ModuleRepository(conn)
def _fetch(uuid: str) -> dict | None:
try:
return repo.get_by_uuid(uuid)
except Exception:
return None
walk = walk_transitive_refs(roots, fetch_module=_fetch)
catalog = walk.snapshots
Uses a fresh random.Random (seeded from os entropy) so each /wp/api/test
request is independently sampled. The endpoint never needs a fixed seed —
it samples N times and returns the distribution.
"""
return {
"__wp_rng__": random.Random(),
"__wp_warnings__": [],
"__wp_catalog__": {},
"__wp_catalog__": catalog,
}
@@ -52,9 +70,12 @@ async def run_test(request: web.Request) -> web.Response:
"instance": body["instance"],
}
results: list[dict[str, str]] = []
all_warnings: list[dict] = []
try:
for _ in range(samples):
results.append(resolve_module(snap, ctx=_make_test_ctx()))
ctx = _build_ctx_with_catalog(request, body)
results.append(resolve_module(snap, ctx=ctx))
all_warnings.extend(ctx["__wp_warnings__"])
except UnknownModuleType as e:
return json_error(f"unknown module type: {e.args[0]!r}", status=400)
@@ -63,7 +84,32 @@ async def run_test(request: web.Request) -> web.Response:
for v in r.values():
histogram[v] += 1
return json_ok({"results": results, "histogram": dict(histogram)})
# Deduplicate warnings by (type, message) to avoid N*samples noise.
seen: set[tuple] = set()
deduped_warnings: list[dict] = []
for w in all_warnings:
key = (w.get("type"), w.get("message"))
if key not in seen:
seen.add(key)
deduped_warnings.append(w)
# Extract flat sample values for the SPA Test Runner display. Each
# resolve_module call returns {binding: value}; we surface the values.
sample_values: list[str] = []
for r in results:
for v in r.values():
sample_values.append(v)
if not r:
sample_values.append("")
return json_ok({
"data": {
"samples": sample_values,
"warnings": deduped_warnings,
"histogram": dict(histogram),
"results": results,
},
})
def register(router) -> None: