Files
resolutionflow/backend/tests/test_tree_markdown.py
chihlasm eac6e184ec 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>
2026-02-10 09:45:26 -05:00

596 lines
22 KiB
Python

"""
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