test: add SubtextRead + _detect_trend tests
Browse filesAdds 19 new test cases covering SubtextRead signal_type coercion (all 5
canonical values, 6 aliases, unknown fallback, uppercase fix), BriefOutput
between_the_lines field (empty default + round-trip), and _detect_trend
pure function (rising/falling/flat/short/empty edge cases).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- tests/test_schemas.py +403 -0
tests/test_schemas.py
CHANGED
|
@@ -3,7 +3,9 @@ from pydantic import ValidationError
|
|
| 3 |
from agent.schemas import (
|
| 4 |
SourcedFact, BriefOutput, TrendPoint, ManagementCommentaryTopic,
|
| 5 |
MDASection, CategorizedRisk, GuidancePoint,
|
|
|
|
| 6 |
)
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
def test_sourced_fact_valid():
|
|
@@ -155,6 +157,37 @@ def test_brief_output_has_management_commentary_field():
|
|
| 155 |
assert "transcript_topics" not in BriefOutput.model_fields
|
| 156 |
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
# ββ CategorizedRisk category normalization βββββββββββββββββββββββββββββββββββ
|
| 159 |
|
| 160 |
def _risk(**kw) -> CategorizedRisk:
|
|
@@ -310,3 +343,373 @@ def test_brief_output_tolerates_compound_source_in_analytical_tension():
|
|
| 310 |
brief = _minimal_brief(analytical_tensions=[tension])
|
| 311 |
assert brief.analytical_tensions[0].bullish_evidence.source == "10-Q"
|
| 312 |
assert brief.analytical_tensions[0].bearish_evidence.source == "10-Q"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from agent.schemas import (
|
| 4 |
SourcedFact, BriefOutput, TrendPoint, ManagementCommentaryTopic,
|
| 5 |
MDASection, CategorizedRisk, GuidancePoint,
|
| 6 |
+
EarningsQualitySignal, AnalyticalTension, SectionSentiment, SubtextRead,
|
| 7 |
)
|
| 8 |
+
from analysis.textdiff import _detect_trend
|
| 9 |
|
| 10 |
|
| 11 |
def test_sourced_fact_valid():
|
|
|
|
| 157 |
assert "transcript_topics" not in BriefOutput.model_fields
|
| 158 |
|
| 159 |
|
| 160 |
+
def test_brief_output_drops_news_commentary_mixed():
|
| 161 |
+
"""BriefOutput drops news-sourced items but keeps valid ones (filing/transcript)."""
|
| 162 |
+
brief = _minimal_brief(
|
| 163 |
+
management_commentary=[
|
| 164 |
+
{"topic": "AI strategy", "summary": "Management outlined AI plans.", "source": "transcript",
|
| 165 |
+
"reliability": "MEDIUM", "evidence_snippet": "We are fully committed to AI integration."},
|
| 166 |
+
{"topic": "Breaking news", "summary": "CNBC reported earnings beat.", "source": "news",
|
| 167 |
+
"reliability": "LOW", "evidence_snippet": "CNBC reported strong results."},
|
| 168 |
+
{"topic": "Revenue guidance", "summary": "Guided 3-5% growth.", "source": "10-Q",
|
| 169 |
+
"reliability": "HIGH", "evidence_snippet": "We expect revenue growth of 3-5%."},
|
| 170 |
+
]
|
| 171 |
+
)
|
| 172 |
+
assert len(brief.management_commentary) == 2
|
| 173 |
+
topics = [t.topic for t in brief.management_commentary]
|
| 174 |
+
assert "AI strategy" in topics
|
| 175 |
+
assert "Revenue guidance" in topics
|
| 176 |
+
assert "Breaking news" not in topics
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_brief_output_all_news_commentary_yields_empty_list():
|
| 180 |
+
"""BriefOutput validates even when all management_commentary items are news-sourced (MSFT regression)."""
|
| 181 |
+
brief = _minimal_brief(
|
| 182 |
+
management_commentary=[
|
| 183 |
+
{"topic": f"Item {i}", "summary": "News summary.", "source": "news",
|
| 184 |
+
"reliability": "LOW", "evidence_snippet": f"News snippet {i}."}
|
| 185 |
+
for i in range(5)
|
| 186 |
+
]
|
| 187 |
+
)
|
| 188 |
+
assert brief.management_commentary == []
|
| 189 |
+
|
| 190 |
+
|
| 191 |
# ββ CategorizedRisk category normalization βββββββββββββββββββββββββββββββββββ
|
| 192 |
|
| 193 |
def _risk(**kw) -> CategorizedRisk:
|
|
|
|
| 343 |
brief = _minimal_brief(analytical_tensions=[tension])
|
| 344 |
assert brief.analytical_tensions[0].bullish_evidence.source == "10-Q"
|
| 345 |
assert brief.analytical_tensions[0].bearish_evidence.source == "10-Q"
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
# ββ EarningsQualitySignal.assessment βββββββββββββββββββββββββββββββββββββββββ
|
| 349 |
+
|
| 350 |
+
def _eqs(**kw) -> EarningsQualitySignal:
|
| 351 |
+
defaults = dict(
|
| 352 |
+
dimension="guidance_dynamics",
|
| 353 |
+
assessment="positive",
|
| 354 |
+
rationale="Guidance was raised.",
|
| 355 |
+
evidence=SourcedFact(text="x", source="10-Q", reliability="HIGH", evidence_snippet="x"),
|
| 356 |
+
)
|
| 357 |
+
return EarningsQualitySignal(**(defaults | kw))
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def test_eqs_assessment_valid_passthrough():
|
| 361 |
+
assert _eqs(assessment="concerning").assessment == "concerning"
|
| 362 |
+
assert _eqs(assessment="positive").assessment == "positive"
|
| 363 |
+
assert _eqs(assessment="neutral").assessment == "neutral"
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def test_eqs_assessment_coerces_negative_to_concerning(capsys):
|
| 367 |
+
s = _eqs(assessment="negative")
|
| 368 |
+
assert s.assessment == "concerning"
|
| 369 |
+
assert "[quality-assessment]" in capsys.readouterr().err
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def test_eqs_assessment_coerces_bearish_to_concerning():
|
| 373 |
+
assert _eqs(assessment="bearish").assessment == "concerning"
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def test_eqs_assessment_coerces_bullish_to_positive():
|
| 377 |
+
assert _eqs(assessment="bullish").assessment == "positive"
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def test_eqs_assessment_coerces_mixed_to_neutral():
|
| 381 |
+
assert _eqs(assessment="mixed").assessment == "neutral"
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def test_eqs_assessment_unknown_falls_back_to_neutral(capsys):
|
| 385 |
+
s = _eqs(assessment="unclear")
|
| 386 |
+
assert s.assessment == "neutral"
|
| 387 |
+
assert "[quality-assessment]" in capsys.readouterr().err
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# ββ EarningsQualitySignal.dimension ββββββββββββββββββββββββββββββββββββββββββ
|
| 391 |
+
|
| 392 |
+
def test_eqs_dimension_valid_passthrough():
|
| 393 |
+
assert _eqs(dimension="consensus_beat_mix").dimension == "consensus_beat_mix"
|
| 394 |
+
assert _eqs(dimension="capital_allocation").dimension == "capital_allocation"
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def test_eqs_dimension_coerces_guidance_synonym(capsys):
|
| 398 |
+
s = _eqs(dimension="guidance")
|
| 399 |
+
assert s.dimension == "guidance_dynamics"
|
| 400 |
+
assert "[quality-dimension]" in capsys.readouterr().err
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def test_eqs_dimension_coerces_beat_mix_synonym():
|
| 404 |
+
assert _eqs(dimension="beat_mix").dimension == "consensus_beat_mix"
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def test_eqs_dimension_coerces_narrative_synonym():
|
| 408 |
+
assert _eqs(dimension="tone").dimension == "narrative_vs_numbers"
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def test_eqs_dimension_coerces_segment_synonym():
|
| 412 |
+
assert _eqs(dimension="segment").dimension == "segment_mix"
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def test_eqs_dimension_coerces_capital_synonym():
|
| 416 |
+
assert _eqs(dimension="capex").dimension == "capital_allocation"
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def test_eqs_dimension_unknown_still_raises():
|
| 420 |
+
"""Unknown dimension passes through _normalize_dimension unchanged β Literal rejects it."""
|
| 421 |
+
with pytest.raises(ValidationError):
|
| 422 |
+
_eqs(dimension="completely_unknown_dim")
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
# ββ BriefOutput._sanitize_quality_signals ββββββββββββββββββββββββββββββββββββ
|
| 426 |
+
|
| 427 |
+
def test_brief_output_drops_unknown_dimension_keeps_valid(capsys):
|
| 428 |
+
"""Unknown-dimension item is dropped at brief level; valid item is kept."""
|
| 429 |
+
sf = {"text": "x", "source": "10-Q", "reliability": "HIGH", "evidence_snippet": "x"}
|
| 430 |
+
brief = _minimal_brief(
|
| 431 |
+
earnings_quality_signals=[
|
| 432 |
+
{"dimension": "totally_unknown", "assessment": "positive", "rationale": "r", "evidence": sf},
|
| 433 |
+
{"dimension": "guidance_dynamics", "assessment": "neutral", "rationale": "r", "evidence": sf},
|
| 434 |
+
]
|
| 435 |
+
)
|
| 436 |
+
assert len(brief.earnings_quality_signals) == 1
|
| 437 |
+
assert brief.earnings_quality_signals[0].dimension == "guidance_dynamics"
|
| 438 |
+
assert "[quality-dimension]" in capsys.readouterr().err
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def test_brief_output_all_unknown_dimension_yields_empty_list():
|
| 442 |
+
"""All items with unknown dimension β empty list, brief still validates."""
|
| 443 |
+
sf = {"text": "x", "source": "10-Q", "reliability": "HIGH", "evidence_snippet": "x"}
|
| 444 |
+
brief = _minimal_brief(
|
| 445 |
+
earnings_quality_signals=[
|
| 446 |
+
{"dimension": "foo", "assessment": "positive", "rationale": "r", "evidence": sf},
|
| 447 |
+
{"dimension": "bar", "assessment": "neutral", "rationale": "r", "evidence": sf},
|
| 448 |
+
]
|
| 449 |
+
)
|
| 450 |
+
assert brief.earnings_quality_signals == []
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
# ββ AnalyticalTension.weight ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 454 |
+
|
| 455 |
+
def _tension(**kw) -> AnalyticalTension:
|
| 456 |
+
sf = SourcedFact(text="x", source="10-Q", reliability="HIGH", evidence_snippet="x")
|
| 457 |
+
defaults = dict(
|
| 458 |
+
headline="H", bullish_reading="B", bearish_reading="Be",
|
| 459 |
+
weight="watch", bullish_evidence=sf, bearish_evidence=sf,
|
| 460 |
+
)
|
| 461 |
+
return AnalyticalTension(**(defaults | kw))
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def test_tension_weight_valid_passthrough():
|
| 465 |
+
assert _tension(weight="material").weight == "material"
|
| 466 |
+
assert _tension(weight="watch").weight == "watch"
|
| 467 |
+
assert _tension(weight="minor").weight == "minor"
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def test_tension_weight_coerces_high_to_material(capsys):
|
| 471 |
+
t = _tension(weight="high")
|
| 472 |
+
assert t.weight == "material"
|
| 473 |
+
assert "[tension-weight]" in capsys.readouterr().err
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def test_tension_weight_coerces_medium_to_watch():
|
| 477 |
+
assert _tension(weight="medium").weight == "watch"
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def test_tension_weight_coerces_low_to_minor():
|
| 481 |
+
assert _tension(weight="low").weight == "minor"
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def test_tension_weight_coerces_critical_to_material():
|
| 485 |
+
assert _tension(weight="critical").weight == "material"
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def test_tension_weight_unknown_falls_back_to_watch(capsys):
|
| 489 |
+
t = _tension(weight="extreme")
|
| 490 |
+
assert t.weight == "watch"
|
| 491 |
+
assert "[tension-weight]" in capsys.readouterr().err
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
# ββ GuidancePoint.verdict βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 495 |
+
|
| 496 |
+
def _gp(**kw) -> GuidancePoint:
|
| 497 |
+
defaults = dict(period="Q1 2025", text="Revenue guided flat.", source="10-Q")
|
| 498 |
+
return GuidancePoint(**(defaults | kw))
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def test_guidance_verdict_valid_passthrough():
|
| 502 |
+
assert _gp(verdict="beat").verdict == "beat"
|
| 503 |
+
assert _gp(verdict="in-line").verdict == "in-line"
|
| 504 |
+
assert _gp(verdict="missed").verdict == "missed"
|
| 505 |
+
assert _gp(verdict="pending").verdict == "pending"
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def test_guidance_verdict_none_passthrough():
|
| 509 |
+
assert _gp(verdict=None).verdict is None
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def test_guidance_verdict_coerces_miss_to_missed(capsys):
|
| 513 |
+
g = _gp(verdict="miss")
|
| 514 |
+
assert g.verdict == "missed"
|
| 515 |
+
assert "[guidance-verdict]" in capsys.readouterr().err
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def test_guidance_verdict_coerces_inline_to_in_line():
|
| 519 |
+
assert _gp(verdict="inline").verdict == "in-line"
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
def test_guidance_verdict_coerces_met_to_in_line():
|
| 523 |
+
assert _gp(verdict="met").verdict == "in-line"
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def test_guidance_verdict_coerces_above_to_beat():
|
| 527 |
+
assert _gp(verdict="above").verdict == "beat"
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def test_guidance_verdict_unknown_becomes_none(capsys):
|
| 531 |
+
g = _gp(verdict="partial")
|
| 532 |
+
assert g.verdict is None
|
| 533 |
+
assert "[guidance-verdict]" in capsys.readouterr().err
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
# ββ SectionSentiment.score ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 537 |
+
|
| 538 |
+
def _ss(**kw) -> SectionSentiment:
|
| 539 |
+
defaults = dict(rationale="Revenue beat.")
|
| 540 |
+
return SectionSentiment(**(defaults | kw))
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def test_sentiment_score_valid_passthrough():
|
| 544 |
+
for s in (-2, -1, 0, 1, 2):
|
| 545 |
+
assert _ss(score=s).score == s
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def test_sentiment_score_none_passthrough():
|
| 549 |
+
assert _ss(score=None).score is None
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
def test_sentiment_score_coerces_string_int(capsys):
|
| 553 |
+
s = _ss(score="1")
|
| 554 |
+
assert s.score == 1
|
| 555 |
+
assert "[sentiment-score]" in capsys.readouterr().err
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def test_sentiment_score_coerces_positive_string():
|
| 559 |
+
assert _ss(score="+2").score == 2
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def test_sentiment_score_coerces_float():
|
| 563 |
+
assert _ss(score=1.0).score == 1
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
def test_sentiment_score_clamps_out_of_range(capsys):
|
| 567 |
+
s = _ss(score=3)
|
| 568 |
+
assert s.score == 2
|
| 569 |
+
assert "[sentiment-score]" in capsys.readouterr().err
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def test_sentiment_score_non_numeric_becomes_none(capsys):
|
| 573 |
+
s = _ss(score="abc")
|
| 574 |
+
assert s.score is None
|
| 575 |
+
assert "[sentiment-score]" in capsys.readouterr().err
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
# ββ SectionSentiment.label ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 579 |
+
|
| 580 |
+
def test_sentiment_label_valid_passthrough():
|
| 581 |
+
for lbl in ("Strongly Bearish", "Bearish", "Neutral", "Bullish", "Strongly Bullish"):
|
| 582 |
+
assert _ss(label=lbl).label == lbl
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
def test_sentiment_label_none_passthrough():
|
| 586 |
+
assert _ss(label=None).label is None
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def test_sentiment_label_coerces_positive_to_bullish(capsys):
|
| 590 |
+
s = _ss(label="positive")
|
| 591 |
+
assert s.label == "Bullish"
|
| 592 |
+
assert "[sentiment-label]" in capsys.readouterr().err
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
def test_sentiment_label_coerces_negative_to_bearish():
|
| 596 |
+
assert _ss(label="negative").label == "Bearish"
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
def test_sentiment_label_unknown_becomes_none(capsys):
|
| 600 |
+
s = _ss(label="meh")
|
| 601 |
+
assert s.label is None
|
| 602 |
+
assert "[sentiment-label]" in capsys.readouterr().err
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
# ββ SubtextRead ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 606 |
+
|
| 607 |
+
def _str(signal_type="language_drift", **kw) -> SubtextRead:
|
| 608 |
+
sf = SourcedFact(text="x", source="10-Q", reliability="HIGH", evidence_snippet="x")
|
| 609 |
+
defaults = dict(
|
| 610 |
+
observation="Management used 'we expect growth' instead of 'we expect strong growth'.",
|
| 611 |
+
reading="The softening qualifier signals reduced conviction in guidance.",
|
| 612 |
+
signal_type=signal_type,
|
| 613 |
+
implication="Watch for a guidance cut next quarter if this trend continues.",
|
| 614 |
+
evidence=sf,
|
| 615 |
+
)
|
| 616 |
+
defaults.update(kw)
|
| 617 |
+
return SubtextRead(**defaults)
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def test_subtext_read_valid_signal_types():
|
| 621 |
+
for st in ("language_drift", "qa_evasion", "omission", "emphasis_shift", "accounting_quality"):
|
| 622 |
+
assert _str(signal_type=st).signal_type == st
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def test_subtext_read_coerces_evasion_to_qa_evasion(capsys):
|
| 626 |
+
s = _str(signal_type="evasion")
|
| 627 |
+
assert s.signal_type == "qa_evasion"
|
| 628 |
+
assert "[signal-type]" in capsys.readouterr().err
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
def test_subtext_read_coerces_drift_to_language_drift():
|
| 632 |
+
assert _str(signal_type="drift").signal_type == "language_drift"
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
def test_subtext_read_coerces_kpi_dropped_to_emphasis_shift():
|
| 636 |
+
assert _str(signal_type="kpi_dropped").signal_type == "emphasis_shift"
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def test_subtext_read_coerces_silence_to_omission():
|
| 640 |
+
assert _str(signal_type="silence").signal_type == "omission"
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
def test_subtext_read_coerces_accounting_quality_alias():
|
| 644 |
+
assert _str(signal_type="earnings_quality").signal_type == "accounting_quality"
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
def test_subtext_read_unknown_falls_back_to_language_drift(capsys):
|
| 648 |
+
s = _str(signal_type="completely_unknown_signal")
|
| 649 |
+
assert s.signal_type == "language_drift"
|
| 650 |
+
assert "[signal-type]" in capsys.readouterr().err
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def test_subtext_read_uppercase_canonical_coerced(capsys):
|
| 654 |
+
s = _str(signal_type="Accounting_Quality")
|
| 655 |
+
assert s.signal_type == "accounting_quality"
|
| 656 |
+
# The .lower() fix means the canonical is recognised directly β no stderr expected
|
| 657 |
+
capsys.readouterr() # consume any output
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def test_subtext_read_evidence_is_sourced_fact():
|
| 661 |
+
s = _str()
|
| 662 |
+
assert isinstance(s.evidence, SourcedFact)
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
def test_brief_output_between_the_lines_empty_by_default():
|
| 666 |
+
brief = _minimal_brief()
|
| 667 |
+
assert brief.between_the_lines == []
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
def test_brief_output_accepts_between_the_lines_list():
|
| 671 |
+
sf = {"text": "x", "source": "10-Q", "reliability": "HIGH", "evidence_snippet": "x"}
|
| 672 |
+
item = {
|
| 673 |
+
"observation": "Analyst asked about China; management pivoted.",
|
| 674 |
+
"reading": "Evasion signals China pricing under pressure.",
|
| 675 |
+
"signal_type": "qa_evasion",
|
| 676 |
+
"implication": "Watch for China revenue disclosure next quarter.",
|
| 677 |
+
"evidence": sf,
|
| 678 |
+
}
|
| 679 |
+
brief = _minimal_brief(between_the_lines=[item])
|
| 680 |
+
assert len(brief.between_the_lines) == 1
|
| 681 |
+
assert brief.between_the_lines[0].signal_type == "qa_evasion"
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
# ββ _detect_trend ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 685 |
+
|
| 686 |
+
def test_detect_trend_rising_4():
|
| 687 |
+
assert _detect_trend([1, 3, 5, 8]) == "rising 4 quarters"
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
def test_detect_trend_falling_4():
|
| 691 |
+
assert _detect_trend([8, 5, 3, 1]) == "falling 4 quarters"
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def test_detect_trend_rising_3_at_tail():
|
| 695 |
+
assert _detect_trend([1, 5, 2, 4, 6]) == "rising 3 quarters"
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
def test_detect_trend_flat_returns_none():
|
| 699 |
+
assert _detect_trend([1, 2, 2, 4]) is None
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
def test_detect_trend_too_short_returns_none():
|
| 703 |
+
assert _detect_trend([1, 3]) is None
|
| 704 |
+
|
| 705 |
+
|
| 706 |
+
def test_detect_trend_empty_returns_none():
|
| 707 |
+
assert _detect_trend([]) is None
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
def test_detect_trend_single_value_returns_none():
|
| 711 |
+
assert _detect_trend([5]) is None
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def test_detect_trend_falling_3_at_tail():
|
| 715 |
+
assert _detect_trend([10, 2, 8, 6, 4]) == "falling 3 quarters"
|