CmdForge/tests/test_contract_testing.py

369 lines
13 KiB
Python

"""Tests for deterministic contract conformance (M8.V1)."""
from pathlib import Path
import pytest
from jsonschema import validate
from cmdforge.contract_testing import (
ConformanceResult,
ConformanceReport,
UnsupportedContract,
_generate_inputs,
_fallback_value,
run_contract_tests,
)
from cmdforge.tool import Tool
class TestInputGeneration:
def test_enum_generates_variants(self):
schema = {
"type": "object",
"properties": {
"mode": {"type": "string", "enum": ["fast", "accurate", "balanced"]},
"limit": {"type": "integer", "default": 10},
},
"required": ["mode"],
}
cases = _generate_inputs(schema)
assert len(cases) >= 1
assert cases[0]["mode"] in ("fast", "accurate", "balanced")
def test_fallback_string(self):
cases = _generate_inputs({
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
})
assert "test-name" in cases[0]["name"]
def test_fallback_integer(self):
cases = _generate_inputs({
"type": "object",
"properties": {"count": {"type": "integer"}},
"required": ["count"],
})
assert cases[0]["count"] == 0
def test_default_used(self):
cases = _generate_inputs({
"type": "object",
"properties": {"name": {"type": "string", "default": "World"}},
"required": ["name"],
})
assert cases[0]["name"] == "World"
def test_scalar_schema_generates_scalar(self):
cases = _generate_inputs({"type": "string"})
assert cases == ["test-input"]
def test_generated_value_respects_numeric_bounds(self):
schema = {
"type": "object",
"properties": {
"count": {"type": "integer", "minimum": 1, "maximum": 3}
},
"required": ["count"],
}
case = _generate_inputs(schema)[0]
validate(case, schema)
assert 1 <= case["count"] <= 3
def test_array_and_nested_object_are_supported(self):
schema = {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
}
value = _generate_inputs(schema)[0]
validate(value, schema)
def test_unsynthesizable_pattern_is_unsupported(self):
with pytest.raises(UnsupportedContract):
_generate_inputs({"type": "string", "pattern": "^z{20}$"})
def test_invalid_default_is_unsupported_not_returned(self):
with pytest.raises(UnsupportedContract, match="valid input"):
_generate_inputs({"type": "integer", "default": "wrong"})
class TestFallbackValue:
def test_explicit_default(self):
assert _fallback_value({"default": "hi"}, "x", []) == "hi"
def test_examples(self):
assert _fallback_value({"examples": ["a", "b"]}, "x", []) == "a"
def test_enum(self):
assert _fallback_value({"enum": ["yes", "no"]}, "x", []) == "yes"
def test_type_fallback(self):
assert _fallback_value({"type": "boolean"}, "flag", []) is True
class TestConformanceReport:
def test_empty_is_not_run_not_pass(self):
report = ConformanceReport()
assert report.outcome == "not_run"
assert not report.passed
def test_invalid_state_is_rejected(self):
with pytest.raises(ValueError, match="Unknown conformance state"):
ConformanceResult("case", "unknown")
def test_summary_counts(self):
report = ConformanceReport(results=[
ConformanceResult("a", "passed"),
ConformanceResult("b", "passed"),
ConformanceResult("c", "failed", "error"),
])
assert report.summary == {"passed": 2, "failed": 1, "unsupported": 0, "not_run": 0}
assert not report.passed
class TestRunContractTests:
def test_missing_schemas_returns_not_run(self, tmp_path):
from unittest.mock import patch
from cmdforge.tool import Tool
with patch("cmdforge.tool.TOOLS_DIR", tmp_path / ".cmdforge"):
tool = Tool(name="bare")
report = run_contract_tests(tool)
assert report.results[0].state == "not_run"
def test_simple_tool_passes(self, tmp_path):
from unittest.mock import patch
from cmdforge.tool import Tool, PromptStep
with patch("cmdforge.tool.TOOLS_DIR", tmp_path / ".cmdforge"):
tool = Tool(
name="greet",
input_schema={
"type": "object",
"properties": {
"name": {"type": "string", "default": "World"},
},
},
output_schema={
"type": "object",
"properties": {"result": {"type": "string"}},
"required": ["result"],
},
steps=[
PromptStep(
prompt="Greet {name}",
provider="real-provider-that-must-not-run",
output_var="response",
output_schema={
"type": "object",
"properties": {"result": {"type": "string"}},
"required": ["result"],
},
),
],
output="{response}",
)
report = run_contract_tests(tool)
assert report.outcome == "passed"
assert report.results[0].state == "passed"
def test_empty_schemas_are_explicit_and_runnable(self):
from cmdforge.tool import Tool
report = run_contract_tests(
Tool(name="anything", input_schema={}, output_schema={}, output="ok")
)
assert report.outcome == "passed"
@pytest.mark.parametrize("step_type", ["code", "tool", "mcp"])
def test_side_effecting_steps_are_unsupported(self, step_type):
from cmdforge.tool import CodeStep, McpStep, Tool, ToolStep
steps = {
"code": CodeStep(code="raise AssertionError('must not run')", output_var="x"),
"tool": ToolStep(tool="external", output_var="x"),
"mcp": McpStep(server="remote", tool="external", output_var="x"),
}
tool = Tool(
name="unsafe",
input_schema={},
output_schema={},
steps=[steps[step_type]],
output="{x}",
)
report = run_contract_tests(tool)
assert report.outcome == "unsupported"
def test_schema_mismatch_is_a_failure(self):
from cmdforge.tool import Tool
report = run_contract_tests(
Tool(
name="mismatch",
input_schema={},
output_schema={"type": "integer"},
output="not-an-integer",
)
)
assert report.outcome == "failed"
class TestRegressionComparison:
@staticmethod
def contracted_tool(version="1.0.0", output_schema=None):
from cmdforge.tool import Tool
return Tool(
name="test",
version=version,
input_schema={},
output_schema=output_schema or {"type": "string"},
output="stable",
)
def passing_report(self, tool=None):
return run_contract_tests(tool or self.contracted_tool())
def test_save_and_load_baseline(self, tmp_path):
from cmdforge.contract_testing import (
save_conformance_baseline, load_conformance_baseline,
)
import stat
tool = self.contracted_tool()
report = self.passing_report(tool)
path = save_conformance_baseline(tool, report, tmp_path)
assert path.exists()
assert stat.S_IMODE(path.stat().st_mode) == 0o600
loaded = load_conformance_baseline(tmp_path)
assert loaded["format_version"] == 1
assert loaded["tool"]["version"] == "1.0.0"
assert loaded["contract_hash"]
assert len(loaded["results"]) == 1
assert loaded["results"][0]["case_id"].startswith("case-")
assert loaded["results"][0]["output_shape"] == {"type": "string"}
def test_missing_baseline(self, tmp_path):
from cmdforge.contract_testing import load_conformance_baseline
assert load_conformance_baseline(tmp_path) is None
def test_corrupt_baseline_is_rejected(self, tmp_path):
from cmdforge.contract_testing import (
BaselineFormatError, CONFORMANCE_FILE, load_conformance_baseline,
)
(tmp_path / CONFORMANCE_FILE).write_text("[]")
with pytest.raises(BaselineFormatError, match="root"):
load_conformance_baseline(tmp_path)
def test_tampered_baseline_evidence_is_rejected(self, tmp_path):
from cmdforge.contract_testing import (
BaselineFormatError, CONFORMANCE_FILE, load_conformance_baseline,
save_conformance_baseline,
)
import json
tool = self.contracted_tool()
save_conformance_baseline(tool, self.passing_report(tool), tmp_path)
path = tmp_path / CONFORMANCE_FILE
data = json.loads(path.read_text())
data["results"][0]["input_value"] = {"tampered": True}
path.write_text(json.dumps(data))
with pytest.raises(BaselineFormatError, match="fingerprint mismatch"):
load_conformance_baseline(tmp_path)
def test_failed_report_cannot_replace_baseline(self, tmp_path):
from cmdforge.contract_testing import save_conformance_baseline
report = run_contract_tests(Tool(
name="failed", input_schema={}, output_schema={"type": "integer"},
output="text",
))
with pytest.raises(ValueError, match="passing"):
save_conformance_baseline(self.contracted_tool(), report, tmp_path)
def baseline_and_report(self, tmp_path):
from cmdforge.contract_testing import (
load_conformance_baseline, save_conformance_baseline,
)
tool = self.contracted_tool()
report = self.passing_report(tool)
save_conformance_baseline(tool, report, tmp_path)
return load_conformance_baseline(tmp_path), report
def test_detects_regression(self):
from dataclasses import replace
from cmdforge.contract_testing import (
ConformanceReport, compare_conformance,
)
import tempfile
with tempfile.TemporaryDirectory() as directory:
baseline, report = self.baseline_and_report(Path(directory))
current = ConformanceReport([
replace(report.results[0], state="failed", detail="now broken")
])
delta = compare_conformance(
baseline, current, self.contracted_tool(version="2.0.0")
)
assert len(delta.regressed) == 1
assert delta.has_regressions
assert delta.current_version == "2.0.0"
def test_lost_coverage_is_regression(self, tmp_path):
from dataclasses import replace
from cmdforge.contract_testing import (
ConformanceReport, compare_conformance,
)
baseline, report = self.baseline_and_report(tmp_path)
current = ConformanceReport([
replace(report.results[0], state="unsupported", detail="lost")
])
delta = compare_conformance(baseline, current, self.contracted_tool())
assert len(delta.coverage_losses) == 1
assert delta.has_regressions
def test_removed_case_is_regression(self, tmp_path):
from cmdforge.contract_testing import (
ConformanceReport, compare_conformance,
)
baseline, _ = self.baseline_and_report(tmp_path)
delta = compare_conformance(
baseline, ConformanceReport(), self.contracted_tool()
)
assert len(delta.removed_cases) == 1
assert delta.has_regressions
def test_output_shape_or_value_change_is_flagged(self, tmp_path):
from dataclasses import replace
from cmdforge.contract_testing import (
ConformanceReport, compare_conformance,
)
baseline, report = self.baseline_and_report(tmp_path)
current = ConformanceReport([
replace(
report.results[0],
output_shape={"type": "object", "properties": {}},
output_fingerprint="changed",
)
])
delta = compare_conformance(baseline, current, self.contracted_tool())
assert len(delta.output_changes) == 1
assert delta.has_regressions
def test_contract_change_is_recorded(self, tmp_path):
from cmdforge.contract_testing import compare_conformance
baseline, report = self.baseline_and_report(tmp_path)
changed = self.contracted_tool(output_schema={"type": ["string", "null"]})
delta = compare_conformance(baseline, report, changed)
assert delta.contract_changed