Skip to content

Commit 88b2fdb

Browse files
authored
Merge pull request #202 from CenterForTheBuiltEnvironment/testing-scheme-v2
status: add full testing scheme v2
2 parents e6a5ffc + f69a174 commit 88b2fdb

28 files changed

Lines changed: 6469 additions & 302 deletions

‎.github/workflows/deploy.yml‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@ jobs:
2424
- name: Install dependencies
2525
run: pip install -r requirements.txt
2626

27-
- name: Run tests
28-
run: pytest
29-
continue-on-error: true # TODO: remove once test suite is complete
27+
- name: Run full test suite (all tiers; gates deployment)
28+
run: python -m pytest -q --tb=short
3029

3130
deploy:
3231
name: Build & deploy to Cloud Run

‎.github/workflows/tests.yml‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Tests
2+
3+
# Full suite on feature-branch pushes and PRs into development.
4+
# Pushes/PRs to main run the same suite in deploy.yml, which gates deployment.
5+
on:
6+
push:
7+
branches-ignore: [main]
8+
pull_request:
9+
branches: [development]
10+
11+
jobs:
12+
pytest:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.13"
20+
cache: pip
21+
22+
- name: Install dependencies
23+
run: pip install -r requirements.txt
24+
25+
- name: Run full test suite
26+
run: python -m pytest -q --tb=short

‎.gitignore‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,7 @@ test-measured-data.ipynb
3131

3232
# AI assistant logs
3333
.claude/
34-
CLAUDE.md
34+
CLAUDE.md
35+
36+
# Local hand-calculation spreadsheet (frozen into tests/fixtures/spreadsheet_cases.json)
37+
tests/testing_cases.xlsx

‎.pre-commit-config.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,12 @@ repos:
55
- id: ruff
66
args: [--fix]
77
- id: ruff-format
8+
9+
- repo: local
10+
hooks:
11+
- id: pytest-unit
12+
name: pytest (unit tier)
13+
entry: python -m pytest -m unit -x -q --tb=short
14+
language: system
15+
pass_filenames: false
16+
stages: [pre-commit]

‎notes/testing-scheme.md‎

Lines changed: 148 additions & 290 deletions
Large diffs are not rendered by default.

‎pyproject.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,11 @@ indent-style = "space"
3434
testpaths = ["tests"]
3535
python_files = ["test_*.py"]
3636
python_functions = ["test_*"]
37+
markers = [
38+
"unit: fast pure-function tests, synthetic data, no I/O (<5s total)",
39+
"verification: engine + emissions checked against independent hand calculations and an independent reference model",
40+
"regression: physics invariants + syrupy snapshots using the real equipment library",
41+
"integration: full 8760-hour runs on real load and Cambium data, golden annual totals (1-3 min)",
42+
"app: Dash server smoke tests and headless end-to-end workflow",
43+
]
3744
addopts = ["-v", "--tb=short", "-ra"]

‎src/loads.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ def _validate(df: pd.DataFrame) -> pd.DataFrame:
7979
if bad_count > 0:
8080
logger.warning(f"{bad_count} invalid values in column {col}, set to NaN")
8181

82+
# Reject negative load values — a negative heating or cooling load indicates
83+
# a data error (unit mismatch, sign convention issue, etc.)
84+
for col in ["heating_W", "cooling_W"]:
85+
if (df[col].dropna() < 0).any():
86+
raise ValueError(
87+
f"Column '{col}' contains negative values. "
88+
"Load values must be >= 0. Check for unit errors or sign convention issues."
89+
)
90+
8291
return df
8392

8493
# --------- Factory methods ---------

‎tests/__snapshots__/test_energy_regression.ambr‎

Lines changed: 271 additions & 0 deletions
Large diffs are not rendered by default.

‎tests/app_helpers.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Helpers for the app-tier tests: walk Dash layouts without a browser."""
2+
3+
import json
4+
import re
5+
from pathlib import Path
6+
7+
from dash.development.base_component import Component
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
_ID_IN_SOURCE = re.compile(r"""\bid\s*=\s*["']([\w\-:.]+)["']""")
11+
12+
13+
def walk(node):
14+
"""Yield every Dash component in a layout tree (children and component-valued props)."""
15+
if isinstance(node, Component):
16+
yield node
17+
for prop in node._prop_names:
18+
value = getattr(node, prop, None)
19+
if isinstance(value, Component | list | tuple):
20+
yield from walk(value)
21+
elif isinstance(node, list | tuple):
22+
for item in node:
23+
yield from walk(item)
24+
25+
26+
def component_ids(layout) -> set[str]:
27+
ids = set()
28+
for comp in walk(layout):
29+
cid = getattr(comp, "id", None)
30+
if isinstance(cid, str):
31+
ids.add(cid)
32+
return ids
33+
34+
35+
def find(layout, component_id: str) -> Component:
36+
for comp in walk(layout):
37+
if getattr(comp, "id", None) == component_id:
38+
return comp
39+
raise KeyError(component_id)
40+
41+
42+
def option_values(layout, component_id: str) -> list:
43+
"""Values of a Select/SegmentedControl's `data` options, read from the live layout."""
44+
return [opt["value"] for opt in find(layout, component_id).data]
45+
46+
47+
def ids_declared_in_source() -> set[str]:
48+
"""Every literal id="..." in the app source, including components built at runtime."""
49+
ids = set()
50+
for folder in ("pages", "layout"):
51+
for path in (ROOT / folder).glob("*.py"):
52+
ids.update(_ID_IN_SOURCE.findall(path.read_text()))
53+
ids.update(_ID_IN_SOURCE.findall((ROOT / "app.py").read_text()))
54+
return ids
55+
56+
57+
def callback_component_ids(dependencies: list[dict]) -> set[str]:
58+
"""String component ids referenced by /_dash-dependencies (pattern-matching ids skipped)."""
59+
ids = set()
60+
for dep in dependencies:
61+
if not dep.get("no_output"):
62+
for output in dep["output"].strip(".").split("..."):
63+
ids.add(output.rsplit(".", 1)[0])
64+
for item in dep["inputs"] + dep["state"]:
65+
if isinstance(item["id"], str):
66+
ids.add(item["id"])
67+
return {i for i in ids if not i.startswith("{")}
68+
69+
70+
def figure_has_data(fig) -> bool:
71+
fig = json.loads(fig.to_json()) if hasattr(fig, "to_json") else fig
72+
return any(trace for trace in fig.get("data", []))

‎tests/conftest.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,33 @@
1010
from src.loads import StandardLoad
1111
from src.metadata import Metadata
1212

13+
TIER_MARKERS = {"unit", "verification", "regression", "integration", "app"}
14+
15+
16+
def pytest_addoption(parser):
17+
parser.addoption(
18+
"--generate-golden",
19+
action="store_true",
20+
default=False,
21+
help="Regenerate integration_annual_totals.json golden values (integration tier)",
22+
)
23+
24+
25+
def pytest_collection_modifyitems(config, items):
26+
"""Fail collection if any test lacks a tier marker.
27+
28+
CI selects tests by marker (e.g. -m "unit or regression"), so an unmarked
29+
test would silently never run there.
30+
"""
31+
unmarked = [
32+
item.nodeid for item in items if not TIER_MARKERS & {m.name for m in item.iter_markers()}
33+
]
34+
if unmarked:
35+
raise pytest.UsageError(
36+
f"Tests without a tier marker ({', '.join(sorted(TIER_MARKERS))}):\n "
37+
+ "\n ".join(unmarked)
38+
)
39+
1340

1441
@pytest.fixture
1542
def sample_load_df():

0 commit comments

Comments
 (0)