feat: asked-field envelope, standalone form components, branched forms

This commit is contained in:
Jonas Miederer 2026-09-28 14:07:09 +02:00
parent 0d43b0e8fb
commit f27be044b4
26 changed files with 3651 additions and 104 deletions

View file

@ -93,6 +93,121 @@ def test_collect_keeps_unknown_envelope_keys():
assert collected["department"] == "Music" # default kept
def test_collect_fields_list_restricts_defaults():
"""Asked-field list: unasked code defaults stay out of the output."""
form = CustomInputForm()
form.form_values = json.dumps(
{"payload": {"full_name": "Maria"}, "fields": ["full_name", "age"]}
)
collected = form.collect().data
assert collected == {"full_name": "Maria", "age": 18}
def test_collect_fields_list_fills_missing_from_defaults():
form = CustomInputForm()
form.form_values = json.dumps(
{"payload": {"full_name": "Maria"}, "fields": ["full_name", "department"]}
)
collected = form.collect().data
assert collected == {"full_name": "Maria", "department": "Music"}
def test_collect_fields_list_ignores_undeclared_names():
"""Asked-but-undeclared template fields contribute no Nones."""
form = CustomInputForm()
form.form_values = json.dumps({"payload": {"room": "Chapel"}, "fields": ["full_name", "room"]})
collected = form.collect().data
assert collected == {"full_name": "", "room": "Chapel"}
def test_collect_raw_dict_ignores_fields_key():
"""A raw dict is payload, not envelope: 'fields' is just a value."""
form = CustomInputForm()
form.form_values = json.dumps({"fields": ["x"], "full_name": "Maria"})
collected = form.collect().data
assert collected["fields"] == ["x"]
assert collected["full_name"] == "Maria"
assert collected["age"] == 18 # all defaults apply without envelope
def test_collect_hides_branch_default_until_trigger_matches():
"""Hidden branch fields contribute no defaults (frontend parity)."""
form = CustomInputForm()
form._inputs["notes"].info = "Anything [visible_if department=Youth]"
assert "notes" not in form.collect().data # department defaults to Music
form.form_values = json.dumps({"payload": {"department": "Youth"}})
assert form.collect().data["notes"] == ""
def test_collect_branch_visibility_uses_submitted_triggers():
form = CustomInputForm()
form._inputs["notes"].info = "[visible_if age=41]"
form.form_values = json.dumps({"payload": {"age": 41}, "fields": ["age", "notes"]})
assert form.collect().data["notes"] == ""
form.form_values = json.dumps({"payload": {"age": 18}, "fields": ["age", "notes"]})
assert "notes" not in form.collect().data
def test_collect_dangling_marker_stays_visible():
form = CustomInputForm()
form._inputs["notes"].info = "[visible_if nope=x]"
assert "notes" in form.collect().data
def test_bauvorhaben_component_contract():
from kirche.bauvorhaben_form import CustomInputBauvorhaben
assert CustomInputBauvorhaben.display_name == "Bauvorhaben Form"
inputs = {i.name: i for i in CustomInputBauvorhaben.inputs}
assert sorted(n for n in inputs if n != "form_values") == sorted(
[
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
]
)
assert inputs["form_values"].show is False
outputs = {o.name: o for o in CustomInputBauvorhaben.outputs}
assert set(outputs) == {"form_data"}
def test_bauvorhaben_collect_only_own_fields():
"""Standalone component: no intake leftovers, hidden branches dropped."""
from kirche.bauvorhaben_form import CustomInputBauvorhaben
form = CustomInputBauvorhaben()
default = form.collect().data
assert "full_name" not in default and "age" not in default
assert default["gebaeude"] == "" # class default (template ships " " sentinel)
assert "bestandteil" not in default # hidden (gebaeude default "").
assert "gebaeudeart" not in default
form.form_values = json.dumps(
{
"payload": {"gebaeude": "Kirche", "bestandteil": ["Glocke"]},
"fields": [
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
],
}
)
collected = form.collect().data
assert collected["bestandteil"] == ["Glocke"]
assert collected["glockenmassnahme"] == "Reinigung" # visible branch default
assert "gussjahr" not in collected # hidden branch
assert "gebaeudeart" not in collected
assert "full_name" not in collected
def test_intake_fixture_shape():
doc = json.loads((FIXTURES / "intake_form.json").read_text())
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
@ -152,12 +267,44 @@ def _parse_chat_json(text: str) -> dict:
return json.loads(text)
def test_embedded_code_matches_template_fields():
"""Node template fields == code-declared inputs (no unasked defaults).
The envelope tolerates drift at runtime, but authoring must stay
honest: what Langflow shows in the code editor must be what the form
asks. Building blocks: lfx's own create_class on the embedded code.
"""
from lfx.custom import validate as validate_mod
cases = {
"intake_form.json": ("CustomInputForm-intake", "CustomInputForm"),
"bauvorhaben.json": ("CustomInputBauvorhaben-bauvorhaben", "CustomInputBauvorhaben"),
"mitarbeiter_einstellen.json": (
"CustomInputMitarbeiter-mitarbeiter",
"CustomInputMitarbeiter",
),
}
for filename, (node_id, class_name) in cases.items():
doc = json.loads((FIXTURES / filename).read_text(encoding="utf-8"))
node = next(n for n in doc["data"]["nodes"] if n["id"] == node_id)
assert node["data"]["type"] == class_name, filename
template = node["data"]["node"]["template"]
template_names = {
field["name"] for field in template.values() if isinstance(field, dict)
} - {"code"}
code = template["code"]["value"]
cls = validate_mod.create_class(code, class_name)
code_names = {inp.name for inp in cls.inputs}
assert code_names == template_names, filename
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"
form = nodes["CustomInputBauvorhaben-bauvorhaben"]
assert form["data"]["type"] == "CustomInputBauvorhaben"
assert form["data"]["node"]["display_name"] == "Bauvorhaben Form"
template = form["data"]["node"]["template"]
assert set(template) >= {
"form_values",
@ -173,8 +320,8 @@ def test_bauvorhaben_fixture_shape():
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
assert ("ChatInput-entry", "CustomInputBauvorhaben-bauvorhaben") in edges
assert ("CustomInputBauvorhaben-bauvorhaben", "ChatOutput-collected") in edges
def test_bauvorhaben_schema_branches():
@ -262,3 +409,92 @@ async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, logi
assert result["bestandteil"] == ["Glocke", "Orgel"]
assert result["gussjahr"] == 1890
assert result["denkmalnummer"] == "D-123"
# Only asked fields: unasked code defaults (age, amount, ...) stay out.
assert set(result) == {
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"denkmalschutz",
"denkmalnummer",
}
def test_mitarbeiter_component_contract():
from kirche.mitarbeiter_form import CustomInputMitarbeiter
assert CustomInputMitarbeiter.display_name == "Mitarbeiter Einstellen Form"
inputs = {i.name: i for i in CustomInputMitarbeiter.inputs}
assert sorted(n for n in inputs if n != "form_values") == sorted(
["rolle", "einstufung", "mitarbeiter_name", "geburtsdatum"]
)
assert inputs["form_values"].show is False
outputs = {o.name: o for o in CustomInputMitarbeiter.outputs}
assert set(outputs) == {"form_data"}
def test_mitarbeiter_collect_branch_default():
"""Einstufung default applies only when Pfarrer:in is selected."""
from kirche.mitarbeiter_form import CustomInputMitarbeiter
form = CustomInputMitarbeiter()
assert "einstufung" not in form.collect().data # rolle defaults to Assistenz
form.form_values = json.dumps({"payload": {"rolle": "Pfarrer:in"}})
assert form.collect().data["einstufung"] == "Stufe 1"
def test_mitarbeiter_fixture_shape():
doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8"))
assert doc["name"] == "mitarbeiter_einstellen"
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
form = nodes["CustomInputMitarbeiter-mitarbeiter"]
assert form["data"]["type"] == "CustomInputMitarbeiter"
assert form["data"]["node"]["display_name"] == "Mitarbeiter Einstellen Form"
template = form["data"]["node"]["template"]
assert set(template) >= {
"form_values",
"rolle",
"einstufung",
"mitarbeiter_name",
"geburtsdatum",
"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", "CustomInputMitarbeiter-mitarbeiter") in edges
assert ("CustomInputMitarbeiter-mitarbeiter", "Gehaltsrechner-gehalt") in edges
assert ("Gehaltsrechner-gehalt", "ChatOutput-collected") in edges
async def test_mitarbeiter_deploy_and_run_salary(client, lfx_server, login):
await login()
doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8"))
created = await client.post("/api/flows", json={"name": "mitarbeiter-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": {
"rolle": "Pfarrer:in",
"einstufung": "Stufe 2",
"mitarbeiter_name": "Maria Muster",
"geburtsdatum": "1980-01-01",
}
},
)
assert run.status_code == 200, run.text
assert run.json()["status"] == "completed"
result = run.json()["output"]["result"]
assert "65000" in result
assert "Maria Muster" in result