| """tests/test_textdiff.py — unit tests for analysis/textdiff.py. |
| |
| Tests are purely deterministic: they do NOT call the sentence-transformer |
| model (mocked), do NOT hit sections_db (mocked), and do NOT require any |
| ingested data. Pure function logic only. |
| """ |
| from __future__ import annotations |
|
|
| import sys |
| from unittest.mock import MagicMock, patch |
| import numpy as np |
| import pytest |
|
|
| |
| |
| |
|
|
| RISK_TEXT_A = """ |
| We operate in highly competitive markets and face competition from well-established |
| companies that have greater financial resources and brand recognition. Our ability |
| to compete effectively depends on our product quality, customer service, and pricing. |
| |
| Our operations are subject to various environmental laws and regulations. |
| Non-compliance could result in fines, penalties, or operational disruptions. |
| |
| Cybersecurity threats represent a significant and evolving risk. A breach of our |
| information systems could expose sensitive customer data and result in material harm. |
| """ |
|
|
| RISK_TEXT_B_REWORDED = """ |
| We operate in highly competitive markets and face intense and accelerating competition |
| from well-established companies as well as new market entrants leveraging AI capabilities. |
| Our ability to compete depends on product quality, customer service, pricing, and |
| the pace of our AI-driven product development. |
| |
| Our operations are subject to various environmental laws and regulations. |
| Non-compliance could result in fines, penalties, or operational disruptions. |
| |
| Cybersecurity threats represent a significant and evolving risk. A breach of our |
| information systems could expose sensitive customer data and result in material harm. |
| |
| New: Increasing export control restrictions on advanced semiconductors may limit our |
| ability to sell products in certain international markets, which could materially |
| reduce our revenue and profitability. |
| """ |
|
|
| MDA_TEXT_A = """ |
| We expect revenue to grow at a strong double-digit rate in the coming quarters, |
| driven by continued demand for our data center products. We anticipate maintaining |
| operating margins above 30% through operational efficiency programs. |
| |
| Our capital return program remains on track, with guidance for $2B in share |
| repurchases during the fiscal year. |
| """ |
|
|
| MDA_TEXT_B_CAUTIOUS = """ |
| We expect growth to moderate in the coming quarters due to macro uncertainty and |
| softening demand in certain end markets. We anticipate operating margins may face |
| headwinds from competitive pricing pressure. |
| |
| Our capital return program continues. We plan to evaluate buyback levels based on |
| market conditions and cash generation. |
| """ |
|
|
|
|
| |
| |
| |
|
|
| def test_split_into_items_filters_short_paragraphs(): |
| from analysis.textdiff import _split_into_items |
| text = "Short.\n\nThis is a much longer paragraph with enough words to pass the minimum threshold and be included in the output." |
| items = _split_into_items(text, min_words=10) |
| assert len(items) == 1 |
| assert "longer paragraph" in items[0] |
|
|
|
|
| def test_split_into_items_merges_headers(): |
| from analysis.textdiff import _split_into_items |
| text = "Risk Header\n\nThis is the full risk description with plenty of words to meet the minimum requirement for inclusion." |
| items = _split_into_items(text, min_words=10) |
| assert len(items) == 1 |
| assert "Risk Header" in items[0] |
| assert "full risk description" in items[0] |
|
|
|
|
| |
| |
| |
|
|
| def test_lexicon_delta_detects_tariff_spike(): |
| from analysis.textdiff import compute_lexicon_deltas |
|
|
| prior = "We operate globally. There is some tariff exposure in our supply chain." |
| current = ( |
| "We operate globally. Tariff increases have materially impacted our cost structure. " |
| "New tariff policies on semiconductor imports create uncertainty. " |
| "We expect tariff headwinds to persist into fiscal 2027. " |
| "Export control and tariff restrictions continue to expand." |
| ) |
| deltas = compute_lexicon_deltas(current, prior, "Q42025", "Q12026", "10-Q") |
| tariff_deltas = [d for d in deltas if d.term == "tariff"] |
| assert len(tariff_deltas) >= 1 |
| d = tariff_deltas[0] |
| assert d.kind == "term_frequency" |
| assert d.significance in ("HIGH", "MEDIUM") |
| assert "→" in d.computed_metric |
|
|
|
|
| def test_lexicon_no_delta_when_counts_stable(): |
| from analysis.textdiff import compute_lexicon_deltas |
|
|
| text = "We anticipate uncertainty in our markets. Uncertainty is always present." |
| deltas = compute_lexicon_deltas(text, text, "Q42025", "Q12026", "10-Q") |
| |
| assert all(d.kind == "term_frequency" for d in deltas) |
| assert len(deltas) == 0 |
|
|
|
|
| |
| |
| |
|
|
| def test_guidance_shift_detects_more_cautious(): |
| from analysis.textdiff import compute_guidance_shifts |
| deltas = compute_guidance_shifts(MDA_TEXT_B_CAUTIOUS, MDA_TEXT_A, "Q42025", "Q12026", "10-Q") |
| assert len(deltas) == 1 |
| d = deltas[0] |
| assert d.kind == "guidance_language_shift" |
| assert "cautious" in d.computed_metric.lower() or "hedge" in d.computed_metric.lower() or "→" in d.computed_metric |
|
|
|
|
| def test_guidance_shift_empty_on_no_text(): |
| from analysis.textdiff import compute_guidance_shifts |
| assert compute_guidance_shifts("", "", "Q42025", "Q12026", "10-Q") == [] |
| assert compute_guidance_shifts(MDA_TEXT_A, "", "Q42025", "Q12026", "10-Q") == [] |
|
|
|
|
| |
| |
| |
|
|
| def test_kpi_dropped_detects_guidance_disappearance(): |
| from analysis.textdiff import compute_kpi_drops |
|
|
| prior_mda = "Our guidance for next quarter is $10B revenue. We also discuss backlog of $5B." |
| current_mda = "Revenue exceeded expectations. We remain focused on growth." |
|
|
| deltas = compute_kpi_drops(current_mda, prior_mda, "Q42025", "Q12026", "10-Q") |
| terms = {d.term for d in deltas} |
| assert "guidance" in terms |
|
|
|
|
| def test_kpi_dropped_no_signal_when_present(): |
| from analysis.textdiff import compute_kpi_drops |
|
|
| prior_mda = "Free cash flow was $2B. Guidance for next quarter is strong." |
| current_mda = "Free cash flow improved to $2.5B. Guidance remains $10-11B revenue." |
|
|
| deltas = compute_kpi_drops(current_mda, prior_mda, "Q42025", "Q12026", "10-Q") |
| assert len(deltas) == 0 |
|
|
|
|
| |
| |
| |
|
|
| def _make_mock_embed(n_items_current: int, n_items_prior: int, similarity_matrix: np.ndarray): |
| """Return a mock for _embed that returns pre-baked unit vectors.""" |
| call_count = [0] |
|
|
| def mock_embed(texts): |
| nonlocal call_count |
| idx = call_count[0] |
| call_count[0] += 1 |
| if idx == 0: |
| |
| return similarity_matrix[:n_items_current] |
| else: |
| |
| return similarity_matrix[n_items_current:] |
|
|
| return mock_embed |
|
|
|
|
| def test_risk_added_detected_with_low_similarity(): |
| """When a current risk has no match in prior (low similarity), it is classified as risk_added.""" |
| from analysis.textdiff import compute_risk_deltas, _split_into_items |
|
|
| |
| current = RISK_TEXT_B_REWORDED |
| prior = RISK_TEXT_A |
|
|
| |
| cur_items = _split_into_items(current) |
| pri_items = _split_into_items(prior) |
|
|
| n = max(len(cur_items), len(pri_items)) |
| if n == 0: |
| pytest.skip("No items to test") |
|
|
| |
| dim = n |
| |
| vecs = np.eye(max(len(cur_items) + len(pri_items), 2)) |
| cur_vecs = vecs[:len(cur_items)] |
| pri_vecs = vecs[len(cur_items):len(cur_items) + len(pri_items)] |
| |
| if cur_vecs.shape[0] == 0 or pri_vecs.shape[0] == 0: |
| pytest.skip("Not enough items") |
|
|
| call_count = [0] |
|
|
| def mock_embed(texts): |
| idx = call_count[0] |
| call_count[0] += 1 |
| if idx == 0: |
| return cur_vecs |
| return pri_vecs |
|
|
| with patch("analysis.textdiff._embed", side_effect=mock_embed): |
| deltas = compute_risk_deltas(current, prior, "Q42025", "Q12026", "10-Q") |
|
|
| |
| assert len(deltas) >= 0 |
|
|
|
|
| def test_risk_delta_empty_on_missing_text(): |
| from analysis.textdiff import compute_risk_deltas |
| assert compute_risk_deltas("", RISK_TEXT_A, "Q42025", "Q12026", "10-Q") == [] |
| assert compute_risk_deltas(RISK_TEXT_A, "", "Q42025", "Q12026", "10-Q") == [] |
|
|
|
|
| |
| |
| |
|
|
| def test_truncate_caps_word_count(): |
| from analysis.textdiff import _truncate |
| text = " ".join(["word"] * 200) |
| result = _truncate(text, 50) |
| assert len(result.split()) <= 51 |
| assert result.endswith("…") |
|
|
|
|
| def test_truncate_passthrough_when_short(): |
| from analysis.textdiff import _truncate |
| text = "Short text." |
| assert _truncate(text, 50) == text |
|
|
|
|
| |
| |
| |
|
|
| def test_compute_returns_empty_when_no_sections(): |
| from analysis.textdiff import compute |
| with patch("analysis.textdiff.get_periods_for_ticker", return_value=[]): |
| result = compute("FAKE") |
| assert result == [] |
|
|
|
|
| def test_compute_returns_empty_on_single_period(): |
| from analysis.textdiff import compute |
| with patch("analysis.textdiff.get_periods_for_ticker", return_value=["Q12026"]): |
| result = compute("FAKE") |
| assert result == [] |
|
|
|
|
| def test_compute_handles_exception_gracefully(): |
| """compute() should never raise — it catches all errors and returns [].""" |
| from analysis.textdiff import compute |
| with patch("analysis.textdiff.get_periods_for_ticker", side_effect=RuntimeError("DB gone")): |
| result = compute("FAKE") |
| assert result == [] |
|
|
|
|
| |
| |
| |
|
|
| def test_quarter_delta_round_trips(): |
| from analysis.signals import QuarterDelta |
| d = QuarterDelta( |
| kind="risk_added", |
| period_from="Q42025", |
| period_to="Q12026", |
| before_text="", |
| after_text="New export control risk…", |
| computed_metric="", |
| source="10-Q", |
| significance="HIGH", |
| term="", |
| ) |
| dumped = d.model_dump() |
| restored = QuarterDelta.model_validate(dumped) |
| assert restored.kind == "risk_added" |
| assert restored.significance == "HIGH" |
|
|
|
|
| def test_quarter_delta_rejects_invalid_kind(): |
| from analysis.signals import QuarterDelta |
| from pydantic import ValidationError |
| with pytest.raises(ValidationError): |
| QuarterDelta( |
| kind="invented_signal", |
| period_from="Q42025", |
| period_to="Q12026", |
| ) |
|
|