Skip to content

Commit 4ff30d5

Browse files
committed
test: enforce spec coverage and add CI
- tests/spec_manifest.yml + tests/test_spec_coverage.py: a working manifest of the spec IDs that must have a case, enforced per entity so missing, orphan or misfiled cases fail loudly. Two hub IDs are tracked under partial_coverage. - .github/workflows/tests.yml: run pip install -e .[test] + python -m pytest on Python 3.11 and 3.13. - findings/testing_framework_status.md: framework overview, scope and known limitations.
1 parent 6838ee4 commit 4ff30d5

4 files changed

Lines changed: 256 additions & 0 deletions

File tree

.github/workflows/tests.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
pytest:
13+
runs-on: ubuntu-latest
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
python-version: ["3.11", "3.13"]
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- name: Set up Python ${{ matrix.python-version }}
23+
uses: actions/setup-python@v5
24+
with:
25+
python-version: ${{ matrix.python-version }}
26+
27+
- name: Install package + test deps
28+
run: |
29+
python -m pip install --upgrade pip
30+
python -m pip install -e ".[test]"
31+
32+
- name: Run test suite
33+
# `python -m` puts the repo root on sys.path; pytest's prepend mode puts
34+
# tests/ on the path so the sibling `cases` package resolves.
35+
run: python -m pytest -q
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# datavault4sqlglot — Execution Testing Framework
2+
3+
A data-driven test framework that executes each generator's SQL against an in-memory
4+
DuckDB and asserts on the resulting rows. A *case* is a YAML file carrying input data,
5+
the expected output and the generator wiring; the mechanics (seed → `to_sql()` → run →
6+
compare) live once in the runner, so a new case only adds data.
7+
8+
## Layout
9+
10+
- `tests/cases/case_schema.py` — pydantic schema + per-entity validation
11+
- `tests/cases/case_loader.py` — discovery/validation of `tests/cases/**/*.yml`
12+
- `tests/cases/dv_case_runner.py` — the engine (dispatch → seed → run → compare)
13+
- `tests/cases/<entity>/*.yml` — the cases
14+
- `tests/test_execution_cases.py` — one parametrized test per case (pytest id = spec id)
15+
- `tests/spec_manifest.yml` + `tests/test_spec_coverage.py` — the coverage gate
16+
- `tests/test_runner_internals.py` — unit tests for the comparison logic
17+
18+
## Coverage (48 cases)
19+
20+
| Entity | Cases | Source of the spec IDs |
21+
|---|---|---|
22+
| hub | 11 | Excel `TestCases` sheet |
23+
| link | 10 | hand-written Python oracle (the Excel has no link cases) |
24+
| satellite v0 | 24 | Excel `TestCases` sheet |
25+
| satellite v1 | 3 | derived from the datavault4dbt `sat_v1` macro semantics |
26+
27+
The coverage gate enforces, per entity, that every manifest spec-ID has exactly one
28+
case (and vice-versa), so missing/orphan/misfiled cases fail loudly. Two hub IDs
29+
(`2.2.1.3`, `2.2.2.4`) are listed under `partial_coverage` — see Known limitations.
30+
31+
## Scope
32+
33+
- **In scope:** the execution behaviour of the loading generators — Hub, Link,
34+
Satellite v0 and v1. The hand-written `test_execution_*.py` are kept as an
35+
independent differential cross-check.
36+
- **Out of scope** (covered by other, existing tests): structural/AST checks, dialect
37+
transpilation, configuration, hashing, and the **Stage** generator (which computes
38+
hashes — a different mechanism).
39+
40+
## Comparison modes
41+
42+
`set` (default), `exact` (multiset), `subset`, `count`, `empty` — projected onto
43+
`key_columns`. On failure the runner reports the differing rows (count-aware for
44+
`exact`). A key column that exists in neither the expected rows nor the actual result
45+
fails loudly rather than passing silently.
46+
47+
## Known limitations
48+
49+
- **Hub same-LDTS tie-break:** the multi-source dedup orders only by `ldts` with no
50+
secondary key, so on equal `ldts` across sources the surviving record source is not
51+
deterministically defined. Cases `2.2.1.3` / `2.2.2.4` therefore assert only the
52+
deterministic single-row dedup (`partial_coverage`).
53+
- **All-VARCHAR seeding:** values and row sets are verified; column data *types* are not.
54+
- Two `xfailed` tests in `tests/test_edge_cases.py` document pre-existing generator
55+
limitations (Hub does not validate a non-empty business-key list; Stage can emit
56+
`SELECT FROM <table>`); they are unrelated to this framework.
57+
58+
## Run
59+
60+
```bash
61+
python -m pip install -e ".[test]"
62+
python -m pytest -q # full suite
63+
python -m pytest tests/test_execution_cases.py -v # framework only (48 cases)
64+
python -m pytest tests/test_spec_coverage.py -v # coverage gate
65+
python -m pytest "tests/test_execution_cases.py::test_execution_case[2.2.2.3]" # one case
66+
```

