Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/routes/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ async def list_specs(
unclaimed: bool | None = Query(None, description="When true, return only specs with no passing submission (no SOTA set yet)"),
):
specs = spec_store.load_all()
if active:
# unclaimed=true implies active-round context: non-competition specs (e.g.
# Thingiverse catalog entries) have no round and are never "claimable".
if active or unclaimed:
active_ids = _active_round_ids()
specs = [s for s in specs if s.round_id in active_ids]
if tier is not None:
Expand Down
32 changes: 28 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,38 @@ def set_env(tmp_path, monkeypatch):
monkeypatch.setenv("SPECS_DIR", "tests/fixtures/specs")


@pytest.fixture
def client(set_env):
# Re-import app after env is set so db.py reads the patched DB_PATH
def _make_client():
import importlib
import app.db
import app.specs
import app.routes.specs
import app.main
importlib.reload(app.db)
importlib.reload(app.specs)
importlib.reload(app.routes.specs)
importlib.reload(app.main)
from app.main import app
with TestClient(app) as c:
return TestClient(app)


@pytest.fixture
def client(set_env):
# Re-import app after env is set so db.py reads the patched DB_PATH
with _make_client() as c:
yield c


@pytest.fixture
def unclaimed_client(tmp_path, monkeypatch):
"""Client with an active round configured (needed for ?unclaimed= filter tests).

Uses specs_round/ fixtures (r01_001_easy has round_id=round_001 via ID prefix)
and rounds_active/ so the round is active. The ?unclaimed filter restricts to
active-round specs only.
"""
db_path = str(tmp_path / "test.db")
monkeypatch.setenv("DB_PATH", db_path)
monkeypatch.setenv("SPECS_DIR", "tests/fixtures/specs_round")
monkeypatch.setenv("ROUNDS_DIR", "tests/fixtures/rounds_active")
with _make_client() as c:
yield c
2 changes: 1 addition & 1 deletion tests/fixtures/rounds_active/round_001.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
"ends": null,
"scoring_metric": "mass_grams",
"scoring_direction": "minimize",
"specs": [{"id": "r01_001_easy", "tier": "easy"}],
"specs": [{"id": "r01_001_easy", "tier": "easy"}, {"id": "001_bracket", "tier": "easy"}],
"notes": null
}
25 changes: 25 additions & 0 deletions tests/fixtures/specs_round/r01_001_easy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"id": "r01_001_easy",
"version": "1.0",
"name": "Cantilever Bracket — PLA — 11 kg @ 86 mm [easy, mass]",
"description": "An easy-difficulty cantilever bracket in PLA (FDM). Minimize mass while surviving the load.",
"material": "pla",
"constraints": {
"load_newtons": 111.78,
"load_point_mm": [86.5, 55.8, 45.0],
"safety_factor": 1.5,
"bolt_pattern_mm": [
[0.0, 0.0], [51.1, 0.0], [102.2, 0.0],
[0.0, 51.1], [51.1, 51.1], [102.2, 51.1]
],
"bolt_diameter_clearance_mm": 6.5,
"mount_face_x_mm": 0.0,
"build_volume_mm": [189.6, 111.6, 90.0],
"max_overhang_deg": 50.0,
"min_wall_thickness_mm": 1.0
},
"scoring": {
"metric": "mass_grams",
"direction": "minimize"
}
}
45 changes: 30 additions & 15 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,32 +44,47 @@ def test_get_spec_missing(client: TestClient):
assert r.status_code == 404


def test_specs_unclaimed_filter_no_submissions(client: TestClient):
"""?unclaimed=true returns all specs when there are no passing submissions."""
r = client.get("/specs?unclaimed=true")
def test_specs_unclaimed_filter_no_submissions(unclaimed_client: TestClient):
"""?unclaimed=true returns active-round specs with no passing submissions."""
r = unclaimed_client.get("/specs?unclaimed=true")
assert r.status_code == 200
assert len(r.json()) == 1 # fixture has one spec; no submissions yet
data = r.json()
# r01_001_easy is in active round_001; 001_bracket has no round_id (not claimable)
assert len(data) == 1
assert data[0]["id"] == "r01_001_easy"


def test_specs_claimed_filter_no_submissions(client: TestClient):
"""?unclaimed=false returns empty list when there are no passing submissions."""
r = client.get("/specs?unclaimed=false")
def test_specs_claimed_filter_no_submissions(unclaimed_client: TestClient):
"""?unclaimed=false returns empty list when no active-round specs have passing submissions."""
r = unclaimed_client.get("/specs?unclaimed=false")
assert r.status_code == 200
assert len(r.json()) == 0 # nothing claimed yet


def test_specs_unclaimed_filter_after_submission(client: TestClient):
ROUND_SUBMISSION = {
"spec_id": "r01_001_easy",
"agent_path": "agents/slim-spine",
"contributor": "TestMiner",
"commit_hash": "abc1234",
"mass_grams": 108.48,
"fea_stress_mpa": 7.50,
"fea_allowable_mpa": 25.0,
"passed": True,
"pr_number": 2,
"notes": "Slim spine agent on round spec",
}


def test_specs_unclaimed_filter_after_submission(unclaimed_client: TestClient):
"""After a passing submission, ?unclaimed=true excludes the spec."""
# Create a passing submission for 001_bracket
client.post("/submissions", json=GOOD_SUBMISSION)
# Now 001_bracket is claimed
r_unclaimed = client.get("/specs?unclaimed=true")
unclaimed_client.post("/submissions", json=ROUND_SUBMISSION)
r_unclaimed = unclaimed_client.get("/specs?unclaimed=true")
assert r_unclaimed.status_code == 200
assert len(r_unclaimed.json()) == 0 # no unclaimed specs
# ?unclaimed=false should return it
r_claimed = client.get("/specs?unclaimed=false")
assert len(r_unclaimed.json()) == 0 # r01_001_easy is now claimed
r_claimed = unclaimed_client.get("/specs?unclaimed=false")
assert r_claimed.status_code == 200
assert len(r_claimed.json()) == 1
assert r_claimed.json()[0]["id"] == "r01_001_easy"


def test_create_submission(client: TestClient):
Expand Down
Loading