AdminAnalytics/tests/test_eada.py
emfurst 726bc232f2 Add Athletics tab from EADA data
New eada/ ingest downloads Equity in Athletics Disclosure Act files
(FY2007-2025) and loads UD's institution totals and per-sport expenses.
The Athletics tab shows expenses by category with the FBS transition
marked, growth against university spending, cost per undergraduate,
share of university expenses, and coach/AD compensation from Schedule J,
with sourced context on UD's FY2025 budget messaging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9jG4CUFcH6n4vFvgtwndY
2026-10-17 11:22:15 -04:00

172 lines
7.5 KiB
Python

"""Tests for EADA athletics download, loader, and dashboard queries."""
import io
import zipfile
import httpx
import pandas as pd
import respx
import admin_analytics.config as config
from admin_analytics.config import UD_UNITID
from admin_analytics.dashboard.queries import (
query_athletics_comp,
query_athletics_expenses,
query_athletics_vs_university,
)
from admin_analytics.eada.download import download_all
from admin_analytics.eada.loader import load_eada
OTHER_UNITID = 999999
def _write_year(year_dir, inst_name="instLevel.xlsx", school_name="Schools.xlsx", lower=False):
"""Write minimal EADA institution and per-sport spreadsheets."""
year_dir.mkdir(parents=True, exist_ok=True)
inst = pd.DataFrame({
"unitid": [UD_UNITID, OTHER_UNITID],
"classification_name": ["NCAA Division I-FCS", "NCAA Division II"],
"EFTotalCount": [18000, 5000],
"STUDENTAID_TOTAL": [15_000_000, 1_000_000],
"HDCOACH_SALARY_MEN": [240_000.0, 80_000.0],
"UNDUP_CT_PARTIC_MEN": [300, 100],
"UNDUP_CT_PARTIC_WOMEN": [320, 110],
"TOT_EXPENSE_ALL_NOTALLOC": [14_000_000, 2_000_000],
"GRND_TOTAL_REVENUE": [48_000_000, 9_000_000],
"GRND_TOTAL_EXPENSE": [48_000_000, 9_000_000],
})
if lower:
inst.columns = [c.lower() for c in inst.columns]
inst.to_excel(year_dir / inst_name, index=False)
pd.DataFrame({
"unitid": [UD_UNITID, UD_UNITID, UD_UNITID, OTHER_UNITID],
"SUM_PARTIC_MEN": [110, 15, 30, 50],
"SUM_PARTIC_WOMEN": [None, 16, 28, 40],
"TOTAL_REVENUE_ALL": [9_000_000, 6_000_000, 2_000_000, 1_000_000],
"TOTAL_EXPENSE_ALL": [9_000_000, 6_000_000, 2_000_000, 1_000_000],
"Sports": ["Football", "Basketball", "Soccer", "Football"],
}).to_excel(year_dir / school_name, index=False)
class TestLoader:
def test_loads_ud_only(self, db_conn, tmp_path, monkeypatch):
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
_write_year(tmp_path / "2024")
totals = load_eada(db_conn, range(2024, 2025))
assert totals == {"institution": 1, "sport": 3}
row = db_conn.execute(
"SELECT total_expenses, unallocated_expenses, participants_men, "
"ft_undergrad_enrollment, classification FROM raw_eada_institution"
).fetchone()
assert row == (48_000_000, 14_000_000, 300, 18000, "NCAA Division I-FCS")
def test_resolves_column_and_filename_variants(self, db_conn, tmp_path, monkeypatch):
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
_write_year(tmp_path / "2010", inst_name="instLevelData.xlsx",
school_name="schools.xlsx", lower=True)
totals = load_eada(db_conn, range(2010, 2011))
assert totals["institution"] == 1
assert db_conn.execute(
"SELECT total_expenses FROM raw_eada_institution WHERE year = 2010"
).fetchone()[0] == 48_000_000
def test_reload_replaces_year(self, db_conn, tmp_path, monkeypatch):
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
_write_year(tmp_path / "2024")
load_eada(db_conn, range(2024, 2025))
load_eada(db_conn, range(2024, 2025))
assert db_conn.execute("SELECT COUNT(*) FROM raw_eada_sport").fetchone()[0] == 3
def test_missing_year_skipped(self, db_conn, tmp_path, monkeypatch):
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
assert load_eada(db_conn, range(2024, 2025)) == {"institution": 0, "sport": 0}
class TestDownload:
@respx.mock
def test_downloads_spreadsheets_only(self, tmp_path, monkeypatch):
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
respx.get(f"{config.EADA_API_URL}/fileList").mock(return_value=httpx.Response(200, json=[
{"Year": 2024, "FileName": "EADA_2023-2024.zip"},
{"Year": 2024, "FileName": "EADA_All_Data_Combined_2023-2024_SAS_SPSS_EXCEL.zip"},
]))
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("instLevel.xlsx", b"x")
zf.writestr("schools.sav", b"x")
route = respx.get(f"{config.EADA_API_URL}/file").mock(
return_value=httpx.Response(200, content=buf.getvalue())
)
download_all(range(2024, 2025))
assert route.calls.last.request.url.params["fileName"] == "EADA_2023-2024.zip"
assert sorted(p.name for p in (tmp_path / "2024").iterdir()) == ["instLevel.xlsx"]
def _seed(conn):
for year, total, unalloc in [(2023, 40_000_000, 10_000_000), (2024, 50_000_000, 20_000_000)]:
conn.execute(
"INSERT INTO raw_eada_institution (unitid, year, total_expenses, unallocated_expenses, "
"participants_men, participants_women, ft_undergrad_enrollment) VALUES (?, ?, ?, ?, 300, 300, 20000)",
[UD_UNITID, year, total, unalloc],
)
for sport, amount in [("Football", 10_000_000), ("Basketball", 5_000_000), ("Soccer", 3_000_000)]:
conn.execute(
"INSERT INTO raw_eada_sport (unitid, year, sport, total_expenses) VALUES (?, ?, ?, ?)",
[UD_UNITID, year, sport, amount],
)
conn.execute(
"INSERT INTO raw_ipeds_finance (unitid, year, total_expenses, instruction_expenses, "
"institutional_support_expenses) VALUES (?, 2023, 1000000000, 400000000, 100000000)",
[UD_UNITID],
)
for year, value in [(2023, 300.0), (2024, 310.0)]:
conn.execute(
"INSERT INTO raw_cpi_u (year, month, value, series_id) VALUES (?, 1, ?, 'CUUR0000SA0')",
[year, value],
)
class TestQueries:
def test_expenses_split_and_per_student(self, db_conn):
_seed(db_conn)
df = query_athletics_expenses(db_conn)
assert df["year"].to_list() == [2023, 2024]
latest = df.row(1, named=True)
assert latest["football"] == 10_000_000
assert latest["other_sports"] == 3_000_000
assert latest["per_student"] == 2500
# Latest year is the CPI base, so adjusted equals nominal
assert latest["total_expenses_cpi"] == 50_000_000
def test_vs_university_only_overlapping_years(self, db_conn):
_seed(db_conn)
df = query_athletics_vs_university(db_conn)
assert df["year"].to_list() == [2023]
assert df["athletics_pct"][0] == 4.0
assert df["athletics_index"][0] == 100.0
assert query_athletics_vs_university(db_conn, since=2024).height == 0
def test_comp_matches_abbreviated_titles(self, db_conn):
for year, name, title, comp in [
(2022, "A", "DIR, INTERCOLLEGIATE ATHLC & REC SR", 700_000),
(2023, "A", "DIRECTOR, INTERCOLLEGIATE ATHELTICS & RECREATION", 710_000),
(2023, "B", "HEAD FOOTBALL COACH", 680_000),
(2023, "C", "HEAD FOOTBALL COACH TO 1/1/23", 500_000),
(2023, "D", "FORMER ATHLETIC DIRECTOR", 400_000),
(2023, "E", "PROVOST", 800_000),
]:
db_conn.execute(
"INSERT INTO raw_990_schedule_j (tax_year, person_name, title, total_compensation) "
"VALUES (?, ?, ?, ?)",
[year, name, title, comp],
)
df = query_athletics_comp(db_conn)
assert set(zip(df["tax_year"], df["role"], df["person_name"])) == {
(2022, "Athletic Director", "A"),
(2023, "Athletic Director", "A"),
(2023, "Head Football Coach", "B"),
}
def test_empty(self, db_conn):
assert query_athletics_expenses(db_conn).height == 0
assert query_athletics_comp(db_conn).height == 0