Files
bls-data/tests/test_series_builders.py
Dave Boyd 9e2c55c568 Add QCEW CSV module, ECEC breakdown, response caching + typed errors
Item 1 — QCEW county/industry detail:
- new bls_client/qcew.py: thin client over the QCEW Open Data CSV service
  (area/industry/size slices; no API key, no quota), with filter_rows helper
- covers the county/industry detail the timeseries API can't reach

Item 2 — ECEC benefit breakdown:
- new series.ecec() builder from the authoritative cm.estimate decode table
- real helpers: wages, total benefits, paid leave, supplemental pay,
  health insurance, retirement & savings, legally required + ecec_dashboard()
- FIX: ecec_total_benefits was CMU1036... (education/health industries only);
  correct all-civilian total benefits is CMU1030000000000D

Item 3 — caching + typed errors:
- bls_client/cache.py FileCache (on-disk, TTL); BLSClient(cache=True),
  queries_used counter (cache hits don't spend quota)
- bls_client/errors.py: BLSError / BLSQuotaError / BLSRequestError; quota
  exhaustion now raises a clear BLSQuotaError instead of a generic RuntimeError

Tests: 119 offline (+24) incl. ECEC goldens, QCEW fixture parse, cache/quota
behavior; live smoke extended to ECEC + QCEW. Helper sweep: 96/96 live.
README: caching/QCEW usage, updated coverage + limitations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:38:12 -04:00

102 lines
5.0 KiB
Python

"""Unit tests for the low-level series-ID builders in bls_client.series.
These lock the exact encodings against known-good BLS series IDs. They run
offline (no network) and are the regression guard for the encoding bugs that
were fixed in June 2026 (JOLTS width, OES area code, ECI component/estimate,
productivity sector/measure widths, QCEW form).
"""
import pytest
from bls_client import series as S
# (callable, kwargs) -> exact expected series ID. Every value here has been
# confirmed to return data from the live BLS API.
GOLDEN = [
# LAUS — 20 chars
(S.laus_state, dict(state_fips=11, measure="rate"), "LAUST110000000000003"),
(S.laus_state, dict(state_fips=11, measure="rate", seasonal=True), "LASST110000000000003"),
(S.laus_county, dict(state_fips=11, county_fips=1, measure="rate"), "LAUCN110010000000003"),
(S.laus_msa, dict(state_fips=11, cbsa_code=47900, measure="rate"),"LAUMT114790000000003"),
# CES national / state
(S.ces_national, dict(), "CES0000000001"),
(S.ces_national, dict(industry="manufacturing"), "CES3000000001"),
(S.ces_state, dict(state_fips=11), "SMS11000000000000001"),
# CPI / PPI
(S.cpi, dict(), "CUUR0000SA0"),
(S.cpi, dict(item="core", seasonal=True), "CUSR0000SA0L1E"),
(S.ppi_commodity,dict(), "WPU00000000"),
# OES — 25 chars, national area 0000000 (was the 0000400 bug)
(S.oes_national, dict(), "OEUN000000000000000000001"),
(S.oes_national, dict(occupation_code="151252", data_type="annual_median"),
"OEUN000000000000015125213"),
# JOLTS — 21 chars / 15 zeros (was 18 chars / 12 zeros)
(S.jolts, dict(), "JTS000000000000000JOL"),
(S.jolts, dict(element="quits", rate_level="R"), "JTS000000000000000QUR"),
(S.jolts, dict(element="hires", seasonal=False), "JTU000000000000000HIL"),
# ECI — 17 chars, unadjusted, component 10/20/30, estimate A
(S.eci, dict(), "CIU1010000000000A"),
(S.eci, dict(component="20"), "CIU1020000000000A"),
(S.eci, dict(component="30"), "CIU1030000000000A"),
(S.eci, dict(owner="20"), "CIU2010000000000A"),
# Productivity — 11 chars, 4-digit sector + 4-digit measure
(S.productivity, dict(), "PRS85006092"),
(S.productivity, dict(sector="manufacturing", measure="unit_labor_cost"), "PRS30006112"),
(S.productivity, dict(sector="business"), "PRS84006092"),
# ECEC — 17 chars, CMU + owner(1) + component(2) + 10 zeros + datatype
(S.ecec, dict(), "CMU1010000000000D"),
(S.ecec, dict(component="health_insurance"), "CMU1150000000000D"),
(S.ecec, dict(component="retirement_and_savings"), "CMU1180000000000D"),
(S.ecec, dict(component="total_benefits", owner="private"), "CMU2030000000000D"),
(S.ecec, dict(component="total_compensation", owner="state_local_gov"), "CMU3010000000000D"),
]
@pytest.mark.parametrize("fn,kwargs,expected", GOLDEN, ids=[f"{f.__name__}-{e}" for f, _, e in GOLDEN])
def test_builder_exact_id(fn, kwargs, expected):
assert fn(**kwargs) == expected
# --- Format invariants (catch width regressions even for un-goldened inputs) ---
def test_jolts_is_21_chars():
for elem in ("job_openings", "hires", "quits", "layoffs", "total_separations"):
sid = S.jolts(elem)
assert len(sid) == 21, f"{sid} should be 21 chars"
assert sid.startswith("JTS")
def test_oes_is_25_chars_national():
sid = S.oes_national()
assert len(sid) == 25
assert sid.startswith("OEUN0000000"), "national area code must be 0000000"
def test_eci_is_17_chars_and_unadjusted_by_default():
sid = S.eci()
assert len(sid) == 17
assert sid.startswith("CIU"), "12-month % change ECI is published unadjusted (CIU)"
def test_productivity_is_11_chars():
sid = S.productivity()
assert len(sid) == 11
assert sid.startswith("PRS")
def test_ecec_is_17_chars_cost_per_hour():
for comp in ("total_compensation", "health_insurance", "retirement_and_savings"):
sid = S.ecec(comp)
assert len(sid) == 17
assert sid.startswith("CMU") and sid.endswith("D")
def test_laus_state_is_20_chars():
assert len(S.laus_state(48, "rate")) == 20
def test_seasonal_flag_flips_adjustment_char():
assert S.cpi(seasonal=False).startswith("CUU")
assert S.cpi(seasonal=True).startswith("CUS")
assert S.jolts(seasonal=False).startswith("JTU")
assert S.jolts(seasonal=True).startswith("JTS")