Files
cali_scraper/tests/test_schema.py
T
2026-09-06 10:18:26 -07:00

179 lines
6.0 KiB
Python

"""Unit tests for scraper.schema (BillAction) and its JSONL contract.
The normalised record schema is spec section 7. This file pins the field set,
the content-hash id, the null-stripping serialisation, and the round-trip
through JSON Lines -- the output contract the downstream RDB / UI lanes depend
on, so it must be stable and asserted explicitly.
"""
from __future__ import annotations
import hashlib
import json
import pytest
from conftest import read_jsonl
from scraper.schema import (
ASSEMBLY_DAILY_FILE,
BillAction,
HOUSE,
SENATE_DAILY_FILE_PDF,
)
# A realistic bill record, mirroring the real AB 28 output.
def sample_bill() -> BillAction:
return BillAction(
date="2026-08-31",
house="assembly",
source=ASSEMBLY_DAILY_FILE,
record_type="bill",
bill_number="AB 28",
bill_id="202520260AB28",
session="2025-2026",
chamber_origin="ASSEMBLY",
author="Schiavo et al.",
short_title="An act relating to solid waste.",
description="An act relating to solid waste.",
location="Unfinished Business",
item_number=32,
supplement=2,
)
class TestFieldSet:
def test_field_names_match_schema(self):
names = set(BillAction.field_names())
# The core fields every downstream consumer expects (spec section 7).
for expected in [
"id", "date", "house", "source", "record_type",
"bill_number", "bill_id", "session", "chamber_origin",
"author", "short_title", "description", "committee",
"hearing_time", "location", "item_number", "supplement",
"ics_url", "agenda_id", "raw_selector",
]:
assert expected in names, f"missing field {expected!r}"
def test_default_house_is_assembly(self):
assert HOUSE == "assembly"
assert BillAction().house == "assembly"
def test_module_source_constants(self):
assert {ASSEMBLY_DAILY_FILE, SENATE_DAILY_FILE_PDF} <= {
ASSEMBLY_DAILY_FILE, SENATE_DAILY_FILE_PDF, "LEGINFO_BILL_RESULTS",
}
class TestWithId:
def test_id_is_deterministic_content_hash(self):
a = sample_bill()
b = sample_bill()
a.with_id()
b.with_id()
assert a.id == b.id
assert a.id == hashlib.sha1(
"|".join([a.house, a.bill_number, a.date, a.committee or ""])
.encode("utf-8")).hexdigest()
def test_missing_components_render_empty_in_hash(self):
rec = sample_bill()
rec.committee = None
rec.date = None
rec.with_id()
expected = hashlib.sha1(
"|".join([rec.house or "", rec.bill_number or "",
rec.date or "", rec.committee or ""]
).encode("utf-8")).hexdigest()
assert rec.id == expected
def test_explicit_content_used_when_supplied(self):
rec = BillAction()
rec.with_id("explicit-key")
assert rec.id == hashlib.sha1("explicit-key".encode()).hexdigest()
def test_with_id_returns_self_for_chaining(self):
rec = sample_bill()
assert rec.with_id() is rec
class TestToDictToJson:
def test_to_dict_strips_nulls_by_default(self):
rec = sample_bill().with_id()
data = rec.to_dict()
assert None not in data.values()
assert data["bill_number"] == "AB 28"
assert data["item_number"] == 32
def test_to_dict_include_nulls(self):
rec = sample_bill().with_id()
data = rec.to_dict(include_nulls=True)
# A field that is None in this record must appear when requested.
assert "committee" in data
assert data["committee"] is None
def test_to_json_is_single_line(self):
rec = sample_bill()
line = rec.to_json()
assert "\n" not in line
# Re-parse and confirm it is a well-formed object.
parsed = json.loads(line)
assert parsed["bill_number"] == "AB 28"
def test_to_json_sorts_keys(self):
rec = sample_bill()
line = rec.to_json()
parsed = json.loads(line)
assert list(parsed.keys()) == sorted(parsed.keys())
def test_to_json_fills_id_when_absent(self):
rec = sample_bill() # id is None
parsed = json.loads(rec.to_json())
assert "id" in parsed
assert parsed["id"]
def test_to_json_omits_nulls(self):
rec = BillAction(record_type="bill", bill_number="AB 1")
parsed = json.loads(rec.to_json())
# null-only fields must not appear
assert "committee" not in parsed
assert "hearing_time" not in parsed
class TestFromDict:
def test_from_dict_ignores_unknown_keys(self):
data = {"bill_number": "AB 1", "surprise": "extra", "date": "2026-01-01"}
rec = BillAction.from_dict(data)
assert rec.bill_number == "AB 1"
assert rec.date == "2026-01-01"
assert not hasattr(rec, "surprise")
def test_from_dict_empty(self):
rec = BillAction.from_dict({})
assert rec.record_type == "bill" # default
assert rec.house == "assembly" # default
class TestJsonlRoundTrip:
def test_write_read_roundtrip(self, tmp_path):
import scraper.io as io
recs = [sample_bill(), BillAction(record_type="hearing",
committee="Budget",
hearing_time="9:30am")]
out = tmp_path / "out.jsonl"
written = io.write_jsonl(recs, str(out))
assert written == 2
rows = read_jsonl(str(out))
assert len(rows) == 2
assert rows[0]["bill_number"] == "AB 28"
assert rows[1]["record_type"] == "hearing"
def test_jsonl_lines_are_valid_json(self, tmp_path):
import scraper.io as io
recs = [sample_bill()] * 5
out = tmp_path / "out.jsonl"
io.write_jsonl(recs, str(out))
with open(str(out), encoding="utf-8") as fh:
for line in fh:
assert line.strip()
json.loads(line) # raises if any line is malformed