test_task_spec_invariants.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. from dataclasses import asdict
  2. import json
  3. import pytest
  4. from agent.orchestration.models import (
  5. AcceptanceCriterion,
  6. TaskSpec,
  7. json_values,
  8. )
  9. from agent.orchestration.wire import TaskSpecView
  10. def criterion(criterion_id="criterion-1", description="The result is correct", hard=True):
  11. return AcceptanceCriterion(criterion_id, description, hard)
  12. def test_acceptance_criterion_normalizes_text_and_requires_boolean_hard():
  13. value = AcceptanceCriterion(" criterion-1 ", " The result is correct ")
  14. assert value.criterion_id == "criterion-1"
  15. assert value.description == "The result is correct"
  16. assert value.hard is True
  17. for criterion_id in ("", " ", None, 1):
  18. with pytest.raises(ValueError, match="criterion_id"):
  19. AcceptanceCriterion(criterion_id, "description")
  20. for description in ("", " ", None, 1):
  21. with pytest.raises(ValueError, match="description"):
  22. AcceptanceCriterion("criterion-1", description)
  23. for hard in (0, 1, "true", None):
  24. with pytest.raises(ValueError, match="hard"):
  25. AcceptanceCriterion("criterion-1", "description", hard)
  26. def test_acceptance_criterion_from_dict_generates_only_missing_id():
  27. generated = AcceptanceCriterion.from_dict({"description": "description"})
  28. assert generated.criterion_id
  29. assert AcceptanceCriterion.from_dict(
  30. {"id": " legacy-id ", "description": "description"}
  31. ).criterion_id == "legacy-id"
  32. for explicit_id in ("", " ", None):
  33. with pytest.raises(ValueError, match="criterion_id"):
  34. AcceptanceCriterion.from_dict(
  35. {"criterion_id": explicit_id, "description": "description"}
  36. )
  37. @pytest.mark.parametrize("version", [True, False, 0, -1, 1.0, "1", None])
  38. def test_task_spec_requires_a_positive_integer_version(version):
  39. with pytest.raises(ValueError, match="positive integer"):
  40. TaskSpec(version, "objective", [criterion()])
  41. @pytest.mark.parametrize("objective", ["", " ", None, 1])
  42. def test_task_spec_requires_a_non_empty_string_objective(objective):
  43. with pytest.raises(ValueError, match="objective"):
  44. TaskSpec(1, objective, [criterion()])
  45. def test_task_spec_requires_criteria_with_unique_normalized_ids():
  46. with pytest.raises(ValueError, match="at least one"):
  47. TaskSpec(1, "objective")
  48. with pytest.raises(ValueError, match="unique"):
  49. TaskSpec(
  50. 1,
  51. "objective",
  52. [criterion(" duplicate "), criterion("duplicate", "another check")],
  53. )
  54. value = TaskSpec(
  55. 1,
  56. "objective",
  57. [criterion("case"), criterion("CASE", "another check")],
  58. )
  59. assert [item.criterion_id for item in value.acceptance_criteria] == ["case", "CASE"]
  60. def test_task_spec_freezes_collections_and_does_not_track_source_mutation():
  61. criteria = [criterion()]
  62. context_refs = ["context-1"]
  63. value = TaskSpec(1, " objective ", criteria, context_refs)
  64. criteria.append(criterion("criterion-2", "another check"))
  65. context_refs.append("context-2")
  66. assert value.objective == "objective"
  67. assert isinstance(value.acceptance_criteria, tuple)
  68. assert isinstance(value.context_refs, tuple)
  69. assert len(value.acceptance_criteria) == 1
  70. assert value.context_refs == ("context-1",)
  71. @pytest.mark.parametrize(
  72. "context_refs",
  73. ["context", {"context": True}, None, [""], [" "], [1], [[]]],
  74. )
  75. def test_task_spec_requires_immutable_string_context_refs(context_refs):
  76. with pytest.raises(ValueError, match="context_refs|collections"):
  77. TaskSpec(1, "objective", [criterion()], context_refs)
  78. with pytest.raises(ValueError, match="context_refs|collections"):
  79. TaskSpec.from_dict({
  80. "objective": "objective",
  81. "acceptance_criteria": [{"description": "description"}],
  82. "context_refs": context_refs,
  83. })
  84. value = TaskSpec(1, "objective", [criterion()], [" context-1 "])
  85. assert value.context_refs == ("context-1",)
  86. def test_task_spec_from_dict_is_strict_and_generates_stable_id_for_instance():
  87. value = TaskSpec.from_dict(
  88. {
  89. "objective": "objective",
  90. "acceptance_criteria": [{"description": "description"}],
  91. }
  92. )
  93. assert value.version == 1
  94. assert value.acceptance_criteria[0].criterion_id
  95. restored = TaskSpec.from_dict(json.loads(json.dumps(json_values(asdict(value)))))
  96. assert restored.acceptance_criteria[0].criterion_id == value.acceptance_criteria[0].criterion_id
  97. with pytest.raises(ValueError, match="positive integer"):
  98. TaskSpec.from_dict(
  99. {
  100. "version": "1",
  101. "objective": "objective",
  102. "acceptance_criteria": [{"description": "description"}],
  103. }
  104. )
  105. @pytest.mark.parametrize("criteria", [None, "criterion", {"description": "description"}])
  106. def test_task_spec_from_dict_rejects_invalid_criteria_collections(criteria):
  107. with pytest.raises(ValueError, match="acceptance_criteria"):
  108. TaskSpec.from_dict({
  109. "objective": "objective",
  110. "acceptance_criteria": criteria,
  111. })
  112. def test_task_spec_wire_shape_keeps_json_arrays():
  113. value = TaskSpec(
  114. 1,
  115. "objective",
  116. [criterion()],
  117. ["context-1"],
  118. created_at="2026-01-01T00:00:00+00:00",
  119. )
  120. payload = json_values(asdict(value))
  121. wire = TaskSpecView.model_validate(payload).model_dump(mode="json")
  122. assert wire["acceptance_criteria"] == [
  123. {
  124. "criterion_id": "criterion-1",
  125. "description": "The result is correct",
  126. "hard": True,
  127. }
  128. ]
  129. assert wire["context_refs"] == ["context-1"]
  130. assert isinstance(json.loads(json.dumps(payload))["acceptance_criteria"], list)