6de61b965e
A new `template` value source evaluates a hardened, sandboxed Jinja expression over the live values of other value sources — the system's first float combinator. Backend: - Shared engine (utils/template_expr.py): ImmutableSandboxedEnvironment with filters/tests and auto-injected globals stripped; only min/max/abs/round/ clamp exposed; rejects **, string/collection-literal repetition, attribute access and non-global calls; NaN/inf-safe result coercion. - TemplateValueSource model + TemplateValueStream runtime: compile-once, primitives-only eval context, raw[name] exposure, eval_interval throttle, ref-counted input acquire/release, rename-safe hot-update. - Validation: unbound-variable + reserved-name rejection, reference cycle/depth guards (depth-only at create, full cycle at update), runtime acquire() depth backstop, and delete referential-integrity. - API: Create/Update/Response schemas + discriminated unions, _RESPONSE_MAP, and an advisory POST /value-sources/validate-template endpoint. - Demo seed: a static source plus a template combinator example. Frontend: - Editor modal section: repeatable inputs list (EntitySelect rows), a zero-dependency Jinja syntax highlighter, a hints/reference panel, and a debounced live validator that gates Save (stale-response-safe). - Graph editor: read-only template node with one edge per input. - i18n (en/ru/zh), icon, and card rendering. Tests: engine, stream, factory/cycle, validate endpoint, and demo seed.
183 lines
6.0 KiB
Python
183 lines
6.0 KiB
Python
"""Tests for template value source API: CRUD, validate-template, delete-protection."""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from ledgrab.api import dependencies as deps
|
|
from ledgrab.api.routes.value_sources import router
|
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
|
|
|
|
|
@pytest.fixture
|
|
def _route_db(tmp_path):
|
|
from ledgrab.storage.database import Database
|
|
|
|
db = Database(tmp_path / "test.db")
|
|
yield db
|
|
db.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def store(_route_db):
|
|
return ValueSourceStore(_route_db)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(store):
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
app.dependency_overrides[verify_api_key] = lambda: "test-user"
|
|
app.dependency_overrides[deps.get_value_source_store] = lambda: store
|
|
app.dependency_overrides[deps.get_processor_manager] = lambda: MagicMock()
|
|
app.dependency_overrides[deps.get_output_target_store] = lambda: MagicMock(
|
|
get_all_targets=lambda: []
|
|
)
|
|
|
|
deps._deps["processor_manager"] = MagicMock()
|
|
return TestClient(app, raise_server_exceptions=False)
|
|
|
|
|
|
def _create(client, **over):
|
|
body = {
|
|
"source_type": "template",
|
|
"name": "Combo",
|
|
"template": "min(a * 2, 1)",
|
|
"inputs": [{"name": "a", "value_source_id": ""}],
|
|
"default_value": 0.2,
|
|
}
|
|
body.update(over)
|
|
return client.post("/api/v1/value-sources", json=body)
|
|
|
|
|
|
class TestCRUD:
|
|
def test_create_get_list_roundtrip(self, client):
|
|
r = _create(client)
|
|
assert r.status_code == 201, r.text
|
|
body = r.json()
|
|
assert body["source_type"] == "template"
|
|
assert body["return_type"] == "float"
|
|
assert body["template"] == "min(a * 2, 1)"
|
|
assert body["inputs"] == [{"name": "a", "value_source_id": ""}]
|
|
assert body["default_value"] == 0.2
|
|
sid = body["id"]
|
|
|
|
got = client.get(f"/api/v1/value-sources/{sid}").json()
|
|
assert got["template"] == "min(a * 2, 1)"
|
|
|
|
lst = client.get("/api/v1/value-sources").json()
|
|
assert any(s["id"] == sid and s["source_type"] == "template" for s in lst["sources"])
|
|
|
|
def test_update(self, client):
|
|
sid = _create(client).json()["id"]
|
|
r = client.put(
|
|
f"/api/v1/value-sources/{sid}",
|
|
json={"source_type": "template", "template": "clamp(a * 3)"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["template"] == "clamp(a * 3)"
|
|
|
|
def test_create_compile_error_returns_400(self, client):
|
|
r = _create(client, template="a +")
|
|
assert r.status_code == 400
|
|
|
|
def test_create_reserved_name_returns_400(self, client):
|
|
r = _create(client, inputs=[{"name": "min", "value_source_id": ""}])
|
|
assert r.status_code == 400
|
|
|
|
|
|
class TestDeleteProtection:
|
|
def test_delete_blocked_when_referenced(self, client):
|
|
base = client.post(
|
|
"/api/v1/value-sources",
|
|
json={"source_type": "static", "name": "Base", "value": 0.5},
|
|
).json()
|
|
_create(
|
|
client,
|
|
name="Uses",
|
|
template="b",
|
|
inputs=[{"name": "b", "value_source_id": base["id"]}],
|
|
)
|
|
r = client.delete(f"/api/v1/value-sources/{base['id']}")
|
|
assert r.status_code == 400
|
|
assert "referenced by" in r.json()["detail"]
|
|
|
|
|
|
class TestValidateEndpoint:
|
|
def _validate(self, client, **body):
|
|
return client.post("/api/v1/value-sources/validate-template", json=body)
|
|
|
|
def test_valid_expression(self, client):
|
|
r = self._validate(
|
|
client,
|
|
template="min(a, b)",
|
|
inputs=[{"name": "a", "value_source_id": ""}, {"name": "b", "value_source_id": ""}],
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["valid"] is True
|
|
assert set(data["variables"]) == {"a", "b"}
|
|
|
|
def test_compile_error(self, client):
|
|
r = self._validate(client, template="a +", inputs=[])
|
|
data = r.json()
|
|
assert data["valid"] is False
|
|
assert data["error"]
|
|
|
|
def test_reserved_name(self, client):
|
|
r = self._validate(
|
|
client, template="min(0,1)", inputs=[{"name": "raw", "value_source_id": ""}]
|
|
)
|
|
assert r.json()["valid"] is False
|
|
|
|
def test_missing_input_is_warning_not_error(self, client):
|
|
r = self._validate(
|
|
client, template="a", inputs=[{"name": "a", "value_source_id": "vs_nope"}]
|
|
)
|
|
data = r.json()
|
|
assert data["valid"] is True
|
|
assert data["warnings"]
|
|
|
|
def test_unbound_variable_is_error(self, client):
|
|
# Typo: expression uses 'ha_enti' but the input is named 'ha_entity'.
|
|
r = self._validate(
|
|
client, template="ha_enti", inputs=[{"name": "ha_entity", "value_source_id": ""}]
|
|
)
|
|
data = r.json()
|
|
assert data["valid"] is False
|
|
assert any("unbound" in e for e in data["errors"])
|
|
|
|
def test_cycle_detected_with_id(self, client):
|
|
t1 = _create(client, name="T1", template="clamp(0.5)", inputs=[]).json()
|
|
t2 = _create(
|
|
client,
|
|
name="T2",
|
|
template="x",
|
|
inputs=[{"name": "x", "value_source_id": t1["id"]}],
|
|
).json()
|
|
# Editing t1 to point at t2 would close a cycle.
|
|
r = self._validate(
|
|
client, template="x", inputs=[{"name": "x", "value_source_id": t2["id"]}], id=t1["id"]
|
|
)
|
|
assert r.json()["valid"] is False
|
|
|
|
|
|
class TestResponseMapCoverage:
|
|
def test_template_in_response_map(self):
|
|
from ledgrab.api.routes.value_sources import _RESPONSE_MAP
|
|
from ledgrab.storage.value_source import TemplateValueSource
|
|
|
|
assert TemplateValueSource in _RESPONSE_MAP
|
|
|
|
def test_template_in_all_unions(self):
|
|
from ledgrab.api.schemas import value_sources as sch
|
|
|
|
for union_name in ("ValueSourceResponse", "ValueSourceCreate", "ValueSourceUpdate"):
|
|
src = repr(getattr(sch, union_name))
|
|
assert "template" in src.lower() or "Template" in src
|