feat: add dual-mode tree editor with Code Mode, variables, and markdown sync
Implements the full dual-mode tree editor (Plan Phases 1-5): Backend: - JSONB↔Markdown bidirectional serializer/parser with mistune - Markdown validator with line/column error reporting - 3 API endpoints: export-markdown, import-markdown, validate-markdown - Variable extraction/resolution service ([USER_INPUT], [VAR], [SAVE_AS]) - Session variables JSONB column (migration 028) - 39 tree markdown tests + variable service tests (403 total passing) Frontend: - Monaco-based Code Mode with custom Monarch tokenizer and dark theme - Autocomplete for @node_id refs, type values, variable names - Debounced validation (800ms) with inline Monaco error markers - Syntax help panel (absolute overlay, toggleable) - Starter template for new trees with valid cross-references - Bidirectional metadata sync (name/description/category/tags frontmatter) - Synchronous tree→markdown serializer (fixes async race condition) - Pre-save validation blocks save on broken refs or missing tree name - Mode-aware undo/redo: Monaco native in Code Mode, throttled zundo in Flow Mode - Variable prompt modal and frontend resolver for session navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
595
backend/tests/test_tree_markdown.py
Normal file
595
backend/tests/test_tree_markdown.py
Normal file
@@ -0,0 +1,595 @@
|
||||
"""
|
||||
Tests for tree markdown serialization, parsing, and round-trip fidelity.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.services.tree_markdown_service import serialize_tree_to_markdown
|
||||
from app.services.tree_markdown_parser import parse_markdown_to_tree
|
||||
from app.services.tree_markdown_validator import validate_tree_markdown
|
||||
|
||||
|
||||
# --- Fixtures: Tree structures ---
|
||||
|
||||
SIMPLE_TREE = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Is this a test?",
|
||||
"help_text": "Check if this is a test scenario",
|
||||
"options": [
|
||||
{"id": "yes", "label": "Yes", "next_node_id": "solution1"},
|
||||
{"id": "no", "label": "No", "next_node_id": "solution2"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "solution1",
|
||||
"type": "solution",
|
||||
"title": "Test Confirmed",
|
||||
"description": "This is indeed a test.",
|
||||
"resolution_steps": ["Step 1", "Step 2"],
|
||||
"solution": "Test confirmed",
|
||||
},
|
||||
{
|
||||
"id": "solution2",
|
||||
"type": "solution",
|
||||
"title": "Not a Test",
|
||||
"description": "This is not a test.",
|
||||
"solution": "Not a test",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
ACTION_TREE = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "What type of issue?",
|
||||
"help_text": "Identify the primary issue",
|
||||
"options": [
|
||||
{"id": "opt_net", "label": "Network issue", "next_node_id": "check_net"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "check_net",
|
||||
"type": "action",
|
||||
"title": "Run Network Diagnostics",
|
||||
"description": "Check basic connectivity.",
|
||||
"commands": ["ping 8.8.8.8", "tracert gateway.local"],
|
||||
"expected_outcome": "Ping replies or timeout",
|
||||
"next_node_id": "resolved",
|
||||
"children": [
|
||||
{
|
||||
"id": "resolved",
|
||||
"type": "solution",
|
||||
"title": "Issue Resolved",
|
||||
"description": "Network is working now.",
|
||||
"solution": "Issue resolved",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
DEEPLY_NESTED_TREE = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Level 1?",
|
||||
"options": [
|
||||
{"id": "o1", "label": "Go deeper", "next_node_id": "level2"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "level2",
|
||||
"type": "decision",
|
||||
"question": "Level 2?",
|
||||
"options": [
|
||||
{"id": "o2", "label": "Go deeper", "next_node_id": "level3"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "level3",
|
||||
"type": "decision",
|
||||
"question": "Level 3?",
|
||||
"options": [
|
||||
{"id": "o3", "label": "Done", "next_node_id": "final"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "final",
|
||||
"type": "solution",
|
||||
"title": "Deep Solution",
|
||||
"description": "Found it at level 3.",
|
||||
"solution": "Deep solution found",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
SINGLE_NODE_TREE = {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
"title": "Quick Fix",
|
||||
"description": "Just restart the computer.",
|
||||
"solution": "Restart the computer",
|
||||
}
|
||||
|
||||
|
||||
# --- Serialization Tests ---
|
||||
|
||||
class TestSerializeTreeToMarkdown:
|
||||
def test_simple_tree(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
assert "id: root" in md
|
||||
assert "type: decision" in md
|
||||
assert "# Is this a test?" in md
|
||||
assert "> Check if this is a test scenario" in md
|
||||
assert "- [A] Yes → @solution1" in md
|
||||
assert "- [B] No → @solution2" in md
|
||||
assert "id: solution1" in md
|
||||
assert "## Test Confirmed" in md
|
||||
assert "1. Step 1" in md
|
||||
assert "2. Step 2" in md
|
||||
|
||||
def test_action_tree(self):
|
||||
md = serialize_tree_to_markdown(ACTION_TREE)
|
||||
assert "## Run Network Diagnostics" in md
|
||||
assert "```commands" in md
|
||||
assert "ping 8.8.8.8" in md
|
||||
assert "**Expected:** Ping replies or timeout" in md
|
||||
assert "→ @resolved" in md
|
||||
|
||||
def test_deeply_nested(self):
|
||||
md = serialize_tree_to_markdown(DEEPLY_NESTED_TREE)
|
||||
assert "id: level2" in md
|
||||
assert "parent: root" in md
|
||||
assert "id: level3" in md
|
||||
assert "parent: level2" in md
|
||||
assert "id: final" in md
|
||||
assert "parent: level3" in md
|
||||
|
||||
def test_single_node(self):
|
||||
md = serialize_tree_to_markdown(SINGLE_NODE_TREE)
|
||||
assert "id: root" in md
|
||||
assert "type: solution" in md
|
||||
assert "## Quick Fix" in md
|
||||
|
||||
def test_root_has_no_parent(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
lines = md.split("\n")
|
||||
# Find the first frontmatter block
|
||||
in_first_block = False
|
||||
for line in lines:
|
||||
if line.strip() == "---":
|
||||
if not in_first_block:
|
||||
in_first_block = True
|
||||
continue
|
||||
else:
|
||||
break
|
||||
if in_first_block:
|
||||
assert not line.startswith("parent:"), "Root node should not have parent"
|
||||
|
||||
|
||||
# --- Parsing Tests ---
|
||||
|
||||
class TestParseMarkdownToTree:
|
||||
def test_simple_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
tree = result.tree_structure
|
||||
assert tree["id"] == "root"
|
||||
assert tree["type"] == "decision"
|
||||
assert tree["question"] == "Is this a test?"
|
||||
assert len(tree["options"]) == 2
|
||||
assert tree["options"][0]["label"] == "Yes"
|
||||
assert tree["options"][0]["next_node_id"] == "solution1"
|
||||
assert len(tree["children"]) == 2
|
||||
|
||||
def test_action_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(ACTION_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
tree = result.tree_structure
|
||||
# Find the action node
|
||||
action = tree["children"][0]
|
||||
assert action["type"] == "action"
|
||||
assert action["title"] == "Run Network Diagnostics"
|
||||
assert action["commands"] == ["ping 8.8.8.8", "tracert gateway.local"]
|
||||
assert action["expected_outcome"] == "Ping replies or timeout"
|
||||
assert action["next_node_id"] == "resolved"
|
||||
# Action should have children
|
||||
assert len(action["children"]) == 1
|
||||
assert action["children"][0]["type"] == "solution"
|
||||
|
||||
def test_deeply_nested_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(DEEPLY_NESTED_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
tree = result.tree_structure
|
||||
assert tree["id"] == "root"
|
||||
level2 = tree["children"][0]
|
||||
assert level2["id"] == "level2"
|
||||
level3 = level2["children"][0]
|
||||
assert level3["id"] == "level3"
|
||||
final = level3["children"][0]
|
||||
assert final["id"] == "final"
|
||||
assert final["type"] == "solution"
|
||||
|
||||
def test_single_node_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(SINGLE_NODE_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
tree = result.tree_structure
|
||||
assert tree["id"] == "root"
|
||||
assert tree["type"] == "solution"
|
||||
assert tree["title"] == "Quick Fix"
|
||||
|
||||
def test_empty_markdown(self):
|
||||
result = parse_markdown_to_tree("")
|
||||
assert result.tree_structure is None
|
||||
assert len(result.errors) > 0
|
||||
|
||||
def test_malformed_frontmatter(self):
|
||||
md = "---\nno_id_here: true\n---\n# Some question"
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert any("missing 'id'" in e.message.lower() or "missing" in e.message.lower()
|
||||
for e in result.errors)
|
||||
|
||||
def test_invalid_type(self):
|
||||
md = "---\nid: root\ntype: invalid_type\n---\n# Question"
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert any("invalid node type" in e.message.lower() for e in result.errors)
|
||||
|
||||
def test_duplicate_ids(self):
|
||||
md = (
|
||||
"---\nid: root\ntype: decision\n---\n# Q1\n- [A] Opt → @dup\n\n"
|
||||
"---\nid: dup\ntype: solution\nparent: root\n---\n## Sol1\n\n"
|
||||
"---\nid: dup\ntype: solution\nparent: root\n---\n## Sol2\n"
|
||||
)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert any("duplicate" in e.message.lower() for e in result.errors)
|
||||
|
||||
def test_broken_reference(self):
|
||||
md = (
|
||||
"---\nid: root\ntype: decision\n---\n"
|
||||
"# Which issue?\n"
|
||||
"- [A] Network → @nonexistent\n"
|
||||
)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert any("non-existent" in e.message.lower() for e in result.errors)
|
||||
|
||||
def test_orphaned_parent_reference(self):
|
||||
md = (
|
||||
"---\nid: root\ntype: decision\n---\n# Q\n- [A] Opt → @child\n\n"
|
||||
"---\nid: child\ntype: solution\nparent: nonexistent_parent\n---\n## Sol\n"
|
||||
)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert any("non-existent parent" in e.message.lower() for e in result.errors)
|
||||
|
||||
def test_resolution_steps_parsed(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
tree = result.tree_structure
|
||||
# solution1 should have resolution_steps
|
||||
sol1 = tree["children"][0]
|
||||
assert sol1["id"] == "solution1"
|
||||
assert sol1["resolution_steps"] == ["Step 1", "Step 2"]
|
||||
|
||||
def test_help_text_preserved(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
tree = result.tree_structure
|
||||
assert tree["help_text"] == "Check if this is a test scenario"
|
||||
|
||||
def test_description_with_markdown(self):
|
||||
"""Test that markdown content in descriptions is preserved."""
|
||||
tree = {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
"title": "Complex Solution",
|
||||
"description": "Use **bold** and *italic* and `code`.\n\nMultiple paragraphs too.",
|
||||
"solution": "Complex solution",
|
||||
}
|
||||
md = serialize_tree_to_markdown(tree)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
assert "**bold**" in result.tree_structure["description"]
|
||||
assert "`code`" in result.tree_structure["description"]
|
||||
|
||||
|
||||
# --- Validation Tests ---
|
||||
|
||||
class TestValidateTreeMarkdown:
|
||||
def test_valid_tree_no_errors(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
errors = validate_tree_markdown(md)
|
||||
hard_errors = [e for e in errors if e.severity == "error"]
|
||||
assert len(hard_errors) == 0
|
||||
|
||||
def test_empty_markdown_errors(self):
|
||||
errors = validate_tree_markdown("")
|
||||
assert len(errors) > 0
|
||||
|
||||
def test_missing_solution_warning(self):
|
||||
tree = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Question?",
|
||||
"options": [],
|
||||
"children": [],
|
||||
}
|
||||
md = serialize_tree_to_markdown(tree)
|
||||
errors = validate_tree_markdown(md)
|
||||
warnings = [e for e in errors if e.severity == "warning"]
|
||||
assert any("solution" in e.message.lower() for e in warnings)
|
||||
|
||||
def test_empty_question_warning(self):
|
||||
tree = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "",
|
||||
"options": [],
|
||||
"children": [],
|
||||
}
|
||||
md = serialize_tree_to_markdown(tree)
|
||||
errors = validate_tree_markdown(md)
|
||||
assert any("empty question" in e.message.lower() for e in errors)
|
||||
|
||||
|
||||
# --- Metadata Tests ---
|
||||
|
||||
class TestMetadataBlocks:
|
||||
def test_serialize_with_metadata(self):
|
||||
metadata = {"name": "Test Tree", "description": "A test", "category": "Help Desk", "tags": ["dns", "network"]}
|
||||
md = serialize_tree_to_markdown(SINGLE_NODE_TREE, metadata=metadata)
|
||||
assert "name: Test Tree" in md
|
||||
assert "description: A test" in md
|
||||
assert "category: Help Desk" in md
|
||||
assert "tags: [dns, network]" in md
|
||||
# Node should still be present
|
||||
assert "id: root" in md
|
||||
|
||||
def test_parse_with_metadata(self):
|
||||
md = (
|
||||
"---\nname: My Tree\ndescription: desc here\ncategory: Help Desk\ntags: [dns, vpn]\n---\n\n"
|
||||
"---\nid: root\ntype: solution\n---\n## Quick Fix\n"
|
||||
)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.metadata is not None
|
||||
assert result.metadata["name"] == "My Tree"
|
||||
assert result.metadata["description"] == "desc here"
|
||||
assert result.metadata["category"] == "Help Desk"
|
||||
assert result.metadata["tags"] == ["dns", "vpn"]
|
||||
assert result.tree_structure is not None
|
||||
assert result.tree_structure["id"] == "root"
|
||||
|
||||
def test_parse_without_metadata(self):
|
||||
md = "---\nid: root\ntype: solution\n---\n## Quick Fix\n"
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.metadata is None
|
||||
assert result.tree_structure is not None
|
||||
|
||||
def test_metadata_roundtrip(self):
|
||||
metadata = {"name": "Roundtrip Tree", "description": "Tests roundtrip", "tags": ["test"]}
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE, metadata=metadata)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.metadata is not None
|
||||
assert result.metadata["name"] == "Roundtrip Tree"
|
||||
assert result.metadata["description"] == "Tests roundtrip"
|
||||
assert result.metadata["tags"] == ["test"]
|
||||
assert result.tree_structure is not None
|
||||
assert result.tree_structure["id"] == "root"
|
||||
|
||||
def test_metadata_only_no_nodes(self):
|
||||
md = "---\nname: Just Metadata\n---\n"
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.metadata is not None
|
||||
assert result.metadata["name"] == "Just Metadata"
|
||||
assert result.tree_structure is None
|
||||
assert any("no node blocks" in e.message.lower() for e in result.errors)
|
||||
|
||||
|
||||
# --- Round-Trip Fidelity Tests ---
|
||||
|
||||
class TestRoundTripFidelity:
|
||||
"""Ensure serialize → parse produces semantically identical trees."""
|
||||
|
||||
def _assert_node_equal(self, original: dict, parsed: dict, path: str = "root"):
|
||||
"""Deep compare two node dicts, ignoring internal fields and option IDs."""
|
||||
assert original["id"] == parsed["id"], f"{path}: id mismatch"
|
||||
assert original["type"] == parsed["type"], f"{path}: type mismatch"
|
||||
|
||||
if original["type"] == "decision":
|
||||
assert original.get("question", "") == parsed.get("question", ""), \
|
||||
f"{path}: question mismatch"
|
||||
# Compare option labels and next_node_ids (not auto-generated option IDs)
|
||||
orig_opts = original.get("options", [])
|
||||
parsed_opts = parsed.get("options", [])
|
||||
assert len(orig_opts) == len(parsed_opts), \
|
||||
f"{path}: options count mismatch ({len(orig_opts)} vs {len(parsed_opts)})"
|
||||
for j, (oo, po) in enumerate(zip(orig_opts, parsed_opts)):
|
||||
assert oo["label"] == po["label"], \
|
||||
f"{path}.options[{j}]: label mismatch"
|
||||
assert oo.get("next_node_id", "") == po.get("next_node_id", ""), \
|
||||
f"{path}.options[{j}]: next_node_id mismatch"
|
||||
|
||||
elif original["type"] == "action":
|
||||
assert original.get("title", "") == parsed.get("title", ""), \
|
||||
f"{path}: title mismatch"
|
||||
assert original.get("commands", []) == parsed.get("commands", []), \
|
||||
f"{path}: commands mismatch"
|
||||
assert original.get("expected_outcome", "") == parsed.get("expected_outcome", ""), \
|
||||
f"{path}: expected_outcome mismatch"
|
||||
assert original.get("next_node_id", "") == parsed.get("next_node_id", ""), \
|
||||
f"{path}: next_node_id mismatch"
|
||||
|
||||
elif original["type"] == "solution":
|
||||
assert original.get("title", "") == parsed.get("title", ""), \
|
||||
f"{path}: title mismatch"
|
||||
assert original.get("resolution_steps", []) == parsed.get("resolution_steps", []), \
|
||||
f"{path}: resolution_steps mismatch"
|
||||
|
||||
# Compare children recursively
|
||||
orig_children = original.get("children", [])
|
||||
parsed_children = parsed.get("children", [])
|
||||
assert len(orig_children) == len(parsed_children), \
|
||||
f"{path}: children count mismatch ({len(orig_children)} vs {len(parsed_children)})"
|
||||
for i, (oc, pc) in enumerate(zip(orig_children, parsed_children)):
|
||||
self._assert_node_equal(oc, pc, f"{path}.children[{i}]")
|
||||
|
||||
def test_simple_tree_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
self._assert_node_equal(SIMPLE_TREE, result.tree_structure)
|
||||
|
||||
def test_action_tree_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(ACTION_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
self._assert_node_equal(ACTION_TREE, result.tree_structure)
|
||||
|
||||
def test_deeply_nested_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(DEEPLY_NESTED_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
self._assert_node_equal(DEEPLY_NESTED_TREE, result.tree_structure)
|
||||
|
||||
def test_double_roundtrip(self):
|
||||
"""serialize → parse → serialize should produce same markdown."""
|
||||
md1 = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
result1 = parse_markdown_to_tree(md1)
|
||||
assert result1.tree_structure is not None
|
||||
md2 = serialize_tree_to_markdown(result1.tree_structure)
|
||||
result2 = parse_markdown_to_tree(md2)
|
||||
assert result2.tree_structure is not None
|
||||
self._assert_node_equal(result1.tree_structure, result2.tree_structure)
|
||||
|
||||
|
||||
# --- API Integration Tests ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTreeMarkdownAPI:
|
||||
async def test_export_markdown(self, client, auth_headers, test_tree):
|
||||
tree_id = test_tree["id"]
|
||||
response = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "markdown" in data
|
||||
# Should include metadata block
|
||||
assert "name:" in data["markdown"]
|
||||
# Should include node content
|
||||
assert "id: root" in data["markdown"]
|
||||
assert "# Is this a test?" in data["markdown"]
|
||||
|
||||
async def test_export_not_found(self, client, auth_headers):
|
||||
import uuid
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await client.get(
|
||||
f"/api/v1/trees/{fake_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_export_requires_auth(self, client, test_tree):
|
||||
tree_id = test_tree["id"]
|
||||
response = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_import_markdown(self, client, auth_headers, test_tree):
|
||||
tree_id = test_tree["id"]
|
||||
|
||||
# Export first
|
||||
export_resp = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
markdown = export_resp.json()["markdown"]
|
||||
|
||||
# Import back
|
||||
response = await client.put(
|
||||
f"/api/v1/trees/{tree_id}/import-markdown",
|
||||
json={"markdown": markdown},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
assert data["tree_structure"] is not None
|
||||
|
||||
async def test_import_invalid_markdown(self, client, auth_headers, test_tree):
|
||||
tree_id = test_tree["id"]
|
||||
response = await client.put(
|
||||
f"/api/v1/trees/{tree_id}/import-markdown",
|
||||
json={"markdown": "this is not valid tree markdown"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is False
|
||||
assert len(data["errors"]) > 0
|
||||
|
||||
async def test_validate_markdown(self, client, auth_headers, test_tree):
|
||||
tree_id = test_tree["id"]
|
||||
|
||||
# Export first
|
||||
export_resp = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
markdown = export_resp.json()["markdown"]
|
||||
|
||||
# Validate
|
||||
response = await client.post(
|
||||
"/api/v1/trees/validate-markdown",
|
||||
json={"markdown": markdown},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
assert data["tree_structure"] is not None
|
||||
# Should return parsed metadata from export
|
||||
assert data["metadata"] is not None
|
||||
|
||||
async def test_validate_requires_auth(self, client):
|
||||
response = await client.post(
|
||||
"/api/v1/trees/validate-markdown",
|
||||
json={"markdown": "---\nid: root\ntype: solution\n---\n## Fix\n"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_roundtrip_via_api(self, client, auth_headers, test_tree):
|
||||
"""Export tree → import same markdown → verify tree unchanged."""
|
||||
tree_id = test_tree["id"]
|
||||
|
||||
# Export
|
||||
export_resp = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
markdown = export_resp.json()["markdown"]
|
||||
|
||||
# Import
|
||||
import_resp = await client.put(
|
||||
f"/api/v1/trees/{tree_id}/import-markdown",
|
||||
json={"markdown": markdown},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert import_resp.json()["valid"] is True
|
||||
|
||||
# Re-export and compare
|
||||
export_resp2 = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
markdown2 = export_resp2.json()["markdown"]
|
||||
|
||||
# Should be identical
|
||||
assert markdown == markdown2
|
||||
119
backend/tests/test_variable_service.py
Normal file
119
backend/tests/test_variable_service.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Tests for variable extraction and resolution service."""
|
||||
import pytest
|
||||
|
||||
from app.services.variable_service import extract_variables, resolve_variables
|
||||
|
||||
|
||||
TREE_WITH_VARIABLES = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "What is the hostname of [USER_INPUT:hostname]?",
|
||||
"help_text": "Enter the server hostname",
|
||||
"options": [
|
||||
{"id": "opt1", "label": "Check [VAR:hostname]", "next_node_id": "action1"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "action1",
|
||||
"type": "action",
|
||||
"title": "Ping [VAR:hostname]",
|
||||
"description": "Run diagnostics on [VAR:hostname].\n\n[SAVE_AS:test_results]",
|
||||
"commands": ["ping [VAR:hostname]"],
|
||||
"expected_outcome": "Replies from [VAR:hostname]",
|
||||
"next_node_id": "solution1",
|
||||
"children": [
|
||||
{
|
||||
"id": "solution1",
|
||||
"type": "solution",
|
||||
"title": "Resolved",
|
||||
"description": "Issue on [VAR:hostname] resolved.",
|
||||
"resolution_steps": [
|
||||
"Document results for [VAR:hostname]",
|
||||
"Save as [SAVE_AS:final_result]"
|
||||
],
|
||||
"solution": "Resolved",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
TREE_WITHOUT_VARIABLES = {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
"title": "Simple Fix",
|
||||
"description": "Just restart it.",
|
||||
"solution": "Restart",
|
||||
}
|
||||
|
||||
|
||||
class TestExtractVariables:
|
||||
def test_extracts_user_input(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
user_inputs = [v for v in variables if v.kind == "user_input"]
|
||||
assert len(user_inputs) == 1
|
||||
assert user_inputs[0].name == "hostname"
|
||||
assert user_inputs[0].node_id == "root"
|
||||
|
||||
def test_extracts_var_references(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
refs = [v for v in variables if v.kind == "reference"]
|
||||
assert len(refs) >= 4 # hostname used in multiple places
|
||||
|
||||
def test_extracts_save_as(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
saves = [v for v in variables if v.kind == "save_as"]
|
||||
assert len(saves) == 2
|
||||
names = {v.name for v in saves}
|
||||
assert "test_results" in names
|
||||
assert "final_result" in names
|
||||
|
||||
def test_no_variables(self):
|
||||
variables = extract_variables(TREE_WITHOUT_VARIABLES)
|
||||
assert len(variables) == 0
|
||||
|
||||
def test_extracts_from_commands(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
cmd_vars = [v for v in variables if v.node_id == "action1" and v.kind == "reference"]
|
||||
# hostname in title, description, commands, expected_outcome
|
||||
assert len(cmd_vars) >= 3
|
||||
|
||||
|
||||
class TestResolveVariables:
|
||||
def test_resolves_var_reference(self):
|
||||
text = "Ping [VAR:hostname] now"
|
||||
result = resolve_variables(text, {"hostname": "server01"})
|
||||
assert result == "Ping server01 now"
|
||||
|
||||
def test_resolves_user_input(self):
|
||||
text = "Server: [USER_INPUT:hostname]"
|
||||
result = resolve_variables(text, {"hostname": "server01"})
|
||||
assert result == "Server: server01"
|
||||
|
||||
def test_removes_save_as(self):
|
||||
text = "Done [SAVE_AS:result] here"
|
||||
result = resolve_variables(text, {})
|
||||
assert result == "Done here"
|
||||
|
||||
def test_unresolved_var_preserved(self):
|
||||
text = "Server: [VAR:unknown_var]"
|
||||
result = resolve_variables(text, {})
|
||||
assert result == "Server: [VAR:unknown_var]"
|
||||
|
||||
def test_multiple_variables(self):
|
||||
text = "[VAR:hostname] ([VAR:ip]) - [USER_INPUT:ticket]"
|
||||
result = resolve_variables(text, {
|
||||
"hostname": "server01",
|
||||
"ip": "10.0.0.1",
|
||||
"ticket": "INC001",
|
||||
})
|
||||
assert result == "server01 (10.0.0.1) - INC001"
|
||||
|
||||
def test_empty_variables_dict(self):
|
||||
text = "No vars here"
|
||||
result = resolve_variables(text, {})
|
||||
assert result == "No vars here"
|
||||
|
||||
def test_empty_text(self):
|
||||
result = resolve_variables("", {"hostname": "server01"})
|
||||
assert result == ""
|
||||
Reference in New Issue
Block a user