"""Unit tests for AI fix service helper functions. Tests pure Python helpers only — no AI mocking needed. """ import pytest from app.core.ai_fix_service import ( _find_node_by_id, _find_parent_node, _serialize_tree_outline, _strip_markdown_fences, _replace_node_in_tree, _describe_fix, ) # ── Sample tree ── SAMPLE_TREE = { "id": "root", "type": "decision", "question": "Is the server up?", "options": [ {"id": "opt-yes", "label": "Yes", "next_node_id": "check-logs"}, {"id": "opt-no", "label": "No", "next_node_id": "restart"}, ], "children": [ { "id": "check-logs", "type": "action", "title": "Check Logs", "description": "Review logs.", "next_node_id": "logs-ok", }, { "id": "logs-ok", "type": "solution", "title": "Logs OK", "description": "Issue in logs.", }, { "id": "restart", "type": "decision", "question": "Did restart work?", "options": [{"id": "opt-r", "label": "Yes", "next_node_id": "done"}], "children": [ { "id": "done", "type": "solution", "title": "Done", "description": "Fixed.", } ], }, ], } # ── _find_node_by_id ── class TestFindNodeById: def test_finds_root(self): node = _find_node_by_id(SAMPLE_TREE, "root") assert node is not None assert node["id"] == "root" assert node["type"] == "decision" def test_finds_nested_child(self): node = _find_node_by_id(SAMPLE_TREE, "done") assert node is not None assert node["id"] == "done" assert node["type"] == "solution" def test_finds_direct_child(self): node = _find_node_by_id(SAMPLE_TREE, "check-logs") assert node is not None assert node["title"] == "Check Logs" def test_returns_none_for_missing(self): node = _find_node_by_id(SAMPLE_TREE, "nonexistent") assert node is None def test_returns_none_for_non_dict(self): assert _find_node_by_id("not a dict", "root") is None # ── _find_parent_node ── class TestFindParentNode: def test_root_has_no_parent(self): parent = _find_parent_node(SAMPLE_TREE, "root") assert parent is None def test_finds_parent_of_direct_child(self): parent = _find_parent_node(SAMPLE_TREE, "check-logs") assert parent is not None assert parent["id"] == "root" def test_finds_parent_of_deeply_nested(self): parent = _find_parent_node(SAMPLE_TREE, "done") assert parent is not None assert parent["id"] == "restart" def test_returns_none_for_missing(self): parent = _find_parent_node(SAMPLE_TREE, "nonexistent") assert parent is None def test_returns_none_for_non_dict(self): assert _find_parent_node("not a dict", "root") is None # ── _serialize_tree_outline ── class TestSerializeTreeOutline: def test_produces_readable_outline(self): outline = _serialize_tree_outline(SAMPLE_TREE) assert "- [decision] Is the server up?" in outline assert " - [action] Check Logs" in outline assert " - [solution] Logs OK" in outline assert " - [solution] Done" in outline def test_marks_error_node(self): outline = _serialize_tree_outline(SAMPLE_TREE, error_node_id="restart") assert "<<< ERROR HERE" in outline # Only the restart node should be marked lines = outline.split("\n") error_lines = [l for l in lines if "ERROR HERE" in l] assert len(error_lines) == 1 assert "Did restart work?" in error_lines[0] def test_no_error_marker_when_none(self): outline = _serialize_tree_outline(SAMPLE_TREE) assert "ERROR HERE" not in outline def test_handles_non_dict(self): assert _serialize_tree_outline("not a dict") == "" def test_indentation_increases_with_depth(self): outline = _serialize_tree_outline(SAMPLE_TREE) lines = outline.split("\n") # Root has no indentation assert lines[0].startswith("- [decision]") # Children have 2-space indent child_lines = [l for l in lines if "Check Logs" in l] assert child_lines[0].startswith(" - ") # ── _strip_markdown_fences ── class TestStripMarkdownFences: def test_strips_json_fences(self): text = '```json\n{"key": "value"}\n```' assert _strip_markdown_fences(text) == '{"key": "value"}' def test_strips_plain_fences(self): text = '```\n{"key": "value"}\n```' assert _strip_markdown_fences(text) == '{"key": "value"}' def test_passes_through_plain_json(self): text = '{"key": "value"}' assert _strip_markdown_fences(text) == '{"key": "value"}' # ── _replace_node_in_tree ── class TestReplaceNodeInTree: def test_replaces_root(self): import copy tree = copy.deepcopy(SAMPLE_TREE) replacement = {"id": "root", "type": "decision", "question": "New question"} assert _replace_node_in_tree(tree, "root", replacement) is True assert tree["question"] == "New question" assert "children" not in tree # cleared and replaced def test_replaces_nested_node(self): import copy tree = copy.deepcopy(SAMPLE_TREE) replacement = {"id": "done", "type": "solution", "title": "All Done", "description": "Complete."} assert _replace_node_in_tree(tree, "done", replacement) is True found = _find_node_by_id(tree, "done") assert found["title"] == "All Done" def test_returns_false_for_missing(self): import copy tree = copy.deepcopy(SAMPLE_TREE) assert _replace_node_in_tree(tree, "nonexistent", {"id": "x"}) is False # ── _describe_fix ── class TestDescribeFix: def test_describes_added_children(self): original = {"id": "n1", "children": [{"id": "c1"}]} fixed = {"id": "n1", "children": [{"id": "c1"}, {"id": "c2"}]} desc = _describe_fix(original, fixed) assert "1 child node" in desc def test_describes_added_options(self): original = {"id": "n1", "options": [{"id": "o1"}]} fixed = {"id": "n1", "options": [{"id": "o1"}, {"id": "o2"}]} desc = _describe_fix(original, fixed) assert "1 option" in desc def test_describes_added_next_node_id(self): original = {"id": "n1", "type": "action"} fixed = {"id": "n1", "type": "action", "next_node_id": "n2"} desc = _describe_fix(original, fixed) assert "next_node_id" in desc def test_fallback_description(self): original = {"id": "n1", "type": "solution"} fixed = {"id": "n1", "type": "solution"} desc = _describe_fix(original, fixed) assert "fixed structural issue" in desc.lower()