diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4cb122c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Normalize line endings: LF in the repo, regardless of OS checkout. +# A Windows clone with core.autocrlf=true would otherwise silently +# recode the golden fixtures to CRLF, breaking the byte-identical +# digest this repo's contract tests pin against (ADR 013 §6 RC5, +# tests/fixtures/querydescriptor_golden.json.sha256). + +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 314ba77..c21e278 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,12 @@ jobs: - name: Sync dev dependencies run: uv sync --frozen - name: Run pytest - run: uv run pytest --color=yes + # -v names every collected test in the log with its outcome + # (PASSED/FAILED/SKIPPED). Plain `pytest --color=yes` only prints a + # dot per file: a test that runs and a test that is present but + # never collected are indiscernible in that output (ADR 013 §1.3.2, + # RC5) — -v is the minimum needed to tell them apart. + run: uv run pytest --color=yes -v deps-audit: name: Dependency audit (pip-audit) diff --git a/tests/fixtures/querydescriptor_golden.json b/tests/fixtures/querydescriptor_golden.json new file mode 100644 index 0000000..c7bb272 --- /dev/null +++ b/tests/fixtures/querydescriptor_golden.json @@ -0,0 +1,15 @@ +{ + "table": "players", + "where": [ + { "column": "summoner_name", "op": "=", "value": "GIDEON" }, + { "column": "deleted_at", "op": "IS NULL", "value": null } + ], + "joins": [ + { "table": "scores", "on": ["id", "player_id"], "select": ["points"] } + ], + "select": ["summoner_name", "region"], + "order": [ + { "column": "points", "direction": "desc" } + ], + "limit": 5 +} diff --git a/tests/fixtures/querydescriptor_golden.json.sha256 b/tests/fixtures/querydescriptor_golden.json.sha256 new file mode 100644 index 0000000..f4fbe7c --- /dev/null +++ b/tests/fixtures/querydescriptor_golden.json.sha256 @@ -0,0 +1 @@ +23e10e33f0ebbb01a212c038b3ec24c3b546e81f7ddc4251009856907026c478 querydescriptor_golden.json diff --git a/tests/test_descriptor_contract.py b/tests/test_descriptor_contract.py new file mode 100644 index 0000000..105d3c9 --- /dev/null +++ b/tests/test_descriptor_contract.py @@ -0,0 +1,79 @@ +"""Cross-repo QueryDescriptor contract test — the QueryMe arm (ADR 013 §6 RC5). + +QueryMe owns the ``QueryDescriptor`` schema, so this arm is the reference +point the other two representations are pinned against: + +1. **QueryMe** ``QueryDescriptor`` Pydantic model (this file) — the golden + must validate and round-trip through it. +2. **Blue's preview executor** ``core.db.*`` builder chain + (``Blue/tests/test_querydescriptor_contract.py``) — pinned against a + byte-identical copy of the same golden. +3. **Orion** ``queryDescriptor`` Go struct + (``Orion/internal/runtime/compute_db_test.go::TestQueryDescriptor_GoldenParity``) + — pinned against its own byte-identical copy. + +A field rename on any side breaks its own arm loudly, so the three +representations can never silently drift apart on the fields they carry. +Key order is not part of that guarantee: this arm compares parsed dicts, +never raw text, so a reordered-but-equal golden re-hashed into a fresh +sidecar would still pass — the contract this test pins is field names and +values, not on-disk byte order of an object's keys (only the golden +file's own bytes are pinned byte-for-byte, by the sidecar check above). + +QueryMe does not consume the ``blue-runtime-go`` module (``pyproject.toml`` +has no path into it — Python cannot import a Go artefact), so this arm has +no digest-pinned authenticity channel back to Blue's published fixture +table: it is the ADR 013 G7 case, its inventory entry is **attested**, not +verified, and this test is the whole of its local control. The one thing +it *can* check on its own is that the copy of the golden it ships has not +drifted from the digest recorded alongside it +(``tests/fixtures/querydescriptor_golden.json.sha256``) — the same +self-consistency channel §3.4.1 gives every implementer, computed here +rather than trusted from a hand-copied literal. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from queryme.descriptor import QueryDescriptor + +_FIXTURES_DIR = Path(__file__).parent / "fixtures" +_GOLDEN_PATH = _FIXTURES_DIR / "querydescriptor_golden.json" +_SIDECAR_PATH = _FIXTURES_DIR / "querydescriptor_golden.json.sha256" + +_GOLDEN_BYTES = _GOLDEN_PATH.read_bytes() +_GOLDEN = json.loads(_GOLDEN_BYTES) + + +def _canonical(d: dict[str, Any]) -> dict[str, Any]: + """Normalise a descriptor dict for cross-arm comparison: drop a + top-level ``offset`` that is absent/None. The golden carries no + ``offset`` key at all; Pydantic fills the field with its ``None`` + default on validation, so a direct dict comparison would fail on that + field alone without touching the contract this test actually guards. + """ + return {k: v for k, v in d.items() if not (k == "offset" and v is None)} + + +def test_golden_fixture_matches_its_sidecar_digest() -> None: + """Local self-consistency channel (§3.4.1): the copy of the golden this + repo ships has not drifted from the digest recorded next to it. This is + QueryMe's only local control — it has no module-pinned digest to check + against (G7) — so the sidecar must be produced from the real file, not + hand-copied, or this test would pass while proving nothing. + """ + want = hashlib.sha256(_GOLDEN_BYTES).hexdigest() + got_line = _SIDECAR_PATH.read_text(encoding="utf-8").strip() + got = got_line.split()[0] + assert got == want, f"sidecar records {got}, golden file actually hashes to {want}" + + +def test_queryme_model_round_trips_golden() -> None: + """The schema owner accepts the golden and re-emits the same shape.""" + desc = QueryDescriptor.model_validate(_GOLDEN) + dumped = desc.model_dump(mode="json") + assert _canonical(dumped) == _canonical(_GOLDEN)