| 1 | """Tests for polymarket.py - Polymarket prediction market search.""" |
| 2 | |
| 3 | import json |
| 4 | from unittest.mock import Mock, patch |
| 5 | |
| 6 | import pytest |
| 7 | |
| 8 | from lib import polymarket |
| 9 | |
| 10 | # === Helper Functions === |
| 11 | |
| 12 | |
| 13 | def create_mock_event( |
| 14 | event_id="evt-123", |
| 15 | title="Test Event", |
| 16 | slug="test-event", |
| 17 | volume24hr=100000, |
| 18 | liquidity=50000, |
| 19 | closed=False, |
| 20 | markets=None, |
| 21 | ): |
| 22 | """Create a mock Polymarket event.""" |
| 23 | if markets is None: |
| 24 | markets = [create_mock_market()] |
| 25 | |
| 26 | return { |
| 27 | "id": event_id, |
| 28 | "title": title, |
| 29 | "slug": slug, |
| 30 | "active": True, |
| 31 | "closed": closed, |
| 32 | "volume24hr": volume24hr, |
| 33 | "liquidity": liquidity, |
| 34 | "markets": markets, |
| 35 | } |
| 36 | |
| 37 | |
| 38 | def create_mock_market( |
| 39 | market_id="mkt-123", |
| 40 | question="Will X happen?", |
| 41 | outcomes='["Yes", "No"]', |
| 42 | prices='["0.60", "0.40"]', |
| 43 | volume="100000", |
| 44 | liquidity="50000", |
| 45 | closed=False, |
| 46 | ): |
| 47 | """Create a mock Polymarket market.""" |
| 48 | return { |
| 49 | "id": market_id, |
| 50 | "question": question, |
| 51 | "active": True, |
| 52 | "closed": closed, |
| 53 | "outcomes": outcomes, |
| 54 | "outcomePrices": prices, |
| 55 | "volume": volume, |
| 56 | "liquidity": liquidity, |
| 57 | } |
| 58 | |
| 59 | # === Tests for _extract_core_subject() === |
| 60 | |
| 61 | |
| 62 | def test_extract_core_subject_basic(): |
| 63 | """Test basic subject extraction.""" |
| 64 | result = polymarket._extract_core_subject("AI frameworks") |
| 65 | assert result == "AI frameworks" |
| 66 | |
| 67 | |
| 68 | def test_extract_core_subject_with_time_prefix(): |
| 69 | """Test stripping time prefixes.""" |
| 70 | result = polymarket._extract_core_subject("last 7 days AI frameworks") |
| 71 | assert result == "AI frameworks" |
| 72 | |
| 73 | |
| 74 | def test_extract_core_subject_with_question_prefix(): |
| 75 | """Test stripping question prefixes.""" |
| 76 | result = polymarket._extract_core_subject("what are people saying about Kanye West") |
| 77 | assert result == "Kanye West" |
| 78 | |
| 79 | |
| 80 | def test_extract_core_subject_multiple_prefixes(): |
| 81 | """Test handling multiple prefix patterns.""" |
| 82 | result = polymarket._extract_core_subject("research AI models") |
| 83 | assert result == "AI models" |
| 84 | |
| 85 | # === Tests for _expand_queries() === |
| 86 | |
| 87 | |
| 88 | def test_expand_queries_basic(): |
| 89 | """Test basic query expansion.""" |
| 90 | queries = polymarket._expand_queries("AI framework") |
| 91 | |
| 92 | # Should include core + individual words |
| 93 | assert "AI framework" in queries or "ai framework" in queries |
| 94 | assert len(queries) >= 2 |
| 95 | |
| 96 | |
| 97 | def test_expand_queries_single_word(): |
| 98 | """Test query expansion with single word.""" |
| 99 | queries = polymarket._expand_queries("AI") |
| 100 | |
| 101 | # Single word, should just return that word |
| 102 | assert len(queries) >= 1 |
| 103 | assert any("ai" in q.lower() for q in queries) |
| 104 | |
| 105 | |
| 106 | def test_expand_queries_filters_noise(): |
| 107 | """Test that low-signal tokens are filtered.""" |
| 108 | queries = polymarket._expand_queries("the AI framework") |
| 109 | |
| 110 | # "the" should be filtered from individual word expansions |
| 111 | # But may appear in the full phrase |
| 112 | assert len(queries) >= 1 |
| 113 | assert any("ai" in q.lower() or "framework" in q.lower() for q in queries) |
| 114 | |
| 115 | |
| 116 | def test_expand_queries_deduplication(): |
| 117 | """Test that duplicate queries are removed.""" |
| 118 | queries = polymarket._expand_queries("test test test") |
| 119 | |
| 120 | # Should dedupe |
| 121 | assert len(queries) == len(set(q.lower() for q in queries)) |
| 122 | |
| 123 | |
| 124 | def test_expand_queries_cap_at_six(): |
| 125 | """Test that query list is capped at 6.""" |
| 126 | long_topic = "one two three four five six seven eight" |
| 127 | queries = polymarket._expand_queries(long_topic) |
| 128 | |
| 129 | assert len(queries) <= 6 |
| 130 | |
| 131 | # === Tests for _passes_topic_filter() === |
| 132 | |
| 133 | |
| 134 | def test_passes_topic_filter_match(): |
| 135 | """Test that matching events pass the filter.""" |
| 136 | assert polymarket._passes_topic_filter("AI safety", "AI Safety Conference 2026") is True |
| 137 | |
| 138 | |
| 139 | def test_passes_topic_filter_no_match(): |
| 140 | """Test that non-matching events are filtered.""" |
| 141 | # "West" is a noise word, so "Kanye West" requires "Kanye" to match |
| 142 | assert polymarket._passes_topic_filter("Kanye West", "NFC West Championship") is False |
| 143 | |
| 144 | |
| 145 | def test_passes_topic_filter_partial_match(): |
| 146 | """Test that at least one informative word must match.""" |
| 147 | # "AI" appears in both topic and title |
| 148 | assert polymarket._passes_topic_filter("AI safety", "New AI Safety Conference") is True |
| 149 | # LOCAL DIVERGENCE from upstream: this asserted False, on the rule that a |
| 150 | # domain word ("ai") is too broad to be the sole match signal. That rule |
| 151 | # made domain sweeps impossible — an AI-news topic matched zero AI markets |
| 152 | # ever, because real titles say "AI" and never "artificial intelligence". |
| 153 | # _passes_topic_filter now falls back to _DOMAIN_WORDS when the informative |
| 154 | # words miss, so "AI models" does match an AI market. |
| 155 | assert polymarket._passes_topic_filter("AI models", "New AI prediction") is True |
| 156 | |
| 157 | |
| 158 | def test_passes_topic_filter_all_noise_words(): |
| 159 | """Test that all-noise-word topics don't filter anything.""" |
| 160 | # "the west" has no informative words, so should pass everything |
| 161 | result = polymarket._passes_topic_filter("the west", "Any title") |
| 162 | assert result is True |
| 163 | |
| 164 | |
| 165 | def test_passes_topic_filter_empty_topic(): |
| 166 | """Test empty topic always passes.""" |
| 167 | assert polymarket._passes_topic_filter("", "Any title") is True |
| 168 | |
| 169 | |
| 170 | def test_passes_topic_filter_multi_word_requires_two_matches(): |
| 171 | """Topics with 3+ informative words require at least 2 to match.""" |
| 172 | # "Mill food recycler" has 3 informative words. "Meek Mill YC" only matches "mill". |
| 173 | assert polymarket._passes_topic_filter( |
| 174 | "Mill food recycler", "Meek Mill gets Y Combinator funding" |
| 175 | ) is False |
| 176 | |
| 177 | |
| 178 | def test_passes_topic_filter_multi_word_passes_with_two_matches(): |
| 179 | """Topics with 3+ informative words pass when 2+ match.""" |
| 180 | assert polymarket._passes_topic_filter( |
| 181 | "Sam Altman OpenAI", "Sam Altman CEO OpenAI" |
| 182 | ) is True |
| 183 | |
| 184 | |
| 185 | def test_passes_topic_filter_two_word_still_needs_one(): |
| 186 | """Topics with 2 informative words still only need 1 match (existing behavior).""" |
| 187 | assert polymarket._passes_topic_filter( |
| 188 | "Kanye West", "Kanye divorce settlement" |
| 189 | ) is True |
| 190 | |
| 191 | |
| 192 | |
| 193 | def test_domain_fallback_keeps_soft_sweep_ai_markets(): |
| 194 | """Soft sweep residue may match via domain words (the #859 fix).""" |
| 195 | assert polymarket._passes_topic_filter( |
| 196 | "AI frontier developments", "Will AI models beat humans at coding?" |
| 197 | ) is True |
| 198 | |
| 199 | |
| 200 | |
| 201 | def test_domain_fallback_treats_plural_domain_terms_as_domain(): |
| 202 | """Plural domain tokens (models) must not block soft AI sweeps.""" |
| 203 | assert polymarket._passes_topic_filter( |
| 204 | "AI models frontier developments", |
| 205 | "Will AI beat humans at coding by 2027?", |
| 206 | ) is True |
| 207 | assert polymarket._passes_topic_filter( |
| 208 | "AI models", "New AI prediction" |
| 209 | ) is True |
| 210 | |
| 211 | |
| 212 | def test_domain_fallback_blocked_when_hard_informative_misses(): |
| 213 | """Mixed topics must not accept unrelated markets via a shared domain token.""" |
| 214 | assert polymarket._passes_topic_filter( |
| 215 | "MCP protocol benchmark", "Will the Kyoto Protocol survive?" |
| 216 | ) is False |
| 217 | assert polymarket._passes_any_informative_word( |
| 218 | "MCP protocol benchmark", "Will the Kyoto Protocol survive?" |
| 219 | ) is False |
| 220 | |
| 221 | |
| 222 | def test_passes_topic_filter_multi_word_edge_exactly_three(): |
| 223 | """Topic with exactly 3 informative words, 1 match -> rejected.""" |
| 224 | assert polymarket._passes_topic_filter( |
| 225 | "Tesla stock price", "Tesla quarterly earnings" |
| 226 | ) is False # only "tesla" matches, needs 2 |
| 227 | |
| 228 | # === Tests for _parse_outcome_prices() === |
| 229 | |
| 230 | |
| 231 | def test_parse_outcome_prices_basic(): |
| 232 | """Test basic outcome price parsing.""" |
| 233 | market = { |
| 234 | "outcomes": '["Yes", "No"]', |
| 235 | "outcomePrices": '["0.65", "0.35"]', |
| 236 | } |
| 237 | |
| 238 | result = polymarket._parse_outcome_prices(market) |
| 239 | |
| 240 | assert len(result) == 2 |
| 241 | assert result[0] == ("Yes", 0.65) |
| 242 | assert result[1] == ("No", 0.35) |
| 243 | |
| 244 | |
| 245 | def test_parse_outcome_prices_already_parsed(): |
| 246 | """Test handling when outcomes/prices are already lists.""" |
| 247 | market = { |
| 248 | "outcomes": ["Yes", "No"], |
| 249 | "outcomePrices": ["0.70", "0.30"], |
| 250 | } |
| 251 | |
| 252 | result = polymarket._parse_outcome_prices(market) |
| 253 | |
| 254 | assert len(result) == 2 |
| 255 | assert result[0][0] == "Yes" |
| 256 | assert result[0][1] == 0.70 |
| 257 | |
| 258 | |
| 259 | def test_parse_outcome_prices_multi_outcome(): |
| 260 | """Test multi-outcome markets.""" |
| 261 | market = { |
| 262 | "outcomes": '["Team A", "Team B", "Team C", "Team D"]', |
| 263 | "outcomePrices": '["0.40", "0.30", "0.20", "0.10"]', |
| 264 | } |
| 265 | |
| 266 | result = polymarket._parse_outcome_prices(market) |
| 267 | |
| 268 | assert len(result) == 4 |
| 269 | assert result[0] == ("Team A", 0.40) |
| 270 | assert result[3] == ("Team D", 0.10) |
| 271 | |
| 272 | |
| 273 | def test_parse_outcome_prices_missing_data(): |
| 274 | """Test handling of missing outcome prices.""" |
| 275 | market = {"outcomes": '["Yes"]'} |
| 276 | |
| 277 | result = polymarket._parse_outcome_prices(market) |
| 278 | |
| 279 | assert result == [] |
| 280 | |
| 281 | |
| 282 | def test_parse_outcome_prices_invalid_json(): |
| 283 | """Test handling of invalid JSON.""" |
| 284 | market = { |
| 285 | "outcomes": "not valid json", |
| 286 | "outcomePrices": "also not valid", |
| 287 | } |
| 288 | |
| 289 | result = polymarket._parse_outcome_prices(market) |
| 290 | |
| 291 | assert result == [] |
| 292 | |
| 293 | # === Tests for _format_price_movement() === |
| 294 | |
| 295 | |
| 296 | def test_format_price_movement_one_day(): |
| 297 | """Test formatting one-day price movement.""" |
| 298 | market = { |
| 299 | "oneDayPriceChange": 0.05, # Up 5% |
| 300 | "oneWeekPriceChange": 0.02, |
| 301 | "oneMonthPriceChange": 0.01, |
| 302 | } |
| 303 | |
| 304 | result = polymarket._format_price_movement(market) |
| 305 | |
| 306 | assert "up" in result |
| 307 | assert "5.0%" in result |
| 308 | assert "today" in result |
| 309 | |
| 310 | |
| 311 | def test_format_price_movement_negative(): |
| 312 | """Test formatting negative price movement.""" |
| 313 | market = { |
| 314 | "oneDayPriceChange": -0.15, # Down 15% |
| 315 | } |
| 316 | |
| 317 | result = polymarket._format_price_movement(market) |
| 318 | |
| 319 | assert "down" in result |
| 320 | assert "15.0%" in result |
| 321 | |
| 322 | |
| 323 | def test_format_price_movement_picks_largest(): |
| 324 | """Test that largest change is picked.""" |
| 325 | market = { |
| 326 | "oneDayPriceChange": 0.02, |
| 327 | "oneWeekPriceChange": 0.10, # Largest |
| 328 | "oneMonthPriceChange": 0.05, |
| 329 | } |
| 330 | |
| 331 | result = polymarket._format_price_movement(market) |
| 332 | |
| 333 | assert "10.0%" in result |
| 334 | assert "this week" in result |
| 335 | |
| 336 | |
| 337 | def test_format_price_movement_below_threshold(): |
| 338 | """Test that small changes return None.""" |
| 339 | market = { |
| 340 | "oneDayPriceChange": 0.005, # 0.5%, below 1% threshold |
| 341 | } |
| 342 | |
| 343 | result = polymarket._format_price_movement(market) |
| 344 | |
| 345 | assert result is None |
| 346 | |
| 347 | |
| 348 | def test_format_price_movement_missing_data(): |
| 349 | """Test handling of missing price change data.""" |
| 350 | market = {} |
| 351 | |
| 352 | result = polymarket._format_price_movement(market) |
| 353 | |
| 354 | assert result is None |
| 355 | |
| 356 | # === Tests for _shorten_question() === |
| 357 | |
| 358 | |
| 359 | def test_shorten_question_will_pattern(): |
| 360 | """Test shortening 'Will X...' questions.""" |
| 361 | result = polymarket._shorten_question("Will Arizona win the NCAA Tournament?") |
| 362 | |
| 363 | assert result == "Arizona" |
| 364 | |
| 365 | |
| 366 | def test_shorten_question_complex_will(): |
| 367 | """Test shortening complex Will questions.""" |
| 368 | result = polymarket._shorten_question("Will Duke be a number 1 seed?") |
| 369 | |
| 370 | assert result == "Duke" |
| 371 | |
| 372 | |
| 373 | def test_shorten_question_no_pattern(): |
| 374 | """Test questions that don't match patterns.""" |
| 375 | result = polymarket._shorten_question("Arizona wins championship") |
| 376 | |
| 377 | # Should return original or truncated |
| 378 | assert len(result) > 0 |
| 379 | |
| 380 | |
| 381 | def test_shorten_question_long(): |
| 382 | """Test truncation of very long questions.""" |
| 383 | long_q = "A" * 100 |
| 384 | result = polymarket._shorten_question(long_q) |
| 385 | |
| 386 | assert len(result) <= 40 |
| 387 | |
| 388 | |
| 389 | def test_shorten_question_fallback_strips_leading_article(): |
| 390 | """The truncation fallback must not keep a leading article like 'an' or 'the'. |
| 391 | |
| 392 | Without stripping, a question like 'an Anthropic Claude model scores...' yields |
| 393 | a lead name of just 'an', which renders as the mangled 'an 19%' footer fragment. |
| 394 | """ |
| 395 | result = polymarket._shorten_question( |
| 396 | "an Anthropic Claude model scores at the top of the leaderboard this month" |
| 397 | ) |
| 398 | lower = result.lower() |
| 399 | assert not lower.startswith("a ") |
| 400 | assert not lower.startswith("an ") |
| 401 | assert not lower.startswith("the ") |
| 402 | |
| 403 | |
| 404 | def test_shorten_question_fallback_keeps_non_article_lead(): |
| 405 | """Stripping only removes a leading article, not the first informative word.""" |
| 406 | result = polymarket._shorten_question( |
| 407 | "Anthropic ships a major Claude model update before the end of this month" |
| 408 | ) |
| 409 | assert result.lower().startswith("anthropic") |
| 410 | |
| 411 | # === Tests for search_polymarket() === |
| 412 | |
| 413 | |
| 414 | def test_search_polymarket_result_cap(): |
| 415 | """Test that result cap configuration exists.""" |
| 416 | assert "quick" in polymarket.RESULT_CAP |
| 417 | assert "default" in polymarket.RESULT_CAP |
| 418 | assert "deep" in polymarket.RESULT_CAP |
| 419 | |
| 420 | # Deep should return more results |
| 421 | assert polymarket.RESULT_CAP["deep"] >= polymarket.RESULT_CAP["quick"] |
| 422 | |
| 423 | |
| 424 | def test_search_polymarket_depth_config(): |
| 425 | """Test that depth configuration exists.""" |
| 426 | # Verify depth config |
| 427 | assert "quick" in polymarket.DEPTH_CONFIG |
| 428 | assert "default" in polymarket.DEPTH_CONFIG |
| 429 | assert "deep" in polymarket.DEPTH_CONFIG |
| 430 | |
| 431 | # Quick should be least pages |
| 432 | assert polymarket.DEPTH_CONFIG["quick"] <= polymarket.DEPTH_CONFIG["default"] |
| 433 | |
| 434 | |
| 435 | def test_search_polymarket_query_expansion(): |
| 436 | """Test that _expand_queries creates multiple queries.""" |
| 437 | queries = polymarket._expand_queries("AI framework") |
| 438 | |
| 439 | # Should expand to multiple queries |
| 440 | assert len(queries) >= 2 |
| 441 | |
| 442 | @patch('lib.polymarket.http.post') |
| 443 | |
| 444 | |
| 445 | def test_search_polymarket_http_error_handling(mock_post): |
| 446 | """Test graceful handling of HTTP errors.""" |
| 447 | from lib.http import HTTPError |
| 448 | mock_post.side_effect = HTTPError("HTTP 429: Rate limit") |
| 449 | |
| 450 | result = polymarket.search_polymarket("test", "2026-01-01", "2026-01-31") |
| 451 | |
| 452 | # Should return structure with error |
| 453 | assert "events" in result or "error" in result |
| 454 | |
| 455 | # === Tests for parse_polymarket_response() === |
| 456 | |
| 457 | |
| 458 | def test_parse_polymarket_response_basic(): |
| 459 | """Test basic response parsing.""" |
| 460 | response = { |
| 461 | "events": [create_mock_event( |
| 462 | title="Will AI surpass humans?", |
| 463 | volume24hr=500000, |
| 464 | )] |
| 465 | } |
| 466 | |
| 467 | items = polymarket.parse_polymarket_response(response, topic="AI") |
| 468 | |
| 469 | assert len(items) >= 0 |
| 470 | # Items may be filtered by topic filter |
| 471 | |
| 472 | |
| 473 | def test_parse_polymarket_response_filters_closed(): |
| 474 | """Test that closed events are filtered.""" |
| 475 | response = { |
| 476 | "events": [ |
| 477 | create_mock_event(closed=False), |
| 478 | create_mock_event(closed=True), |
| 479 | ] |
| 480 | } |
| 481 | |
| 482 | items = polymarket.parse_polymarket_response(response) |
| 483 | |
| 484 | # Closed events should be filtered |
| 485 | # (exact count depends on market filtering) |
| 486 | assert isinstance(items, list) |
| 487 | |
| 488 | |
| 489 | def test_parse_polymarket_response_empty(): |
| 490 | """Test handling of empty response.""" |
| 491 | response = {"events": []} |
| 492 | |
| 493 | items = polymarket.parse_polymarket_response(response) |
| 494 | |
| 495 | assert items == [] |
| 496 | |
| 497 | |
| 498 | def test_parse_polymarket_response_market_url(): |
| 499 | """Test that Polymarket URLs are generated.""" |
| 500 | response = { |
| 501 | "events": [create_mock_event( |
| 502 | slug="test-event-slug", |
| 503 | title="Test Event" |
| 504 | )] |
| 505 | } |
| 506 | |
| 507 | items = polymarket.parse_polymarket_response(response, topic="test") |
| 508 | |
| 509 | if items: # If not filtered |
| 510 | assert "url" in items[0] |
| 511 | assert "polymarket.com" in items[0]["url"] |
| 512 | |
| 513 | |
| 514 | def test_parse_polymarket_response_engagement(): |
| 515 | """Test that engagement/volume metrics are captured.""" |
| 516 | response = { |
| 517 | "events": [create_mock_event( |
| 518 | title="AI Event", |
| 519 | volume24hr=250000, |
| 520 | liquidity=100000, |
| 521 | )] |
| 522 | } |
| 523 | |
| 524 | items = polymarket.parse_polymarket_response(response, topic="AI") |
| 525 | |
| 526 | if items: # If not filtered |
| 527 | # Check for volume or liquidity fields |
| 528 | assert "volume24hr" in items[0] or "liquidity" in items[0] or isinstance(items[0], dict) |
| 529 | |
| 530 | |
| 531 | def _claude_downtime_response(): |
| 532 | """An off-topic Polymarket event that mentions only the generic word 'Claude'.""" |
| 533 | return { |
| 534 | "events": [ |
| 535 | create_mock_event( |
| 536 | event_id="evt-noise", |
| 537 | title="Will Claude go down 3-5 times in June?", |
| 538 | slug="claude-downtime", |
| 539 | ), |
| 540 | ] |
| 541 | } |
| 542 | |
| 543 | |
| 544 | def test_parse_polymarket_response_filters_noise_on_full_subquery(): |
| 545 | """A multi-word subquery filters off-topic 'Claude downtime' noise. |
| 546 | |
| 547 | 'Claude Code subagents workflow' carries 3 informative words; the downtime |
| 548 | title matches only one ('claude'), so the min-2 rule drops it. |
| 549 | """ |
| 550 | items = polymarket.parse_polymarket_response( |
| 551 | _claude_downtime_response(), topic="Claude Code subagents workflow" |
| 552 | ) |
| 553 | assert items == [] |
| 554 | |
| 555 | |
| 556 | def test_parse_polymarket_response_narrow_subquery_leaks_noise(): |
| 557 | """The SAME off-topic market leaks through a single-word subquery. |
| 558 | |
| 559 | 'claude' has one informative word, so the min-match threshold drops to 1 and |
| 560 | the downtime market passes. Because the pipeline previously fed the per-subquery |
| 561 | search_query, filtering swung between these two outcomes across the fanout; |
| 562 | keying off the stable original topic makes it consistent. This pair pins that |
| 563 | threshold-by-word-count behavior the wiring fix depends on. |
| 564 | """ |
| 565 | items = polymarket.parse_polymarket_response( |
| 566 | _claude_downtime_response(), topic="claude" |
| 567 | ) |
| 568 | assert len(items) == 1 |
| 569 | |
| 570 | # === Tests for engagement scoring === |
| 571 | |
| 572 | |
| 573 | def test_engagement_with_volume(): |
| 574 | """Test engagement calculation with volume.""" |
| 575 | response = { |
| 576 | "events": [create_mock_event( |
| 577 | title="Test", |
| 578 | volume24hr=500000, |
| 579 | )] |
| 580 | } |
| 581 | |
| 582 | items = polymarket.parse_polymarket_response(response, topic="test") |
| 583 | |
| 584 | if items: |
| 585 | engagement = items[0].get("engagement", {}) |
| 586 | # volume24hr should be captured |
| 587 | assert "volume24hr" in engagement or isinstance(engagement, dict) |
| 588 | |
| 589 | # === Tests for noise-word query skipping === |
| 590 | |
| 591 | |
| 592 | def test_expand_queries_skips_noise_words(): |
| 593 | """Noise words like 'west' should not become standalone queries.""" |
| 594 | queries = polymarket._expand_queries("kanye west") |
| 595 | lowered = [q.lower() for q in queries] |
| 596 | assert "kanye west" in lowered # full phrase kept |
| 597 | assert "kanye" in lowered # informative word kept |
| 598 | assert "west" not in lowered # noise word skipped |
| 599 | |
| 600 | |
| 601 | def test_expand_queries_keeps_informative_words(): |
| 602 | """Non-noise words should still be expanded as standalone queries.""" |
| 603 | queries = polymarket._expand_queries("arizona basketball") |
| 604 | lowered = [q.lower() for q in queries] |
| 605 | assert "arizona" in lowered |
| 606 | assert "basketball" in lowered |
| 607 | |
| 608 | |
| 609 | def test_expand_queries_all_noise_words_keeps_phrase(): |
| 610 | """If all words are noise, the full phrase is still searched.""" |
| 611 | queries = polymarket._expand_queries("north west") |
| 612 | assert len(queries) >= 1 |
| 613 | assert any("north west" in q.lower() for q in queries) |
| 614 | # Neither individual word should be a standalone query |
| 615 | lowered = [q.lower() for q in queries] |
| 616 | assert "north" not in lowered or "north west" in lowered # only as part of phrase |
| 617 | |
| 618 | # === Tests for per-item relevance floor === |
| 619 | |
| 620 | |
| 621 | def test_per_item_relevance_floor_drops_zero_items(): |
| 622 | """Items with relevance 0.0 should be dropped even if best item is high.""" |
| 623 | # Simulate the filtering logic directly |
| 624 | items = [ |
| 625 | {"relevance": 0.85, "title": "Kanye market"}, |
| 626 | {"relevance": 0.45, "title": "Related market"}, |
| 627 | {"relevance": 0.0, "title": "Golf noise"}, |
| 628 | {"relevance": 0.0, "title": "Cycling noise"}, |
| 629 | ] |
| 630 | filtered = [i for i in items if i["relevance"] >= 0.10] |
| 631 | assert len(filtered) == 2 |
| 632 | assert all(i["title"] != "Golf noise" for i in filtered) |
| 633 | |
| 634 | |
| 635 | def test_per_item_relevance_floor_keeps_borderline(): |
| 636 | """Items at exactly 0.10 should be kept.""" |
| 637 | items = [ |
| 638 | {"relevance": 0.85, "title": "Main market"}, |
| 639 | {"relevance": 0.10, "title": "Borderline market"}, |
| 640 | {"relevance": 0.09, "title": "Below floor"}, |
| 641 | ] |
| 642 | filtered = [i for i in items if i["relevance"] >= 0.10] |
| 643 | assert len(filtered) == 2 |
| 644 | assert filtered[1]["title"] == "Borderline market" |
| 645 | |
| 646 | |
| 647 | def test_per_item_relevance_floor_no_drops_when_all_high(): |
| 648 | """Nothing dropped when all items are above the floor.""" |
| 649 | items = [ |
| 650 | {"relevance": 0.85, "title": "A"}, |
| 651 | {"relevance": 0.50, "title": "B"}, |
| 652 | {"relevance": 0.30, "title": "C"}, |
| 653 | ] |
| 654 | filtered = [i for i in items if i["relevance"] >= 0.10] |
| 655 | assert len(filtered) == 3 |
| 656 | |
| 657 | if __name__ == "__main__": |
| 658 | pytest.main([__file__, "-v"]) |
| 659 |