622 lines
25 KiB
Python
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")
|