tests/spec_manifest.yml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Spec-coverage manifest — the in-repo list of which spec-IDs must have an executable
2+
# YAML case. It follows the 'TestCases' worksheet as its source of truth (see
3+
# provenance below). `test_spec_coverage` fails on any drift between this list and
4+
# tests/cases/**/*.yml (missing case, orphan case, or a case filed under the wrong
5+
# entity), so coverage gaps can never pass silently — an enforced spec<->case
6+
# reconciliation.
7+
#
8+
# Lives in tests/ (not tests/cases/) on purpose: the case loader globs
9+
# tests/cases/**/*.yml and would otherwise try to parse this file as a Case.
10+
#
11+
# Provenance of the IDs:
12+
# hub (2.x), satellite (4.x): leaf IDs from the 'TestCases' worksheet of the
13+
# 'datavault4sqlglot test cases' workbook (the source
14+
# of truth this manifest follows). The older 'Test Cases' worksheet
15+
# (with a space) is superseded.
16+
# link (3.x): from the hand-written Python oracle
17+
# (test_execution_link.py); the workbook has no link
18+
# cases yet (adding them to the spec is a future option).
19+
# satellite_v1: execution cases for the end-dated sat; no spec-IDs exist
20+
# in the workbook yet, derived from the datavault4dbt
21+
# sat_v1 macro semantics and tagged v1.*.
22+
23+
hub:
24+
- "2.1.1.1"
25+
- "2.1.2.1"
26+
- "2.1.2.2"
27+
- "2.1.2.3"
28+
- "2.2.1.1"
29+
- "2.2.1.2"
30+
- "2.2.1.3"
31+
- "2.2.2.1"
32+
- "2.2.2.2"
33+
- "2.2.2.3"
34+
- "2.2.2.4"
35+
36+
link:
37+
- "3.1.1.1"
38+
- "3.1.2.1"
39+
- "3.1.2.2"
40+
- "3.1.2.3"
41+
- "3.2.1.1"
42+
- "3.2.1.2"
43+
- "3.2.2.1"
44+
- "3.2.2.2"
45+
- "3.2.2.3"
46+
- "3.2.3.1"
47+
48+
satellite:
49+
- "4.1.1.1.1"
50+
- "4.1.1.2.1"
51+
- "4.1.1.2.2"
52+
- "4.1.1.2.3"
53+
- "4.1.2.1.1"
54+
- "4.1.2.1.2"
55+
- "4.1.2.2.1"
56+
- "4.1.2.2.2"
57+
- "4.1.2.2.3"
58+
- "4.1.2.2.4"
59+
- "4.1.2.2.5"
60+
- "4.1.2.2.6"
61+
- "4.2.1.1.1"
62+
- "4.2.1.2.1"
63+
- "4.2.1.2.2"
64+
- "4.2.1.2.3"
65+
- "4.2.2.1.1"
66+
- "4.2.2.1.2"
67+
- "4.2.2.2.1"
68+
- "4.2.2.2.2"
69+
- "4.2.2.2.3"
70+
- "4.2.2.2.4"
71+
- "4.2.2.2.5"
72+
- "4.2.2.2.6"
73+
74+
satellite_v1:
75+
- "v1.1.1"
76+
- "v1.1.2"
77+
- "v1.1.3"
78+
79+
# Spec-IDs whose case EXISTS but only PARTIALLY asserts the spec, with the reason.
80+
# They still count as covered above (a case exists), and test_spec_coverage also
81+
# checks each ID here is a real case so the marker can't drift. This is the honest
82+
# counterpart to the coverage list: the limitation is a documented generator gap,
83+
# flagged on purpose — not a silent partial pass.
84+
partial_coverage:
85+
"2.2.1.3": "Asserts only the deterministic single-row dedup; the spec's 'leading
86+
source wins' tie-break is NOT verified. hub.py dedup orders by ldts with no
87+
secondary key, so the surviving source is nondeterministic on ties (generator gap)."
88+
"2.2.2.4": "Same generator gap as 2.2.1.3 (incremental variant)."

