Files
resolutionflow/backend/tests/test_ai_fix_service.py
2026-02-26 17:25:34 -05:00

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()