feat: add AI fix service with prompt building and validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
224
backend/tests/test_ai_fix_service.py
Normal file
224
backend/tests/test_ai_fix_service.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user