tests/test_spec_coverage.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""
2+
Forced spec<->case coverage check.
3+
4+
This test guarantees every spec-ID has an executable case: it loads the manifest
5+
(``spec_manifest.yml``) and the
6+
actual YAML cases, then asserts they match exactly *per entity*. A spec-ID without
7+
a case, a case without a manifest entry, or a case filed under the wrong entity all
8+
fail here — so coverage drift can never pass silently.
9+
10+
Run with: python -m pytest tests/test_spec_coverage.py -v
11+
"""
12+
from __future__ import annotations
13+
14+
from collections import defaultdict
15+
from pathlib import Path
16+
17+
import yaml
18+
19+
from cases.case_loader import load_all_cases
20+
21+
_MANIFEST = Path(__file__).parent / "spec_manifest.yml"
22+
23+
24+
# Top-level manifest keys that are NOT entity -> id lists.
25+
_NON_ENTITY_KEYS = {"partial_coverage"}
26+
27+
28+
def _load_manifest() -> dict:
29+
return yaml.safe_load(_MANIFEST.read_text(encoding="utf-8"))
30+
31+
32+
def _manifest_ids(data: dict) -> dict[str, set[str]]:
33+
return {k: set(v or []) for k, v in data.items() if k not in _NON_ENTITY_KEYS}
34+
35+
36+
def _case_ids() -> dict[str, set[str]]:
37+
by_entity: dict[str, set[str]] = defaultdict(set)
38+
for case in load_all_cases():
39+
by_entity[case.entity.value].add(case.id)
40+
return dict(by_entity)
41+
42+
43+
def test_spec_coverage_matches_manifest():
44+
manifest = _manifest_ids(_load_manifest())
45+
cases = _case_ids()
46+
47+
problems: list[str] = []
48+
for entity in sorted(set(manifest) | set(cases)):
49+
want = manifest.get(entity, set())
50+
have = cases.get(entity, set())
51+
missing = sorted(want - have) # spec-ID with no case
52+
orphan = sorted(have - want) # case with no manifest entry
53+
if missing:
54+
problems.append(f"[{entity}] spec-IDs without a case: {missing}")
55+
if orphan:
56+
problems.append(f"[{entity}] cases without a manifest entry: {orphan}")
57+
58+
assert not problems, "Spec coverage drift:\n" + "\n".join(problems)
59+
60+
61+
def test_partial_coverage_entries_reference_real_cases():
62+
# partial_coverage documents IDs whose case only PARTIALLY asserts the spec.
63+
# Guard against drift: every ID listed there must correspond to a real case.
64+
partial = _load_manifest().get("partial_coverage") or {}
65+
case_ids = {case.id for case in load_all_cases()}
66+
unknown = sorted(set(partial) - case_ids)
67+
assert not unknown, f"partial_coverage lists IDs with no case: {unknown}"

0 commit comments

Comments
 (0)