From e24c06fff0a3d306308e69de613417bce14c114e Mon Sep 17 00:00:00 2001 From: CJud25 <293205910+CJud25@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:04:30 -0500 Subject: [PATCH 1/6] test: exercise every validate_demo_data error branch and the thin-history warning --- tests/test_validation.py | 149 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/tests/test_validation.py b/tests/test_validation.py index 3955729..cf475cc 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,9 +1,123 @@ from __future__ import annotations +from dataclasses import replace + +import pytest + from tens_hq.constants import COUNTY_NAMES, SITE_PROFILES from tens_hq.validation import expected_row_counts, validate_demo_data +def _with_frame(data, name, mutate): + frame = getattr(data, name).copy() + mutate(frame) + return replace(data, **{name: frame}) + + +def _wrong_row_count(data): + return replace(data, counties=data.counties.iloc[:-1].copy()) + + +def _prohibited_column(data): + return _with_frame(data, "sites", lambda frame: frame.__setitem__("medical_record", "none")) + + +def _missing_synthetic_flag(data): + return replace(data, sites=data.sites.drop(columns="synthetic_flag").copy()) + + +def _unmarked_synthetic_row(data): + return _with_frame(data, "sites", lambda frame: frame.__setitem__("synthetic_flag", False)) + + +def _invalid_id_prefix(data): + return _with_frame(data, "sites", lambda frame: frame.__setitem__("site_id", "INVALID")) + + +def _duplicate_id(data): + def mutate(frame): + frame.loc[frame.index[1], "site_id"] = frame.loc[frame.index[0], "site_id"] + + return _with_frame(data, "sites", mutate) + + +def _non_reserved_email(data): + return _with_frame( + data, + "contacts", + lambda frame: frame.__setitem__("contact_email", "person@example.com"), + ) + + +def _non_reserved_website(data): + return _with_frame( + data, + "organizations", + lambda frame: frame.__setitem__("website_url", "https://example.com"), + ) + + +def _qdl_exceeds_total(data): + def mutate(frame): + frame.loc[frame.index[0], "mock_qdl_hours"] = frame.loc[frame.index[0], "total_direct_labor_hours"] + 1 + + return _with_frame(data, "labor_hours", mutate) + + +def _negative_hours(data): + def mutate(frame): + frame.loc[frame.index[0], "mock_qdl_hours"] = -1 + + return _with_frame(data, "labor_hours", mutate) + + +def _ratio_does_not_reproduce(data): + def mutate(frame): + frame.loc[frame.index[0], "current_ratio_pct"] += 1 + + return _with_frame(data, "labor_hours", mutate) + + +def _missing_synthetic_display_label(data): + return _with_frame( + data, + "applicants", + lambda frame: frame.__setitem__("display_label", "Applicant"), + ) + + +def _eligibility_before_start(data): + def mutate(frame): + index = frame.index[frame["start_date"].isna()][0] + frame.loc[index, "eligibility_review_status"] = "Cleared" + + return _with_frame(data, "applicants", mutate) + + +def _documentation_before_start(data): + def mutate(frame): + index = frame.index[frame["start_date"].isna()][0] + frame.loc[index, "documentation_status"] = "Complete" + + return _with_frame(data, "applicants", mutate) + + +def _unknown_source_organization(data): + return _with_frame( + data, + "applicants", + lambda frame: frame.__setitem__("source_organization_id", "SYN-ORG-UNKNOWN"), + ) + + +def _unknown_target_site(data): + return _with_frame( + data, + "applicants", + lambda frame: frame.__setitem__("target_site_id", "SYN-SITE-UNKNOWN"), + ) + + def test_derivable_counts_track_generator_constants(): counts = expected_row_counts() assert counts["sites"] == len(SITE_PROFILES) == 12 @@ -16,3 +130,38 @@ def test_expected_counts_reproduce_actual_generation(demo_data): for name, expected in counts.items(): assert len(getattr(demo_data, name)) == expected, name assert validate_demo_data(demo_data).ok + + +@pytest.mark.parametrize( + ("mutator", "error"), + [ + (_wrong_row_count, "counties: expected 48 rows, found 47"), + (_prohibited_column, "prohibited columns present"), + (_missing_synthetic_flag, "synthetic_flag is missing"), + (_unmarked_synthetic_row, "one or more rows are not marked synthetic"), + (_invalid_id_prefix, "ID prefix contract failed"), + (_duplicate_id, "duplicate IDs detected"), + (_non_reserved_email, "non-reserved email domain detected"), + (_non_reserved_website, "non-reserved website domain detected"), + (_qdl_exceeds_total, "QDL hours exceed total direct labor hours"), + (_negative_hours, "negative hours detected"), + (_ratio_does_not_reproduce, "stored ratios do not reproduce"), + (_missing_synthetic_display_label, "synthetic display label missing"), + (_eligibility_before_start, "eligibility status used before the synthetic start-stage gate"), + (_documentation_before_start, "documentation status used before the synthetic start-stage gate"), + (_unknown_source_organization, "unknown source organization reference"), + (_unknown_target_site, "unknown target site reference"), + ], + ids=lambda value: value.__name__ if callable(value) else None, +) +def test_validation_flags_each_corrupted_contract(demo_data, mutator, error): + result = validate_demo_data(mutator(demo_data)) + assert result.ok is False + assert any(error in message for message in result.errors) + + +def test_thin_stage_history_is_a_warning_not_an_error(demo_data): + corrupted = replace(demo_data, stage_history=demo_data.stage_history.iloc[:3999].copy()) + result = validate_demo_data(corrupted) + assert result.ok is True + assert "stage_history: fewer than 4,000 events; dashboard story may be thin" in result.warnings From 352f14f9431397c2060dbff17e797f066f9c4905 Mon Sep 17 00:00:00 2001 From: CJud25 <293205910+CJud25@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:05:36 -0500 Subject: [PATCH 2/6] =?UTF-8?q?test:=20cover=20services.py=20=E2=80=94=20D?= =?UTF-8?q?o=20Not=20Contact=20exclusion,=20draft=20safety,=20and=20report?= =?UTF-8?q?=20decision=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_services.py | 80 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/test_services.py diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..faa1c89 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import replace +import inspect + +import tens_hq.services as services +from tens_hq.metrics import forecast_sites, source_performance +from tens_hq.services import ( + build_outreach_queue, + leadership_summary_markdown, + outreach_email_draft, + site_report_markdown, +) + + +def test_do_not_contact_orgs_are_excluded_from_outreach_queue(demo_data): + do_not_contact = demo_data.organizations["relationship_status"].eq("Do Not Contact") + assert do_not_contact.sum() > 0 + + forecasts = forecast_sites(demo_data) + scores = source_performance(demo_data) + queue = build_outreach_queue(demo_data, scores, forecasts) + + assert not queue["relationship_status"].eq("Do Not Contact").any() + + +def test_do_not_contact_partner_priority_score_is_zero(demo_data): + scored = source_performance(demo_data) + organization_id = scored.iloc[0]["organization_id"] + organizations = demo_data.organizations.copy() + organizations.loc[ + organizations["organization_id"].eq(organization_id), "relationship_status" + ] = "Do Not Contact" + + scores = source_performance(replace(demo_data, organizations=organizations)) + do_not_contact = scores.loc[scores["organization_id"].eq(organization_id)] + + assert len(do_not_contact) == 1 + assert do_not_contact.iloc[0]["partner_priority_score"] == 0 + + +def test_outreach_drafts_are_synthetic_and_carry_no_delivery_capability(): + warm_subject, warm_body = outreach_email_draft( + "Synthetic Partner", + "Taylor Example", + "Synthetic Site", + "Administrative Support", + "Warm", + ) + cold_subject, cold_body = outreach_email_draft( + "Synthetic Partner", + "Taylor Example", + "Synthetic Site", + "Administrative Support", + "Cold", + ) + + assert warm_subject + assert cold_subject + assert "ROCC Demo User (Synthetic)" in warm_body + assert "ROCC Demo User (Synthetic)" in cold_body + assert "has not been sent" in cold_body + + source = inspect.getsource(services) + assert "smtplib" not in source + assert "requests" not in source + assert ".send(" not in source + assert "sendmail" not in source + + +def test_reports_carry_decision_boundary_banners(demo_data): + forecasts = forecast_sites(demo_data) + scores = source_performance(demo_data) + queue = build_outreach_queue(demo_data, scores, forecasts) + + site_report = site_report_markdown(forecasts.iloc[0]) + leadership_summary = leadership_summary_markdown(forecasts, queue) + + assert "NOT AN OFFICIAL ODLH COMPLIANCE DETERMINATION" in site_report + assert "SYNTHETIC DEMO DATA — NOT FOR EMPLOYMENT OR COMPLIANCE DECISIONS" in leadership_summary From 1fc3ab01f834dd6341f97fb65356c180e27be784 Mon Sep 17 00:00:00 2001 From: CJud25 <293205910+CJud25@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:06:22 -0500 Subject: [PATCH 3/6] test: machine-check the Trust controls section against named tests (control parity sweep) --- docs/ARCHITECTURE.md | 12 +++---- tests/test_trust_controls.py | 67 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 tests/test_trust_controls.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c7bc927..31accca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,12 +108,12 @@ their output contract contains aggregate dimensions and measures only. ## Trust controls -- Synthetic markers and reserved identifiers/domains are validated. -- The test harness blocks socket access. -- Do Not Contact removes an organization from outreach priority. -- Drafts and reports are local, synthetic, and human-reviewed. -- No page can obtain applicant-level output through a role or navigation path. -- Generated artifacts are reproducible but excluded from version control. +- Synthetic markers and reserved identifiers/domains are validated. +- The test harness blocks socket access. +- Do Not Contact removes an organization from outreach priority. +- Drafts and reports are local, synthetic, and human-reviewed. +- No page can obtain applicant-level output through a role or navigation path. +- Generated artifacts are reproducible but excluded from version control. ## Production transition diff --git a/tests/test_trust_controls.py b/tests/test_trust_controls.py new file mode 100644 index 0000000..99b89df --- /dev/null +++ b/tests/test_trust_controls.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import ast +from pathlib import Path +import re + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +ARCHITECTURE = ROOT / "docs" / "ARCHITECTURE.md" +CONTROL_TEST_MAP: dict[str, tuple[str, str]] = { + "synthetic-markers-validated": ( + "test_validation.py", + "test_validation_flags_each_corrupted_contract", + ), + "socket-guard": ("test_socket_guard.py", "test_direct_socket_connect_is_blocked"), + "do-not-contact-override": ( + "test_services.py", + "test_do_not_contact_orgs_are_excluded_from_outreach_queue", + ), + "drafts-human-reviewed": ( + "test_services.py", + "test_outreach_drafts_are_synthetic_and_carry_no_delivery_capability", + ), + "no-applicant-level-output": ( + "test_ui_contract.py", + "test_pipeline_health_never_renders_applicant_identifiers_or_names", + ), + "generated-artifacts-untracked": ( + "test_trust_controls.py", + "test_generated_artifacts_directory_is_gitignored", + ), +} + + +def _extract_control_ids() -> tuple[bool, set[str]]: + text = ARCHITECTURE.read_text(encoding="utf-8") + match = re.search(r"^## Trust controls\s*$([\s\S]*?)(?=^##\s|\Z)", text, re.MULTILINE) + if match is None: + return False, set() + return True, set(re.findall(r"", match.group(1))) + + +def test_trust_controls_sweep_covers_every_documented_control(): + heading_found, control_ids = _extract_control_ids() + + assert heading_found + assert control_ids == set(CONTROL_TEST_MAP) + assert len(control_ids) >= 6 + assert {"do-not-contact-override", "socket-guard"} <= control_ids + + +@pytest.mark.parametrize("control_id", sorted(CONTROL_TEST_MAP)) +def test_control_maps_to_an_existing_test(control_id): + filename, function_name = CONTROL_TEST_MAP[control_id] + tree = ast.parse((ROOT / "tests" / filename).read_text(encoding="utf-8")) + + assert any( + isinstance(node, ast.FunctionDef) and node.name == function_name + for node in ast.walk(tree) + ) + + +def test_generated_artifacts_directory_is_gitignored(): + ignored_lines = (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines() + assert "data/generated/" in ignored_lines From d8e0dd1a452676ac5fe3523636c778637becc382 Mon Sep 17 00:00:00 2001 From: CJud25 <293205910+CJud25@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:07:24 -0500 Subject: [PATCH 4/6] ci: add scheduled freshness check for the demo as-of date --- .github/workflows/freshness.yml | 25 +++++++++++ scripts/check_freshness.py | 80 +++++++++++++++++++++++++++++++++ tests/test_freshness_check.py | 29 ++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 .github/workflows/freshness.yml create mode 100644 scripts/check_freshness.py create mode 100644 tests/test_freshness_check.py diff --git a/.github/workflows/freshness.yml b/.github/workflows/freshness.yml new file mode 100644 index 0000000..cc1083b --- /dev/null +++ b/.github/workflows/freshness.yml @@ -0,0 +1,25 @@ +name: Freshness + +on: + schedule: + - cron: "47 9 2 * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-demo-date: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + # The checker imports only stdlib-backed constants, so no dependency install is needed. + - name: Check demo-data freshness + run: python scripts/check_freshness.py diff --git a/scripts/check_freshness.py b/scripts/check_freshness.py new file mode 100644 index 0000000..e02382a --- /dev/null +++ b/scripts/check_freshness.py @@ -0,0 +1,80 @@ +"""Scheduled freshness runbook. +Reads: DATA_AS_OF_DATE from src/tens_hq/constants.py. +Red: the synthetic demo date is more than 365 days old or the fact registry is empty. +Remediation: review the demo facts, regenerate them, and advance DATA_AS_OF_DATE. +Notification: GitHub emails the scheduled-run actor; 60 days of inactivity may auto-disable the schedule with warning. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import date, timedelta +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from tens_hq.constants import DATA_AS_OF_DATE # noqa: E402 + + +MAX_FACT_AGE_DAYS = 365 + + +@dataclass(frozen=True) +class Fact: + name: str + as_of: date + remediation: str + + @property + def deadline(self) -> date: + return self.as_of + timedelta(days=MAX_FACT_AGE_DAYS) + + +def fact_registry() -> tuple[Fact, ...]: + return ( + Fact( + name="DATA_AS_OF_DATE", + as_of=DATA_AS_OF_DATE, + remediation="review the demo facts, regenerate them, and advance DATA_AS_OF_DATE", + ), + ) + + +def stale_facts(today: date) -> list[str]: + facts = fact_registry() + if not facts: + return ["fact registry is empty; restore at least one machine-readable dated fact"] + return [ + f"{fact.name}: deadline {fact.deadline.isoformat()}; {fact.remediation}" + for fact in facts + if today > fact.deadline + ] + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check ROCC's dated demo facts for staleness.") + parser.add_argument("--today", type=date.fromisoformat, help="override today with YYYY-MM-DD") + args = parser.parse_args() + today = args.today or date.today() + + facts = fact_registry() + findings = stale_facts(today) + if not facts: + for finding in findings: + print(f"STALE: {finding}") + return 1 + + for fact in facts: + status = "STALE" if today > fact.deadline else "FRESH" + print( + f"{status}: {fact.name} as of {fact.as_of.isoformat()} " + f"(deadline {fact.deadline.isoformat()})" + ) + return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_freshness_check.py b/tests/test_freshness_check.py new file mode 100644 index 0000000..d6ce3fb --- /dev/null +++ b/tests/test_freshness_check.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import check_freshness # noqa: E402 + + +def test_fresh_on_2026_07_27(): + assert check_freshness.stale_facts(date(2026, 7, 27)) == [] + + +def test_stale_after_365_days(): + findings = check_freshness.stale_facts(date(2027, 7, 1)) + assert len(findings) == 1 + assert "DATA_AS_OF_DATE" in findings[0] + assert "deadline 2027-06-30" in findings[0] + + +def test_deadline_day_is_fresh(): + assert check_freshness.stale_facts(date(2027, 6, 30)) == [] + + +def test_registry_is_not_empty(): + assert check_freshness.fact_registry() From 0f1c5fb281b46b22602a8bdb6fd85ac0815f6cc8 Mon Sep 17 00:00:00 2001 From: CJud25 <293205910+CJud25@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:55:20 -0500 Subject: [PATCH 5/6] fix: parity sweep flags unannotated control bullets; stale output names the remediation; DNC score test gains its positive control --- scripts/check_freshness.py | 5 +++-- tests/test_freshness_check.py | 12 ++++++++++++ tests/test_services.py | 2 ++ tests/test_trust_controls.py | 24 ++++++++++++++++++++---- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/scripts/check_freshness.py b/scripts/check_freshness.py index e02382a..684f34d 100644 --- a/scripts/check_freshness.py +++ b/scripts/check_freshness.py @@ -63,10 +63,11 @@ def main() -> int: facts = fact_registry() findings = stale_facts(today) if not facts: - for finding in findings: - print(f"STALE: {finding}") return 1 + for finding in findings: + print(f"STALE: {finding}") + for fact in facts: status = "STALE" if today > fact.deadline else "FRESH" print( diff --git a/tests/test_freshness_check.py b/tests/test_freshness_check.py index d6ce3fb..786325b 100644 --- a/tests/test_freshness_check.py +++ b/tests/test_freshness_check.py @@ -25,5 +25,17 @@ def test_deadline_day_is_fresh(): assert check_freshness.stale_facts(date(2027, 6, 30)) == [] +def test_stale_main_prints_remediation(monkeypatch, capsys): + monkeypatch.setattr( + sys, "argv", ["check_freshness.py", "--today", "2027-07-01"] + ) + + assert check_freshness.main() == 1 + assert ( + "review the demo facts, regenerate them, and advance DATA_AS_OF_DATE" + in capsys.readouterr().out + ) + + def test_registry_is_not_empty(): assert check_freshness.fact_registry() diff --git a/tests/test_services.py b/tests/test_services.py index faa1c89..1e34c2a 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -27,6 +27,8 @@ def test_do_not_contact_orgs_are_excluded_from_outreach_queue(demo_data): def test_do_not_contact_partner_priority_score_is_zero(demo_data): scored = source_performance(demo_data) organization_id = scored.iloc[0]["organization_id"] + assert scored.iloc[0]["partner_priority_score"] != 0 + organizations = demo_data.organizations.copy() organizations.loc[ organizations["organization_id"].eq(organization_id), "relationship_status" diff --git a/tests/test_trust_controls.py b/tests/test_trust_controls.py index 99b89df..62e93b5 100644 --- a/tests/test_trust_controls.py +++ b/tests/test_trust_controls.py @@ -34,18 +34,34 @@ } -def _extract_control_ids() -> tuple[bool, set[str]]: +def _extract_trust_controls() -> tuple[bool, set[str], list[str], list[str]]: text = ARCHITECTURE.read_text(encoding="utf-8") match = re.search(r"^## Trust controls\s*$([\s\S]*?)(?=^##\s|\Z)", text, re.MULTILINE) if match is None: - return False, set() - return True, set(re.findall(r"", match.group(1))) + return False, set(), [], [] + + section = match.group(1) + bullet_lines = [line for line in section.splitlines() if line.startswith("- ")] + annotated_bullets = [ + line + for line in bullet_lines + if re.search(r"", line) + ] + control_ids = set(re.findall(r"", section)) + return True, control_ids, bullet_lines, annotated_bullets def test_trust_controls_sweep_covers_every_documented_control(): - heading_found, control_ids = _extract_control_ids() + heading_found, control_ids, bullet_lines, annotated_bullets = _extract_trust_controls() + unannotated_bullets = [ + line for line in bullet_lines if line not in annotated_bullets + ] assert heading_found + assert len(bullet_lines) == len(annotated_bullets), ( + "Every bullet in the Trust controls section must carry a control annotation; " + f"unannotated bullets: {unannotated_bullets}" + ) assert control_ids == set(CONTROL_TEST_MAP) assert len(control_ids) >= 6 assert {"do-not-contact-override", "socket-guard"} <= control_ids From 105859fe2c19cf4786ba5624dde79959ba273f8b Mon Sep 17 00:00:00 2001 From: CJud25 <293205910+CJud25@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:46:26 -0500 Subject: [PATCH 6/6] fix: guard qualified_hiring_need against NaN at both int() sites NaN in the float64 qualified_hiring_need column is truthy, so the 'or 0' idiom at pages.py:347 and services.py:126 did not guard it and int(NaN) raised ValueError. Both sites now use the pd.isna idiom already established at services.py:94; the deliberate None branch keeps working (pd.isna covers both). Regression test drives None and NaN through the real forecast-frame path for both the page render and the leadership summary. Suite: 59 passed. Co-Authored-By: GPT 5.6 Sol Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JBGunVuQranA9dUGqxGuBD --- src/tens_hq/pages.py | 5 +++- src/tens_hq/services.py | 2 +- tests/test_services.py | 61 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/tens_hq/pages.py b/src/tens_hq/pages.py index 41c2bc2..211eec7 100644 --- a/src/tens_hq/pages.py +++ b/src/tens_hq/pages.py @@ -344,7 +344,10 @@ def render_site_readiness(data: DemoData, target: float, scenario: str) -> None: cols[1].metric("90-day", f"{row90['projected_ratio']:.1%}", f"{row90['direction']:+.1%}") cols[2].metric("180-day", f"{row180['projected_ratio']:.1%}") cols[3].metric("Open roles", int(row90["open_roles_count"])) - cols[4].metric("Ready hires still needed", int(row90["qualified_hiring_need"] or 0)) + cols[4].metric( + "Ready hires still needed", + int(0 if pd.isna(row90["qualified_hiring_need"]) else row90["qualified_hiring_need"]), + ) cols[5].metric("Projected pipeline arrivals", f"{row90['expected_ready_hires']:.1f}") st.markdown(f'
{html.escape(row90["explanation"])}
', unsafe_allow_html=True) _cov = row90["pipeline_coverage"] diff --git a/src/tens_hq/services.py b/src/tens_hq/services.py index 25bfa1b..eade88b 100644 --- a/src/tens_hq/services.py +++ b/src/tens_hq/services.py @@ -123,7 +123,7 @@ def leadership_summary_markdown(forecasts: pd.DataFrame, queue: pd.DataFrame) -> 3, "qualified_hiring_need" ) risks = "\n".join( - f"- {row.site_name}: {row.risk_status}, projected {row.projected_ratio:.1%}, need {int(row.qualified_hiring_need or 0)}" + f"- {row.site_name}: {row.risk_status}, projected {row.projected_ratio:.1%}, need {int(0 if pd.isna(row.qualified_hiring_need) else row.qualified_hiring_need)}" for row in risk_sites.itertuples(index=False) ) or "- No At Risk or Critical sites in the selected scenario." actions = "\n".join( diff --git a/tests/test_services.py b/tests/test_services.py index 1e34c2a..3b10284 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -3,6 +3,9 @@ from dataclasses import replace import inspect +import pytest + +from tens_hq import pages import tens_hq.services as services from tens_hq.metrics import forecast_sites, source_performance from tens_hq.services import ( @@ -80,3 +83,61 @@ def test_reports_carry_decision_boundary_banners(demo_data): assert "NOT AN OFFICIAL ODLH COMPLIANCE DETERMINATION" in site_report assert "SYNTHETIC DEMO DATA — NOT FOR EMPLOYMENT OR COMPLIANCE DECISIONS" in leadership_summary + + +@pytest.mark.parametrize("missing_need", [None, float("nan")], ids=["none", "nan"]) +def test_missing_qualified_hiring_need_is_zero_in_frame_consumers( + monkeypatch, demo_data, missing_need +): + forecasts90 = forecast_sites(demo_data) + needs = forecasts90["qualified_hiring_need"].astype(float).tolist() + needs[0] = missing_need + forecasts90 = forecasts90.assign(qualified_hiring_need=needs) + forecasts180 = forecast_sites(demo_data, horizon_days=180) + + assert str(forecasts90["qualified_hiring_need"].dtype) == "float64" + + scores = source_performance(demo_data) + queue = build_outreach_queue(demo_data, scores, forecasts90) + leadership_summary = leadership_summary_markdown(forecasts90, queue) + + assert "North Harbor Services: At Risk" in leadership_summary + assert "need 0" in leadership_summary + + rendered_metrics = {} + + class MetricColumn: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def metric(self, label, value, *_args, **_kwargs): + rendered_metrics[label] = value + + monkeypatch.setattr( + pages, + "_cached_forecast_sites", + lambda _data, _seed, _target, _scenario, horizon: ( + forecasts90 if horizon == 90 else forecasts180 + ), + ) + monkeypatch.setattr(pages, "_cached_source_performance", lambda *_args: scores) + monkeypatch.setattr( + pages.st, + "columns", + lambda spec, *_args, **_kwargs: [ + MetricColumn() for _ in range(spec if isinstance(spec, int) else len(spec)) + ], + ) + monkeypatch.setattr(pages.st, "selectbox", lambda _label, options: options[0]) + monkeypatch.setattr(pages.st, "markdown", lambda *_args, **_kwargs: None) + monkeypatch.setattr(pages.st, "caption", lambda *_args, **_kwargs: None) + monkeypatch.setattr(pages.st, "plotly_chart", lambda *_args, **_kwargs: None) + monkeypatch.setattr(pages.st, "dataframe", lambda *_args, **_kwargs: None) + monkeypatch.setattr(pages.st, "info", lambda *_args, **_kwargs: None) + + pages.render_site_readiness(demo_data, target=0.75, scenario="Base") + + assert rendered_metrics["Ready hires still needed"] == 0