167 lines
5.4 KiB
Python
167 lines
5.4 KiB
Python
"""Contracts for the shared evaluator primitives and dispatch."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from testing.tests.test_hermes_handoff_support import (
|
|
load_handoff_module,
|
|
outcome,
|
|
spec,
|
|
step,
|
|
)
|
|
|
|
rules = load_handoff_module("hermes_handoff_rules")
|
|
model = load_handoff_module("hermes_handoff_model")
|
|
load_handoff_module("hermes_handoff_evaluators")
|
|
|
|
|
|
PAYLOAD = {
|
|
"a": {"b": [{"c": 1}, {"c": 2}]},
|
|
"items": [{"number": 14, "merged": True}, {"number": 15, "merged": False}],
|
|
"flag": True,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "expected"),
|
|
[
|
|
("", PAYLOAD),
|
|
("flag", True),
|
|
("a.b[0].c", 1),
|
|
("a.b[].c", [1, 2]),
|
|
("a.b[]", [{"c": 1}, {"c": 2}]),
|
|
("items[].number", [14, 15]),
|
|
],
|
|
)
|
|
def test_dotted_paths_index_and_map(path: str, expected) -> None:
|
|
assert rules.dotted(PAYLOAD, path) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path", ["missing", "a.missing", "a.b[9].c", "flag[0]", "flag.b"]
|
|
)
|
|
def test_an_absent_path_raises_rather_than_returning_none(path: str) -> None:
|
|
with pytest.raises(KeyError):
|
|
rules.dotted(PAYLOAD, path)
|
|
|
|
|
|
def test_parse_json_rejects_raw_control_characters_and_empty_output() -> None:
|
|
"""Only RFC-compliant, nonempty JSON is accepted as release evidence."""
|
|
with pytest.raises(ValueError):
|
|
rules.parse_json(outcome(stdout='{"title": "a\nb"}'))
|
|
with pytest.raises(ValueError, match="empty output"):
|
|
rules.parse_json(outcome(stdout=" "))
|
|
with pytest.raises(ValueError):
|
|
rules.parse_json(outcome(stdout="not json"))
|
|
|
|
|
|
def test_lines_drops_blank_rows_and_trims() -> None:
|
|
assert rules.lines(outcome(stdout=" a \n\n b\n")) == ["a", "b"]
|
|
|
|
|
|
def test_missing_steps_reports_the_first_step_that_produced_nothing() -> None:
|
|
built = spec("allowed", steps=(step("a"), step("b")))
|
|
|
|
never_run = rules.missing_steps(built, {})
|
|
assert never_run.status == model.NOT_RUN
|
|
assert "never executed" in never_run.reason
|
|
|
|
errored = rules.missing_steps(
|
|
built, {"a": outcome(error="timeout"), "b": outcome()}
|
|
)
|
|
assert errored.status == model.NOT_RUN
|
|
assert "did not run" in errored.reason
|
|
|
|
truncated = rules.missing_steps(
|
|
built, {"a": outcome(truncated=True), "b": outcome()}
|
|
)
|
|
assert truncated.status == model.NOT_RUN and "truncated" in truncated.reason
|
|
|
|
assert rules.missing_steps(built, {"a": outcome(), "b": outcome()}) is None
|
|
|
|
|
|
def test_an_optional_step_may_be_absent() -> None:
|
|
built = spec("allowed", steps=(step("a"), step("b", optional=True)))
|
|
assert rules.missing_steps(built, {"a": outcome()}) is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("stderr", "returncode", "denied"),
|
|
[
|
|
("Error from server (Forbidden): pods is forbidden", 1, True),
|
|
("User cannot list resource", 1, True),
|
|
("Gitea API returned HTTP 403", 1, True),
|
|
("connection refused", 1, False),
|
|
("", 0, False),
|
|
],
|
|
)
|
|
def test_denial_detection_needs_a_refusal_not_merely_a_failure(
|
|
stderr, returncode, denied
|
|
) -> None:
|
|
assert rules.looks_denied(outcome(stderr=stderr, returncode=returncode)) is denied
|
|
|
|
|
|
def test_step_keys_selects_by_kind() -> None:
|
|
built = spec(
|
|
"denied",
|
|
steps=(step("r", kind=model.REVIEW), step("a", kind=model.ATTEMPT), step("x")),
|
|
)
|
|
assert rules.step_keys(built, model.REVIEW) == ("r",)
|
|
assert rules.step_keys(built, model.ATTEMPT) == ("a",)
|
|
assert rules.step_keys(built, model.READ) == ("x",)
|
|
|
|
|
|
def test_parsed_step_returns_the_payload_or_the_skip_to_report() -> None:
|
|
built = spec("json_field", {"step": "s"}, steps=(step("s"),))
|
|
|
|
payload, key = rules.parsed_step(built, {"s": outcome(stdout='{"a": 1}')})
|
|
assert (payload, key) == ({"a": 1}, "s")
|
|
|
|
failed = rules.parsed_step(built, {"s": outcome(returncode=1)})
|
|
assert failed.status == model.NOT_RUN and "failed" in failed.reason
|
|
|
|
unparsable = rules.parsed_step(built, {"s": outcome(stdout="nope")})
|
|
assert (
|
|
unparsable.status == model.NOT_RUN
|
|
and "did not return JSON" in unparsable.reason
|
|
)
|
|
|
|
|
|
def test_an_unknown_rule_is_not_run_rather_than_a_crash() -> None:
|
|
evaluation = rules.evaluate(spec("no-such-rule"), {})
|
|
assert evaluation.status == model.NOT_RUN
|
|
assert "unknown evaluator" in evaluation.reason
|
|
|
|
|
|
def test_an_evaluator_that_raises_is_not_run_rather_than_a_pass() -> None:
|
|
"""A harness bug must never be indistinguishable from a clean release."""
|
|
|
|
@rules.evaluator("explodes-for-test")
|
|
def _explode(_spec, _outcomes):
|
|
raise RuntimeError("boom")
|
|
|
|
evaluation = rules.evaluate(spec("explodes-for-test"), {})
|
|
|
|
assert evaluation.status == model.NOT_RUN
|
|
assert "RuntimeError" in evaluation.reason and "boom" in evaluation.reason
|
|
del rules.EVALUATORS["explodes-for-test"]
|
|
|
|
|
|
def test_an_evaluator_returning_an_unknown_status_is_not_run() -> None:
|
|
@rules.evaluator("unknown-status-for-test")
|
|
def _unknown(_spec, _outcomes):
|
|
return rules.Evaluation("MAYBE", "bad")
|
|
|
|
evaluation = rules.evaluate(spec("unknown-status-for-test"), {})
|
|
assert evaluation.status == model.NOT_RUN
|
|
assert "unknown status" in evaluation.reason
|
|
del rules.EVALUATORS["unknown-status-for-test"]
|
|
|
|
|
|
def test_is_list_distinguishes_arrays_from_strings() -> None:
|
|
assert rules.is_list([1]) and rules.is_list(())
|
|
assert not rules.is_list("text")
|
|
assert not rules.is_list(b"bytes")
|
|
assert not rules.is_list({"a": 1})
|