Skip to content

Commit 8bf8b12

Browse files
committed
now we include testS
1 parent c2a24f8 commit 8bf8b12

18 files changed

+6315
-24
lines changed

.github/workflows/test.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# .github/workflows/test.yml
2+
#
3+
# Runs all automated tests on every pull request and every push to main.
4+
#
5+
# Jobs:
6+
# test-backend – pytest tests for block_schema structure and Flask routes
7+
# (no Athena needed; runs on a plain ubuntu runner)
8+
# test-frontend – vitest unit tests for all JS utility modules
9+
#
10+
# The Athena introspection tests (TestAthenaIntrospection in test_block_schema.py)
11+
# are automatically skipped here because Athena is not available; they only
12+
# run inside the Docker build step (see deploy.yml).
13+
14+
name: Tests
15+
16+
on:
17+
push:
18+
branches: [main]
19+
pull_request:
20+
21+
jobs:
22+
23+
# ── Backend: schema structure + Flask routes ────────────────────────────────
24+
test-backend:
25+
runs-on: ubuntu-latest
26+
27+
steps:
28+
- uses: actions/checkout@v4
29+
30+
- name: Set up Python
31+
uses: actions/setup-python@v5
32+
with:
33+
python-version: '3.11'
34+
35+
- name: Install dependencies
36+
run: pip install flask flask-cors pyyaml pytest
37+
38+
- name: Run backend tests
39+
working-directory: backend
40+
run: pytest tests/ -v
41+
42+
# ── Frontend: utility unit tests ────────────────────────────────────────────
43+
test-frontend:
44+
runs-on: ubuntu-latest
45+
46+
steps:
47+
- uses: actions/checkout@v4
48+
49+
- name: Set up Node
50+
uses: actions/setup-node@v4
51+
with:
52+
node-version: '20'
53+
cache: 'npm'
54+
cache-dependency-path: frontend/package-lock.json
55+
56+
- name: Install dependencies
57+
working-directory: frontend
58+
run: npm ci
59+
60+
- name: Run frontend tests
61+
working-directory: frontend
62+
run: npm test

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
secret_token.sh
2-
*.DS_Store*
1+
sec*en.sh
2+
*.DS_Store*
3+
*node_modules*

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ RUN source /home/atlas/release_setup.sh \
2525
&& python3 -m ensurepip --upgrade 2>/dev/null \
2626
|| (curl -sSL https://bootstrap.pypa.io/get-pip.py | python3)
2727
RUN source /home/atlas/release_setup.sh \
28-
&& python3 -m pip install --quiet flask flask-cors pyyaml
28+
&& python3 -m pip install --quiet flask flask-cors pyyaml pytest
2929

3030
# Install pdflatex for INTnote PDF generation
3131
# Tries dnf (RHEL 8/9) first, falls back to yum (RHEL 7); silently continues if unavailable

README.md

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ An interactive web GUI for building [TopCPToolkit](https://topcptoolkit.docs.cer
1212
2. [Architecture](#architecture)
1313
3. [Running locally](#running-locally)
1414
4. [Deployment](#deployment)
15-
5. [How to add a new config block](#how-to-add-a-new-config-block)
16-
6. [How to add a new sub-block](#how-to-add-a-new-sub-block)
17-
7. [How to add a new event-selection keyword](#how-to-add-a-new-event-selection-keyword)
18-
8. [How to add a new physics-object type to the collection registry](#how-to-add-a-new-physics-object-type-to-the-collection-registry)
19-
9. [How to add a new application mode](#how-to-add-a-new-application-mode)
20-
10. [Code map](#code-map)
21-
11. [Contributing](#contributing)
15+
5. [Testing](#testing)
16+
6. [How to add a new config block](#how-to-add-a-new-config-block)
17+
7. [How to add a new sub-block](#how-to-add-a-new-sub-block)
18+
8. [How to add a new event-selection keyword](#how-to-add-a-new-event-selection-keyword)
19+
9. [How to add a new physics-object type to the collection registry](#how-to-add-a-new-physics-object-type-to-the-collection-registry)
20+
10. [How to add a new application mode](#how-to-add-a-new-application-mode)
21+
11. [Code map](#code-map)
22+
12. [Contributing](#contributing)
2223

2324
---
2425

@@ -137,7 +138,81 @@ oc logs deployment/itopcptoolkit --follow
137138

138139
---
139140

140-
## How to add a new config block
141+
## Testing
142+
143+
The test suite is fully automatic — no expected outputs need to be maintained by hand. Tests either assert structural invariants (e.g. every block has the required keys) or roundtrip properties (e.g. parse→serialize→re-parse recovers the original). They will fail if you introduce a regression, and the only time you need to update them is when you *intentionally* change behaviour.
144+
145+
### Running the tests locally
146+
147+
**Backend** (no Docker needed):
148+
149+
```bash
150+
cd backend
151+
pip install flask flask-cors pyyaml pytest
152+
pytest tests/ -v
153+
```
154+
155+
**Frontend** (no Docker needed):
156+
157+
```bash
158+
cd frontend
159+
npm install
160+
npm test
161+
```
162+
163+
**Frontend via Docker** (if you don't have Node installed locally):
164+
165+
```bash
166+
docker run --rm -v $(pwd)/frontend:/app -w /app node:20-slim sh -c "npm install && npm test"
167+
```
168+
169+
### What each suite covers
170+
171+
| File | What it tests |
172+
|---|---|
173+
| `backend/tests/test_block_schema.py` | Every entry in `BLOCK_TREE` has the required keys with the right types; no duplicate names; sub-blocks don't nest; `class_path` strings are well-formed |
174+
| `backend/tests/test_app.py` | All Flask routes return the correct HTTP status, content type, and response shape (schema mocked, no Athena needed) |
175+
| `frontend/src/__tests__/selectionCutsSerializer.test.js` | Every event-selection keyword parses and re-serialises correctly; roundtrip invariant holds for multi-cut strings and full `selectionCutsDict` objects |
176+
| `frontend/src/__tests__/yamlLineBuilder.test.js` | Line numbers are sequential; every line has required fields; `formatScalar` never throws; diff detection is correct |
177+
| `frontend/src/__tests__/yamlSerializer.test.js` | Options at their default value are omitted; non-default values survive serialisation; output is valid YAML |
178+
| `frontend/src/__tests__/yamlValidator.test.js` | Unknown blocks/options are flagged as errors; type mismatches are flagged as warnings; required options are checked; validator never throws |
179+
| `frontend/src/__tests__/collectionRegistry.test.js` | `buildRegistryFromState` and `buildRegistryFromYaml` produce identical registries for the same logical config; JVT adds `baselineJvt` implicitly; Thinning `outputName` creates an alias container |
180+
181+
### Athena introspection tests (inside Docker only)
182+
183+
`test_block_schema.py` contains an additional test class `TestAthenaIntrospection` that is automatically **skipped** outside Docker. When Athena is available (i.e. inside the container), it verifies that every `class_path` in `BLOCK_TREE` can be imported and returns a non-empty option list. This catches the most common cross-release breakage — a class being renamed or moved between AnalysisBase versions.
184+
185+
To run these manually inside a built container:
186+
187+
```bash
188+
docker run --rm tct-gui bash -c "
189+
source /home/atlas/release_setup.sh &&
190+
python -m pytest /app/backend/tests/ -v
191+
"
192+
```
193+
194+
Note: `pytest` must be present in the image. Make sure the `pip install` line in the `Dockerfile` includes it:
195+
196+
```dockerfile
197+
RUN source /home/atlas/release_setup.sh \
198+
&& python3 -m pip install --quiet flask flask-cors pyyaml pytest
199+
```
200+
201+
### CI
202+
203+
Tests run automatically on every pull request and every push to `main` via `.github/workflows/test.yml`. The backend and frontend suites run in parallel on a plain Ubuntu runner — no Docker or Athena is required for CI. A pull request cannot be merged if any test fails.
204+
205+
### When to update the tests
206+
207+
The tests are designed to need minimal maintenance:
208+
209+
- **Adding a new block**`test_block_schema.py` picks it up automatically via parametrisation over `BLOCK_TREE`. No test changes needed.
210+
- **Adding a new event-selection keyword** — add one line to the `KEYWORD_CASES` list in `selectionCutsSerializer.test.js` (the `[rawLine, expectedKeyword, description]` tuple). The roundtrip test is then generated automatically.
211+
- **Intentionally changing serialisation behaviour** — re-run the tests; the failing test tells you exactly what changed. Fix the test to match the new intended behaviour.
212+
213+
---
214+
215+
141216

142217
Everything you need to touch is in **`backend/block_schema.py`**.
143218

@@ -341,6 +416,6 @@ frontend/src/
341416
`backend/app.py` / `backend/introspect.py` (infrastructure).
342417
3. For frontend changes: the utility files in `frontend/src/utils/` are the
343418
most common extension point; component files are self-contained.
344-
4. Test locally with Docker (see [Running locally](#running-locally)).
345-
5. Open a pull request against `main`. Merging and bumping `VERSION` triggers
346-
automatic deployment.
419+
4. Run the test suite locally before opening a PR (see [Testing](#testing)).
420+
5. Open a pull request against `main`. CI will run all tests automatically.
421+
Merging and bumping `VERSION` triggers automatic deployment.

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.7.0
1+
0.7.1

backend/tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# backend/tests/conftest.py
2+
#
3+
# Adds the backend/ directory to sys.path so that test files can import
4+
# app, block_schema, and introspect directly without package installation.
5+
6+
import sys
7+
import os
8+
9+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

backend/tests/test_app.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# backend/tests/test_app.py
2+
#
3+
# Tests for the Flask routes in app.py.
4+
#
5+
# The Athena introspection layer (get_options) is mocked to return a fixed
6+
# schema, so these tests run anywhere without Docker.
7+
#
8+
# What is tested:
9+
# - /api/health returns the right shape
10+
# - /api/schema returns a list whose entries match BLOCK_TREE
11+
# - /api/export-yaml returns valid YAML containing the submitted config
12+
13+
import json
14+
import pytest
15+
import yaml
16+
from unittest.mock import patch
17+
18+
# Adjust path so we can import app from the backend directory
19+
import sys, os
20+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
21+
22+
23+
# Mock get_options to return a minimal option list — avoids needing Athena
24+
MOCK_OPTIONS = [
25+
{"name": "containerName", "type": "str", "default": "AnaJets",
26+
"info": "The container name", "required": False, "noneAction": "ignore"},
27+
]
28+
29+
@pytest.fixture()
30+
def client():
31+
"""Return a Flask test client with a pre-built schema cache."""
32+
with patch("introspect.get_options", return_value=MOCK_OPTIONS):
33+
import app as flask_app
34+
flask_app.app.config["TESTING"] = True
35+
# Force schema rebuild with the mock in place
36+
flask_app._schema_cache = None
37+
with flask_app.app.test_client() as c:
38+
# Trigger the schema build
39+
c.get("/api/schema")
40+
yield c
41+
42+
43+
# ─────────────────────────────────────────────────────────────────────────────
44+
# /api/health
45+
# ─────────────────────────────────────────────────────────────────────────────
46+
47+
class TestHealth:
48+
49+
def test_returns_200(self, client):
50+
r = client.get("/api/health")
51+
assert r.status_code == 200
52+
53+
def test_contains_status_ok(self, client):
54+
data = r = client.get("/api/health").get_json()
55+
assert data["status"] == "ok"
56+
57+
def test_contains_app_version(self, client):
58+
data = client.get("/api/health").get_json()
59+
assert "app_version" in data
60+
assert data["app_version"] # non-empty
61+
62+
def test_contains_athena_key(self, client):
63+
data = client.get("/api/health").get_json()
64+
assert "athena" in data
65+
66+
67+
# ─────────────────────────────────────────────────────────────────────────────
68+
# /api/schema
69+
# ─────────────────────────────────────────────────────────────────────────────
70+
71+
class TestSchema:
72+
from block_schema import BLOCK_TREE as _BLOCK_TREE
73+
74+
def test_returns_200(self, client):
75+
assert client.get("/api/schema").status_code == 200
76+
77+
def test_returns_list(self, client):
78+
data = client.get("/api/schema").get_json()
79+
assert isinstance(data, list)
80+
81+
def test_length_matches_block_tree(self, client):
82+
from block_schema import BLOCK_TREE
83+
data = client.get("/api/schema").get_json()
84+
assert len(data) == len(BLOCK_TREE)
85+
86+
def test_each_entry_has_required_fields(self, client):
87+
data = client.get("/api/schema").get_json()
88+
for block in data:
89+
assert "name" in block
90+
assert "label" in block
91+
assert "options" in block
92+
assert "sub_blocks" in block
93+
assert isinstance(block["options"], list)
94+
assert isinstance(block["sub_blocks"], list)
95+
96+
def test_options_have_required_fields(self, client):
97+
data = client.get("/api/schema").get_json()
98+
for block in data:
99+
for opt in block["options"]:
100+
assert "name" in opt
101+
assert "type" in opt
102+
assert "default" in opt
103+
assert "info" in opt
104+
assert "required" in opt
105+
assert "noneAction" in opt
106+
107+
def test_block_names_match_block_tree(self, client):
108+
from block_schema import BLOCK_TREE
109+
schema_names = {b["name"] for b in client.get("/api/schema").get_json()}
110+
tree_names = {b["name"] for b in BLOCK_TREE}
111+
assert schema_names == tree_names
112+
113+
114+
# ─────────────────────────────────────────────────────────────────────────────
115+
# /api/export-yaml
116+
# ─────────────────────────────────────────────────────────────────────────────
117+
118+
class TestExportYaml:
119+
120+
def _post(self, client, config, filename="out.yaml"):
121+
return client.post(
122+
"/api/export-yaml",
123+
data=json.dumps({"config": config, "filename": filename}),
124+
content_type="application/json",
125+
)
126+
127+
def test_returns_200(self, client):
128+
assert self._post(client, {"Jets": [{"containerName": "AnaJets"}]}).status_code == 200
129+
130+
def test_content_type_is_yaml(self, client):
131+
r = self._post(client, {"Jets": [{"containerName": "AnaJets"}]})
132+
assert "yaml" in r.content_type
133+
134+
def test_output_is_valid_yaml(self, client):
135+
r = self._post(client, {"Jets": [{"containerName": "AnaJets"}]})
136+
parsed = yaml.safe_load(r.data)
137+
assert parsed is not None
138+
139+
def test_output_contains_submitted_key(self, client):
140+
r = self._post(client, {"Jets": [{"containerName": "AnaJets"}]})
141+
parsed = yaml.safe_load(r.data)
142+
assert "Jets" in parsed
143+
144+
def test_empty_config_is_valid_yaml(self, client):
145+
r = self._post(client, {})
146+
assert r.status_code == 200
147+
# Should not raise
148+
yaml.safe_load(r.data)
149+
150+
def test_content_disposition_uses_filename(self, client):
151+
r = self._post(client, {}, filename="my_config.yaml")
152+
assert "my_config.yaml" in r.headers.get("Content-Disposition", "")

0 commit comments

Comments
 (0)