264 lines
9.8 KiB
Python
264 lines
9.8 KiB
Python
"""Phase 2 tests: kirche CustomInput* library + intake fixture flow."""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from lfx.custom import Component
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
|
|
|
from kirche.form_inputs import CustomInputForm
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
|
|
|
FORM_FIELDS = [
|
|
"full_name",
|
|
"notes",
|
|
"password",
|
|
"age",
|
|
"amount",
|
|
"newsletter",
|
|
"department",
|
|
"topics",
|
|
"satisfaction",
|
|
"attachment",
|
|
]
|
|
|
|
|
|
def test_component_contract():
|
|
assert issubclass(CustomInputForm, Component)
|
|
assert CustomInputForm.display_name.startswith("CustomInput")
|
|
inputs = {i.name: i for i in CustomInputForm.inputs}
|
|
# Ten form fields plus the hidden run-envelope plumbing input.
|
|
assert sorted(n for n in inputs if n != "form_values") == sorted(FORM_FIELDS)
|
|
assert inputs["form_values"].show is False
|
|
outputs = {o.name: o for o in CustomInputForm.outputs}
|
|
assert set(outputs) == {"form_data"}
|
|
|
|
|
|
def test_collect_merges_submitted_values_over_defaults():
|
|
from lfx.schema.data import Data
|
|
|
|
form = CustomInputForm()
|
|
default = form.collect()
|
|
assert isinstance(default, Data)
|
|
assert default.data["full_name"] == ""
|
|
assert default.data["department"] == "Music"
|
|
|
|
form.form_values = json.dumps({"full_name": "Maria", "age": 41})
|
|
merged = form.collect().data
|
|
assert merged["full_name"] == "Maria"
|
|
assert merged["age"] == 41
|
|
assert merged["department"] == "Music" # default kept
|
|
|
|
# Run envelope shape {"payload": {...}} unwraps like the other components.
|
|
form.form_values = json.dumps({"payload": {"notes": "hi"}})
|
|
assert form.collect().data["notes"] == "hi"
|
|
|
|
form.form_values = "not json"
|
|
assert form.collect().data["full_name"] == ""
|
|
|
|
|
|
def test_collect_picks_up_langflow_added_field():
|
|
"""A field added in the Langflow code editor needs no collect() change."""
|
|
from lfx.io import StrInput
|
|
|
|
form = CustomInputForm()
|
|
form._inputs["room"] = StrInput(name="room", display_name="Room", info="Where", value="Chapel")
|
|
collected = form.collect().data
|
|
assert collected["room"] == "Chapel"
|
|
assert collected["full_name"] == ""
|
|
|
|
|
|
def test_collect_survives_rename():
|
|
"""Renaming `full_name` to `name` drops the old key, keeps the new one."""
|
|
from lfx.io import StrInput
|
|
|
|
form = CustomInputForm()
|
|
del form._inputs["full_name"]
|
|
form._inputs["name"] = StrInput(name="name", display_name="Name", info="", value="Maria")
|
|
collected = form.collect().data
|
|
assert "full_name" not in collected
|
|
assert collected["name"] == "Maria"
|
|
|
|
|
|
def test_collect_keeps_unknown_envelope_keys():
|
|
"""Frontend payloads with renamed keys merge even if undeclared here."""
|
|
form = CustomInputForm()
|
|
form.form_values = json.dumps({"payload": {"nickname": "Mia"}})
|
|
collected = form.collect().data
|
|
assert collected["nickname"] == "Mia"
|
|
assert collected["department"] == "Music" # default kept
|
|
|
|
|
|
def test_intake_fixture_shape():
|
|
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
|
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
|
|
form = nodes["CustomInputForm-intake"]
|
|
assert form["data"]["type"] == "CustomInputForm"
|
|
assert form["data"]["node"]["display_name"].startswith("CustomInput")
|
|
template = form["data"]["node"]["template"]
|
|
for field in FORM_FIELDS:
|
|
assert field in template, field
|
|
assert template["form_values"]["show"] is False
|
|
assert len(template["code"]["value"]) > 200
|
|
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
|
|
assert outputs["form_data"]["types"] == ["JSON"] # JSON object, not Message
|
|
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
|
|
assert ("ChatInput-entry", "CustomInputForm-intake") in edges
|
|
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
|
|
|
|
|
|
async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
|
|
await login()
|
|
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
|
created = await client.post("/api/flows", json={"name": "intake-e2e"})
|
|
assert created.status_code == 201, created.text
|
|
flow_id = created.json()["id"]
|
|
|
|
version = await client.post(
|
|
f"/api/flows/{flow_id}/versions",
|
|
json={"langflow_json": doc, "author": "test"},
|
|
)
|
|
assert version.status_code == 201, version.text
|
|
|
|
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
|
|
assert deployed.status_code == 200, deployed.text
|
|
|
|
run = await client.post(
|
|
f"/api/flows/{flow_id}/run",
|
|
json={"input": {"full_name": "Maria Muster", "age": 41}},
|
|
)
|
|
assert run.status_code == 200, run.text
|
|
assert run.json()["status"] == "completed"
|
|
# collect() returns Data: the terminal ChatOutput renders it as a
|
|
# fenced ```json block, so unwrap before parsing.
|
|
result = _parse_chat_json(run.json()["output"]["result"])
|
|
assert result["full_name"] == "Maria Muster"
|
|
assert result["age"] == 41
|
|
assert result["department"] == "Music" # default preserved
|
|
|
|
|
|
def _parse_chat_json(text: str) -> dict:
|
|
"""Parse terminal ChatOutput text: raw JSON or a ```json fenced block."""
|
|
text = text.strip()
|
|
if text.startswith("```"):
|
|
lines = text.splitlines()
|
|
assert lines[0].startswith("```"), text[:60]
|
|
assert lines[-1].startswith("```"), text[-60:]
|
|
text = "\n".join(lines[1:-1])
|
|
return json.loads(text)
|
|
|
|
|
|
def test_bauvorhaben_fixture_shape():
|
|
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
|
|
assert doc["name"] == "bauvorhaben"
|
|
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
|
|
form = nodes["CustomInputForm-bauvorhaben"]
|
|
assert form["data"]["type"] == "CustomInputForm"
|
|
template = form["data"]["node"]["template"]
|
|
assert set(template) >= {
|
|
"form_values",
|
|
"gebaeude",
|
|
"bestandteil",
|
|
"glockenmassnahme",
|
|
"gussjahr",
|
|
"gebaeudeart",
|
|
"denkmalschutz",
|
|
"denkmalnummer",
|
|
"code",
|
|
}
|
|
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
|
|
assert outputs["form_data"]["types"] == ["JSON"]
|
|
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
|
|
assert ("ChatInput-entry", "CustomInputForm-bauvorhaben") in edges
|
|
assert ("CustomInputForm-bauvorhaben", "ChatOutput-collected") in edges
|
|
|
|
|
|
def test_bauvorhaben_schema_branches():
|
|
from app.flows import input_schema as parser
|
|
|
|
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
|
|
out = parser.extract_form_schema(doc)
|
|
schema = out["json_schema"]
|
|
assert set(schema["properties"]) == {"gebaeude", "denkmalschutz"}
|
|
assert schema["required"] == ["gebaeude"]
|
|
assert len(schema["allOf"]) == 5
|
|
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
|
|
assert set(by_field) == {
|
|
"bestandteil",
|
|
"glockenmassnahme",
|
|
"gussjahr",
|
|
"gebaeudeart",
|
|
"denkmalnummer",
|
|
}
|
|
assert by_field["bestandteil"]["if"]["properties"] == {"gebaeude": {"const": "Kirche"}}
|
|
assert by_field["glockenmassnahme"]["if"]["properties"] == {
|
|
"bestandteil": {"contains": {"const": "Glocke"}}
|
|
}
|
|
assert by_field["gussjahr"]["if"]["properties"] == {"glockenmassnahme": {"const": "Neuguss"}}
|
|
assert by_field["gebaeudeart"]["if"]["properties"] == {"gebaeude": {"const": "Anderes Gebäude"}}
|
|
assert by_field["denkmalnummer"]["if"]["properties"] == {"denkmalschutz": {"const": True}}
|
|
assert by_field["bestandteil"]["then"]["required"] == ["bestandteil"]
|
|
assert by_field["denkmalnummer"]["then"]["required"] == ["denkmalnummer"]
|
|
assert "required" not in by_field["gussjahr"]["then"]
|
|
# Markers stripped from descriptions.
|
|
assert "visible_if" not in out["json_schema"]["allOf"][0]["then"]["properties"][
|
|
"bestandteil"
|
|
].get("description", "")
|
|
assert out["ui_schema"]["ui:order"] == [
|
|
"gebaeude",
|
|
"bestandteil",
|
|
"glockenmassnahme",
|
|
"gussjahr",
|
|
"gebaeudeart",
|
|
"denkmalschutz",
|
|
"denkmalnummer",
|
|
"*",
|
|
]
|
|
|
|
|
|
async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login):
|
|
await login()
|
|
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
|
|
created = await client.post("/api/flows", json={"name": "bauvorhaben-e2e"})
|
|
assert created.status_code == 201, created.text
|
|
flow_id = created.json()["id"]
|
|
|
|
version = await client.post(
|
|
f"/api/flows/{flow_id}/versions",
|
|
json={"langflow_json": doc, "author": "test"},
|
|
)
|
|
assert version.status_code == 201, version.text
|
|
# Branch rules are cached per version and served via input-schema.
|
|
cached = version.json()["input_schema"]
|
|
assert len(cached["json_schema"]["allOf"]) == 5
|
|
|
|
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
|
|
assert deployed.status_code == 200, deployed.text
|
|
schema = await client.get(f"/api/flows/{flow_id}/input-schema")
|
|
assert schema.status_code == 200, schema.text
|
|
assert set(schema.json()["json_schema"]["properties"]) == {"gebaeude", "denkmalschutz"}
|
|
|
|
run = await client.post(
|
|
f"/api/flows/{flow_id}/run",
|
|
json={
|
|
"input": {
|
|
"gebaeude": "Kirche",
|
|
"bestandteil": ["Glocke", "Orgel"],
|
|
"glockenmassnahme": "Neuguss",
|
|
"gussjahr": 1890,
|
|
"denkmalschutz": True,
|
|
"denkmalnummer": "D-123",
|
|
}
|
|
},
|
|
)
|
|
assert run.status_code == 200, run.text
|
|
assert run.json()["status"] == "completed"
|
|
result = _parse_chat_json(run.json()["output"]["result"])
|
|
assert result["gebaeude"] == "Kirche"
|
|
assert result["bestandteil"] == ["Glocke", "Orgel"]
|
|
assert result["gussjahr"] == 1890
|
|
assert result["denkmalnummer"] == "D-123"
|