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