Files

166 lines
7.9 KiB
Python

"""Verify monthly ADP identity, signed changes, revisions and publication matching."""
import asyncio
from datetime import UTC, date, datetime
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
from alphapulse.calendar import adp
FIXTURES = Path(__file__).parent / "fixtures/calendar"
SEPTEMBER = (FIXTURES / "adp-2026-09.html").read_text()
NEGATIVE = (FIXTURES / "adp-2025-06.html").read_text()
CROSS_YEAR = (FIXTURES / "adp-2024-12.html").read_text()
INDEX = (FIXTURES / "adp-index.html").read_text()
@pytest.mark.parametrize("body,period,actual,previous,at", [
(SEPTEMBER, "2026-09", "9", "3.6", datetime(2026, 9, 30, 12, 15, tzinfo=UTC)),
(NEGATIVE, "2025-06", "-3.3", "2.9", datetime(2025, 7, 2, 12, 15, tzinfo=UTC)),
(CROSS_YEAR, "2024-12", "12.2", None, datetime(2025, 1, 8, 13, 15, tzinfo=UTC)),
])
def test_official_monthly_readings_preserve_signs_revisions_and_eastern_time(body, period, actual, previous, at):
report = adp.report(body)
assert (report.period, report.at) == (period, at)
assert report.values["adp_jobs"]["actual"] == actual
assert report.values["adp_jobs"]["previous"] == previous
def test_next_release_uses_explicit_dates_and_both_published_sentence_orders():
next_month = adp.next_release(SEPTEMBER)
assert next_month.period == "2026-10"
assert next_month.at == datetime(2026, 11, 4, 13, 15, tzinfo=UTC)
assert adp.next_release(NEGATIVE).at == datetime(2025, 7, 30, 12, 15, tzinfo=UTC)
assert adp.next_release(CROSS_YEAR).period == "2025-01"
events = adp.schedule(SEPTEMBER, "https://mediacenter.adp.com/report")
assert events[0].external_id == "adp-2026-09"
assert events[0].release_url == "https://mediacenter.adp.com/report"
assert events[1].release_url is None
@pytest.mark.parametrize("body", [
"Access denied",
SEPTEMBER.replace("Report Highlights", "Other period"),
SEPTEMBER.replace("Change in U.S. Private Employment", "Other metric"),
SEPTEMBER.replace("Sep 30, 2026", "Sep 30, 2025"),
SEPTEMBER.replace("8:15 a.m.", "Time pending"),
SEPTEMBER.replace("September 2026 Report Highlights", "August 2026 Report Highlights"),
SEPTEMBER.replace("90,000 Jobs", "91,000 Jobs"),
SEPTEMBER.replace("Private-Sector Employment Increased", "Preliminary Estimate Employment Increased"),
], ids=["denied", "missing-period", "missing-reading", "wrong-year", "unknown-time", "wrong-month", "different-total", "weekly-report"])
def test_changed_or_inconsistent_report_fails(body):
with pytest.raises(ValueError):
adp.report(body)
def test_revisions_to_another_month_are_not_used_as_previous():
body = SEPTEMBER.replace("The August total", "The July total")
assert adp.report(body).values["adp_jobs"]["previous"] is None
def test_invalid_future_schedule_does_not_discard_independent_current_readings():
body = (FIXTURES / "adp-2025-11.html").read_text()
assert adp.report(body).period == "2025-11"
assert adp.report(body).values["adp_jobs"]["actual"] == "-3.2"
with pytest.raises(ValueError, match="下次公布日期与统计月不符"):
adp.next_release(body)
with pytest.raises(ValueError, match="相邻统计月"):
adp.next_release(SEPTEMBER.replace("The October 2026 ADP", "The November 2026 ADP"))
def test_index_excludes_weekly_pulse_and_follows_actual_pagination():
links, following = adp.release_links(INDEX, adp.INDEX, date(2025, 1, 1), date(2026, 10, 31))
assert len(links) == 5
assert all("Preliminary-Estimate" not in link for link in links)
assert following.endswith("&o=9")
links, following = adp.release_links(INDEX, adp.INDEX, date(2026, 10, 1), date(2026, 10, 31))
assert links == []
assert following is None
links, following = adp.release_links(INDEX, adp.INDEX, date(2025, 1, 1), date(2025, 12, 31))
assert links == []
assert following is not None
@pytest.mark.parametrize("body", ["Access denied", INDEX.replace("https://mediacenter.adp.com/2026-09-30", "https://example.com/2026-09-30")])
def test_invalid_index_or_external_report_link_fails(body):
with pytest.raises(ValueError):
adp.release_links(body, adp.INDEX, date(2025, 1, 1), date(2026, 10, 31))
@pytest.mark.parametrize("period,release_url,outcome", [("2026-09", "saved-report", "success"), ("2026-08", "saved-report", "error"),
("2026-10", None, "pending"), ("2026-09", None, "success")])
def test_worker_matches_actual_publication_and_statistical_month(trading, period, release_url, outcome):
from alphapulse.calendar.worker import CalendarWorker
async def run():
worker = CalendarWorker()
try:
async def fetch(source, url):
text = INDEX if url == adp.INDEX else SEPTEMBER
return httpx.Response(200, text=text, request=httpx.Request("GET", url if url.startswith("https") else "https://mediacenter.adp.com/report")), 1
worker.fetch = fetch
row = SimpleNamespace(source="adp", period=period, release_url=release_url,
scheduled_at=datetime(2026, 11, 4, 13, 15, tzinfo=UTC) if period == "2026-10" else datetime(2026, 9, 30, 12, 15, tzinfo=UTC))
if outcome == "error":
with pytest.raises(ValueError, match="所选日程不符"):
await worker.result_page(row)
else:
assert (await worker.result_page(row) is not None) == (outcome == "success")
finally:
await worker.http.aclose()
await worker.bls.aclose()
asyncio.run(run())
def test_history_preview_parses_stored_monthly_evidence_without_writes(trading, monkeypatch):
from alphapulse.calendar import backfill
class Session:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
async def execute(self, _):
return SimpleNamespace(all=lambda: [])
async def scalars(self, _):
return []
doc = backfill.Document("adp", "https://mediacenter.adp.com/2026-09-30-ADP-National-Employment-Report-Private-Sector-Employment-Increased-by-90,000-Jobs-in-September", SEPTEMBER.encode(), datetime(2026, 10, 31, tzinfo=UTC))
monkeypatch.setattr(backfill, "SessionLocal", Session)
monkeypatch.setattr(backfill, "fetch_documents", AsyncMock(return_value=[doc]))
save = AsyncMock()
monkeypatch.setattr(backfill, "save_announcement", save)
result = asyncio.run(backfill.run(date(2026, 9, 30), date(2026, 9, 30), sources={"adp"}))
assert result["announcements"] == {"adp_jobs": 1}
assert result["errors"] == []
assert result["mode"] == "dry-run"
save.assert_not_awaited()
def test_history_discovery_continues_past_newer_pages_without_matching_reports(trading, monkeypatch):
from alphapulse.calendar import backfill
historical = '<ul class="wd_item_list"><li class="wd_item"><div class="wd_date">Jan 8, 2025</div><div class="wd_title"><a href="/2025-01-08-ADP-National-Employment-Report-Private-Sector-Employment-Increased-by-122,000-Jobs-in-December">ADP National Employment Report: Private Sector Employment Increased by 122,000 Jobs in December</a></div></li></ul>'
calls = []
async def get(self, url, **kwargs):
calls.append(url)
body = CROSS_YEAR if "2025-01-08" in url else INDEX if url == adp.INDEX else historical
return httpx.Response(200, text=body, request=httpx.Request("GET", url))
monkeypatch.setattr(httpx.AsyncClient, "get", get)
monkeypatch.setattr(backfill, "ensure_quiet_window", AsyncMock())
monkeypatch.setattr(backfill.asyncio, "sleep", AsyncMock())
errors = []
docs = asyncio.run(backfill.fetch_documents(date(2025, 1, 1), date(2025, 12, 31), set(), errors, sources={"adp"}))
assert errors == []
assert len(docs) == 1
assert len(calls) == 3
assert adp.report(docs[0].body.decode()).period == "2024-12"