| 1 | """Tests for the Techmeme source adapter (lib/techmeme.py). |
| 2 | |
| 3 | Covers the --json (not --agent) surface choice, header-row filtering, |
| 4 | field mapping, date windowing to the research range, old-binary prose |
| 5 | tolerance, and graceful degradation. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import json |
| 11 | from datetime import datetime, timezone |
| 12 | |
| 13 | import pytest |
| 14 | |
| 15 | from lib import techmeme |
| 16 | |
| 17 | |
| 18 | class _FakeProc: |
| 19 | returncode = 0 |
| 20 | stdout = "{}" |
| 21 | stderr = "" |
| 22 | |
| 23 | def __init__(self, stdout: str = "{}", returncode: int = 0, stderr: str = ""): |
| 24 | self.stdout = stdout |
| 25 | self.returncode = returncode |
| 26 | self.stderr = stderr |
| 27 | |
| 28 | |
| 29 | # ---- surface choice ---- |
| 30 | |
| 31 | def test_search_args_use_json_not_agent(): |
| 32 | args = techmeme._build_search_args("AI agents") |
| 33 | assert "--json" in args |
| 34 | # --agent implies --compact, which blanked records pre-PR-1383. |
| 35 | assert "--agent" not in args |
| 36 | assert "--compact" not in args |
| 37 | # Techmeme `search` has no result-limit flag; --max-results breaks it. |
| 38 | assert "--max-results" not in args |
| 39 | assert "search" in args and "AI agents" in args |
| 40 | |
| 41 | |
| 42 | def test_search_invokes_only_search_no_sync(monkeypatch): |
| 43 | """search_techmeme must issue exactly one subprocess call -- the search -- |
| 44 | and never a `sync` (search hits Techmeme's live archive, not the cache).""" |
| 45 | calls = [] |
| 46 | |
| 47 | def fake_run(cmd, timeout): |
| 48 | calls.append(list(cmd)) |
| 49 | return _FakeProc(stdout=json.dumps([ |
| 50 | {"num": 1, "source": "a.com", |
| 51 | "headline": "A real in-window headline about the topic", |
| 52 | "link": "https://t.co/a", "date": "2026-06-15"}, |
| 53 | ])) |
| 54 | |
| 55 | monkeypatch.setattr(techmeme, "_is_available", lambda: True) |
| 56 | monkeypatch.setattr(techmeme.subproc, "run_with_timeout", fake_run) |
| 57 | out = techmeme.search_techmeme("topic", "2026-06-01", "2026-06-27") |
| 58 | assert len(out["results"]) == 1 |
| 59 | assert calls == [[techmeme.CLI_BIN, "search", "topic", "--json"]] |
| 60 | assert not any("sync" in c for c in calls) |
| 61 | |
| 62 | |
| 63 | # ---- date windowing ---- |
| 64 | |
| 65 | def _search_with_records(monkeypatch, records, from_date="2026-06-01", to_date="2026-06-27"): |
| 66 | monkeypatch.setattr(techmeme, "_is_available", lambda: True) |
| 67 | monkeypatch.setattr( |
| 68 | techmeme.subproc, "run_with_timeout", |
| 69 | lambda cmd, timeout: _FakeProc(stdout=json.dumps(records)), |
| 70 | ) |
| 71 | return techmeme.search_techmeme("topic", from_date, to_date) |
| 72 | |
| 73 | |
| 74 | def test_in_window_record_kept_with_real_date(monkeypatch): |
| 75 | out = _search_with_records(monkeypatch, [ |
| 76 | {"num": 1, "source": "a.com", |
| 77 | "headline": "Fresh in-window story about the research topic", |
| 78 | "link": "https://t.co/a", "date": "2026-06-15"}, |
| 79 | ]) |
| 80 | assert len(out["results"]) == 1 |
| 81 | items = techmeme.parse_techmeme_response(out, query="topic") |
| 82 | assert len(items) == 1 |
| 83 | # The item carries the record's real date, never today's. |
| 84 | assert items[0]["date"] == "2026-06-15" |
| 85 | |
| 86 | |
| 87 | def test_out_of_window_record_dropped(monkeypatch): |
| 88 | out = _search_with_records(monkeypatch, [ |
| 89 | {"num": 1, "source": "a.com", |
| 90 | "headline": "Stale Parler acquisition story from years ago", |
| 91 | "link": "https://t.co/old", "date": "2022-12-02"}, |
| 92 | {"num": 2, "source": "b.com", |
| 93 | "headline": "Fresh in-window story about the research topic", |
| 94 | "link": "https://t.co/new", "date": "2026-06-15"}, |
| 95 | ]) |
| 96 | links = [r["link"] for r in out["results"]] |
| 97 | assert links == ["https://t.co/new"] |
| 98 | |
| 99 | |
| 100 | def test_zero_in_window_records_means_zero_results(monkeypatch): |
| 101 | """No keep-all fallback: staleness is the bug, so all-stale means empty.""" |
| 102 | out = _search_with_records(monkeypatch, [ |
| 103 | {"num": 1, "source": "a.com", |
| 104 | "headline": "Stale story number one from the archive", |
| 105 | "link": "https://t.co/1", "date": "2022-12-02"}, |
| 106 | {"num": 2, "source": "b.com", |
| 107 | "headline": "Stale story number two from the archive", |
| 108 | "link": "https://t.co/2", "date": "2023-08-24"}, |
| 109 | ]) |
| 110 | assert out["results"] == [] |
| 111 | |
| 112 | |
| 113 | def test_window_endpoints_inclusive(monkeypatch): |
| 114 | """Records dated exactly from_date or to_date survive the window; a |
| 115 | strict-< regression would silently shave both edges.""" |
| 116 | out = _search_with_records(monkeypatch, [ |
| 117 | {"num": 1, "source": "a.com", |
| 118 | "headline": "Story published exactly on the window start date", |
| 119 | "link": "https://t.co/start", "date": "2026-06-01"}, |
| 120 | {"num": 2, "source": "b.com", |
| 121 | "headline": "Story published exactly on the window end date", |
| 122 | "link": "https://t.co/end", "date": "2026-06-27"}, |
| 123 | ]) |
| 124 | assert [r["link"] for r in out["results"]] == ["https://t.co/start", "https://t.co/end"] |
| 125 | |
| 126 | |
| 127 | def test_non_string_and_whitespace_dates(monkeypatch): |
| 128 | """Non-string date values (int/null) are treated as undated and kept; |
| 129 | a valid ISO date with surrounding whitespace still parses.""" |
| 130 | out = _search_with_records(monkeypatch, [ |
| 131 | {"num": 1, "source": "a.com", |
| 132 | "headline": "Record carrying an integer where the date belongs", |
| 133 | "link": "https://t.co/int", "date": 20260615}, |
| 134 | {"num": 2, "source": "b.com", |
| 135 | "headline": "Record carrying an explicit null date value", |
| 136 | "link": "https://t.co/null", "date": None}, |
| 137 | {"num": 3, "source": "c.com", |
| 138 | "headline": "Record with whitespace padded valid ISO date", |
| 139 | "link": "https://t.co/ws", "date": " 2026-06-15 "}, |
| 140 | ]) |
| 141 | assert len(out["results"]) == 3 |
| 142 | items = techmeme.parse_techmeme_response(out, query="record") |
| 143 | by_url = {it["url"]: it["date"] for it in items} |
| 144 | assert by_url["https://t.co/int"] is None |
| 145 | assert by_url["https://t.co/null"] is None |
| 146 | assert by_url["https://t.co/ws"] == "2026-06-15" |
| 147 | |
| 148 | |
| 149 | def test_dropped_records_logged(monkeypatch): |
| 150 | logs = [] |
| 151 | monkeypatch.setattr(techmeme, "_is_available", lambda: True) |
| 152 | monkeypatch.setattr(techmeme, "_log", lambda msg: logs.append(msg)) |
| 153 | monkeypatch.setattr( |
| 154 | techmeme.subproc, "run_with_timeout", |
| 155 | lambda cmd, timeout: _FakeProc(stdout=json.dumps([ |
| 156 | {"num": 1, "source": "a.com", |
| 157 | "headline": "Stale story from the deep archive years back", |
| 158 | "link": "https://t.co/old", "date": "2022-12-02"}, |
| 159 | ])), |
| 160 | ) |
| 161 | techmeme.search_techmeme("topic", "2026-06-01", "2026-06-27") |
| 162 | assert "dropped 1 records outside 2026-06-01..2026-06-27" in logs |
| 163 | |
| 164 | |
| 165 | def test_undated_records_kept_and_never_stamped_today(monkeypatch): |
| 166 | """Old binaries emit no date key; unparseable dates come through as "" or |
| 167 | junk. All are kept by the window, and parse yields date None -- never |
| 168 | today's date.""" |
| 169 | out = _search_with_records(monkeypatch, [ |
| 170 | {"num": 1, "source": "a.com", |
| 171 | "headline": "Headline with no date key from an old binary", |
| 172 | "link": "https://t.co/nodate"}, |
| 173 | {"num": 2, "source": "b.com", |
| 174 | "headline": "Headline whose date was unparseable upstream", |
| 175 | "link": "https://t.co/empty", "date": ""}, |
| 176 | {"num": 3, "source": "c.com", |
| 177 | "headline": "Headline carrying non ISO junk in the date", |
| 178 | "link": "https://t.co/junk", "date": "May 22, 2025"}, |
| 179 | ]) |
| 180 | assert len(out["results"]) == 3 |
| 181 | items = techmeme.parse_techmeme_response(out, query="headline") |
| 182 | assert len(items) == 3 |
| 183 | today = datetime.now(timezone.utc).date().isoformat() |
| 184 | for item in items: |
| 185 | assert item["date"] is None |
| 186 | assert item["date"] != today |
| 187 | |
| 188 | |
| 189 | # ---- old-binary prose tolerance ---- |
| 190 | |
| 191 | def test_no_results_prose_parses_as_empty_without_error(monkeypatch): |
| 192 | """Old binaries print `No results for "q"` prose to stdout with exit 0 in |
| 193 | JSON mode. That is a zero-hit response, not a decode failure.""" |
| 194 | logs = [] |
| 195 | monkeypatch.setattr(techmeme, "_is_available", lambda: True) |
| 196 | monkeypatch.setattr(techmeme, "_log", lambda msg: logs.append(msg)) |
| 197 | monkeypatch.setattr( |
| 198 | techmeme.subproc, "run_with_timeout", |
| 199 | lambda cmd, timeout: _FakeProc(stdout='No results for "whatever query"\n'), |
| 200 | ) |
| 201 | resp = techmeme._run_cli([techmeme.CLI_BIN, "search", "whatever query", "--json"], |
| 202 | timeout=techmeme.SEARCH_TIMEOUT) |
| 203 | assert resp == {"results": []} |
| 204 | assert "error" not in resp |
| 205 | assert not any("decode" in m.lower() for m in logs) |
| 206 | |
| 207 | |
| 208 | def test_malformed_stdout_still_yields_decode_error(monkeypatch): |
| 209 | """Genuinely malformed non-JSON stdout (not the prose sentinel) keeps the |
| 210 | existing decode-error envelope.""" |
| 211 | logs = [] |
| 212 | monkeypatch.setattr(techmeme, "_is_available", lambda: True) |
| 213 | monkeypatch.setattr(techmeme, "_log", lambda msg: logs.append(msg)) |
| 214 | monkeypatch.setattr( |
| 215 | techmeme.subproc, "run_with_timeout", |
| 216 | lambda cmd, timeout: _FakeProc(stdout="garbage <<<"), |
| 217 | ) |
| 218 | resp = techmeme._run_cli([techmeme.CLI_BIN, "search", "topic", "--json"], |
| 219 | timeout=techmeme.SEARCH_TIMEOUT) |
| 220 | assert resp["results"] == [] |
| 221 | assert "json decode" in resp.get("error", "") |
| 222 | assert any("decode" in m.lower() for m in logs) |
| 223 | |
| 224 | |
| 225 | # ---- header-row filtering ---- |
| 226 | |
| 227 | def test_story_headline_accepts_sentence(): |
| 228 | assert techmeme._is_story_headline("OpenAI ships a new coding agent today", "techcrunch.com") |
| 229 | |
| 230 | |
| 231 | def test_story_headline_rejects_publication_name_rows(): |
| 232 | # Short, publication-name-only rows are section headers, not stories. |
| 233 | assert not techmeme._is_story_headline("TechCrunch", "techcrunch.com") |
| 234 | assert not techmeme._is_story_headline("New York Times", "nytimes.com") |
| 235 | |
| 236 | |
| 237 | def test_parse_drops_header_rows_keeps_stories(): |
| 238 | resp = { |
| 239 | "results": [ |
| 240 | {"num": 1, "source": "techcrunch.com", "headline": "TechCrunch", |
| 241 | "link": "http://techcrunch.com/", "date": "2026-06-27"}, |
| 242 | {"num": 2, "source": "techcrunch.com", |
| 243 | "headline": "Sakana AI's Fugu claims to rival frontier models", |
| 244 | "link": "https://www.techmeme.com/260627/p2", "date": "2026-06-27"}, |
| 245 | ] |
| 246 | } |
| 247 | items = techmeme.parse_techmeme_response(resp, query="AI") |
| 248 | assert len(items) == 1 |
| 249 | assert items[0]["title"].startswith("Sakana AI") |
| 250 | assert items[0]["url"] == "https://www.techmeme.com/260627/p2" |
| 251 | assert items[0]["source_name"] == "techcrunch.com" |
| 252 | assert items[0]["date"] == "2026-06-27" |
| 253 | |
| 254 | |
| 255 | def test_parse_drops_records_without_link(): |
| 256 | resp = {"results": [{"num": 1, "source": "x.com", "headline": "A real headline sentence here", |
| 257 | "link": "", "date": "2026-06-27"}]} |
| 258 | assert techmeme.parse_techmeme_response(resp, query="x") == [] |
| 259 | |
| 260 | |
| 261 | # ---- relevance ranking ---- |
| 262 | |
| 263 | def test_more_relevant_headline_ranks_higher(): |
| 264 | resp = { |
| 265 | "results": [ |
| 266 | {"num": 1, "source": "a.com", "headline": "Unrelated quarterly earnings report released today", |
| 267 | "link": "https://t.co/a", "date": "2026-06-20"}, |
| 268 | {"num": 2, "source": "b.com", "headline": "New AI agent framework launches for developers", |
| 269 | "link": "https://t.co/b", "date": "2026-06-20"}, |
| 270 | ] |
| 271 | } |
| 272 | items = techmeme.parse_techmeme_response(resp, query="AI agent framework") |
| 273 | by_url = {it["url"]: it["relevance"] for it in items} |
| 274 | assert by_url["https://t.co/b"] > by_url["https://t.co/a"] |
| 275 | |
| 276 | |
| 277 | # ---- envelope tolerance ---- |
| 278 | |
| 279 | def test_coerce_list_handles_bare_array_and_wrapped(): |
| 280 | assert techmeme._coerce_list([{"a": 1}]) == [{"a": 1}] |
| 281 | assert techmeme._coerce_list({"results": [{"a": 1}]}) == [{"a": 1}] |
| 282 | assert techmeme._coerce_list({"nope": 1}) == [] |
| 283 | |
| 284 | |
| 285 | # ---- depth cap + degradation ---- |
| 286 | |
| 287 | @pytest.mark.parametrize("depth,cap", [("quick", 8), ("default", 16), ("deep", 30)]) |
| 288 | def test_depth_cap_truncates_client_side(monkeypatch, depth, cap): |
| 289 | """Techmeme `search` has no limit flag, so the depth cap is applied after |
| 290 | windowing -- regression guard for the other half of the --max-results fix. |
| 291 | All records are in-window, so the cap alone decides the count.""" |
| 292 | records = [ |
| 293 | {"num": i, "source": "x.com", "headline": f"A real headline sentence number {i}", |
| 294 | "link": f"https://t.co/{i}", "date": "2026-06-15"} |
| 295 | for i in range(cap + 12) |
| 296 | ] |
| 297 | monkeypatch.setattr(techmeme, "_is_available", lambda: True) |
| 298 | monkeypatch.setattr(techmeme, "_run_cli", lambda cmd, timeout: {"results": list(records)}) |
| 299 | out = techmeme.search_techmeme("topic", "2026-06-01", "2026-06-27", depth=depth) |
| 300 | assert len(out["results"]) == cap |
| 301 | |
| 302 | |
| 303 | def test_depth_cap_applies_after_windowing(monkeypatch): |
| 304 | """Stale records must not consume cap slots: with cap in-window records |
| 305 | plus stale ones interleaved ahead of them, every in-window record |
| 306 | survives.""" |
| 307 | cap = techmeme.DEPTH_CONFIG["quick"] |
| 308 | stale = [ |
| 309 | {"num": i, "source": "old.com", "headline": f"Stale archive headline number {i}", |
| 310 | "link": f"https://t.co/old{i}", "date": "2022-01-10"} |
| 311 | for i in range(cap) |
| 312 | ] |
| 313 | fresh = [ |
| 314 | {"num": 100 + i, "source": "new.com", "headline": f"Fresh in window headline number {i}", |
| 315 | "link": f"https://t.co/new{i}", "date": "2026-06-15"} |
| 316 | for i in range(cap) |
| 317 | ] |
| 318 | monkeypatch.setattr(techmeme, "_is_available", lambda: True) |
| 319 | monkeypatch.setattr(techmeme, "_run_cli", |
| 320 | lambda cmd, timeout: {"results": stale + fresh}) |
| 321 | out = techmeme.search_techmeme("topic", "2026-06-01", "2026-06-27", depth="quick") |
| 322 | links = [r["link"] for r in out["results"]] |
| 323 | assert links == [f"https://t.co/new{i}" for i in range(cap)] |
| 324 | |
| 325 | |
| 326 | def test_dated_records_take_cap_slots_before_undated(monkeypatch): |
| 327 | """Undated archive hits must never evict confirmed in-window stories: |
| 328 | dated in-window records fill cap slots first, undated fill the rest.""" |
| 329 | cap = techmeme.DEPTH_CONFIG["quick"] |
| 330 | undated = [ |
| 331 | {"num": i, "source": "old.com", "headline": f"Undated old binary headline number {i}", |
| 332 | "link": f"https://t.co/u{i}"} |
| 333 | for i in range(cap) |
| 334 | ] |
| 335 | dated = [ |
| 336 | {"num": 100 + i, "source": "new.com", "headline": f"Dated in window headline number {i}", |
| 337 | "link": f"https://t.co/d{i}", "date": "2026-06-15"} |
| 338 | for i in range(cap) |
| 339 | ] |
| 340 | monkeypatch.setattr(techmeme, "_is_available", lambda: True) |
| 341 | # Undated arrive first in CLI order; dated in-window must still win the cap. |
| 342 | monkeypatch.setattr(techmeme, "_run_cli", |
| 343 | lambda cmd, timeout: {"results": undated + dated}) |
| 344 | out = techmeme.search_techmeme("topic", "2026-06-01", "2026-06-27", depth="quick") |
| 345 | links = [r["link"] for r in out["results"]] |
| 346 | assert links == [f"https://t.co/d{i}" for i in range(cap)] |
| 347 | |
| 348 | |
| 349 | def test_all_undated_flood_capped_with_windowing_inactive_hint(monkeypatch): |
| 350 | """The realistic old-binary case: every record undated. The cap still |
| 351 | applies, and a windowing-inactive hint is logged.""" |
| 352 | cap = techmeme.DEPTH_CONFIG["quick"] |
| 353 | logs = [] |
| 354 | records = [ |
| 355 | {"num": i, "source": "x.com", "headline": f"Undated archive headline number {i}", |
| 356 | "link": f"https://t.co/{i}"} |
| 357 | for i in range(cap + 12) |
| 358 | ] |
| 359 | monkeypatch.setattr(techmeme, "_is_available", lambda: True) |
| 360 | monkeypatch.setattr(techmeme, "_log", lambda msg: logs.append(msg)) |
| 361 | monkeypatch.setattr(techmeme, "_run_cli", lambda cmd, timeout: {"results": records}) |
| 362 | out = techmeme.search_techmeme("topic", "2026-06-01", "2026-06-27", depth="quick") |
| 363 | assert len(out["results"]) == cap |
| 364 | assert any("date windowing inactive" in m for m in logs) |
| 365 | |
| 366 | |
| 367 | def test_windowing_inactive_hint_absent_when_dates_present(monkeypatch): |
| 368 | """The hint must not fire when the binary emits dates -- even if every |
| 369 | dated record is out of window (windowing IS active there).""" |
| 370 | logs = [] |
| 371 | monkeypatch.setattr(techmeme, "_is_available", lambda: True) |
| 372 | monkeypatch.setattr(techmeme, "_log", lambda msg: logs.append(msg)) |
| 373 | monkeypatch.setattr(techmeme, "_run_cli", lambda cmd, timeout: {"results": [ |
| 374 | {"num": 1, "source": "a.com", "headline": "Stale but properly dated archive story", |
| 375 | "link": "https://t.co/old", "date": "2022-12-02"}, |
| 376 | {"num": 2, "source": "b.com", "headline": "Undated companion record from mixed output", |
| 377 | "link": "https://t.co/mixed"}, |
| 378 | ]}) |
| 379 | techmeme.search_techmeme("topic", "2026-06-01", "2026-06-27") |
| 380 | assert not any("date windowing inactive" in m for m in logs) |
| 381 | |
| 382 | |
| 383 | @pytest.mark.parametrize("words,expected", [(3, False), (4, True)]) |
| 384 | def test_story_headline_word_count_boundary(words, expected): |
| 385 | headline = " ".join(["word"] * words) |
| 386 | assert techmeme._is_story_headline(headline, "x.com") is expected |
| 387 | |
| 388 | |
| 389 | def test_story_headline_rejects_when_equal_to_source(): |
| 390 | # A >=4-word headline that exactly equals its source is still a header row. |
| 391 | assert not techmeme._is_story_headline("the daily example tribune", "the daily example tribune") |
| 392 | |
| 393 | |
| 394 | def test_binary_absent_returns_empty(monkeypatch): |
| 395 | monkeypatch.setattr(techmeme.shutil, "which", lambda _bin: None) |
| 396 | resp = techmeme.search_techmeme("anything", "2026-06-01", "2026-06-27") |
| 397 | assert resp["results"] == [] |
| 398 | assert "error" in resp |
| 399 | |
| 400 | |
| 401 | def test_empty_topic_returns_empty(): |
| 402 | assert techmeme.search_techmeme(" ", "2026-06-01", "2026-06-27") == {"results": []} |
| 403 | |
| 404 | |
| 405 | def test_parse_handles_non_list_results(): |
| 406 | assert techmeme.parse_techmeme_response({"results": "oops"}, query="x") == [] |
| 407 | assert techmeme.parse_techmeme_response({}, query="x") == [] |
| 408 |