| 1 | """Tests for :mod:`dlm_sway.probes.base`.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from typing import Literal |
| 6 | |
| 7 | import pytest |
| 8 | |
| 9 | from dlm_sway.core.errors import SpecValidationError |
| 10 | from dlm_sway.core.result import ProbeResult, Verdict |
| 11 | from dlm_sway.probes.base import ( |
| 12 | Probe, |
| 13 | ProbeSpec, |
| 14 | RunContext, |
| 15 | build_probe, |
| 16 | registry, |
| 17 | validate_all_probes, |
| 18 | ) |
| 19 | |
| 20 | |
| 21 | class _DummySpec(ProbeSpec): |
| 22 | kind: Literal["__test_dummy"] = "__test_dummy" |
| 23 | payload: str = "x" |
| 24 | |
| 25 | |
| 26 | class _DummyProbe(Probe): |
| 27 | kind = "__test_dummy" |
| 28 | spec_cls = _DummySpec |
| 29 | category = "adherence" |
| 30 | |
| 31 | def run(self, spec: ProbeSpec, ctx: RunContext) -> ProbeResult: |
| 32 | assert isinstance(spec, _DummySpec) |
| 33 | return ProbeResult( |
| 34 | name=spec.name, |
| 35 | kind=spec.kind, |
| 36 | verdict=Verdict.PASS, |
| 37 | score=1.0, |
| 38 | message=spec.payload, |
| 39 | ) |
| 40 | |
| 41 | |
| 42 | class TestRegistry: |
| 43 | def test_autoregister(self) -> None: |
| 44 | assert "__test_dummy" in registry() |
| 45 | assert registry()["__test_dummy"] is _DummyProbe |
| 46 | |
| 47 | def test_duplicate_kind_rejected(self) -> None: |
| 48 | with pytest.raises(ValueError, match="duplicate probe kind"): |
| 49 | |
| 50 | class _Clash(Probe): |
| 51 | kind = "__test_dummy" |
| 52 | spec_cls = _DummySpec |
| 53 | |
| 54 | def run(self, spec: ProbeSpec, ctx: RunContext) -> ProbeResult: |
| 55 | raise NotImplementedError |
| 56 | |
| 57 | |
| 58 | class TestBuildProbe: |
| 59 | def test_valid_entry(self) -> None: |
| 60 | probe, spec = build_probe({"name": "t", "kind": "__test_dummy", "payload": "hi"}) |
| 61 | assert isinstance(probe, _DummyProbe) |
| 62 | assert isinstance(spec, _DummySpec) |
| 63 | assert spec.payload == "hi" |
| 64 | |
| 65 | def test_unknown_kind(self) -> None: |
| 66 | with pytest.raises(SpecValidationError, match="unknown probe kind"): |
| 67 | build_probe({"name": "t", "kind": "no_such_kind"}) |
| 68 | |
| 69 | def test_missing_kind(self) -> None: |
| 70 | with pytest.raises(SpecValidationError, match="missing string 'kind'"): |
| 71 | build_probe({"name": "t"}) |
| 72 | |
| 73 | def test_extra_field_forbidden(self) -> None: |
| 74 | with pytest.raises(SpecValidationError) as exc_info: |
| 75 | build_probe({"name": "t", "kind": "__test_dummy", "bogus": "y"}) |
| 76 | assert "bogus" in str(exc_info.value).lower() |
| 77 | |
| 78 | |
| 79 | class TestValidateAllProbes: |
| 80 | """B7: collect every probe-entry error in a single message.""" |
| 81 | |
| 82 | def test_clean_suite_passes(self) -> None: |
| 83 | validate_all_probes( |
| 84 | [ |
| 85 | {"name": "p1", "kind": "__test_dummy"}, |
| 86 | {"name": "p2", "kind": "__test_dummy", "payload": "y"}, |
| 87 | ] |
| 88 | ) |
| 89 | |
| 90 | def test_collects_multiple_errors(self) -> None: |
| 91 | with pytest.raises(SpecValidationError) as exc_info: |
| 92 | validate_all_probes( |
| 93 | [ |
| 94 | {"name": "good", "kind": "__test_dummy"}, |
| 95 | {"name": "typo1", "kind": "no_such_kind"}, |
| 96 | {"name": "typo2", "kind": "another_typo"}, |
| 97 | ] |
| 98 | ) |
| 99 | msg = str(exc_info.value) |
| 100 | # Both typos surface in one message — the user fixes everything in one pass. |
| 101 | assert "typo1" in msg |
| 102 | assert "typo2" in msg |
| 103 | assert "no_such_kind" in msg |
| 104 | assert "another_typo" in msg |
| 105 | |
| 106 | def test_unnamed_entry_uses_index_label(self) -> None: |
| 107 | with pytest.raises(SpecValidationError, match="entry #0"): |
| 108 | validate_all_probes([{"kind": "no_such_kind"}]) |