codeflash-agent/packages/codeflash-api/tests/test_workflow.py
Kevin Turcios 935c6f229e Add remaining endpoints: repair, refinement, adaptive, explain, review, ranking, jit, workflow, testgen, log_features
Port all P1 endpoints from the Django aiservice to FastAPI:

- repair: 2-attempt LLM retry, SearchAndReplaceDiff patch application
- refinement: parallel LLM calls via asyncio.gather, single/multi-file
  context dispatch, XML explanation extraction, deduplication
- adaptive: single LLM call with previous candidate history
- explain: conditional throughput/concurrency/acceptance prompt sections,
  XML <explain> tag extraction
- review: 4-dimension scoring, JSON code block extraction, 2-attempt retry
- ranking: 4-dimension weighted scoring, JSON extraction with 3 fallbacks
  (direct parse, markdown block, brace matching), legacy XML fallback
- jit: reuses optimize pipeline with JIT-specific prompts
- workflow: 3-tier regex YAML extraction, LLM-generated CI steps
- testgen: stub returning 501 (language-specific logic deferred)
- log_features: trace_id validation, DB write stubbed

Also adds:
- Task-specific model assignments in llm/_models.py
- XML tag extraction utility in languages/python/_xml.py
- All 11 routers registered in _app.py

348 tests passing, all lint clean.
2026-04-21 22:36:31 -05:00

225 lines
7.2 KiB
Python

from __future__ import annotations
import pytest
from pydantic import ValidationError
from codeflash_api.workflow._router import _extract_yaml_steps
from codeflash_api.workflow.schemas import (
WorkflowGenErrorResponse,
WorkflowGenRequest,
WorkflowGenResponse,
)
class TestWorkflowGenRequest:
"""Tests for WorkflowGenRequest schema."""
def test_minimal_valid(self) -> None:
"""
Empty dicts are accepted; defaults fill in trace_id and config.
"""
req = WorkflowGenRequest(
repo_files={},
directory_structure={},
)
assert {} == req.repo_files
assert {} == req.directory_structure
assert req.codeflash_config is None
assert "" == req.trace_id
def test_all_fields(self) -> None:
"""
All fields are accepted when provided.
"""
req = WorkflowGenRequest(
repo_files={"setup.py": "from setuptools import setup"},
directory_structure={"src": {"main.py": None}},
codeflash_config={"language": "python"},
trace_id="abc-123",
)
assert {"setup.py": "from setuptools import setup"} == req.repo_files
assert {"src": {"main.py": None}} == req.directory_structure
assert {"language": "python"} == req.codeflash_config
assert "abc-123" == req.trace_id
def test_missing_repo_files_rejected(self) -> None:
"""
Omitting repo_files raises a validation error.
"""
with pytest.raises(ValidationError):
WorkflowGenRequest(
directory_structure={},
) # type: ignore[call-arg]
def test_missing_directory_structure_rejected(self) -> None:
"""
Omitting directory_structure raises a validation error.
"""
with pytest.raises(ValidationError):
WorkflowGenRequest(
repo_files={},
) # type: ignore[call-arg]
class TestWorkflowGenResponse:
"""Tests for WorkflowGenResponse schema."""
def test_workflow_steps(self) -> None:
"""
Response carries the workflow_steps string.
"""
resp = WorkflowGenResponse(workflow_steps="steps:\n- name: Checkout")
assert "steps:\n- name: Checkout" == resp.workflow_steps
def test_missing_workflow_steps_rejected(self) -> None:
"""
Omitting workflow_steps raises a validation error.
"""
with pytest.raises(ValidationError):
WorkflowGenResponse() # type: ignore[call-arg]
class TestWorkflowGenErrorResponse:
"""Tests for WorkflowGenErrorResponse schema."""
def test_error_message(self) -> None:
"""
Error response carries the error string.
"""
resp = WorkflowGenErrorResponse(error="bad input")
assert "bad input" == resp.error
def test_missing_error_rejected(self) -> None:
"""
Omitting error raises a validation error.
"""
with pytest.raises(ValidationError):
WorkflowGenErrorResponse() # type: ignore[call-arg]
class TestExtractYamlSteps:
"""Tests for _extract_yaml_steps YAML extraction logic."""
def test_full_steps_section(self) -> None:
"""
Extracts steps content when 'steps:' header is present.
"""
text = (
"steps:\n"
"- name: Checkout\n"
" uses: actions/checkout@v4\n"
"- name: Setup Python\n"
" uses: actions/setup-python@v5\n"
)
result = _extract_yaml_steps(text)
assert result is not None
assert result.startswith("steps:\n")
assert "- name: Checkout" in result
assert "- name: Setup Python" in result
def test_steps_section_with_indented_dashes(self) -> None:
"""
Lines with leading whitespace before '-' get normalized to 2-space indent.
"""
text = "steps:\n - name: Checkout\n - name: Install\n"
result = _extract_yaml_steps(text)
assert result is not None
assert " - name: Checkout" in result
assert " - name: Install" in result
def test_non_dash_lines_preserved(self) -> None:
"""
Lines that don't start with '-' are kept as-is.
"""
text = (
"steps:\n"
"- name: Checkout\n"
" uses: actions/checkout@v4\n"
" with:\n"
" fetch-depth: 0\n"
)
result = _extract_yaml_steps(text)
assert result is not None
assert " uses: actions/checkout@v4" in result
assert " with:" in result
def test_fallback_array_match(self) -> None:
"""
Falls back to array_match_re when no 'steps:' header is found.
"""
text = (
"Here is the output:\n\n"
"- name: Checkout\n"
" uses: actions/checkout@v4\n"
"- name: Install deps\n"
" run: pip install -r requirements.txt\n"
)
result = _extract_yaml_steps(text)
assert result is not None
assert result.startswith("steps:\n")
assert "- name: Checkout" in result
def test_fallback_yaml_like(self) -> None:
"""
Falls back to yaml_like_re as last resort.
"""
text = "Some preamble text\n- name: Only step\n run: echo hello"
result = _extract_yaml_steps(text)
assert result is not None
assert result.startswith("steps:\n")
assert "- name: Only step" in result
def test_no_yaml_returns_none(self) -> None:
"""
Returns None when no YAML-like content is found.
"""
result = _extract_yaml_steps("No yaml here at all.")
assert result is None
def test_empty_string_returns_none(self) -> None:
"""
Returns None for empty input.
"""
result = _extract_yaml_steps("")
assert result is None
def test_steps_header_only_returns_bare_header(self) -> None:
"""
Returns bare 'steps:\\n' when header has no content lines.
"""
result = _extract_yaml_steps("steps:\n")
assert "steps:\n" == result
def test_blank_line_terminates_steps_capture(self) -> None:
"""
The steps regex stops capturing at the first blank line.
"""
text = "steps:\n- name: A\n\n- name: B\n"
result = _extract_yaml_steps(text)
assert result is not None
assert "- name: A" in result
assert "- name: B" not in result
def test_markdown_fenced_content_with_steps(self) -> None:
"""
Extracts steps even when surrounded by markdown fences.
"""
text = (
"```yaml\n"
"steps:\n"
"- name: Checkout\n"
" uses: actions/checkout@v4\n"
"```\n"
)
result = _extract_yaml_steps(text)
assert result is not None
assert "- name: Checkout" in result
def test_double_newline_terminates_array_match(self) -> None:
"""
The array_match_re stops at a double newline boundary.
"""
text = "- name: First\n run: echo 1\n\n\nSome extra text after"
result = _extract_yaml_steps(text)
assert result is not None
assert "Some extra text" not in result