Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| Regression test: re-extract contacts from cached `_crawl_html/` snapshots for the | |
| first 10 Georgia counties (alphabetical by FIPS) and compare against the on-disk | |
| `_contact_images/contacts.json` golden bundles. | |
| The test is purely offline — it reads `_manifest.json` for homepage / jurisdiction | |
| metadata, walks the saved HTML snapshots, re-runs the structured extractor + | |
| bundler, and asserts that the resulting bundle matches the golden file on stable | |
| fields (contact_count, department_office_count, set of emails, set of names, | |
| email→name mapping). | |
| Counties that lack a complete fixture pair (missing `_manifest.json` or | |
| `contacts.json`, or with no HTML snapshots) are skipped with a clear reason. | |
| A meta-test asserts the qualifying-county count never drops below the level | |
| recorded here, so a regression in crawl completeness shows up as a failure | |
| rather than a silent skip. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Tuple | |
| from urllib.parse import urljoin, urlparse | |
| import pytest | |
| from scripts.discovery.contact_directory_heuristics import classify_contact_directory_page | |
| from scrapers.discovery.contact_extract_from_html import ( | |
| extract_structured_contacts_from_html, | |
| infer_profile_url_from_source_page, | |
| ) | |
| from scripts.discovery.contacts_bundle import build_contacts_bundle | |
| _REPO_ROOT = Path(__file__).resolve().parents[1] | |
| _GA_COUNTY_CACHE = _REPO_ROOT / "data" / "cache" / "scraped_meetings" / "GA" / "county" | |
| _FIXTURE_DIR = _REPO_ROOT / "tests" / "fixtures" / "contact_extract" | |
| # First 10 GA counties alphabetically by FIPS (5-digit state+county code). | |
| FIRST_10_GA_COUNTIES: Tuple[str, ...] = ( | |
| "appling_13001", | |
| "atkinson_13003", | |
| "bacon_13005", | |
| "baker_13007", | |
| "baldwin_13009", | |
| "banks_13011", | |
| "barrow_13013", | |
| "bartow_13015", | |
| "ben_hill_13017", | |
| "berrien_13019", | |
| ) | |
| # Floor on counties that must have a complete fixture pair. If a previously | |
| # qualifying county loses its fixtures (manifest deleted, contacts.json gone), | |
| # the meta-test below fails — the regression net should grow, not shrink. | |
| MIN_QUALIFYING_COUNTIES = 5 | |
| def _snapshot_stem_to_page_url(homepage: str, snap_stem: str) -> str: | |
| """Reconstruct the page URL a crawl-html snapshot was scraped from. | |
| Mirrors `scripts.discovery.refresh_contacts_from_crawl_html._snapshot_stem_to_page_url`. | |
| """ | |
| slug = snap_stem[5:] if snap_stem.startswith("page_") else snap_stem | |
| slug = slug.lstrip("_") | |
| if slug == "index": | |
| path = "/" | |
| elif slug.isdigit(): | |
| path = f"/{slug}" | |
| else: | |
| m = re.match(r"^(\d+)_(.+)$", slug) | |
| path = f"/{m.group(1)}/{m.group(2)}" if m else "/" + slug.replace("_", "/") | |
| base = (homepage or "").strip().rstrip("/") | |
| if not base: | |
| return path | |
| p = urlparse(base) | |
| return urljoin(f"{p.scheme}://{p.netloc}", path) | |
| def _regenerate_contacts_bundle(jurisdiction_dir: Path) -> Dict[str, Any]: | |
| """Rebuild the contacts bundle purely from on-disk crawl artifacts. | |
| Reads `_manifest.json` for homepage / jurisdiction / cached profile images, | |
| iterates `_crawl_html/page_*.html` snapshots, classifies each as a contact | |
| directory page, extracts structured contact rows, and assembles the bundle | |
| via `build_contacts_bundle`. No network I/O. | |
| """ | |
| manifest = json.loads((jurisdiction_dir / "_manifest.json").read_text(encoding="utf-8")) | |
| homepage = str(manifest.get("homepage_url") or "").strip() | |
| structured: List[Dict[str, Any]] = [] | |
| for snap in sorted((jurisdiction_dir / "_crawl_html").glob("page_*.html")): | |
| page_url = _snapshot_stem_to_page_url(homepage, snap.stem) | |
| html = snap.read_text(encoding="utf-8", errors="replace") | |
| cdir = classify_contact_directory_page(page_url, html) | |
| if not cdir.get("is_directory"): | |
| continue | |
| kind = str(cdir.get("directory_kind") or "unknown") | |
| score = int(cdir.get("score") or 0) | |
| for prow in extract_structured_contacts_from_html(html, page_url): | |
| prow["source_page_url"] = page_url | |
| prow["page_classification"] = kind | |
| prow["directory_score"] = score | |
| infer_profile_url_from_source_page(prow) | |
| structured.append(prow) | |
| return build_contacts_bundle( | |
| jurisdiction_id=str(manifest.get("jurisdiction_id") or ""), | |
| state=str(manifest.get("state") or ""), | |
| homepage_url=homepage, | |
| scraped_at=manifest.get("scraped_at"), | |
| scrape_batch_id=str(manifest.get("scrape_batch_id") or ""), | |
| structured_contacts=structured, | |
| contact_profile_images=list(manifest.get("contact_profile_images") or []), | |
| extracted_contacts=manifest.get("extracted_contacts"), | |
| ) | |
| def _skip_if_no_fixture(jurisdiction_dir: Path) -> None: | |
| manifest = jurisdiction_dir / "_manifest.json" | |
| crawl_html = jurisdiction_dir / "_crawl_html" | |
| golden = jurisdiction_dir / "_contact_images" / "contacts.json" | |
| if not manifest.is_file(): | |
| pytest.skip(f"{jurisdiction_dir.name}: missing _manifest.json (incomplete crawl)") | |
| if not crawl_html.is_dir() or not list(crawl_html.glob("page_*.html")): | |
| pytest.skip(f"{jurisdiction_dir.name}: no crawl_html snapshots") | |
| if not golden.is_file(): | |
| pytest.skip(f"{jurisdiction_dir.name}: missing contacts.json golden") | |
| def _email_name_map(contacts: List[Dict[str, Any]]) -> Dict[str, str]: | |
| """Lowercased email → first non-empty person_name seen for that email.""" | |
| out: Dict[str, str] = {} | |
| for row in contacts: | |
| em = str(row.get("email") or "").strip().lower() | |
| if not em: | |
| continue | |
| nm = str(row.get("person_name") or "").strip().lower() | |
| if em not in out and nm: | |
| out[em] = nm | |
| return out | |
| def test_contact_scraper_regression_first_10_ga_counties(county: str) -> None: | |
| """Re-extract contacts from cached HTML; assert bundle matches golden.""" | |
| jurisdiction_dir = _GA_COUNTY_CACHE / county | |
| _skip_if_no_fixture(jurisdiction_dir) | |
| golden = json.loads( | |
| (jurisdiction_dir / "_contact_images" / "contacts.json").read_text(encoding="utf-8") | |
| ) | |
| actual = _regenerate_contacts_bundle(jurisdiction_dir) | |
| assert actual["contact_count"] == golden["contact_count"], ( | |
| f"{county}: contact_count drifted ({actual['contact_count']} vs golden {golden['contact_count']})" | |
| ) | |
| assert actual["department_office_count"] == golden["department_office_count"], ( | |
| f"{county}: department_office_count drifted " | |
| f"({actual['department_office_count']} vs golden {golden['department_office_count']})" | |
| ) | |
| actual_emails = {str(r.get("email") or "").lower() for r in actual["contacts"] if r.get("email")} | |
| golden_emails = {str(r.get("email") or "").lower() for r in golden["contacts"] if r.get("email")} | |
| assert actual_emails == golden_emails, ( | |
| f"{county}: email set drifted\n missing: {golden_emails - actual_emails}\n extra: {actual_emails - golden_emails}" | |
| ) | |
| actual_names = {str(r.get("person_name") or "").lower() for r in actual["contacts"] if r.get("person_name")} | |
| golden_names = {str(r.get("person_name") or "").lower() for r in golden["contacts"] if r.get("person_name")} | |
| assert actual_names == golden_names, ( | |
| f"{county}: person_name set drifted\n missing: {golden_names - actual_names}\n extra: {actual_names - golden_names}" | |
| ) | |
| actual_map = _email_name_map(actual["contacts"]) | |
| golden_map = _email_name_map(golden["contacts"]) | |
| shared = actual_map.keys() & golden_map.keys() | |
| mismatched = {em: (actual_map[em], golden_map[em]) for em in shared if actual_map[em] != golden_map[em]} | |
| assert not mismatched, f"{county}: email→name mapping drifted: {mismatched}" | |
| _APPLING_DIR = _GA_COUNTY_CACHE / "appling_13001" | |
| _APPLING_TARGET_DOMAIN = "applingcountyga.org" | |
| _APPLING_TARGET_URL_FRAGMENT = "page_id=1464" | |
| _APPLING_EXPECTED_NAMES = frozenset({ | |
| "reid lovett", | |
| "daryl edwards", | |
| "jakharis jones", | |
| "chad kent", | |
| "leslie burch", | |
| "randy sellers", | |
| "ricky barnes", | |
| }) | |
| def test_appling_cache_proves_commissioners_page_was_crawled_and_extracted() -> None: | |
| """Offline regression on appling_13001/: cache must show the BOC page was reached. | |
| Fails unless Appling County's scraped-meetings cache demonstrates the full chain: | |
| 1. ``applingcountyga.org`` is listed in ``homepage_url_candidates`` (candidate | |
| discovery added the real county domain — not just Baxley / applingcounty.gov). | |
| 2. A URL containing ``applingcountyga.org`` AND ``page_id=1464`` appears in | |
| ``pages_fetched`` (the slow site was crawled deeply enough to reach the BOC | |
| roster page at https://applingcountyga.org/?page_id=1464). | |
| 3. All seven officials named on that page appear in the rebuilt contacts bundle | |
| (Reid Lovett, Daryl Edwards, Jakharis Jones, Chad Kent, Leslie Burch, | |
| Randy Sellers, Ricky Barnes) — the extractor handled the two prose paragraphs | |
| ("L – R Standing …" / "L – R Seated …") separated by semicolons. | |
| If this test fails, the fix is upstream in the scrape pipeline (extend Appling's | |
| homepage candidates, give the crawler enough patience for the slow host, and let | |
| the AI extractor at the resulting HTML) — not in this assertion. | |
| """ | |
| manifest_path = _APPLING_DIR / "_manifest.json" | |
| assert manifest_path.is_file(), ( | |
| f"appling_13001: missing _manifest.json at {manifest_path} — crawl never ran" | |
| ) | |
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | |
| candidates = [str(c) for c in (manifest.get("homepage_url_candidates") or [])] | |
| assert any(_APPLING_TARGET_DOMAIN in c for c in candidates), ( | |
| f"appling_13001: homepage_url_candidates does not include {_APPLING_TARGET_DOMAIN!r}; " | |
| f"got {candidates}. Candidate discovery must add the real county domain." | |
| ) | |
| pages_fetched = [str(u) for u in (manifest.get("pages_fetched") or [])] | |
| matching = [ | |
| u for u in pages_fetched | |
| if _APPLING_TARGET_DOMAIN in u and _APPLING_TARGET_URL_FRAGMENT in u | |
| ] | |
| assert matching, ( | |
| f"appling_13001: no fetched page matches {_APPLING_TARGET_DOMAIN}/?{_APPLING_TARGET_URL_FRAGMENT}. " | |
| f"Pages fetched: {pages_fetched}. The crawler must reach the BOC roster page." | |
| ) | |
| bundle = _regenerate_contacts_bundle(_APPLING_DIR) | |
| extracted_names = { | |
| str(r.get("person_name") or "").strip().lower() | |
| for r in bundle.get("contacts", []) | |
| if r.get("person_name") | |
| } | |
| missing = _APPLING_EXPECTED_NAMES - extracted_names | |
| assert not missing, ( | |
| f"appling_13001: contacts bundle missing {sorted(missing)} from " | |
| f"https://{_APPLING_TARGET_DOMAIN}/?{_APPLING_TARGET_URL_FRAGMENT}; " | |
| f"got {sorted(extracted_names)}" | |
| ) | |
| def test_applingcountyga_commissioners_page_extracts_all_seven() -> None: | |
| """AI extractor must recover every official named on the Appling County BOC page. | |
| Live-fetches https://applingcountyga.org/?page_id=1464 via crawl4ai + Groq LLM | |
| extraction (``scripts.discovery.contact_extract_crawl4ai``). The page lists the | |
| seven officials in two prose paragraphs ("L – R Standing …" / "L – R Seated …") | |
| separated by semicolons — a pattern the heuristic HTML extractor cannot follow. | |
| Skipped when ``GROQ_API_KEY`` is unset so this stays green in CI without keys. | |
| """ | |
| import os | |
| if not os.getenv("GROQ_API_KEY"): | |
| pytest.skip("GROQ_API_KEY not set; skipping live AI extraction test") | |
| from scripts.discovery.contact_extract_crawl4ai import extract_contact_directory_sync | |
| directory = extract_contact_directory_sync("https://applingcountyga.org/?page_id=1464") | |
| extracted_names = { | |
| (rec.name or "").strip().lower() for rec in directory.contacts if rec.name | |
| } | |
| expected_names = { | |
| "reid lovett", | |
| "daryl edwards", | |
| "jakharis jones", | |
| "chad kent", | |
| "leslie burch", | |
| "randy sellers", | |
| "ricky barnes", | |
| } | |
| missing = expected_names - extracted_names | |
| assert not missing, ( | |
| f"applingcountyga.org/?page_id=1464: AI extractor missed {sorted(missing)}; " | |
| f"got {sorted(extracted_names)}" | |
| ) | |
| _BAKER_DIR = _GA_COUNTY_CACHE / "baker_13007" | |
| _BAKER_BOC_PAGE_URL = "https://www.bakercountyga.com/board-of-commissioners-1" | |
| _BAKER_EXPECTED_NAMES = frozenset({ | |
| "connie hobbs", | |
| "vann irvin", | |
| "matt bryan", | |
| "tommy rentz", | |
| "chris moore", | |
| }) | |
| _BAKER_EXPECTED_HEADSHOTS = frozenset({ | |
| "headshot_ConnieHobbs-768x1024_edited.jpg", | |
| "headshot_VannIrvin-768x1024_edited.jpg", | |
| "headshot_MattBryan-768x1024_edited.jpg", | |
| "headshot_TommyRentz-768x1024_edited.jpg", | |
| }) | |
| def test_baker_cache_proves_commissioners_page_was_crawled_and_extracted() -> None: | |
| """Offline regression on baker_13007/: BOC page must yield all 5 commissioners. | |
| Baker County's board-of-commissioners-1 page (Wix-built) lists the 5 commissioners | |
| as ``<h2>Name, District</h2>`` headings paired with ``<img alt="headshot_<Name>-... | |
| _edited.jpg">`` tags. The cached snapshot | |
| (``page__board-of-commissioners-1.html``) contains every name and headshot today, | |
| but the structured extractor currently recovers none of them. | |
| This test fails until the rebuilt bundle includes: | |
| 1. ``board-of-commissioners-1`` in ``pages_fetched`` (proves the crawl reached it), | |
| 2. ``person_name`` rows for all five commissioners (Connie Hobbs, Vann Irvin, | |
| Matt Bryan, Tommy Rentz, Chris Moore), and | |
| 3. the four named headshot filenames referenced on that page | |
| (ConnieHobbs / VannIrvin / MattBryan / TommyRentz). | |
| If this test fails, the fix is upstream: teach the Wix-style HTML extractor to | |
| pair adjacent rich-text headings with their sibling headshot ``<img>`` and to | |
| resolve ``srcSet`` URLs against ``static.wixstatic.com`` (the manifest currently | |
| records them rooted at the county domain, which 404s). | |
| """ | |
| manifest_path = _BAKER_DIR / "_manifest.json" | |
| assert manifest_path.is_file(), ( | |
| f"baker_13007: missing _manifest.json at {manifest_path} — crawl never ran" | |
| ) | |
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | |
| pages_fetched = [str(u) for u in (manifest.get("pages_fetched") or [])] | |
| assert _BAKER_BOC_PAGE_URL in pages_fetched, ( | |
| f"baker_13007: {_BAKER_BOC_PAGE_URL} not in pages_fetched; " | |
| f"got {pages_fetched}. The crawler must reach the BOC page." | |
| ) | |
| bundle = _regenerate_contacts_bundle(_BAKER_DIR) | |
| extracted_names = { | |
| str(r.get("person_name") or "").strip().lower() | |
| for r in bundle.get("contacts", []) | |
| if r.get("person_name") | |
| } | |
| missing_names = _BAKER_EXPECTED_NAMES - extracted_names | |
| assert not missing_names, ( | |
| f"baker_13007: contacts bundle missing {sorted(missing_names)} from " | |
| f"{_BAKER_BOC_PAGE_URL}; got {sorted(extracted_names)}" | |
| ) | |
| bundle_blob = json.dumps(bundle, ensure_ascii=False) | |
| missing_headshots = sorted( | |
| h for h in _BAKER_EXPECTED_HEADSHOTS if h not in bundle_blob | |
| ) | |
| assert not missing_headshots, ( | |
| f"baker_13007: rebuilt bundle does not reference headshot filenames " | |
| f"{missing_headshots} from {_BAKER_BOC_PAGE_URL}" | |
| ) | |
| _BERRIEN_DIR = _GA_COUNTY_CACHE / "berrien_13019" | |
| _BERRIEN_BOC_PAGE_URL = "https://berriencountygeorgia.com/commissioners/" | |
| _BERRIEN_EXPECTED_COMMISSIONERS: Tuple[Tuple[str, str], ...] = ( | |
| ("john nugent", "district 1"), | |
| ("ronnie gaskins", "district 2"), | |
| ("jimmy parker", "district 3"), | |
| ("kylon fort", "district 4"), | |
| ("david harrod", "district 5"), | |
| ) | |
| _BANKS_DIR = _GA_COUNTY_CACHE / "banks_13011" | |
| _BANKS_BOC_PAGE_URL = "https://www.bankscountyga.org/1227/Board-of-Commissioners" | |
| _BANKS_DANNY_EXPECTED = { | |
| "person_name": "danny maxwell", | |
| "title_or_role": "vice chairman district 1", | |
| "phone": "(706) 654-8326", | |
| "email": "djmaxwell@co.banks.ga.us", | |
| } | |
| _BULLOCH_DIR = _GA_COUNTY_CACHE / "bulloch_13031" | |
| _BULLOCH_BOC_PAGE_PATH = "/commissioners/" | |
| _BULLOCH_EXPECTED_EMAILS = frozenset( | |
| { | |
| "dbennett@bullochcounty.net", | |
| "rmosley@bullochcounty.net", | |
| "asimmons@bullochcounty.net", | |
| "rdavis@bullochcounty.net", | |
| "toby.conner@bullochcounty.net", | |
| "nnewkirk@bullochcounty.net", | |
| "trushing@bullochcounty.net", | |
| } | |
| ) | |
| def test_berrien_cache_proves_commissioners_page_was_crawled_and_extracted() -> None: | |
| """Offline regression on berrien_13019/: BOC page must yield exactly 5 commissioners. | |
| Berrien County's commissioners page (Elementor-built WordPress) lists the 5 | |
| commissioners as ``elementor-image-box`` blocks pairing | |
| ``<h3 class="elementor-image-box-title">Name</h3>`` with | |
| ``<p class="elementor-image-box-description">District N</p>``. The cached | |
| snapshot (``page__commissioners_.html``) contains every name and district | |
| label today, but the structured extractor currently recovers only the staff | |
| ``mailto:`` rows (Brenda/Teresa/Jenny/Ashley) and none of the elected officials. | |
| This test fails until the rebuilt bundle includes person rows for all five | |
| commissioners — John Nugent (District 1), Ronnie Gaskins (District 2), | |
| Jimmy Parker (District 3), Kylon Fort (District 4), David Harrod (District 5) — | |
| each with their district as ``title_or_role``. | |
| If this test fails, the fix is upstream: teach the Elementor HTML extractor to | |
| pair adjacent ``elementor-image-box-title`` headings with their sibling | |
| ``elementor-image-box-description`` so name + district are emitted together. | |
| """ | |
| manifest_path = _BERRIEN_DIR / "_manifest.json" | |
| assert manifest_path.is_file(), ( | |
| f"berrien_13019: missing _manifest.json at {manifest_path} — crawl never ran" | |
| ) | |
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | |
| pages_fetched = [str(u) for u in (manifest.get("pages_fetched") or [])] | |
| assert _BERRIEN_BOC_PAGE_URL in pages_fetched, ( | |
| f"berrien_13019: {_BERRIEN_BOC_PAGE_URL} not in pages_fetched; " | |
| f"got {pages_fetched}. The crawler must reach the BOC page." | |
| ) | |
| bundle = _regenerate_contacts_bundle(_BERRIEN_DIR) | |
| extracted_pairs = { | |
| ( | |
| str(r.get("person_name") or "").strip().lower(), | |
| str(r.get("title_or_role") or "").strip().lower(), | |
| ) | |
| for r in bundle.get("contacts", []) | |
| if r.get("person_name") | |
| and str(r.get("source_page_url") or "") == _BERRIEN_BOC_PAGE_URL | |
| } | |
| missing_pairs = sorted(set(_BERRIEN_EXPECTED_COMMISSIONERS) - extracted_pairs) | |
| assert not missing_pairs, ( | |
| f"berrien_13019: contacts bundle missing {missing_pairs} from " | |
| f"{_BERRIEN_BOC_PAGE_URL}; got {sorted(extracted_pairs)}" | |
| ) | |
| commissioner_names = {name for name, _ in _BERRIEN_EXPECTED_COMMISSIONERS} | |
| extracted_commissioner_rows = [ | |
| r for r in bundle.get("contacts", []) | |
| if str(r.get("person_name") or "").strip().lower() in commissioner_names | |
| and str(r.get("source_page_url") or "") == _BERRIEN_BOC_PAGE_URL | |
| ] | |
| assert len(extracted_commissioner_rows) == 5, ( | |
| f"berrien_13019: expected exactly 5 commissioner rows from " | |
| f"{_BERRIEN_BOC_PAGE_URL}, got {len(extracted_commissioner_rows)}: " | |
| f"{[(r.get('person_name'), r.get('title_or_role')) for r in extracted_commissioner_rows]}" | |
| ) | |
| def test_banks_cache_requires_danny_maxwell_full_contact_fields() -> None: | |
| """Banks County BOC page must emit Danny Maxwell with name, role, phone, and email. | |
| This fails unless the rebuilt bundle includes a contact row from | |
| ``/1227/Board-of-Commissioners`` with all of: | |
| - person_name = Danny Maxwell | |
| - title_or_role = Vice Chairman District 1 | |
| - phone = (706) 654-8326 | |
| - email = djmaxwell@co.banks.ga.us | |
| """ | |
| manifest_path = _BANKS_DIR / "_manifest.json" | |
| assert manifest_path.is_file(), ( | |
| f"banks_13011: missing _manifest.json at {manifest_path} — crawl never ran" | |
| ) | |
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | |
| pages_fetched = [str(u) for u in (manifest.get("pages_fetched") or [])] | |
| assert _BANKS_BOC_PAGE_URL in pages_fetched, ( | |
| f"banks_13011: {_BANKS_BOC_PAGE_URL} not in pages_fetched; got {pages_fetched}" | |
| ) | |
| bundle = _regenerate_contacts_bundle(_BANKS_DIR) | |
| target_rows = [ | |
| r | |
| for r in bundle.get("contacts", []) | |
| if str(r.get("source_page_url") or "") == _BANKS_BOC_PAGE_URL | |
| and str(r.get("email") or "").strip().lower() == _BANKS_DANNY_EXPECTED["email"] | |
| ] | |
| assert target_rows, ( | |
| "banks_13011: missing Danny Maxwell email row from " | |
| f"{_BANKS_BOC_PAGE_URL}; got emails " | |
| f"{sorted({str(r.get('email') or '').lower() for r in bundle.get('contacts', []) if r.get('email')})}" | |
| ) | |
| row = target_rows[0] | |
| assert str(row.get("person_name") or "").strip().lower() == _BANKS_DANNY_EXPECTED["person_name"], ( | |
| f"banks_13011: expected person_name {_BANKS_DANNY_EXPECTED['person_name']!r} " | |
| f"for {_BANKS_DANNY_EXPECTED['email']}, got {row.get('person_name')!r}" | |
| ) | |
| assert str(row.get("title_or_role") or "").strip().lower() == _BANKS_DANNY_EXPECTED["title_or_role"], ( | |
| f"banks_13011: expected title_or_role {_BANKS_DANNY_EXPECTED['title_or_role']!r} " | |
| f"for {_BANKS_DANNY_EXPECTED['email']}, got {row.get('title_or_role')!r}" | |
| ) | |
| assert str(row.get("phone") or "").strip() == _BANKS_DANNY_EXPECTED["phone"], ( | |
| f"banks_13011: expected phone {_BANKS_DANNY_EXPECTED['phone']!r} " | |
| f"for {_BANKS_DANNY_EXPECTED['email']}, got {row.get('phone')!r}" | |
| ) | |
| def test_bulloch_cache_proves_commissioners_page_was_crawled_and_extracted() -> None: | |
| """Bulloch commissioners page must be crawled and yield commissioner emails. | |
| Asserts that the cache includes a fetched ``/commissioners/`` page and that | |
| rebuilt contacts contain the seven commissioner emails sourced from that page. | |
| """ | |
| manifest_path = _BULLOCH_DIR / "_manifest.json" | |
| assert manifest_path.is_file(), ( | |
| f"bulloch_13031: missing _manifest.json at {manifest_path} — crawl never ran" | |
| ) | |
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | |
| pages_fetched = [str(u) for u in (manifest.get("pages_fetched") or [])] | |
| commissioners_pages = [u for u in pages_fetched if _BULLOCH_BOC_PAGE_PATH in u] | |
| assert commissioners_pages, ( | |
| f"bulloch_13031: no fetched URL contains {_BULLOCH_BOC_PAGE_PATH!r}; " | |
| f"got {pages_fetched}" | |
| ) | |
| bundle = _regenerate_contacts_bundle(_BULLOCH_DIR) | |
| contacts = list(bundle.get("contacts") or []) | |
| by_email = { | |
| str(r.get("email") or "").strip().lower(): r | |
| for r in contacts | |
| if str(r.get("email") or "").strip() | |
| } | |
| extracted_emails = set(by_email.keys()) | |
| missing = sorted(_BULLOCH_EXPECTED_EMAILS - extracted_emails) | |
| assert not missing, ( | |
| f"bulloch_13031: missing commissioner emails {missing}; " | |
| f"got {sorted(extracted_emails)}" | |
| ) | |
| wrong_source = [] | |
| for email in sorted(_BULLOCH_EXPECTED_EMAILS): | |
| src = str((by_email.get(email) or {}).get("source_page_url") or "") | |
| if _BULLOCH_BOC_PAGE_PATH not in src: | |
| wrong_source.append((email, src)) | |
| assert not wrong_source, ( | |
| "bulloch_13031: commissioner emails were not attributed to commissioners page: " | |
| f"{wrong_source}" | |
| ) | |
| def test_qualifying_county_count_does_not_shrink() -> None: | |
| """Guard against silent skips: at least MIN_QUALIFYING_COUNTIES must run.""" | |
| qualifying = [] | |
| for county in FIRST_10_GA_COUNTIES: | |
| jdir = _GA_COUNTY_CACHE / county | |
| if ( | |
| (jdir / "_manifest.json").is_file() | |
| and (jdir / "_contact_images" / "contacts.json").is_file() | |
| and (jdir / "_crawl_html").is_dir() | |
| and list((jdir / "_crawl_html").glob("page_*.html")) | |
| ): | |
| qualifying.append(county) | |
| assert len(qualifying) >= MIN_QUALIFYING_COUNTIES, ( | |
| f"Only {len(qualifying)} of the first 10 GA counties have a complete fixture pair " | |
| f"(expected >= {MIN_QUALIFYING_COUNTIES}). Qualifying: {qualifying}" | |
| ) | |