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:
@@ -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 ""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user