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>
596 lines
22 KiB
Python
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
|