Files
AlphaPulse/backend/tests/test_news_vocabulary.py
eezd 4929a32a0a feat(news): index news packages and research persistent events
Extract reusable literal keywords once per source revision, assign complete news packages to long-lived events, and research all event members with immutable evidence and references.

Add additive research storage, vocabulary snapshots, the two-stage workspace, isolated acceptance coverage, and a paused rollout with an initial fixed 50-news production trial.
2026-11-04 12:09:58 +08:00

157 lines
5.2 KiB
Python

"""Vocabulary and fixed-version behavior, isolated from production I/O."""
import asyncio
from types import SimpleNamespace as N
from unittest.mock import AsyncMock
import pytest
def test_equivalence_preserves_literal_aliases_and_quotes(trading):
from alphapulse.news_research.vocabulary import normalize, expand, VocabularyInput
data = VocabularyInput(
base_revision=0, entries=[{"canonical": "币安", "aliases": ["Binance"]}]
)
snapshot = {"revision": 1, "entries": [e.model_dump() for e in data.entries]}
original = [
{
"canonical": "Binance交易所",
"aliases": ["Binance"],
"quote": "Binance reported",
}
]
assert normalize(original, snapshot) == [{**original[0], "canonical": "币安"}]
assert original[0]["canonical"] == "Binance交易所"
assert expand([" BINANCE "], snapshot) == ["币安", "binance"]
assert expand(["Bank of Canada"], snapshot) == ["bank of canada"]
@pytest.mark.parametrize(
"entries",
[
[{"canonical": " ", "aliases": []}],
[{"canonical": "币安", "aliases": ["Binance", "BINANCE"]}],
[
{"canonical": "币安", "aliases": ["Binance"]},
{"canonical": "别家", "aliases": ["binance"]},
],
[{"canonical": "币安", "aliases": []}, {"canonical": "币安", "aliases": []}],
],
)
def test_reject_ambiguous_or_empty_vocabulary(trading, entries):
from alphapulse.news_research.vocabulary import VocabularyInput
with pytest.raises(ValueError):
VocabularyInput(base_revision=0, entries=entries)
def test_merging_tags_never_invents_literal_alias(trading):
from alphapulse.news_research.vocabulary import normalize
snapshot = {"entries": [{"canonical": "币安", "aliases": ["Binance"]}]}
result = normalize(
[
{"canonical": c, "aliases": [a], "quote": a}
for c, a in [("币安", "币安"), ("Binance", "Binance")]
],
snapshot,
)
assert len(result) == 1 and result[0]["aliases"] == ["币安", "Binance"]
def test_stale_vocabulary_save_conflicts_without_writing(trading, monkeypatch):
from fastapi import HTTPException
from alphapulse.news_research import api
monkeypatch.setattr(
api.vocab, "snapshot", AsyncMock(return_value={"revision": 2, "entries": []})
)
session = N(execute=AsyncMock(), add=AsyncMock(), commit=AsyncMock())
with pytest.raises(HTTPException) as error:
asyncio.run(
api.save_vocabulary(
api.vocab.VocabularyInput(base_revision=1, entries=[]), session
)
)
assert error.value.status_code == 409
session.add.assert_not_called()
session.commit.assert_not_called()
@pytest.mark.parametrize(
"event_id,state", [(2, "done"), (1, "failed"), (1, "superseded")]
)
def test_fixed_version_rejects_wrong_event_and_unsuccessful_versions(
trading, event_id, state
):
from fastapi import HTTPException
from alphapulse.news_research.api import event_version
session = N(get=AsyncMock(return_value=N(event_id=event_id, state=state)))
with pytest.raises(HTTPException) as error:
asyncio.run(event_version(1, 9, session))
assert error.value.status_code == 404
def test_acceptance_budget_is_shared_and_counts_failed_streams(trading, tmp_path):
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location(
"acceptance_budget",
Path(__file__).resolve().parents[2] / "scripts/accept_news_research.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
counter = tmp_path / "requests"
first = module.Budget(counter, limit=2)
second = module.Budget(counter, limit=2)
first.take()
second.take()
with pytest.raises(module.BudgetExceeded):
first.take()
assert counter.read_text() == "2"
def test_budget_counts_transport_failures_before_retry(trading, tmp_path):
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location(
"failed_stream_budget",
Path(__file__).resolve().parents[2] / "scripts/accept_news_research.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
budget = module.Budget(tmp_path / "counter", limit=2)
sent = []
async def failing():
sent.append(True)
raise ConnectionError("isolated failure")
yield
async def attempt():
async for _ in budget.wrap(failing)():
pass
async def run():
return await asyncio.gather(
*(attempt() for _ in range(3)), return_exceptions=True
)
errors = asyncio.run(run())
assert len(sent) == 2
assert sum(isinstance(e, module.BudgetExceeded) for e in errors) == 1
def test_supplemental_citation_must_use_returned_article_id(trading):
from alphapulse.news_risk.pipeline import validate_evidence, Evidence
sources = {-1: {"news_id": 1004, "content": "Original correction"}}
validate_evidence([Evidence(article_id=-1, quote="Original correction")], sources)
with pytest.raises(ValueError):
validate_evidence(
[Evidence(article_id=1004, quote="Original correction")], sources
)