Files
shd.build/tools/game-data-capture/tests/contract/test_contracts.py
T
jb f48cc878aa
Validate and deploy production / validate (push) Successful in 24s
Validate and deploy staging / validate (push) Successful in 44s
Validate and deploy production / deploy (push) Failing after 29s
Validate and deploy staging / deploy (push) Failing after 6s
feat: classify gear and weapon mods
2026-07-31 21:54:47 -04:00

622 lines
25 KiB
Python

from __future__ import annotations
import copy
import json
import math
from pathlib import Path
from typing import Any
import pytest
from jsonschema import Draft202012Validator, FormatChecker
from jsonschema.exceptions import ValidationError
from jsonschema.protocols import Validator
from game_data_capture.domain.ports import ResourceBudget
from game_data_capture.domain.state_machines import (
STATE_MACHINES,
InvalidStateTransition,
require_transition,
)
from game_data_capture.domain.taxonomy import (
RECORD_TYPES_BY_VIEW,
SCREEN_VIEWS,
Family,
RecordType,
)
from game_data_capture.domain.validation import (
ContractValidationError,
validate_archive_document,
validate_classification,
validate_handoff_document,
)
ROOT = Path(__file__).resolve().parents[2]
CONTRACTS = ROOT / "contracts"
CLASSIFICATION_EXAMPLES = CONTRACTS / "examples" / "classification"
MOD_FAMILY_CONTRACT = {
Family.WEAPON_MOD: RecordType.WEAPON_MOD,
Family.GEAR_MOD: RecordType.GEAR_MOD,
}
def load_json(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as handle:
value = json.load(handle)
assert isinstance(value, dict)
return value
def schema_validator(schema_name: str) -> Validator:
schema = load_json(CONTRACTS / schema_name)
Draft202012Validator.check_schema(schema)
return Draft202012Validator(schema, format_checker=FormatChecker())
@pytest.mark.parametrize(
"schema_name",
[
"screenshot-classification.schema.json",
"evidence-archive-manifest-v1.schema.json",
"isac-handoff-v1.schema.json",
],
)
def test_schemas_compile_as_portable_draft_2020_12(schema_name: str) -> None:
schema = load_json(CONTRACTS / schema_name)
assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
assert not any(key.startswith("x-python") for key in _all_keys(schema))
Draft202012Validator.check_schema(schema)
validator = Draft202012Validator(schema)
assert validator.schema == schema
def _all_keys(value: object) -> list[str]:
keys: list[str] = []
if isinstance(value, dict):
for key, child in value.items():
keys.append(key)
keys.extend(_all_keys(child))
elif isinstance(value, list):
for child in value:
keys.extend(_all_keys(child))
return keys
def test_domain_contract_and_python_taxonomy_cover_every_family_view() -> None:
contract = load_json(CONTRACTS / "domain-contract-v1.json")
families = contract["families"]
for family, views in SCREEN_VIEWS.items():
assert set(families[family.value]["views"]) == set(views)
for view in views:
expected = [record_type.value for record_type in RECORD_TYPES_BY_VIEW[(family, view)]]
assert families[family.value]["views"][view] == expected
def test_mod_families_have_exact_frozen_detail_semantics() -> None:
contract = load_json(CONTRACTS / "domain-contract-v1.json")
assert contract["contract_version"] == 1
assert set(MOD_FAMILY_CONTRACT) == {Family.WEAPON_MOD, Family.GEAR_MOD}
for family, record_type in MOD_FAMILY_CONTRACT.items():
assert SCREEN_VIEWS[family] == ("detail",)
assert RECORD_TYPES_BY_VIEW[(family, "detail")] == (record_type,)
assert contract["families"][family.value] == {
"gear_slot": False,
"views": {"detail": [record_type.value]},
}
def test_approved_taxonomy_change_remains_contract_version_one() -> None:
archive_schema = load_json(CONTRACTS / "evidence-archive-manifest-v1.schema.json")
handoff_schema = load_json(CONTRACTS / "isac-handoff-v1.schema.json")
classification_schema = load_json(CONTRACTS / "screenshot-classification.schema.json")
assert archive_schema["properties"]["schema_version"] == {"const": 1}
assert handoff_schema["properties"]["schema_version"] == {"const": 1}
assert handoff_schema["properties"]["source_archive"]["properties"]["schema_version"] == {
"const": 1
}
assert classification_schema["$id"].endswith("screenshot-classification-v1.json")
def test_domain_contract_and_python_state_machines_match() -> None:
contract_states = load_json(CONTRACTS / "domain-contract-v1.json")["states"]
assert set(contract_states) == set(STATE_MACHINES)
for machine_name, machine in STATE_MACHINES.items():
expected = {state: sorted(targets) for state, targets in machine.transitions.items()}
actual = {
state: sorted(targets) for state, targets in contract_states[machine_name].items()
}
assert actual == expected
@pytest.mark.parametrize("family", [family.value for family in Family])
def test_each_family_has_a_valid_and_invalid_example(family: str) -> None:
validator = schema_validator("screenshot-classification.schema.json")
valid = load_json(CLASSIFICATION_EXAMPLES / f"{family}.valid.json")
invalid = load_json(CLASSIFICATION_EXAMPLES / f"{family}.invalid.json")
validator.validate(valid)
with pytest.raises(ValidationError):
validator.validate(invalid)
validate_classification(valid)
with pytest.raises(ContractValidationError):
validate_classification(invalid)
@pytest.mark.parametrize(("family", "record_type"), MOD_FAMILY_CONTRACT.items())
def test_mod_family_examples_validate_through_schema_and_semantics(
family: Family, record_type: RecordType
) -> None:
validator = schema_validator("screenshot-classification.schema.json")
valid_path = CLASSIFICATION_EXAMPLES / f"{family.value}.valid.json"
invalid_path = CLASSIFICATION_EXAMPLES / f"{family.value}.invalid.json"
assert valid_path.is_file()
assert invalid_path.is_file()
valid = load_json(valid_path)
invalid = load_json(invalid_path)
assert valid == {
"family": family.value,
"view": "detail",
"gear_slot": None,
"record_types": [record_type.value],
"confidence": valid["confidence"],
"abstention_reason": None,
}
validator.validate(valid)
validate_classification(valid)
with pytest.raises(ValidationError):
validator.validate(invalid)
with pytest.raises(ContractValidationError):
validate_classification(invalid)
@pytest.mark.parametrize(("family", "record_type"), MOD_FAMILY_CONTRACT.items())
def test_archive_and_handoff_schemas_accept_mod_record_types(
family: Family, record_type: RecordType
) -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["screens"][0]["classification"] = {
"family": family.value,
"view": "detail",
"gear_slot": None,
"record_types": [record_type.value],
"confidence": 0.96,
"abstention_reason": None,
}
archive["observed_instances"][0]["family"] = family.value
archive["canonical_candidates"][0]["record_type"] = record_type.value
schema_validator("evidence-archive-manifest-v1.schema.json").validate(archive)
validate_archive_document(archive)
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
handoff["records"][0]["record_type"] = record_type.value
schema_validator("isac-handoff-v1.schema.json").validate(handoff)
validate_handoff_document(handoff)
@pytest.mark.parametrize(("family", "record_type"), MOD_FAMILY_CONTRACT.items())
@pytest.mark.parametrize(
("field", "invalid_value"),
[
("view", "quality_profile"),
("record_types", ["weapon"]),
("record_types", ["weapon_mod_effect"]),
("gear_slot", "Mask"),
],
)
def test_mod_classifications_fail_closed_on_unsupported_values(
family: Family, record_type: RecordType, field: str, invalid_value: object
) -> None:
classification = load_json(CLASSIFICATION_EXAMPLES / f"{family.value}.valid.json")
assert classification["record_types"] == [record_type.value]
classification[field] = invalid_value
with pytest.raises(ValidationError):
schema_validator("screenshot-classification.schema.json").validate(classification)
with pytest.raises(ContractValidationError):
validate_classification(classification)
def test_composite_classification_preserves_multiple_record_types() -> None:
classification = load_json(CLASSIFICATION_EXAMPLES / "weapon.valid.json")
validate_classification(classification)
assert classification["record_types"] == ["weapon", "weapon_talent"]
def test_unknown_enum_value_is_rejected() -> None:
classification = load_json(CLASSIFICATION_EXAMPLES / "gear.valid.json")
classification["gear_slot"] = "Vest"
with pytest.raises(ValidationError):
schema_validator("screenshot-classification.schema.json").validate(classification)
with pytest.raises(ContractValidationError, match="Gear slot"):
validate_classification(classification)
def test_semantic_validator_enforces_the_bounded_record_type_collection() -> None:
classification = load_json(CLASSIFICATION_EXAMPLES / "weapon.valid.json")
classification["record_types"] = [f"future_record_{index}" for index in range(9)]
with pytest.raises(ValidationError):
schema_validator("screenshot-classification.schema.json").validate(classification)
with pytest.raises(ContractValidationError, match="more than 8"):
validate_classification(classification)
@pytest.mark.parametrize(
("schema_name", "valid_fixture", "invalid_fixture"),
[
(
"evidence-archive-manifest-v1.schema.json",
"archive/manifest.valid.json",
"archive/manifest.invalid.json",
),
(
"isac-handoff-v1.schema.json",
"handoff/isac-handoff.valid.json",
"handoff/isac-handoff.invalid.json",
),
],
)
def test_versioned_schema_fixtures_validate_and_reject(
schema_name: str, valid_fixture: str, invalid_fixture: str
) -> None:
validator = schema_validator(schema_name)
validator.validate(load_json(CONTRACTS / "examples" / valid_fixture))
with pytest.raises(ValidationError):
validator.validate(load_json(CONTRACTS / "examples" / invalid_fixture))
@pytest.mark.parametrize(
("schema_name", "valid_fixture"),
[
("evidence-archive-manifest-v1.schema.json", "archive/manifest.valid.json"),
("isac-handoff-v1.schema.json", "handoff/isac-handoff.valid.json"),
],
)
def test_versioned_schemas_reject_future_versions_from_otherwise_valid_documents(
schema_name: str, valid_fixture: str
) -> None:
document = load_json(CONTRACTS / "examples" / valid_fixture)
document["schema_version"] = 2
with pytest.raises(ValidationError):
schema_validator(schema_name).validate(document)
def test_valid_archive_preserves_observed_values_without_canonicalizing_them() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
schema_validator("evidence-archive-manifest-v1.schema.json").validate(archive)
validate_archive_document(archive)
observed_paths = {field["path"] for field in archive["observed_instances"][0]["fields"]}
canonical_paths = {field["path"] for field in archive["canonical_candidates"][0]["fields"]}
assert "observed.damage" in observed_paths
assert "observed.damage" not in canonical_paths
assert archive["observed_instances"][0]["observed_context"]["upgrade_state"] == {
"path": "observed.upgrade_state",
"value": None,
"missing_reason": "not_visible",
"evidence_refs": ["evidence-upgrade"],
"uncertainty_note": "The screen does not show an upgrade indicator.",
}
def test_malformed_evidence_bounds_are_rejected() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["evidence_values"][0]["region"] = {
"coordinate_space": "normalized",
"x": 0.8,
"y": 0.1,
"width": 0.3,
"height": 0.1,
}
with pytest.raises(ContractValidationError, match="bounds"):
validate_archive_document(archive)
def test_archive_schema_rejects_nonpositive_evidence_bounds() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["evidence_values"][0]["region"]["width"] = 0
with pytest.raises(ValidationError):
schema_validator("evidence-archive-manifest-v1.schema.json").validate(archive)
def test_null_evidence_without_missing_reason_is_rejected() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
missing = next(
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-upgrade"
)
missing["missing_reason"] = None
with pytest.raises(ValidationError):
schema_validator("evidence-archive-manifest-v1.schema.json").validate(archive)
with pytest.raises(ContractValidationError, match="missing_reason"):
validate_archive_document(archive)
def test_archive_video_limit_matches_the_configured_16_gib_maximum() -> None:
validator = schema_validator("evidence-archive-manifest-v1.schema.json")
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
video = {
"artifact_id": "video-limit",
"sha256": "c" * 64,
"media_type": "video/mp4",
"byte_size": 16 * 1024 * 1024 * 1024,
"sensitivity": "private",
"parent_artifact_id": None,
"video_timestamp_ms": None,
}
archive["artifacts"].append(video)
validator.validate(archive)
video_byte_size = video["byte_size"]
assert isinstance(video_byte_size, int)
oversized_video_bytes = video_byte_size + 1
video["byte_size"] = oversized_video_bytes
with pytest.raises(ValidationError):
validator.validate(archive)
def test_archive_image_limit_is_64_mib() -> None:
validator = schema_validator("evidence-archive-manifest-v1.schema.json")
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
artifact = archive["artifacts"][0]
artifact["byte_size"] = 64 * 1024 * 1024
validator.validate(archive)
artifact["byte_size"] += 1
with pytest.raises(ValidationError):
validator.validate(archive)
def test_image_handoff_limit_is_64_mib() -> None:
validator = schema_validator("isac-handoff-v1.schema.json")
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
artifact = handoff["source_artifacts"][0]
artifact["byte_size"] = 64 * 1024 * 1024
validator.validate(handoff)
artifact["byte_size"] += 1
with pytest.raises(ValidationError):
validator.validate(handoff)
def test_dangling_evidence_reference_is_rejected() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["canonical_candidates"][0]["fields"][0]["evidence_refs"] = ["not-present"]
with pytest.raises(ContractValidationError, match="dangling references"):
validate_archive_document(archive)
@pytest.mark.parametrize(
("collection", "id_field", "new_item"),
[
("artifacts", "artifact_id", None),
("captures", "capture_id", None),
("screens", "screen_id", None),
("jobs", "job_id", {"job_id": "duplicate", "last_error_id": None}),
(
"extractions",
"extraction_id",
{
"extraction_id": "duplicate",
"screen_id": "screen-001",
"evidence_refs": [],
},
),
(
"groups",
"group_id",
{
"group_id": "duplicate",
"screen_ids": ["screen-001"],
"supersedes_group_ids": [],
},
),
("reviews", "review_id", None),
("exports", "export_id", {"export_id": "duplicate", "artifact_id": None}),
("errors", "error_id", {"error_id": "duplicate"}),
("evidence_values", "evidence_id", None),
("observed_instances", "instance_id", None),
("canonical_candidates", "candidate_id", None),
],
)
def test_archive_rejects_duplicate_entity_ids(
collection: str, id_field: str, new_item: dict[str, Any] | None
) -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
item = new_item if new_item is not None else archive[collection][0]
archive[collection] = [copy.deepcopy(item), copy.deepcopy(item)]
with pytest.raises(ContractValidationError, match=f"Duplicate {id_field}"):
validate_archive_document(archive)
def test_archive_rejects_dangling_job_error_reference() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["jobs"].append({"job_id": "job-001", "last_error_id": "missing-error"})
with pytest.raises(ContractValidationError, match=r"job\.last_error_id"):
validate_archive_document(archive)
@pytest.mark.parametrize("supersedes", [["missing-group"], ["group-001"]])
def test_archive_rejects_invalid_group_supersession(supersedes: list[str]) -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["groups"].append(
{
"group_id": "group-001",
"screen_ids": ["screen-001"],
"supersedes_group_ids": supersedes,
}
)
with pytest.raises(ContractValidationError, match="supersedes_group_ids"):
validate_archive_document(archive)
def test_archive_rejects_dangling_export_artifact_reference() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["exports"].append({"export_id": "export-001", "artifact_id": "missing-artifact"})
with pytest.raises(ContractValidationError, match=r"export\.artifact_id"):
validate_archive_document(archive)
def test_archive_rejects_self_referential_artifact_parent() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["artifacts"][0]["parent_artifact_id"] = "image-001"
with pytest.raises(ContractValidationError, match="cannot reference itself"):
validate_archive_document(archive)
def test_correction_history_final_state_must_match_current_evidence() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
evidence = next(
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
)
evidence["normalized_value"] = 12401
with pytest.raises(ContractValidationError, match="final state"):
validate_archive_document(archive)
def test_correction_history_rejects_disconnected_value_states() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
evidence = next(
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
)
evidence["correction_history"].append(
{
"correction_id": "correction-002",
"corrected_at": "2026-07-31T12:01:00Z",
"actor": "local_user",
"reason": "Disconnected fixture.",
"before": {"raw_text": "wrong", "normalized_value": 0, "missing_reason": None},
"after": {
"raw_text": evidence["raw_text"],
"normalized_value": evidence["normalized_value"],
"missing_reason": evidence["missing_reason"],
},
}
)
with pytest.raises(ContractValidationError, match="disconnected"):
validate_archive_document(archive)
def test_correction_history_rejects_out_of_order_timestamps() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
evidence = next(
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
)
current_state = {
"raw_text": evidence["raw_text"],
"normalized_value": evidence["normalized_value"],
"missing_reason": evidence["missing_reason"],
}
evidence["correction_history"].append(
{
"correction_id": "correction-002",
"corrected_at": "2026-07-31T11:59:00Z",
"actor": "local_user",
"reason": "Out-of-order fixture.",
"before": copy.deepcopy(current_state),
"after": copy.deepcopy(current_state),
}
)
with pytest.raises(ContractValidationError, match="strictly ordered"):
validate_archive_document(archive)
def test_correction_ids_are_unique_across_the_archive() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
evidence = next(
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
)
duplicate = copy.deepcopy(evidence)
duplicate["evidence_id"] = "evidence-damage-copy"
archive["evidence_values"].append(duplicate)
with pytest.raises(ContractValidationError, match="Duplicate correction_id"):
validate_archive_document(archive)
@pytest.mark.parametrize(
("example", "validator"),
[
("archive/manifest.invalid.json", validate_archive_document),
("handoff/isac-handoff.invalid.json", validate_handoff_document),
],
)
def test_future_schema_versions_fail_closed(example: str, validator: Any) -> None:
with pytest.raises(ContractValidationError, match="Unsupported schema_version"):
validator(load_json(CONTRACTS / "examples" / example))
def test_valid_handoff_has_no_mutation_authority_and_resolves_evidence() -> None:
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
schema_validator("isac-handoff-v1.schema.json").validate(handoff)
validate_handoff_document(handoff)
assert handoff["trust_boundary"] == {
"intended_destination": "authenticated_isac_intake",
"mutation_authority": "none",
"requires_authenticated_action": True,
"preserves_moderation": True,
"creates_audit_events": False,
}
def test_handoff_cannot_claim_mutation_authority() -> None:
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
handoff["trust_boundary"]["mutation_authority"] = "canonical_write"
with pytest.raises(ValidationError):
schema_validator("isac-handoff-v1.schema.json").validate(handoff)
with pytest.raises(ContractValidationError, match="trust boundary"):
validate_handoff_document(handoff)
def test_handoff_dangling_evidence_reference_is_rejected() -> None:
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
broken = copy.deepcopy(handoff)
broken["records"][0]["fields"][0]["evidence_refs"] = ["missing"]
with pytest.raises(ContractValidationError, match="dangling references"):
validate_handoff_document(broken)
def test_handoff_source_archive_provenance_does_not_require_local_entities() -> None:
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
handoff["records"][0]["source_candidate_ids"] = ["archive-candidate-not-embedded"]
handoff["records"][0]["observed_instance_ids"] = ["archive-instance-not-embedded"]
validate_handoff_document(handoff)
@pytest.mark.parametrize("field", ["max_input_bytes", "max_output_bytes", "max_items"])
def test_resource_budget_rejects_negative_limits(field: str) -> None:
with pytest.raises(ValueError, match="non-negative integer"):
ResourceBudget(
deadline_monotonic=1.0,
max_input_bytes=-1 if field == "max_input_bytes" else 1,
max_output_bytes=-1 if field == "max_output_bytes" else 1,
max_items=-1 if field == "max_items" else 1,
)
@pytest.mark.parametrize("deadline", [math.nan, math.inf, -math.inf])
def test_resource_budget_rejects_nonfinite_deadlines(deadline: float) -> None:
with pytest.raises(ValueError, match="finite or null"):
ResourceBudget(
deadline_monotonic=deadline,
max_input_bytes=1,
max_output_bytes=1,
max_items=1,
)
def test_resource_budget_allows_zero_limits_and_no_deadline() -> None:
assert (
ResourceBudget(
deadline_monotonic=None,
max_input_bytes=0,
max_output_bytes=0,
max_items=0,
).deadline_monotonic
is None
)
@pytest.mark.parametrize("machine", sorted(STATE_MACHINES))
def test_each_state_machine_rejects_an_invalid_transition(machine: str) -> None:
first_state = next(iter(STATE_MACHINES[machine].transitions))
with pytest.raises(InvalidStateTransition):
require_transition(machine, first_state, "not-a-state")
def test_terminal_transition_is_rejected_and_recovery_transition_is_allowed() -> None:
with pytest.raises(InvalidStateTransition, match="Invalid review transition"):
require_transition("review", "cancelled", "approved")
require_transition("job", "failed", "queued")
require_transition("error", "retrying", "recovered")