225 lines
7.0 KiB
Python
225 lines
7.0 KiB
Python
"""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()
|