"""Unit tests for scraper.parsers -- the HTML -> record transformation (spec section 3). Scope for task t_8e5a3821 item (1): * fixtures for at least 3 dates, one per chamber, plus one edge-case file; * the archived Assembly sample must still parse to the expected record set; * malformed rows are skipped with a logged warning and never abort the batch; * cross-chamber origin, committee vs floor discrimination, and the data-sort preference are all asserted. The parser is the coverage-gated module, so these tests aim for near-complete branch coverage of scraper.parsers and scraper.bill_id. """ from __future__ import annotations import pytest from scraper import parsers from scraper.parsers import normalise_date from tests import fixtures as F # ---------------------------------------------------------------- fixtures # # Three legislative-day fixtures, both chambers, plus the edge-case file. Three # dates come from assembly_daily_file_html (2026-08-31), senate_daily_file_html # (2026-09-02) and extra_daily_file_html (2026-09-03). class TestFixtureCoverage: def test_daily_file_fixtures_parse_without_raising(self): for name in ("assembly_daily_file_html", "senate_daily_file_html", "extra_daily_file_html"): recs = parsers.parse_html(getattr(F, name)()) # Each full daily file carries at least one bill and one hearing. assert len(recs) >= 2, f"{name} produced no records" assert any(r.record_type == "bill" for r in recs), f"{name}: no bills" assert any(r.record_type == "hearing" for r in recs), f"{name}: no hearings" def test_edge_fixture_parses_without_raising(self): # The edge-case file is deliberately bills-only and malformed; it must # parse without raising and must keep the recoverable rows. recs = parsers.parse_html(F.edge_case_html()) assert {r.bill_number for r in recs} >= {"AB 100", "AB 300", "AB 150"} def test_three_distinct_dates(self): dates = set() for name in ("assembly_daily_file_html", "senate_daily_file_html", "extra_daily_file_html"): recs = parsers.parse_html(getattr(F, name)()) d = next((r.date for r in recs if r.date), None) assert d is not None, f"{name}: no date derived from hearing table" dates.add(d) assert len(dates) == 3, f"expected 3 distinct dates, got {dates}" def test_both_chambers_covered(self): origins = set() for name in ("assembly_daily_file_html", "senate_daily_file_html", "extra_daily_file_html"): for r in parsers.parse_html(getattr(F, name)()): if r.chamber_origin: origins.add(r.chamber_origin.upper()) assert {"ASSEMBLY", "SENATE"} <= origins, f"missing a chamber: {origins}" # ---------------------------------------------------------------- the archived sample class TestArchivedSample: def test_sample_parses_expected_counts(self): recs = parsers.parse("samples/assembly_dailyfile_latest.html") by = {} for r in recs: by[r.record_type] = by.get(r.record_type, 0) + 1 # Grounded values (verified 2026-09-05 against the 401 192-byte sample): # 156 floor + 1 committee = 157 bills, 4 hearings. assert by.get("bill", 0) >= 150, by assert by.get("hearing", 0) >= 4, by def test_sample_has_ab28(self): recs = parsers.parse("samples/assembly_dailyfile_latest.html") ab28 = next((r for r in recs if r.bill_number == "AB 28"), None) assert ab28 is not None assert ab28.bill_id == "202520260AB28" assert ab28.session == "2025-2026" assert ab28.chamber_origin == "ASSEMBLY" assert ab28.item_number == 32 assert ab28.supplement == 2 def test_sample_cross_chamber(self): recs = parsers.parse("samples/assembly_dailyfile_latest.html") origins = {r.chamber_origin for r in recs if r.record_type == "bill"} assert "ASSEMBLY" in origins and "SENATE" in origins, origins # ---------------------------------------------------------------- floor / committee _FIXTURE_BUILDERS = ("assembly_daily_file_html", "senate_daily_file_html", "extra_daily_file_html") class TestRecordTypes: @pytest.mark.parametrize("builder_name", _FIXTURE_BUILDERS) def test_bills_cannot_be_hearings(self, builder_name): recs = parsers.parse_html(getattr(F, builder_name)()) for r in recs: if r.record_type == "bill": assert not r.hearing_time, "bill should not carry a hearing time" assert r.raw_selector in ("floor-bills-table", "committee-bills-table") if r.record_type == "hearing": assert r.hearing_time is not None assert r.raw_selector == "committee-hearing-table" # Hearings carry no bill identity. assert r.bill_number is None assert r.bill_id is None def test_floor_vs_committee_discrimination(self): recs = parsers.parse_html(F.assembly_daily_file_html()) floor = [r for r in recs if r.raw_selector == "floor-bills-table"] comm = [r for r in recs if r.raw_selector == "committee-bills-table"] assert floor, "no floor records" assert comm, "no committee records" # Floor rows have an item_number; committee rows do not. assert all(r.item_number is not None for r in floor) assert all(r.item_number is None for r in comm) # Committee rows carry a committee name; floor rows do not. assert all(r.committee is None for r in floor) assert all(r.committee for r in comm) def test_committee_bill_has_session_from_id(self): recs = parsers.parse_html(F.assembly_daily_file_html()) sb795 = next(r for r in recs if r.bill_number == "SB 795") assert sb795.session == "2025-2026" assert sb795.chamber_origin == "SENATE" assert sb795.committee == "Emergency Management" def test_display_only_row_falls_back_to_display_text(self): # EDGE_DISPLAY_ONLY has no bill_id in the link; the parser must recover # "AB 150" from the clean display text (bill_id stays None). recs = parsers.parse_html(F.edge_case_html()) ab150 = next((r for r in recs if r.bill_number == "AB 150"), None) assert ab150 is not None assert ab150.bill_id is None # no decodable id available assert ab150.chamber_origin == "assembly" # origin derived from display assert ab150.item_number == 150 def test_data_sort_preferred_over_text(self): # A floor row whose text-content and data-sort disagree: the parser must # take the clean data-sort value for author/location. import bs4 row = ( '
| ' 'AB 777 | ' 'Rivera (with extra spaces) | ' 'Subject text. | ' 'Capitol Room 444 | ' '777 |