179 lines
6.0 KiB
Python
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
|