feat: asked-field envelope, standalone form components, branched forms
This commit is contained in:
parent
0d43b0e8fb
commit
f27be044b4
26 changed files with 3651 additions and 104 deletions
44
backend/tests/fixtures/flows/bauvorhaben.json
vendored
44
backend/tests/fixtures/flows/bauvorhaben.json
vendored
File diff suppressed because one or more lines are too long
991
backend/tests/fixtures/flows/mitarbeiter_einstellen.json
vendored
Normal file
991
backend/tests/fixtures/flows/mitarbeiter_einstellen.json
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -43,6 +43,28 @@ def test_iter_flow_files_only_top_level_json(tmp_path):
|
|||
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
|
||||
|
||||
|
||||
def test_flowsignore_parsing_and_filtering(tmp_path):
|
||||
assert export.ignored_stems(tmp_path) == set()
|
||||
(tmp_path / export.IGNORE_FILENAME).write_text(
|
||||
"# experiments stay Langflow-only\n\ni_love_this_app\n", encoding="utf-8"
|
||||
)
|
||||
assert export.ignored_stems(tmp_path) == {"i_love_this_app"}
|
||||
(tmp_path / "a.json").write_text("{}")
|
||||
(tmp_path / "i_love_this_app.json").write_text("{}")
|
||||
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
|
||||
# Explicit override still works.
|
||||
assert export.iter_flow_files(tmp_path, ignored=set()) == [
|
||||
tmp_path / "a.json",
|
||||
tmp_path / "i_love_this_app.json",
|
||||
]
|
||||
|
||||
|
||||
def test_find_reuse_path_ignores_ignored_files(tmp_path):
|
||||
(tmp_path / export.IGNORE_FILENAME).write_text("alt\n", encoding="utf-8")
|
||||
(tmp_path / "alt.json").write_text(json.dumps({"name": "Neu"}))
|
||||
assert export.find_reuse_path(tmp_path, {"name": "Neu"}) == (None, False)
|
||||
|
||||
|
||||
def test_find_reuse_path_matches_identical_content_first(tmp_path):
|
||||
doc = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}}
|
||||
other = tmp_path / "other-name.json"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -347,3 +347,64 @@ def test_no_markers_no_allof():
|
|||
schema = parser.extract_form_schema(doc)["json_schema"]
|
||||
assert "allOf" not in schema
|
||||
assert set(schema["properties"]) == {"a"}
|
||||
|
||||
|
||||
def test_custom_component_form_display_match():
|
||||
"""User-built * Form components parse without a kirche base class."""
|
||||
doc = _doc(
|
||||
_field("mitarbeiter_name", "StrInput", info="Vor- und Nachname"),
|
||||
_dropdown("rolle", ["Assistenz", "Pfarrer:in"]),
|
||||
node_type="CustomComponent",
|
||||
display="Mitarbeiter Einstellen Form",
|
||||
)
|
||||
schema = parser.extract_form_schema(doc)
|
||||
assert "fallback" not in schema
|
||||
props = schema["json_schema"]["properties"]
|
||||
assert set(props) == {"mitarbeiter_name", "rolle"}
|
||||
assert props["rolle"]["enum"] == ["Assistenz", "Pfarrer:in"]
|
||||
assert props["mitarbeiter_name"]["description"] == "Vor- und Nachname"
|
||||
|
||||
|
||||
def test_custom_component_non_form_falls_back():
|
||||
doc = _doc(
|
||||
_field("payload", "StrInput"),
|
||||
node_type="CustomComponent",
|
||||
display="Rule Check",
|
||||
)
|
||||
assert parser.extract_form_schema(doc)["fallback"] == "textarea"
|
||||
|
||||
|
||||
def test_real_mitarbeiter_file_parses():
|
||||
path = Path(__file__).parent.parent / "flows" / "mitarbeiter_einstellen.json"
|
||||
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||
schema = parser.extract_form_schema(doc)
|
||||
assert "fallback" not in schema
|
||||
props = schema["json_schema"]["properties"]
|
||||
assert set(props) == {"rolle", "mitarbeiter_name", "geburtsdatum"}
|
||||
assert schema["json_schema"]["required"] == ["rolle", "mitarbeiter_name"]
|
||||
# Einstufung is branched (visible only for Pfarrer:in).
|
||||
assert len(schema["json_schema"]["allOf"]) == 1
|
||||
rule = schema["json_schema"]["allOf"][0]
|
||||
assert rule["if"]["properties"] == {"rolle": {"const": "Pfarrer:in"}}
|
||||
assert set(rule["then"]["properties"]) == {"einstufung"}
|
||||
|
||||
|
||||
def test_schema_field_names_covers_branches():
|
||||
doc = _doc(
|
||||
_dropdown("a", ["x", "y"]),
|
||||
_dropdown("b", ["p", "q"], info="[visible_if a=x]"),
|
||||
_field("c", "IntInput", info="[visible_if b=p]"),
|
||||
)
|
||||
cached = parser.extract_form_schema(doc)
|
||||
assert parser.schema_field_names(cached) == ["a", "b", "c"]
|
||||
plain = parser.extract_form_schema(
|
||||
{
|
||||
"data": {
|
||||
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}],
|
||||
"edges": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
assert parser.schema_field_names(plain) == []
|
||||
assert parser.schema_field_names({}) == []
|
||||
assert parser.schema_field_names({"json_schema": None}) == []
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ import subprocess
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.flows import export
|
||||
|
||||
FLOW_DIR = Path(__file__).resolve().parent.parent / "flows"
|
||||
|
||||
|
||||
def test_canonical_flows_pass_lfx_validate():
|
||||
files = sorted(FLOW_DIR.glob("*.json"))
|
||||
files = export.iter_flow_files(FLOW_DIR)
|
||||
assert files, "no canonical flows found"
|
||||
process = subprocess.run(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ async def test_sync_adds_new_flow(session, lfx_server, tmp_path):
|
|||
assert versions[0].deployed_at is not None
|
||||
|
||||
|
||||
async def test_sync_skips_ignored_files(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
(tmp_path / ".flowsignore").write_text("simple_rule\n", encoding="utf-8")
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert report.added == [] and report.errors == {}
|
||||
assert store.get_definition_by_source_path(session, "simple_rule.json") is None
|
||||
|
||||
|
||||
async def test_sync_skips_unchanged(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
first = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue