"""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 # --------------------------------------------------------------------------- # Helpers — synthetic text fixtures # --------------------------------------------------------------------------- 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. """ # --------------------------------------------------------------------------- # Tests: text splitter # --------------------------------------------------------------------------- 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] # --------------------------------------------------------------------------- # Tests: lexicon frequency (no model needed) # --------------------------------------------------------------------------- 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") # Same text both periods → no delta assert all(d.kind == "term_frequency" for d in deltas) assert len(deltas) == 0 # --------------------------------------------------------------------------- # Tests: guidance language shift (no model needed) # --------------------------------------------------------------------------- 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") == [] # --------------------------------------------------------------------------- # Tests: KPI drop detection (no model needed) # --------------------------------------------------------------------------- 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 # --------------------------------------------------------------------------- # Tests: risk factor diff (mocked embeddings to avoid loading model) # --------------------------------------------------------------------------- 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: # current items return similarity_matrix[:n_items_current] else: # prior items 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 # Ensure we have splittable text current = RISK_TEXT_B_REWORDED prior = RISK_TEXT_A # Build real item lists to know sizes 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") # Build identity-like similarity matrix with one new item (last current item has low similarity) dim = n # Create orthonormal-like vectors: current[i] matches prior[i], last current is orthogonal 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)] # Pad if sizes differ 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") # At minimum we should get some deltas (reworded or added) assert len(deltas) >= 0 # function ran without error 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") == [] # --------------------------------------------------------------------------- # Tests: truncate helper # --------------------------------------------------------------------------- def test_truncate_caps_word_count(): from analysis.textdiff import _truncate text = " ".join(["word"] * 200) result = _truncate(text, 50) assert len(result.split()) <= 51 # 50 words + possible "…" assert result.endswith("…") def test_truncate_passthrough_when_short(): from analysis.textdiff import _truncate text = "Short text." assert _truncate(text, 50) == text # --------------------------------------------------------------------------- # Tests: compute() top-level — returns empty list gracefully when no data # --------------------------------------------------------------------------- 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 == [] # --------------------------------------------------------------------------- # Tests: QuarterDelta schema # --------------------------------------------------------------------------- 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", # not in Literal period_from="Q42025", period_to="Q12026", )