返回 last30days-skill
test_techmeme_source.py
根目录 / tests / test_techmeme_source.py
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
408 lines PYTHON