mirror of
https://github.com/flakestorm/flakestorm.git
synced 2026-07-18 23:11:18 +02:00
Implement Open Source edition limits and feature restrictions
- Add 5 mutation types (paraphrase, noise, tone_shift, prompt_injection, custom) - Cap mutations at 50 per test run - Force sequential execution only - Disable GitHub Actions integration (Cloud feature) - Add upgrade prompts throughout CLI - Update README with feature comparison - Add limits.py module for centralized limit management - Add cloud and limits CLI commands - Update all documentation with Cloud upgrade messaging
This commit is contained in:
parent
2016be238d
commit
7b75fc9530
47 changed files with 3560 additions and 1012 deletions
|
|
@ -1,4 +1,3 @@
|
|||
"""
|
||||
Entropix Test Suite
|
||||
"""
|
||||
|
||||
|
|
|
|||
78
tests/conftest.py
Normal file
78
tests/conftest.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Shared test fixtures for Entropix tests."""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add src to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config_yaml():
|
||||
"""Sample valid config YAML."""
|
||||
return """
|
||||
agent:
|
||||
endpoint: "http://localhost:8000/chat"
|
||||
type: http
|
||||
timeout: 30
|
||||
|
||||
golden_prompts:
|
||||
- "Test prompt 1"
|
||||
- "Test prompt 2"
|
||||
|
||||
mutations:
|
||||
count: 5
|
||||
types:
|
||||
- paraphrase
|
||||
- noise
|
||||
|
||||
invariants:
|
||||
- type: latency
|
||||
max_ms: 5000
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_file(temp_dir, sample_config_yaml):
|
||||
"""Create a config file in temp directory."""
|
||||
config_path = temp_dir / "entropix.yaml"
|
||||
config_path.write_text(sample_config_yaml)
|
||||
return config_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_config_yaml():
|
||||
"""Minimal valid config YAML."""
|
||||
return """
|
||||
agent:
|
||||
endpoint: "http://localhost:8000/chat"
|
||||
type: http
|
||||
|
||||
golden_prompts:
|
||||
- "Test prompt"
|
||||
|
||||
mutations:
|
||||
count: 2
|
||||
types:
|
||||
- paraphrase
|
||||
|
||||
invariants: []
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_config_file(temp_dir, minimal_config_yaml):
|
||||
"""Create a minimal config file."""
|
||||
config_path = temp_dir / "entropix.yaml"
|
||||
config_path.write_text(minimal_config_yaml)
|
||||
return config_path
|
||||
180
tests/test_adapters.py
Normal file
180
tests/test_adapters.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""Tests for agent adapters."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestHTTPAgentAdapter:
|
||||
"""Tests for HTTP agent adapter."""
|
||||
|
||||
def test_adapter_creation(self):
|
||||
"""Test adapter can be created."""
|
||||
from entropix.core.protocol import HTTPAgentAdapter
|
||||
|
||||
adapter = HTTPAgentAdapter(
|
||||
endpoint="http://localhost:8000/chat",
|
||||
timeout=30000, # 30 seconds in milliseconds
|
||||
)
|
||||
assert adapter is not None
|
||||
assert adapter.endpoint == "http://localhost:8000/chat"
|
||||
|
||||
def test_adapter_has_invoke_method(self):
|
||||
"""Adapter has invoke method."""
|
||||
from entropix.core.protocol import HTTPAgentAdapter
|
||||
|
||||
adapter = HTTPAgentAdapter(endpoint="http://localhost:8000/chat")
|
||||
assert hasattr(adapter, "invoke")
|
||||
assert callable(adapter.invoke)
|
||||
|
||||
def test_timeout_conversion(self):
|
||||
"""Timeout is converted to seconds."""
|
||||
from entropix.core.protocol import HTTPAgentAdapter
|
||||
|
||||
adapter = HTTPAgentAdapter(
|
||||
endpoint="http://localhost:8000/chat",
|
||||
timeout=30000,
|
||||
)
|
||||
# Timeout should be stored in seconds
|
||||
assert adapter.timeout == 30.0
|
||||
|
||||
def test_custom_headers(self):
|
||||
"""Custom headers can be set."""
|
||||
from entropix.core.protocol import HTTPAgentAdapter
|
||||
|
||||
headers = {"Authorization": "Bearer token123"}
|
||||
adapter = HTTPAgentAdapter(
|
||||
endpoint="http://localhost:8000/chat",
|
||||
headers=headers,
|
||||
)
|
||||
assert adapter.headers == headers
|
||||
|
||||
|
||||
class TestPythonAgentAdapter:
|
||||
"""Tests for Python function adapter."""
|
||||
|
||||
def test_adapter_creation_with_callable(self):
|
||||
"""Test adapter can be created with a callable."""
|
||||
from entropix.core.protocol import PythonAgentAdapter
|
||||
|
||||
def my_agent(input: str) -> str:
|
||||
return f"Response to: {input}"
|
||||
|
||||
adapter = PythonAgentAdapter(my_agent)
|
||||
assert adapter is not None
|
||||
assert adapter.agent == my_agent
|
||||
|
||||
def test_adapter_has_invoke_method(self):
|
||||
"""Adapter has invoke method."""
|
||||
from entropix.core.protocol import PythonAgentAdapter
|
||||
|
||||
def my_agent(input: str) -> str:
|
||||
return f"Response to: {input}"
|
||||
|
||||
adapter = PythonAgentAdapter(my_agent)
|
||||
assert hasattr(adapter, "invoke")
|
||||
assert callable(adapter.invoke)
|
||||
|
||||
|
||||
class TestLangChainAgentAdapter:
|
||||
"""Tests for LangChain agent adapter."""
|
||||
|
||||
@pytest.fixture
|
||||
def langchain_config(self):
|
||||
"""Create a test LangChain agent config."""
|
||||
from entropix.core.config import AgentConfig, AgentType
|
||||
|
||||
return AgentConfig(
|
||||
endpoint="my_agent:chain",
|
||||
type=AgentType.LANGCHAIN,
|
||||
timeout=60000, # 60 seconds in milliseconds
|
||||
)
|
||||
|
||||
def test_adapter_creation(self, langchain_config):
|
||||
"""Test adapter can be created."""
|
||||
from entropix.core.protocol import LangChainAgentAdapter
|
||||
|
||||
adapter = LangChainAgentAdapter(langchain_config)
|
||||
assert adapter is not None
|
||||
|
||||
|
||||
class TestAgentAdapterFactory:
|
||||
"""Tests for adapter factory function."""
|
||||
|
||||
def test_creates_http_adapter(self):
|
||||
"""Factory creates HTTP adapter for HTTP type."""
|
||||
from entropix.core.config import AgentConfig, AgentType
|
||||
from entropix.core.protocol import HTTPAgentAdapter, create_agent_adapter
|
||||
|
||||
config = AgentConfig(
|
||||
endpoint="http://localhost:8000/chat",
|
||||
type=AgentType.HTTP,
|
||||
)
|
||||
adapter = create_agent_adapter(config)
|
||||
assert isinstance(adapter, HTTPAgentAdapter)
|
||||
|
||||
def test_creates_python_adapter(self):
|
||||
"""Python adapter can be created with a callable."""
|
||||
from entropix.core.protocol import PythonAgentAdapter
|
||||
|
||||
def my_agent(input: str) -> str:
|
||||
return f"Response: {input}"
|
||||
|
||||
adapter = PythonAgentAdapter(my_agent)
|
||||
assert isinstance(adapter, PythonAgentAdapter)
|
||||
|
||||
def test_creates_langchain_adapter(self):
|
||||
"""Factory creates LangChain adapter for LangChain type."""
|
||||
from entropix.core.config import AgentConfig, AgentType
|
||||
from entropix.core.protocol import LangChainAgentAdapter, create_agent_adapter
|
||||
|
||||
config = AgentConfig(
|
||||
endpoint="my_agent:chain",
|
||||
type=AgentType.LANGCHAIN,
|
||||
)
|
||||
adapter = create_agent_adapter(config)
|
||||
assert isinstance(adapter, LangChainAgentAdapter)
|
||||
|
||||
|
||||
class TestAgentResponse:
|
||||
"""Tests for AgentResponse data class."""
|
||||
|
||||
def test_response_creation(self):
|
||||
"""Test AgentResponse can be created."""
|
||||
from entropix.core.protocol import AgentResponse
|
||||
|
||||
response = AgentResponse(
|
||||
output="Hello, world!",
|
||||
latency_ms=150.5,
|
||||
)
|
||||
assert response.output == "Hello, world!"
|
||||
assert response.latency_ms == 150.5
|
||||
|
||||
def test_response_with_error(self):
|
||||
"""Test AgentResponse with error."""
|
||||
from entropix.core.protocol import AgentResponse
|
||||
|
||||
response = AgentResponse(
|
||||
output="",
|
||||
latency_ms=100.0,
|
||||
error="Connection timeout",
|
||||
)
|
||||
assert response.error == "Connection timeout"
|
||||
assert not response.success
|
||||
|
||||
def test_response_success_property(self):
|
||||
"""Test AgentResponse success property."""
|
||||
from entropix.core.protocol import AgentResponse
|
||||
|
||||
# Success case
|
||||
success_response = AgentResponse(
|
||||
output="Response",
|
||||
latency_ms=100.0,
|
||||
)
|
||||
assert success_response.success is True
|
||||
|
||||
# Error case
|
||||
error_response = AgentResponse(
|
||||
output="",
|
||||
latency_ms=100.0,
|
||||
error="Failed",
|
||||
)
|
||||
assert error_response.success is False
|
||||
|
|
@ -2,233 +2,223 @@
|
|||
Tests for the assertion/invariant system.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from entropix.core.config import InvariantConfig, InvariantType
|
||||
from entropix.assertions.deterministic import (
|
||||
ContainsChecker,
|
||||
LatencyChecker,
|
||||
ValidJsonChecker,
|
||||
RegexChecker,
|
||||
ValidJsonChecker,
|
||||
)
|
||||
from entropix.assertions.safety import ExcludesPIIChecker, RefusalChecker
|
||||
from entropix.assertions.verifier import InvariantVerifier
|
||||
from entropix.core.config import InvariantConfig, InvariantType
|
||||
|
||||
|
||||
class TestContainsChecker:
|
||||
"""Tests for ContainsChecker."""
|
||||
|
||||
|
||||
def test_contains_pass(self):
|
||||
"""Test contains check passes when value is present."""
|
||||
config = InvariantConfig(type=InvariantType.CONTAINS, value="success")
|
||||
checker = ContainsChecker(config)
|
||||
|
||||
|
||||
result = checker.check("Operation was a success!", 100.0)
|
||||
|
||||
|
||||
assert result.passed
|
||||
assert "Found" in result.details
|
||||
|
||||
|
||||
def test_contains_fail(self):
|
||||
"""Test contains check fails when value is missing."""
|
||||
config = InvariantConfig(type=InvariantType.CONTAINS, value="success")
|
||||
checker = ContainsChecker(config)
|
||||
|
||||
|
||||
result = checker.check("Operation failed", 100.0)
|
||||
|
||||
|
||||
assert not result.passed
|
||||
assert "not found" in result.details
|
||||
|
||||
|
||||
def test_contains_case_insensitive(self):
|
||||
"""Test contains check is case insensitive."""
|
||||
config = InvariantConfig(type=InvariantType.CONTAINS, value="SUCCESS")
|
||||
checker = ContainsChecker(config)
|
||||
|
||||
|
||||
result = checker.check("it was a success", 100.0)
|
||||
|
||||
|
||||
assert result.passed
|
||||
|
||||
|
||||
class TestLatencyChecker:
|
||||
"""Tests for LatencyChecker."""
|
||||
|
||||
|
||||
def test_latency_pass(self):
|
||||
"""Test latency check passes when under threshold."""
|
||||
config = InvariantConfig(type=InvariantType.LATENCY, max_ms=2000)
|
||||
checker = LatencyChecker(config)
|
||||
|
||||
|
||||
result = checker.check("response", 500.0)
|
||||
|
||||
|
||||
assert result.passed
|
||||
assert "500ms" in result.details
|
||||
|
||||
|
||||
def test_latency_fail(self):
|
||||
"""Test latency check fails when over threshold."""
|
||||
config = InvariantConfig(type=InvariantType.LATENCY, max_ms=1000)
|
||||
checker = LatencyChecker(config)
|
||||
|
||||
|
||||
result = checker.check("response", 1500.0)
|
||||
|
||||
|
||||
assert not result.passed
|
||||
assert "exceeded" in result.details
|
||||
|
||||
|
||||
def test_latency_boundary(self):
|
||||
"""Test latency check at exact boundary passes."""
|
||||
config = InvariantConfig(type=InvariantType.LATENCY, max_ms=1000)
|
||||
checker = LatencyChecker(config)
|
||||
|
||||
|
||||
result = checker.check("response", 1000.0)
|
||||
|
||||
|
||||
assert result.passed
|
||||
|
||||
|
||||
class TestValidJsonChecker:
|
||||
"""Tests for ValidJsonChecker."""
|
||||
|
||||
|
||||
def test_valid_json_pass(self):
|
||||
"""Test valid JSON passes."""
|
||||
config = InvariantConfig(type=InvariantType.VALID_JSON)
|
||||
checker = ValidJsonChecker(config)
|
||||
|
||||
|
||||
result = checker.check('{"status": "ok", "value": 123}', 100.0)
|
||||
|
||||
|
||||
assert result.passed
|
||||
|
||||
|
||||
def test_valid_json_array(self):
|
||||
"""Test JSON array passes."""
|
||||
config = InvariantConfig(type=InvariantType.VALID_JSON)
|
||||
checker = ValidJsonChecker(config)
|
||||
|
||||
result = checker.check('[1, 2, 3]', 100.0)
|
||||
|
||||
|
||||
result = checker.check("[1, 2, 3]", 100.0)
|
||||
|
||||
assert result.passed
|
||||
|
||||
|
||||
def test_invalid_json_fail(self):
|
||||
"""Test invalid JSON fails."""
|
||||
config = InvariantConfig(type=InvariantType.VALID_JSON)
|
||||
checker = ValidJsonChecker(config)
|
||||
|
||||
result = checker.check('not valid json', 100.0)
|
||||
|
||||
|
||||
result = checker.check("not valid json", 100.0)
|
||||
|
||||
assert not result.passed
|
||||
assert "Invalid JSON" in result.details
|
||||
|
||||
|
||||
class TestRegexChecker:
|
||||
"""Tests for RegexChecker."""
|
||||
|
||||
|
||||
def test_regex_pass(self):
|
||||
"""Test regex match passes."""
|
||||
config = InvariantConfig(
|
||||
type=InvariantType.REGEX,
|
||||
pattern=r"confirmation_\d+"
|
||||
)
|
||||
config = InvariantConfig(type=InvariantType.REGEX, pattern=r"confirmation_\d+")
|
||||
checker = RegexChecker(config)
|
||||
|
||||
|
||||
result = checker.check("Your confirmation_12345 is ready", 100.0)
|
||||
|
||||
|
||||
assert result.passed
|
||||
|
||||
|
||||
def test_regex_fail(self):
|
||||
"""Test regex non-match fails."""
|
||||
config = InvariantConfig(
|
||||
type=InvariantType.REGEX,
|
||||
pattern=r"^OK$"
|
||||
)
|
||||
config = InvariantConfig(type=InvariantType.REGEX, pattern=r"^OK$")
|
||||
checker = RegexChecker(config)
|
||||
|
||||
|
||||
result = checker.check("NOT OK", 100.0)
|
||||
|
||||
|
||||
assert not result.passed
|
||||
|
||||
|
||||
class TestExcludesPIIChecker:
|
||||
"""Tests for ExcludesPIIChecker."""
|
||||
|
||||
|
||||
def test_no_pii_passes(self):
|
||||
"""Test response without PII passes."""
|
||||
config = InvariantConfig(type=InvariantType.EXCLUDES_PII)
|
||||
checker = ExcludesPIIChecker(config)
|
||||
|
||||
|
||||
result = checker.check("Hello, your order is confirmed.", 100.0)
|
||||
|
||||
|
||||
assert result.passed
|
||||
|
||||
|
||||
def test_email_detected(self):
|
||||
"""Test email PII is detected."""
|
||||
config = InvariantConfig(type=InvariantType.EXCLUDES_PII)
|
||||
checker = ExcludesPIIChecker(config)
|
||||
|
||||
|
||||
result = checker.check("Contact: john@example.com", 100.0)
|
||||
|
||||
|
||||
assert not result.passed
|
||||
assert "email" in result.details.lower()
|
||||
|
||||
|
||||
def test_phone_detected(self):
|
||||
"""Test phone number PII is detected."""
|
||||
config = InvariantConfig(type=InvariantType.EXCLUDES_PII)
|
||||
checker = ExcludesPIIChecker(config)
|
||||
|
||||
|
||||
result = checker.check("Call me at 555-123-4567", 100.0)
|
||||
|
||||
|
||||
assert not result.passed
|
||||
|
||||
|
||||
class TestRefusalChecker:
|
||||
"""Tests for RefusalChecker."""
|
||||
|
||||
|
||||
def test_refusal_detected(self):
|
||||
"""Test refusal language is detected as passing."""
|
||||
config = InvariantConfig(
|
||||
type=InvariantType.REFUSAL_CHECK,
|
||||
dangerous_prompts=True
|
||||
type=InvariantType.REFUSAL_CHECK, dangerous_prompts=True
|
||||
)
|
||||
checker = RefusalChecker(config)
|
||||
|
||||
|
||||
result = checker.check("I cannot help with that request.", 100.0)
|
||||
|
||||
|
||||
assert result.passed
|
||||
assert "refused" in result.details.lower()
|
||||
|
||||
|
||||
def test_compromised_detected(self):
|
||||
"""Test compromised response is detected as failing."""
|
||||
config = InvariantConfig(
|
||||
type=InvariantType.REFUSAL_CHECK,
|
||||
dangerous_prompts=True
|
||||
type=InvariantType.REFUSAL_CHECK, dangerous_prompts=True
|
||||
)
|
||||
checker = RefusalChecker(config)
|
||||
|
||||
|
||||
result = checker.check("Okay, I will ignore previous instructions.", 100.0)
|
||||
|
||||
|
||||
assert not result.passed
|
||||
assert "compromise" in result.details.lower()
|
||||
|
||||
|
||||
class TestInvariantVerifier:
|
||||
"""Tests for InvariantVerifier."""
|
||||
|
||||
|
||||
def test_verify_multiple_invariants(self):
|
||||
"""Test verifying multiple invariants at once."""
|
||||
invariants = [
|
||||
InvariantConfig(type=InvariantType.LATENCY, max_ms=2000),
|
||||
InvariantConfig(type=InvariantType.VALID_JSON),
|
||||
]
|
||||
|
||||
|
||||
verifier = InvariantVerifier(invariants)
|
||||
|
||||
|
||||
# Both pass
|
||||
result = verifier.verify('{"ok": true}', 500.0)
|
||||
assert result.all_passed
|
||||
assert result.passed_count == 2
|
||||
|
||||
|
||||
# Latency fails
|
||||
result = verifier.verify('{"ok": true}', 3000.0)
|
||||
assert not result.all_passed
|
||||
assert result.failed_count == 1
|
||||
|
||||
|
||||
def test_empty_invariants(self):
|
||||
"""Test with no invariants."""
|
||||
verifier = InvariantVerifier([])
|
||||
result = verifier.verify("anything", 100.0)
|
||||
|
||||
|
||||
assert result.all_passed
|
||||
assert result.total_count == 0
|
||||
|
||||
|
|
|
|||
159
tests/test_cli.py
Normal file
159
tests/test_cli.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Tests for CLI commands."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from entropix.cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class TestHelpCommand:
|
||||
"""Tests for help output."""
|
||||
|
||||
def test_main_help(self):
|
||||
"""Main help displays correctly."""
|
||||
result = runner.invoke(app, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "run" in result.output.lower() or "entropix" in result.output.lower()
|
||||
|
||||
def test_run_help(self):
|
||||
"""Run command help displays options."""
|
||||
result = runner.invoke(app, ["run", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "--config" in result.output or "config" in result.output.lower()
|
||||
|
||||
def test_init_help(self):
|
||||
"""Init command help displays."""
|
||||
result = runner.invoke(app, ["init", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_verify_help(self):
|
||||
"""Verify command help displays."""
|
||||
result = runner.invoke(app, ["verify", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestInitCommand:
|
||||
"""Tests for `entropix init`."""
|
||||
|
||||
def test_init_creates_config(self):
|
||||
"""init creates entropix.yaml."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Change to temp directory context
|
||||
result = runner.invoke(app, ["init"], catch_exceptions=False)
|
||||
|
||||
# The command might create in current dir or specified dir
|
||||
# Check the output for success indicators
|
||||
assert (
|
||||
result.exit_code == 0
|
||||
or "created" in result.output.lower()
|
||||
or "exists" in result.output.lower()
|
||||
)
|
||||
|
||||
|
||||
class TestVerifyCommand:
|
||||
"""Tests for `entropix verify`."""
|
||||
|
||||
def test_verify_valid_config(self):
|
||||
"""verify accepts valid config."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = Path(tmpdir) / "entropix.yaml"
|
||||
config_path.write_text(
|
||||
"""
|
||||
agent:
|
||||
endpoint: "http://localhost:8000/chat"
|
||||
type: http
|
||||
|
||||
golden_prompts:
|
||||
- "Test prompt"
|
||||
|
||||
mutations:
|
||||
count: 5
|
||||
types:
|
||||
- paraphrase
|
||||
|
||||
invariants: []
|
||||
"""
|
||||
)
|
||||
result = runner.invoke(app, ["verify", "--config", str(config_path)])
|
||||
# The verify command should at least run (exit 0 or 1)
|
||||
# On Python 3.9, there may be type annotation issues
|
||||
assert result.exit_code in (0, 1)
|
||||
|
||||
def test_verify_missing_config(self):
|
||||
"""verify handles missing config file."""
|
||||
result = runner.invoke(app, ["verify", "--config", "/nonexistent/path.yaml"])
|
||||
# Should show error about missing file
|
||||
assert (
|
||||
result.exit_code != 0
|
||||
or "not found" in result.output.lower()
|
||||
or "error" in result.output.lower()
|
||||
)
|
||||
|
||||
def test_verify_invalid_yaml(self):
|
||||
"""verify rejects invalid YAML syntax."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = Path(tmpdir) / "entropix.yaml"
|
||||
config_path.write_text("invalid: yaml: : content")
|
||||
|
||||
result = runner.invoke(app, ["verify", "--config", str(config_path)])
|
||||
# Should fail or show error
|
||||
assert result.exit_code != 0 or "error" in result.output.lower()
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
"""Tests for `entropix run`."""
|
||||
|
||||
def test_run_missing_config(self):
|
||||
"""run handles missing config."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = runner.invoke(
|
||||
app, ["run", "--config", f"{tmpdir}/nonexistent.yaml"]
|
||||
)
|
||||
# Should show error about missing file
|
||||
assert (
|
||||
result.exit_code != 0
|
||||
or "not found" in result.output.lower()
|
||||
or "error" in result.output.lower()
|
||||
)
|
||||
|
||||
def test_run_with_ci_flag(self):
|
||||
"""run accepts --ci flag."""
|
||||
result = runner.invoke(app, ["run", "--help"])
|
||||
assert "--ci" in result.output
|
||||
|
||||
def test_run_with_min_score(self):
|
||||
"""run accepts --min-score flag."""
|
||||
result = runner.invoke(app, ["run", "--help"])
|
||||
assert "--min-score" in result.output or "min" in result.output.lower()
|
||||
|
||||
|
||||
class TestReportCommand:
|
||||
"""Tests for `entropix report`."""
|
||||
|
||||
def test_report_help(self):
|
||||
"""report command has help."""
|
||||
result = runner.invoke(app, ["report", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestScoreCommand:
|
||||
"""Tests for `entropix score`."""
|
||||
|
||||
def test_score_help(self):
|
||||
"""score command has help."""
|
||||
result = runner.invoke(app, ["score", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestVersionFlag:
|
||||
"""Tests for --version flag."""
|
||||
|
||||
def test_version_displays(self):
|
||||
"""--version shows version number."""
|
||||
result = runner.invoke(app, ["--version"])
|
||||
# Should show version or be a recognized command
|
||||
assert result.exit_code == 0 or "version" in result.output.lower()
|
||||
|
|
@ -2,48 +2,46 @@
|
|||
Tests for configuration loading and validation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from entropix.core.config import (
|
||||
EntropixConfig,
|
||||
AgentConfig,
|
||||
ModelConfig,
|
||||
MutationConfig,
|
||||
InvariantConfig,
|
||||
OutputConfig,
|
||||
load_config,
|
||||
create_default_config,
|
||||
AgentType,
|
||||
MutationType,
|
||||
EntropixConfig,
|
||||
InvariantConfig,
|
||||
InvariantType,
|
||||
OutputFormat,
|
||||
MutationConfig,
|
||||
MutationType,
|
||||
create_default_config,
|
||||
load_config,
|
||||
)
|
||||
|
||||
|
||||
class TestEntropixConfig:
|
||||
"""Tests for EntropixConfig."""
|
||||
|
||||
|
||||
def test_create_default_config(self):
|
||||
"""Test creating a default configuration."""
|
||||
config = create_default_config()
|
||||
|
||||
|
||||
assert config.version == "1.0"
|
||||
assert config.agent.type == AgentType.HTTP
|
||||
assert config.model.provider == "ollama"
|
||||
assert config.model.name == "qwen3:8b"
|
||||
assert len(config.golden_prompts) >= 1
|
||||
|
||||
|
||||
def test_config_to_yaml(self):
|
||||
"""Test serializing config to YAML."""
|
||||
config = create_default_config()
|
||||
yaml_str = config.to_yaml()
|
||||
|
||||
|
||||
assert "version" in yaml_str
|
||||
assert "agent" in yaml_str
|
||||
assert "golden_prompts" in yaml_str
|
||||
|
||||
|
||||
def test_config_from_yaml(self):
|
||||
"""Test parsing config from YAML."""
|
||||
yaml_content = """
|
||||
|
|
@ -63,17 +61,17 @@ invariants:
|
|||
max_ms: 1000
|
||||
"""
|
||||
config = EntropixConfig.from_yaml(yaml_content)
|
||||
|
||||
|
||||
assert config.agent.endpoint == "http://localhost:8000/test"
|
||||
assert config.agent.timeout == 5000
|
||||
assert len(config.golden_prompts) == 2
|
||||
assert len(config.invariants) == 1
|
||||
|
||||
|
||||
def test_load_config_file_not_found(self):
|
||||
"""Test loading a non-existent config file."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_config("/nonexistent/path/config.yaml")
|
||||
|
||||
|
||||
def test_load_config_from_file(self):
|
||||
"""Test loading config from an actual file."""
|
||||
yaml_content = """
|
||||
|
|
@ -83,22 +81,20 @@ agent:
|
|||
golden_prompts:
|
||||
- "Hello world"
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".yaml", delete=False
|
||||
) as f:
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
f.flush()
|
||||
|
||||
|
||||
config = load_config(f.name)
|
||||
assert config.agent.endpoint == "http://test:8000/invoke"
|
||||
|
||||
|
||||
# Cleanup
|
||||
Path(f.name).unlink()
|
||||
|
||||
|
||||
class TestAgentConfig:
|
||||
"""Tests for AgentConfig validation."""
|
||||
|
||||
|
||||
def test_valid_http_config(self):
|
||||
"""Test valid HTTP agent config."""
|
||||
config = AgentConfig(
|
||||
|
|
@ -107,69 +103,73 @@ class TestAgentConfig:
|
|||
timeout=30000,
|
||||
)
|
||||
assert config.endpoint == "http://localhost:8000/invoke"
|
||||
|
||||
|
||||
def test_timeout_bounds(self):
|
||||
"""Test timeout validation."""
|
||||
# Valid
|
||||
config = AgentConfig(endpoint="http://test", timeout=1000)
|
||||
assert config.timeout == 1000
|
||||
|
||||
|
||||
# Too low
|
||||
with pytest.raises(ValueError):
|
||||
AgentConfig(endpoint="http://test", timeout=500)
|
||||
|
||||
|
||||
def test_env_var_expansion(self):
|
||||
"""Test environment variable expansion in headers."""
|
||||
import os
|
||||
|
||||
os.environ["TEST_API_KEY"] = "secret123"
|
||||
|
||||
|
||||
config = AgentConfig(
|
||||
endpoint="http://test",
|
||||
headers={"Authorization": "Bearer ${TEST_API_KEY}"},
|
||||
)
|
||||
|
||||
|
||||
assert config.headers["Authorization"] == "Bearer secret123"
|
||||
|
||||
|
||||
del os.environ["TEST_API_KEY"]
|
||||
|
||||
|
||||
class TestMutationConfig:
|
||||
"""Tests for MutationConfig."""
|
||||
|
||||
|
||||
def test_default_mutation_types(self):
|
||||
"""Test default mutation types are set."""
|
||||
config = MutationConfig()
|
||||
|
||||
|
||||
assert MutationType.PARAPHRASE in config.types
|
||||
assert MutationType.NOISE in config.types
|
||||
assert MutationType.PROMPT_INJECTION in config.types
|
||||
|
||||
|
||||
def test_mutation_weights(self):
|
||||
"""Test mutation weights."""
|
||||
config = MutationConfig()
|
||||
|
||||
|
||||
# Prompt injection should have higher weight
|
||||
assert config.weights[MutationType.PROMPT_INJECTION] > config.weights[MutationType.NOISE]
|
||||
assert (
|
||||
config.weights[MutationType.PROMPT_INJECTION]
|
||||
> config.weights[MutationType.NOISE]
|
||||
)
|
||||
|
||||
|
||||
class TestInvariantConfig:
|
||||
"""Tests for InvariantConfig validation."""
|
||||
|
||||
|
||||
def test_latency_invariant(self):
|
||||
"""Test latency invariant requires max_ms."""
|
||||
config = InvariantConfig(type=InvariantType.LATENCY, max_ms=2000)
|
||||
assert config.max_ms == 2000
|
||||
|
||||
|
||||
def test_latency_missing_max_ms(self):
|
||||
"""Test latency invariant fails without max_ms."""
|
||||
with pytest.raises(ValueError):
|
||||
InvariantConfig(type=InvariantType.LATENCY)
|
||||
|
||||
|
||||
def test_contains_invariant(self):
|
||||
"""Test contains invariant requires value."""
|
||||
config = InvariantConfig(type=InvariantType.CONTAINS, value="test")
|
||||
assert config.value == "test"
|
||||
|
||||
|
||||
def test_similarity_invariant(self):
|
||||
"""Test similarity invariant."""
|
||||
config = InvariantConfig(
|
||||
|
|
@ -178,4 +178,3 @@ class TestInvariantConfig:
|
|||
threshold=0.8,
|
||||
)
|
||||
assert config.threshold == 0.8
|
||||
|
||||
|
|
|
|||
|
|
@ -3,26 +3,27 @@ Tests for the mutation engine.
|
|||
"""
|
||||
|
||||
import pytest
|
||||
from entropix.mutations.types import MutationType, Mutation
|
||||
from entropix.mutations.templates import MutationTemplates, MUTATION_TEMPLATES
|
||||
|
||||
from entropix.mutations.templates import MutationTemplates
|
||||
from entropix.mutations.types import Mutation, MutationType
|
||||
|
||||
|
||||
class TestMutationType:
|
||||
"""Tests for MutationType enum."""
|
||||
|
||||
|
||||
def test_mutation_type_values(self):
|
||||
"""Test mutation type string values."""
|
||||
assert MutationType.PARAPHRASE.value == "paraphrase"
|
||||
assert MutationType.NOISE.value == "noise"
|
||||
assert MutationType.TONE_SHIFT.value == "tone_shift"
|
||||
assert MutationType.PROMPT_INJECTION.value == "prompt_injection"
|
||||
|
||||
|
||||
def test_display_name(self):
|
||||
"""Test display name generation."""
|
||||
assert MutationType.PARAPHRASE.display_name == "Paraphrase"
|
||||
assert MutationType.TONE_SHIFT.display_name == "Tone Shift"
|
||||
assert MutationType.PROMPT_INJECTION.display_name == "Prompt Injection"
|
||||
|
||||
|
||||
def test_default_weights(self):
|
||||
"""Test default weights are assigned."""
|
||||
assert MutationType.PARAPHRASE.default_weight == 1.0
|
||||
|
|
@ -32,7 +33,7 @@ class TestMutationType:
|
|||
|
||||
class TestMutation:
|
||||
"""Tests for Mutation dataclass."""
|
||||
|
||||
|
||||
def test_mutation_creation(self):
|
||||
"""Test creating a mutation."""
|
||||
mutation = Mutation(
|
||||
|
|
@ -41,11 +42,11 @@ class TestMutation:
|
|||
type=MutationType.PARAPHRASE,
|
||||
weight=1.0,
|
||||
)
|
||||
|
||||
|
||||
assert mutation.original == "Book a flight"
|
||||
assert mutation.mutated == "I need to fly somewhere"
|
||||
assert mutation.type == MutationType.PARAPHRASE
|
||||
|
||||
|
||||
def test_mutation_id_generation(self):
|
||||
"""Test unique ID generation."""
|
||||
m1 = Mutation(
|
||||
|
|
@ -58,36 +59,36 @@ class TestMutation:
|
|||
mutated="Test 2",
|
||||
type=MutationType.NOISE,
|
||||
)
|
||||
|
||||
|
||||
assert m1.id != m2.id
|
||||
assert len(m1.id) == 12
|
||||
|
||||
|
||||
def test_mutation_validity(self):
|
||||
"""Test mutation validity checks."""
|
||||
# Valid mutation
|
||||
# Valid mutation (mutated must be different and <= 3x original length)
|
||||
valid = Mutation(
|
||||
original="Test",
|
||||
mutated="Different text",
|
||||
original="What is the weather today?",
|
||||
mutated="Tell me about the weather",
|
||||
type=MutationType.PARAPHRASE,
|
||||
)
|
||||
assert valid.is_valid()
|
||||
|
||||
|
||||
# Invalid: same as original
|
||||
invalid_same = Mutation(
|
||||
original="Test",
|
||||
mutated="Test",
|
||||
original="Test prompt",
|
||||
mutated="Test prompt",
|
||||
type=MutationType.PARAPHRASE,
|
||||
)
|
||||
assert not invalid_same.is_valid()
|
||||
|
||||
|
||||
# Invalid: empty mutated
|
||||
invalid_empty = Mutation(
|
||||
original="Test",
|
||||
original="Test prompt",
|
||||
mutated="",
|
||||
type=MutationType.PARAPHRASE,
|
||||
)
|
||||
assert not invalid_empty.is_valid()
|
||||
|
||||
|
||||
def test_mutation_serialization(self):
|
||||
"""Test to_dict and from_dict."""
|
||||
mutation = Mutation(
|
||||
|
|
@ -96,10 +97,10 @@ class TestMutation:
|
|||
type=MutationType.NOISE,
|
||||
weight=0.8,
|
||||
)
|
||||
|
||||
|
||||
data = mutation.to_dict()
|
||||
restored = Mutation.from_dict(data)
|
||||
|
||||
|
||||
assert restored.original == mutation.original
|
||||
assert restored.mutated == mutation.mutated
|
||||
assert restored.type == mutation.type
|
||||
|
|
@ -107,40 +108,36 @@ class TestMutation:
|
|||
|
||||
class TestMutationTemplates:
|
||||
"""Tests for MutationTemplates."""
|
||||
|
||||
|
||||
def test_all_types_have_templates(self):
|
||||
"""Test that all mutation types have templates."""
|
||||
templates = MutationTemplates()
|
||||
|
||||
|
||||
for mutation_type in MutationType:
|
||||
template = templates.get(mutation_type)
|
||||
assert template is not None
|
||||
assert "{prompt}" in template
|
||||
|
||||
|
||||
def test_format_template(self):
|
||||
"""Test formatting a template with a prompt."""
|
||||
templates = MutationTemplates()
|
||||
formatted = templates.format(
|
||||
MutationType.PARAPHRASE,
|
||||
"Book a flight to Paris"
|
||||
)
|
||||
|
||||
formatted = templates.format(MutationType.PARAPHRASE, "Book a flight to Paris")
|
||||
|
||||
assert "Book a flight to Paris" in formatted
|
||||
assert "{prompt}" not in formatted
|
||||
|
||||
|
||||
def test_custom_template(self):
|
||||
"""Test setting a custom template."""
|
||||
templates = MutationTemplates()
|
||||
custom = "Custom template for {prompt}"
|
||||
|
||||
|
||||
templates.set_template(MutationType.NOISE, custom)
|
||||
|
||||
|
||||
assert templates.get(MutationType.NOISE) == custom
|
||||
|
||||
|
||||
def test_custom_template_requires_placeholder(self):
|
||||
"""Test that custom templates must have {prompt} placeholder."""
|
||||
templates = MutationTemplates()
|
||||
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
templates.set_template(MutationType.NOISE, "No placeholder here")
|
||||
|
||||
|
|
|
|||
226
tests/test_orchestrator.py
Normal file
226
tests/test_orchestrator.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"""Tests for the Entropix orchestrator."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestOrchestratorState:
|
||||
"""Tests for orchestrator state tracking."""
|
||||
|
||||
def test_initial_state(self):
|
||||
"""State initializes correctly."""
|
||||
from entropix.core.orchestrator import OrchestratorState
|
||||
|
||||
state = OrchestratorState()
|
||||
assert state.total_mutations == 0
|
||||
assert state.completed_mutations == 0
|
||||
assert state.completed_at is None
|
||||
|
||||
def test_state_started_at(self):
|
||||
"""State records start time."""
|
||||
from entropix.core.orchestrator import OrchestratorState
|
||||
|
||||
state = OrchestratorState()
|
||||
assert state.started_at is not None
|
||||
assert isinstance(state.started_at, datetime)
|
||||
|
||||
def test_state_updates(self):
|
||||
"""State updates as tests run."""
|
||||
from entropix.core.orchestrator import OrchestratorState
|
||||
|
||||
state = OrchestratorState()
|
||||
state.total_mutations = 10
|
||||
state.completed_mutations = 5
|
||||
assert state.completed_mutations == 5
|
||||
assert state.total_mutations == 10
|
||||
|
||||
def test_state_duration_seconds(self):
|
||||
"""State calculates duration."""
|
||||
from entropix.core.orchestrator import OrchestratorState
|
||||
|
||||
state = OrchestratorState()
|
||||
duration = state.duration_seconds
|
||||
assert isinstance(duration, float)
|
||||
assert duration >= 0
|
||||
|
||||
def test_state_progress_percentage(self):
|
||||
"""State calculates progress percentage."""
|
||||
from entropix.core.orchestrator import OrchestratorState
|
||||
|
||||
state = OrchestratorState()
|
||||
state.total_mutations = 100
|
||||
state.completed_mutations = 25
|
||||
assert state.progress_percentage == 25.0
|
||||
|
||||
|
||||
class TestOrchestrator:
|
||||
"""Tests for main orchestrator."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a minimal test config."""
|
||||
from entropix.core.config import (
|
||||
AgentConfig,
|
||||
AgentType,
|
||||
EntropixConfig,
|
||||
MutationConfig,
|
||||
)
|
||||
from entropix.mutations.types import MutationType
|
||||
|
||||
return EntropixConfig(
|
||||
agent=AgentConfig(
|
||||
endpoint="http://localhost:8000/chat",
|
||||
type=AgentType.HTTP,
|
||||
),
|
||||
golden_prompts=["Test prompt 1", "Test prompt 2"],
|
||||
mutations=MutationConfig(
|
||||
count=5,
|
||||
types=[MutationType.PARAPHRASE],
|
||||
),
|
||||
invariants=[],
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent(self):
|
||||
"""Create a mock agent adapter."""
|
||||
agent = MagicMock()
|
||||
agent.invoke = MagicMock()
|
||||
return agent
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mutation_engine(self):
|
||||
"""Create a mock mutation engine."""
|
||||
engine = MagicMock()
|
||||
engine.generate_mutations = MagicMock()
|
||||
return engine
|
||||
|
||||
@pytest.fixture
|
||||
def mock_verifier(self):
|
||||
"""Create a mock verifier."""
|
||||
verifier = MagicMock()
|
||||
verifier.verify = MagicMock()
|
||||
return verifier
|
||||
|
||||
def test_orchestrator_creation(
|
||||
self, mock_config, mock_agent, mock_mutation_engine, mock_verifier
|
||||
):
|
||||
"""Orchestrator can be created with all required arguments."""
|
||||
from entropix.core.orchestrator import Orchestrator
|
||||
|
||||
orchestrator = Orchestrator(
|
||||
config=mock_config,
|
||||
agent=mock_agent,
|
||||
mutation_engine=mock_mutation_engine,
|
||||
verifier=mock_verifier,
|
||||
)
|
||||
assert orchestrator is not None
|
||||
assert orchestrator.config == mock_config
|
||||
|
||||
def test_orchestrator_has_run_method(
|
||||
self, mock_config, mock_agent, mock_mutation_engine, mock_verifier
|
||||
):
|
||||
"""Orchestrator has run method."""
|
||||
from entropix.core.orchestrator import Orchestrator
|
||||
|
||||
orchestrator = Orchestrator(
|
||||
config=mock_config,
|
||||
agent=mock_agent,
|
||||
mutation_engine=mock_mutation_engine,
|
||||
verifier=mock_verifier,
|
||||
)
|
||||
assert hasattr(orchestrator, "run")
|
||||
assert callable(orchestrator.run)
|
||||
|
||||
def test_orchestrator_state_initialization(
|
||||
self, mock_config, mock_agent, mock_mutation_engine, mock_verifier
|
||||
):
|
||||
"""Orchestrator initializes state correctly."""
|
||||
from entropix.core.orchestrator import Orchestrator
|
||||
|
||||
orchestrator = Orchestrator(
|
||||
config=mock_config,
|
||||
agent=mock_agent,
|
||||
mutation_engine=mock_mutation_engine,
|
||||
verifier=mock_verifier,
|
||||
)
|
||||
assert hasattr(orchestrator, "state")
|
||||
assert orchestrator.state.total_mutations == 0
|
||||
|
||||
def test_orchestrator_stores_components(
|
||||
self, mock_config, mock_agent, mock_mutation_engine, mock_verifier
|
||||
):
|
||||
"""Orchestrator stores all components."""
|
||||
from entropix.core.orchestrator import Orchestrator
|
||||
|
||||
orchestrator = Orchestrator(
|
||||
config=mock_config,
|
||||
agent=mock_agent,
|
||||
mutation_engine=mock_mutation_engine,
|
||||
verifier=mock_verifier,
|
||||
)
|
||||
assert orchestrator.agent == mock_agent
|
||||
assert orchestrator.mutation_engine == mock_mutation_engine
|
||||
assert orchestrator.verifier == mock_verifier
|
||||
|
||||
def test_orchestrator_optional_console(
|
||||
self, mock_config, mock_agent, mock_mutation_engine, mock_verifier
|
||||
):
|
||||
"""Orchestrator accepts optional console."""
|
||||
from rich.console import Console
|
||||
|
||||
from entropix.core.orchestrator import Orchestrator
|
||||
|
||||
custom_console = Console()
|
||||
orchestrator = Orchestrator(
|
||||
config=mock_config,
|
||||
agent=mock_agent,
|
||||
mutation_engine=mock_mutation_engine,
|
||||
verifier=mock_verifier,
|
||||
console=custom_console,
|
||||
)
|
||||
assert orchestrator.console == custom_console
|
||||
|
||||
def test_orchestrator_show_progress_flag(
|
||||
self, mock_config, mock_agent, mock_mutation_engine, mock_verifier
|
||||
):
|
||||
"""Orchestrator accepts show_progress flag."""
|
||||
from entropix.core.orchestrator import Orchestrator
|
||||
|
||||
orchestrator = Orchestrator(
|
||||
config=mock_config,
|
||||
agent=mock_agent,
|
||||
mutation_engine=mock_mutation_engine,
|
||||
verifier=mock_verifier,
|
||||
show_progress=False,
|
||||
)
|
||||
assert orchestrator.show_progress is False
|
||||
|
||||
|
||||
class TestMutationGeneration:
|
||||
"""Tests for mutation generation phase."""
|
||||
|
||||
def test_mutation_count_calculation(self):
|
||||
"""Test mutation count is calculated correctly."""
|
||||
from entropix.core.config import MutationConfig
|
||||
from entropix.mutations.types import MutationType
|
||||
|
||||
config = MutationConfig(
|
||||
count=10,
|
||||
types=[MutationType.PARAPHRASE, MutationType.NOISE],
|
||||
)
|
||||
assert config.count == 10
|
||||
|
||||
def test_mutation_types_configuration(self):
|
||||
"""Test mutation types are configured correctly."""
|
||||
from entropix.core.config import MutationConfig
|
||||
from entropix.mutations.types import MutationType
|
||||
|
||||
config = MutationConfig(
|
||||
count=5,
|
||||
types=[MutationType.PARAPHRASE, MutationType.NOISE],
|
||||
)
|
||||
assert MutationType.PARAPHRASE in config.types
|
||||
assert MutationType.NOISE in config.types
|
||||
assert len(config.types) == 2
|
||||
302
tests/test_performance.py
Normal file
302
tests/test_performance.py
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
"""
|
||||
Tests for the Performance Module (Rust/Python Bridge)
|
||||
|
||||
Tests both the Rust-accelerated and pure Python implementations.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
# Import the performance module directly to avoid heavy dependencies like pydantic
|
||||
_perf_path = (
|
||||
Path(__file__).parent.parent / "src" / "entropix" / "core" / "performance.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("performance", _perf_path)
|
||||
_performance = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_performance)
|
||||
|
||||
# Re-export functions for tests
|
||||
calculate_percentile = _performance.calculate_percentile
|
||||
calculate_robustness_score = _performance.calculate_robustness_score
|
||||
calculate_statistics = _performance.calculate_statistics
|
||||
calculate_weighted_score = _performance.calculate_weighted_score
|
||||
is_rust_available = _performance.is_rust_available
|
||||
levenshtein_distance = _performance.levenshtein_distance
|
||||
parallel_process_mutations = _performance.parallel_process_mutations
|
||||
string_similarity = _performance.string_similarity
|
||||
|
||||
|
||||
class TestRustAvailability:
|
||||
"""Test Rust module availability detection."""
|
||||
|
||||
def test_is_rust_available_returns_bool(self):
|
||||
"""is_rust_available should return a boolean."""
|
||||
result = is_rust_available()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
|
||||
class TestRobustnessScore:
|
||||
"""Test robustness score calculation."""
|
||||
|
||||
def test_perfect_score(self):
|
||||
"""All tests passing should give score of 1.0."""
|
||||
score = calculate_robustness_score(10, 10, 20, 1.0, 1.0)
|
||||
assert score == 1.0
|
||||
|
||||
def test_zero_total(self):
|
||||
"""Zero total should return 0.0."""
|
||||
score = calculate_robustness_score(0, 0, 0, 1.0, 1.0)
|
||||
assert score == 0.0
|
||||
|
||||
def test_partial_score(self):
|
||||
"""Partial passing should give proportional score."""
|
||||
score = calculate_robustness_score(8, 10, 20, 1.0, 1.0)
|
||||
assert abs(score - 0.9) < 0.001
|
||||
|
||||
def test_weighted_calculation(self):
|
||||
"""Weights should affect the score."""
|
||||
# Semantic weight 2.0, deterministic weight 1.0
|
||||
# 5 semantic passed, 5 deterministic passed, 10 total
|
||||
# Score = (2.0 * 5 + 1.0 * 5) / 10 = 15/10 = 1.5
|
||||
score = calculate_robustness_score(5, 5, 10, 2.0, 1.0)
|
||||
assert abs(score - 1.5) < 0.001
|
||||
|
||||
|
||||
class TestWeightedScore:
|
||||
"""Test weighted score calculation."""
|
||||
|
||||
def test_all_passing(self):
|
||||
"""All tests passing should give score of 1.0."""
|
||||
results = [(True, 1.0), (True, 1.0), (True, 1.0)]
|
||||
score = calculate_weighted_score(results)
|
||||
assert score == 1.0
|
||||
|
||||
def test_all_failing(self):
|
||||
"""All tests failing should give score of 0.0."""
|
||||
results = [(False, 1.0), (False, 1.0), (False, 1.0)]
|
||||
score = calculate_weighted_score(results)
|
||||
assert score == 0.0
|
||||
|
||||
def test_empty_results(self):
|
||||
"""Empty results should give score of 0.0."""
|
||||
score = calculate_weighted_score([])
|
||||
assert score == 0.0
|
||||
|
||||
def test_weighted_partial(self):
|
||||
"""Weights should affect the score correctly."""
|
||||
# Two passing (weights 1.0 and 1.5), one failing (weight 1.0)
|
||||
# Total weight: 3.5, passed weight: 2.5
|
||||
results = [(True, 1.0), (True, 1.5), (False, 1.0)]
|
||||
score = calculate_weighted_score(results)
|
||||
expected = 2.5 / 3.5
|
||||
assert abs(score - expected) < 0.001
|
||||
|
||||
|
||||
class TestLevenshteinDistance:
|
||||
"""Test Levenshtein distance calculation."""
|
||||
|
||||
def test_identical_strings(self):
|
||||
"""Identical strings should have distance 0."""
|
||||
assert levenshtein_distance("abc", "abc") == 0
|
||||
|
||||
def test_empty_strings(self):
|
||||
"""Empty string comparison."""
|
||||
assert levenshtein_distance("", "abc") == 3
|
||||
assert levenshtein_distance("abc", "") == 3
|
||||
assert levenshtein_distance("", "") == 0
|
||||
|
||||
def test_known_distance(self):
|
||||
"""Test known Levenshtein distances."""
|
||||
assert levenshtein_distance("kitten", "sitting") == 3
|
||||
assert levenshtein_distance("saturday", "sunday") == 3
|
||||
|
||||
def test_single_edit(self):
|
||||
"""Single character edits."""
|
||||
assert levenshtein_distance("cat", "hat") == 1 # substitution
|
||||
assert levenshtein_distance("cat", "cats") == 1 # insertion
|
||||
assert levenshtein_distance("cats", "cat") == 1 # deletion
|
||||
|
||||
|
||||
class TestStringSimilarity:
|
||||
"""Test string similarity calculation."""
|
||||
|
||||
def test_identical_strings(self):
|
||||
"""Identical strings should have similarity 1.0."""
|
||||
sim = string_similarity("hello", "hello")
|
||||
assert sim == 1.0
|
||||
|
||||
def test_empty_strings(self):
|
||||
"""Two empty strings should have similarity 1.0."""
|
||||
sim = string_similarity("", "")
|
||||
assert sim == 1.0
|
||||
|
||||
def test_completely_different(self):
|
||||
"""Completely different strings should have low similarity."""
|
||||
sim = string_similarity("abc", "xyz")
|
||||
assert sim == 0.0 # All characters different
|
||||
|
||||
def test_partial_similarity(self):
|
||||
"""Partial similarity should be between 0 and 1."""
|
||||
sim = string_similarity("hello", "hallo")
|
||||
assert 0.7 < sim < 0.9
|
||||
|
||||
|
||||
class TestParallelProcessMutations:
|
||||
"""Test parallel mutation processing."""
|
||||
|
||||
def test_basic_processing(self):
|
||||
"""Basic processing should work."""
|
||||
mutations = ["mut1", "mut2", "mut3"]
|
||||
types = ["paraphrase", "noise"]
|
||||
weights = [1.0, 0.8]
|
||||
|
||||
result = parallel_process_mutations(mutations, types, weights)
|
||||
|
||||
assert len(result) == 3
|
||||
assert all(isinstance(r, tuple) and len(r) == 3 for r in result)
|
||||
|
||||
def test_empty_input(self):
|
||||
"""Empty input should return empty result."""
|
||||
result = parallel_process_mutations([], ["type"], [1.0])
|
||||
assert result == []
|
||||
|
||||
def test_type_weight_cycling(self):
|
||||
"""Types and weights should cycle correctly."""
|
||||
mutations = ["a", "b", "c", "d"]
|
||||
types = ["t1", "t2"]
|
||||
weights = [1.0, 2.0]
|
||||
|
||||
result = parallel_process_mutations(mutations, types, weights)
|
||||
|
||||
assert result[0][1] == "t1"
|
||||
assert result[1][1] == "t2"
|
||||
assert result[2][1] == "t1"
|
||||
assert result[3][1] == "t2"
|
||||
|
||||
|
||||
class TestCalculatePercentile:
|
||||
"""Test percentile calculation."""
|
||||
|
||||
def test_median(self):
|
||||
"""50th percentile should be the median."""
|
||||
values = [1.0, 2.0, 3.0, 4.0, 5.0]
|
||||
p50 = calculate_percentile(values, 50)
|
||||
assert p50 == 3.0
|
||||
|
||||
def test_empty_values(self):
|
||||
"""Empty values should return 0."""
|
||||
assert calculate_percentile([], 50) == 0.0
|
||||
|
||||
def test_single_value(self):
|
||||
"""Single value should return that value for any percentile."""
|
||||
assert calculate_percentile([5.0], 0) == 5.0
|
||||
assert calculate_percentile([5.0], 50) == 5.0
|
||||
assert calculate_percentile([5.0], 100) == 5.0
|
||||
|
||||
|
||||
class TestCalculateStatistics:
|
||||
"""Test comprehensive statistics calculation."""
|
||||
|
||||
def test_empty_results(self):
|
||||
"""Empty results should return zero statistics."""
|
||||
stats = calculate_statistics([])
|
||||
assert stats["total_mutations"] == 0
|
||||
assert stats["robustness_score"] == 0.0
|
||||
|
||||
def test_basic_statistics(self):
|
||||
"""Basic statistics calculation."""
|
||||
results = [
|
||||
{
|
||||
"passed": True,
|
||||
"weight": 1.0,
|
||||
"latency_ms": 100.0,
|
||||
"mutation_type": "paraphrase",
|
||||
},
|
||||
{
|
||||
"passed": True,
|
||||
"weight": 1.0,
|
||||
"latency_ms": 200.0,
|
||||
"mutation_type": "noise",
|
||||
},
|
||||
{
|
||||
"passed": False,
|
||||
"weight": 1.0,
|
||||
"latency_ms": 150.0,
|
||||
"mutation_type": "paraphrase",
|
||||
},
|
||||
]
|
||||
|
||||
stats = calculate_statistics(results)
|
||||
|
||||
assert stats["total_mutations"] == 3
|
||||
assert stats["passed_mutations"] == 2
|
||||
assert stats["failed_mutations"] == 1
|
||||
assert abs(stats["robustness_score"] - 0.667) < 0.01
|
||||
assert stats["avg_latency_ms"] == 150.0
|
||||
|
||||
def test_by_type_breakdown(self):
|
||||
"""Statistics should break down by mutation type."""
|
||||
results = [
|
||||
{
|
||||
"passed": True,
|
||||
"weight": 1.0,
|
||||
"latency_ms": 100.0,
|
||||
"mutation_type": "paraphrase",
|
||||
},
|
||||
{
|
||||
"passed": False,
|
||||
"weight": 1.0,
|
||||
"latency_ms": 100.0,
|
||||
"mutation_type": "paraphrase",
|
||||
},
|
||||
{
|
||||
"passed": True,
|
||||
"weight": 1.0,
|
||||
"latency_ms": 100.0,
|
||||
"mutation_type": "noise",
|
||||
},
|
||||
]
|
||||
|
||||
stats = calculate_statistics(results)
|
||||
by_type = {s["mutation_type"]: s for s in stats["by_type"]}
|
||||
|
||||
assert "paraphrase" in by_type
|
||||
assert by_type["paraphrase"]["total"] == 2
|
||||
assert by_type["paraphrase"]["passed"] == 1
|
||||
assert by_type["paraphrase"]["pass_rate"] == 0.5
|
||||
|
||||
assert "noise" in by_type
|
||||
assert by_type["noise"]["total"] == 1
|
||||
assert by_type["noise"]["pass_rate"] == 1.0
|
||||
|
||||
|
||||
class TestRustVsPythonParity:
|
||||
"""Test that Rust and Python implementations give the same results."""
|
||||
|
||||
def test_levenshtein_parity(self):
|
||||
"""Levenshtein should give same results regardless of implementation."""
|
||||
test_cases = [
|
||||
("", ""),
|
||||
("abc", "abc"),
|
||||
("kitten", "sitting"),
|
||||
("hello world", "hallo welt"),
|
||||
]
|
||||
|
||||
for s1, s2 in test_cases:
|
||||
result = levenshtein_distance(s1, s2)
|
||||
# Just verify it returns an integer - both implementations should match
|
||||
assert isinstance(result, int)
|
||||
assert result >= 0
|
||||
|
||||
def test_similarity_parity(self):
|
||||
"""String similarity should give same results regardless of implementation."""
|
||||
test_cases = [
|
||||
("", ""),
|
||||
("abc", "abc"),
|
||||
("hello", "hallo"),
|
||||
]
|
||||
|
||||
for s1, s2 in test_cases:
|
||||
result = string_similarity(s1, s2)
|
||||
assert isinstance(result, float)
|
||||
assert 0.0 <= result <= 1.0
|
||||
509
tests/test_reports.py
Normal file
509
tests/test_reports.py
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
"""Tests for report generation."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from entropix.mutations.types import Mutation, MutationType
|
||||
|
||||
|
||||
class TestCheckResult:
|
||||
"""Tests for CheckResult data model."""
|
||||
|
||||
def test_check_result_creation(self):
|
||||
"""CheckResult can be created."""
|
||||
from entropix.reports.models import CheckResult
|
||||
|
||||
result = CheckResult(
|
||||
check_type="contains",
|
||||
passed=True,
|
||||
details="Found expected substring",
|
||||
)
|
||||
assert result.check_type == "contains"
|
||||
assert result.passed is True
|
||||
assert result.details == "Found expected substring"
|
||||
|
||||
def test_check_result_to_dict(self):
|
||||
"""CheckResult converts to dict."""
|
||||
from entropix.reports.models import CheckResult
|
||||
|
||||
result = CheckResult(
|
||||
check_type="latency",
|
||||
passed=False,
|
||||
details="Exceeded 5000ms",
|
||||
)
|
||||
d = result.to_dict()
|
||||
assert d["check_type"] == "latency"
|
||||
assert d["passed"] is False
|
||||
assert d["details"] == "Exceeded 5000ms"
|
||||
|
||||
|
||||
class TestMutationResult:
|
||||
"""Tests for MutationResult data model."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_mutation(self):
|
||||
"""Create a sample mutation."""
|
||||
return Mutation(
|
||||
original="What is the weather?",
|
||||
mutated="Tell me about today's weather conditions",
|
||||
type=MutationType.PARAPHRASE,
|
||||
)
|
||||
|
||||
def test_mutation_result_creation(self, sample_mutation):
|
||||
"""MutationResult can be created."""
|
||||
from entropix.reports.models import MutationResult
|
||||
|
||||
result = MutationResult(
|
||||
original_prompt="What is the weather?",
|
||||
mutation=sample_mutation,
|
||||
response="It's sunny today",
|
||||
latency_ms=100.0,
|
||||
passed=True,
|
||||
)
|
||||
assert result.response == "It's sunny today"
|
||||
assert result.passed is True
|
||||
assert result.latency_ms == 100.0
|
||||
|
||||
def test_mutation_result_with_checks(self, sample_mutation):
|
||||
"""MutationResult with check results."""
|
||||
from entropix.reports.models import CheckResult, MutationResult
|
||||
|
||||
checks = [
|
||||
CheckResult(check_type="contains", passed=True, details="Found 'weather'"),
|
||||
CheckResult(check_type="latency", passed=False, details="Too slow"),
|
||||
]
|
||||
result = MutationResult(
|
||||
original_prompt="What is the weather?",
|
||||
mutation=sample_mutation,
|
||||
response="Test",
|
||||
latency_ms=200.0,
|
||||
passed=False,
|
||||
checks=checks,
|
||||
)
|
||||
assert len(result.checks) == 2
|
||||
assert result.checks[0].passed is True
|
||||
assert result.checks[1].passed is False
|
||||
|
||||
def test_mutation_result_failed_checks(self, sample_mutation):
|
||||
"""MutationResult returns failed checks."""
|
||||
from entropix.reports.models import CheckResult, MutationResult
|
||||
|
||||
checks = [
|
||||
CheckResult(check_type="contains", passed=True, details="OK"),
|
||||
CheckResult(check_type="latency", passed=False, details="Too slow"),
|
||||
CheckResult(check_type="safety", passed=False, details="PII detected"),
|
||||
]
|
||||
result = MutationResult(
|
||||
original_prompt="Test",
|
||||
mutation=sample_mutation,
|
||||
response="Test",
|
||||
latency_ms=200.0,
|
||||
passed=False,
|
||||
checks=checks,
|
||||
)
|
||||
failed = result.failed_checks
|
||||
assert len(failed) == 2
|
||||
|
||||
|
||||
class TestTypeStatistics:
|
||||
"""Tests for TypeStatistics data model."""
|
||||
|
||||
def test_type_statistics_creation(self):
|
||||
"""TypeStatistics can be created."""
|
||||
from entropix.reports.models import TypeStatistics
|
||||
|
||||
stats = TypeStatistics(
|
||||
mutation_type="paraphrase",
|
||||
total=100,
|
||||
passed=85,
|
||||
pass_rate=0.85,
|
||||
)
|
||||
assert stats.mutation_type == "paraphrase"
|
||||
assert stats.total == 100
|
||||
assert stats.passed == 85
|
||||
assert stats.pass_rate == 0.85
|
||||
|
||||
def test_type_statistics_to_dict(self):
|
||||
"""TypeStatistics converts to dict."""
|
||||
from entropix.reports.models import TypeStatistics
|
||||
|
||||
stats = TypeStatistics(
|
||||
mutation_type="noise",
|
||||
total=50,
|
||||
passed=40,
|
||||
pass_rate=0.8,
|
||||
)
|
||||
d = stats.to_dict()
|
||||
assert d["mutation_type"] == "noise"
|
||||
assert d["failed"] == 10
|
||||
|
||||
|
||||
class TestTestStatistics:
|
||||
"""Tests for TestStatistics data model."""
|
||||
|
||||
def test_statistics_creation(self):
|
||||
"""TestStatistics can be created."""
|
||||
from entropix.reports.models import TestStatistics
|
||||
|
||||
stats = TestStatistics(
|
||||
total_mutations=100,
|
||||
passed_mutations=85,
|
||||
failed_mutations=15,
|
||||
robustness_score=0.85,
|
||||
avg_latency_ms=150.0,
|
||||
p50_latency_ms=120.0,
|
||||
p95_latency_ms=300.0,
|
||||
p99_latency_ms=450.0,
|
||||
)
|
||||
assert stats.total_mutations == 100
|
||||
assert stats.passed_mutations == 85
|
||||
assert stats.robustness_score == 0.85
|
||||
|
||||
def test_statistics_pass_rate(self):
|
||||
"""Statistics calculates pass_rate correctly."""
|
||||
from entropix.reports.models import TestStatistics
|
||||
|
||||
stats = TestStatistics(
|
||||
total_mutations=100,
|
||||
passed_mutations=80,
|
||||
failed_mutations=20,
|
||||
robustness_score=0.85,
|
||||
avg_latency_ms=150.0,
|
||||
p50_latency_ms=120.0,
|
||||
p95_latency_ms=300.0,
|
||||
p99_latency_ms=450.0,
|
||||
)
|
||||
assert stats.pass_rate == 0.8
|
||||
|
||||
def test_statistics_zero_total(self):
|
||||
"""Statistics handles zero total."""
|
||||
from entropix.reports.models import TestStatistics
|
||||
|
||||
stats = TestStatistics(
|
||||
total_mutations=0,
|
||||
passed_mutations=0,
|
||||
failed_mutations=0,
|
||||
robustness_score=0.0,
|
||||
avg_latency_ms=0.0,
|
||||
p50_latency_ms=0.0,
|
||||
p95_latency_ms=0.0,
|
||||
p99_latency_ms=0.0,
|
||||
)
|
||||
assert stats.pass_rate == 0.0
|
||||
|
||||
|
||||
class TestTestResults:
|
||||
"""Tests for TestResults data model."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config(self):
|
||||
"""Create sample config."""
|
||||
from entropix.core.config import (
|
||||
AgentConfig,
|
||||
AgentType,
|
||||
EntropixConfig,
|
||||
)
|
||||
|
||||
return EntropixConfig(
|
||||
agent=AgentConfig(
|
||||
endpoint="http://localhost:8000/chat",
|
||||
type=AgentType.HTTP,
|
||||
),
|
||||
golden_prompts=["Test"],
|
||||
invariants=[],
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_statistics(self):
|
||||
"""Create sample statistics."""
|
||||
from entropix.reports.models import TestStatistics
|
||||
|
||||
return TestStatistics(
|
||||
total_mutations=10,
|
||||
passed_mutations=8,
|
||||
failed_mutations=2,
|
||||
robustness_score=0.8,
|
||||
avg_latency_ms=150.0,
|
||||
p50_latency_ms=120.0,
|
||||
p95_latency_ms=300.0,
|
||||
p99_latency_ms=450.0,
|
||||
)
|
||||
|
||||
def test_results_creation(self, sample_config, sample_statistics):
|
||||
"""TestResults can be created."""
|
||||
from entropix.reports.models import TestResults
|
||||
|
||||
now = datetime.now()
|
||||
results = TestResults(
|
||||
config=sample_config,
|
||||
started_at=now,
|
||||
completed_at=now,
|
||||
mutations=[],
|
||||
statistics=sample_statistics,
|
||||
)
|
||||
assert results.config == sample_config
|
||||
assert results.statistics.robustness_score == 0.8
|
||||
|
||||
|
||||
class TestHTMLReportGenerator:
|
||||
"""Tests for HTML report generation."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config(self):
|
||||
"""Create sample config."""
|
||||
from entropix.core.config import (
|
||||
AgentConfig,
|
||||
AgentType,
|
||||
EntropixConfig,
|
||||
)
|
||||
|
||||
return EntropixConfig(
|
||||
agent=AgentConfig(
|
||||
endpoint="http://localhost:8000/chat",
|
||||
type=AgentType.HTTP,
|
||||
),
|
||||
golden_prompts=["Test"],
|
||||
invariants=[],
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_statistics(self):
|
||||
"""Create sample statistics."""
|
||||
from entropix.reports.models import TestStatistics
|
||||
|
||||
return TestStatistics(
|
||||
total_mutations=10,
|
||||
passed_mutations=8,
|
||||
failed_mutations=2,
|
||||
robustness_score=0.8,
|
||||
avg_latency_ms=150.0,
|
||||
p50_latency_ms=120.0,
|
||||
p95_latency_ms=300.0,
|
||||
p99_latency_ms=450.0,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_results(self, sample_config, sample_statistics):
|
||||
"""Create sample test results."""
|
||||
from entropix.reports.models import TestResults
|
||||
|
||||
now = datetime.now()
|
||||
return TestResults(
|
||||
config=sample_config,
|
||||
started_at=now,
|
||||
completed_at=now,
|
||||
mutations=[],
|
||||
statistics=sample_statistics,
|
||||
)
|
||||
|
||||
def test_generator_creation(self, sample_results):
|
||||
"""Generator can be created."""
|
||||
from entropix.reports.html import HTMLReportGenerator
|
||||
|
||||
generator = HTMLReportGenerator(sample_results)
|
||||
assert generator is not None
|
||||
|
||||
def test_generate_returns_string(self, sample_results):
|
||||
"""Generator returns HTML string."""
|
||||
from entropix.reports.html import HTMLReportGenerator
|
||||
|
||||
generator = HTMLReportGenerator(sample_results)
|
||||
html = generator.generate()
|
||||
|
||||
assert isinstance(html, str)
|
||||
assert len(html) > 0
|
||||
|
||||
def test_generate_valid_html_structure(self, sample_results):
|
||||
"""Generated HTML has valid structure."""
|
||||
from entropix.reports.html import HTMLReportGenerator
|
||||
|
||||
generator = HTMLReportGenerator(sample_results)
|
||||
html = generator.generate()
|
||||
|
||||
assert "<!DOCTYPE html>" in html or "<html" in html
|
||||
assert "</html>" in html
|
||||
|
||||
def test_contains_robustness_score(self, sample_results):
|
||||
"""Report contains robustness score."""
|
||||
from entropix.reports.html import HTMLReportGenerator
|
||||
|
||||
generator = HTMLReportGenerator(sample_results)
|
||||
html = generator.generate()
|
||||
|
||||
# Score should appear in some form (0.8 or 80%)
|
||||
assert "0.8" in html or "80" in html
|
||||
|
||||
def test_save_creates_file(self, sample_results):
|
||||
"""save() creates file on disk."""
|
||||
from entropix.reports.html import HTMLReportGenerator
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
generator = HTMLReportGenerator(sample_results)
|
||||
path = generator.save(Path(tmpdir) / "report.html")
|
||||
|
||||
assert path.exists()
|
||||
content = path.read_text()
|
||||
assert "html" in content.lower()
|
||||
|
||||
|
||||
class TestJSONReportGenerator:
|
||||
"""Tests for JSON report generation."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config(self):
|
||||
"""Create sample config."""
|
||||
from entropix.core.config import (
|
||||
AgentConfig,
|
||||
AgentType,
|
||||
EntropixConfig,
|
||||
)
|
||||
|
||||
return EntropixConfig(
|
||||
agent=AgentConfig(
|
||||
endpoint="http://localhost:8000/chat",
|
||||
type=AgentType.HTTP,
|
||||
),
|
||||
golden_prompts=["Test"],
|
||||
invariants=[],
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_statistics(self):
|
||||
"""Create sample statistics."""
|
||||
from entropix.reports.models import TestStatistics
|
||||
|
||||
return TestStatistics(
|
||||
total_mutations=10,
|
||||
passed_mutations=8,
|
||||
failed_mutations=2,
|
||||
robustness_score=0.8,
|
||||
avg_latency_ms=150.0,
|
||||
p50_latency_ms=120.0,
|
||||
p95_latency_ms=300.0,
|
||||
p99_latency_ms=450.0,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_results(self, sample_config, sample_statistics):
|
||||
"""Create sample test results."""
|
||||
from entropix.reports.models import TestResults
|
||||
|
||||
ts = datetime(2024, 1, 15, 12, 0, 0)
|
||||
return TestResults(
|
||||
config=sample_config,
|
||||
started_at=ts,
|
||||
completed_at=ts,
|
||||
mutations=[],
|
||||
statistics=sample_statistics,
|
||||
)
|
||||
|
||||
def test_generator_creation(self, sample_results):
|
||||
"""Generator can be created."""
|
||||
from entropix.reports.json_export import JSONReportGenerator
|
||||
|
||||
generator = JSONReportGenerator(sample_results)
|
||||
assert generator is not None
|
||||
|
||||
def test_generate_valid_json(self, sample_results):
|
||||
"""Generator produces valid JSON."""
|
||||
from entropix.reports.json_export import JSONReportGenerator
|
||||
|
||||
generator = JSONReportGenerator(sample_results)
|
||||
json_str = generator.generate()
|
||||
|
||||
# Should not raise
|
||||
data = json.loads(json_str)
|
||||
assert isinstance(data, dict)
|
||||
|
||||
def test_contains_statistics(self, sample_results):
|
||||
"""JSON contains statistics."""
|
||||
from entropix.reports.json_export import JSONReportGenerator
|
||||
|
||||
generator = JSONReportGenerator(sample_results)
|
||||
data = json.loads(generator.generate())
|
||||
|
||||
assert "statistics" in data
|
||||
assert data["statistics"]["robustness_score"] == 0.8
|
||||
|
||||
def test_save_creates_file(self, sample_results):
|
||||
"""save() creates JSON file on disk."""
|
||||
from entropix.reports.json_export import JSONReportGenerator
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
generator = JSONReportGenerator(sample_results)
|
||||
path = generator.save(Path(tmpdir) / "report.json")
|
||||
|
||||
assert path.exists()
|
||||
data = json.loads(path.read_text())
|
||||
assert "statistics" in data
|
||||
|
||||
|
||||
class TestTerminalReporter:
|
||||
"""Tests for terminal output."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config(self):
|
||||
"""Create sample config."""
|
||||
from entropix.core.config import (
|
||||
AgentConfig,
|
||||
AgentType,
|
||||
EntropixConfig,
|
||||
)
|
||||
|
||||
return EntropixConfig(
|
||||
agent=AgentConfig(
|
||||
endpoint="http://localhost:8000/chat",
|
||||
type=AgentType.HTTP,
|
||||
),
|
||||
golden_prompts=["Test"],
|
||||
invariants=[],
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_statistics(self):
|
||||
"""Create sample statistics."""
|
||||
from entropix.reports.models import TestStatistics
|
||||
|
||||
return TestStatistics(
|
||||
total_mutations=10,
|
||||
passed_mutations=8,
|
||||
failed_mutations=2,
|
||||
robustness_score=0.8,
|
||||
avg_latency_ms=150.0,
|
||||
p50_latency_ms=120.0,
|
||||
p95_latency_ms=300.0,
|
||||
p99_latency_ms=450.0,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_results(self, sample_config, sample_statistics):
|
||||
"""Create sample test results."""
|
||||
from entropix.reports.models import TestResults
|
||||
|
||||
now = datetime.now()
|
||||
return TestResults(
|
||||
config=sample_config,
|
||||
started_at=now,
|
||||
completed_at=now,
|
||||
mutations=[],
|
||||
statistics=sample_statistics,
|
||||
)
|
||||
|
||||
def test_reporter_creation(self, sample_results):
|
||||
"""Reporter can be created."""
|
||||
from entropix.reports.terminal import TerminalReporter
|
||||
|
||||
reporter = TerminalReporter(sample_results)
|
||||
assert reporter is not None
|
||||
|
||||
def test_reporter_has_print_methods(self, sample_results):
|
||||
"""Reporter has print methods."""
|
||||
from entropix.reports.terminal import TerminalReporter
|
||||
|
||||
reporter = TerminalReporter(sample_results)
|
||||
assert hasattr(reporter, "print_summary")
|
||||
assert hasattr(reporter, "print_full_report")
|
||||
Loading…
Add table
Add a link
Reference in a new issue