"""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_research.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 )