返回 last30days-skill
test_arxiv_source.py
根目录 / tests / test_arxiv_source.py
1 """Tests for the arXiv source adapter (lib/arxiv.py).
2
3 Covers query construction (quoted phrase + relevance sort), the recency
4 cutoff that doubles as off-topic gating, field mapping, and graceful
5 degradation when the binary is absent.
6 """
7
8 from __future__ import annotations
9
10 from datetime import datetime, timedelta, timezone
11
12 from lib import arxiv
13
14
15 NOW = datetime(2026, 6, 27, tzinfo=timezone.utc)
16
17
18 def _entry(title, published, summary="An abstract.", authors=("Ada Lovelace",), abs_url="https://arxiv.org/abs/2606.00001v1"):
19 return {
20 "id": "http://arxiv.org/abs/2606.00001v1",
21 "title": title,
22 "summary": summary,
23 "published": published,
24 "authors": [{"name": a} for a in authors],
25 "categories": [{"term": "cs.AI"}],
26 "links": [
27 {"rel": "alternate", "href": abs_url, "type": "text/html"},
28 {"rel": "related", "href": "https://arxiv.org/pdf/2606.00001v1", "title": "pdf"},
29 ],
30 }
31
32
33 # ---- query construction ----
34
35 def test_search_query_is_quoted_phrase_relevance_sorted():
36 args = arxiv._build_search_args("AI coding agents", 10)
37 joined = " ".join(args)
38 assert '--search-query' in args
39 assert 'all:"AI coding agents"' in args # quoted phrase
40 assert "--sort-by" in args and "relevance" in args # NOT submittedDate
41 assert "submittedDate" not in joined
42
43
44 def test_search_query_strips_inner_quotes():
45 q = arxiv._build_search_query('say "hello" world')
46 assert q == 'all:"say hello world"'
47
48
49 def test_unquoted_fallback_search_args_conjoin_terms_at_command_boundary():
50 args = arxiv._build_search_args("AI video generation advances", 10, quoted=False)
51 assert args == [
52 "arxiv-pp-cli",
53 "query",
54 "--search-query",
55 'all:"AI" AND all:"video" AND all:"generation" AND all:"advances"',
56 "--sort-by",
57 "relevance",
58 "--max-results",
59 "10",
60 "--agent",
61 ]
62
63
64 def test_unquoted_fallback_quotes_operator_and_colon_terms_at_command_boundary():
65 args = arxiv._build_search_args("AI AND OR title:video", 5, quoted=False)
66 assert args[args.index("--search-query") + 1] == (
67 'all:"AI" AND all:"AND" AND all:"OR" AND all:"title:video"'
68 )
69
70
71 # ---- envelope extraction ----
72
73 def test_extract_entries_handles_nested_results_envelope():
74 data = {"meta": {"source": "live"}, "results": {"entries": [{"title": "X"}]}}
75 entries = arxiv._extract_entries(data)
76 assert len(entries) == 1 and entries[0]["title"] == "X"
77
78
79 # ---- happy path + field mapping ----
80
81 def test_happy_path_maps_fields():
82 recent = (NOW - timedelta(days=10)).strftime("%Y-%m-%dT12:00:00Z")
83 resp = {"results": [_entry("Agent Memory as a Database", recent, authors=("A", "B"))]}
84 items = arxiv.parse_arxiv_response(resp, query="agent memory", today=NOW)
85 assert len(items) == 1
86 it = items[0]
87 assert it["title"] == "Agent Memory as a Database"
88 assert it["url"] == "https://arxiv.org/abs/2606.00001v1" # alternate, not pdf
89 assert it["date"] == (NOW - timedelta(days=10)).date().isoformat()
90 assert it["authors"] == ["A", "B"]
91 assert it["author"] == "A et al." # multi-author label
92
93
94 def test_url_prefers_alternate_over_pdf():
95 recent = (NOW - timedelta(days=5)).strftime("%Y-%m-%dT12:00:00Z")
96 resp = {"results": [_entry("T", recent)]}
97 items = arxiv.parse_arxiv_response(resp, query="t", today=NOW)
98 assert "/pdf/" not in items[0]["url"]
99 assert "/abs/" in items[0]["url"]
100
101
102 # ---- recency cutoff (also off-topic gating) ----
103
104 def test_recency_cutoff_drops_stale_paper():
105 stale = (NOW - timedelta(days=arxiv.RECENCY_DAYS + 30)).strftime("%Y-%m-%dT12:00:00Z")
106 resp = {"results": [_entry("Old Paper", stale)]}
107 items = arxiv.parse_arxiv_response(resp, query="old paper", today=NOW)
108 assert items == []
109
110
111 def test_off_topic_control_drops_via_recency():
112 # The "Golden State Warriors" keyword match is a 2017 stats paper -- the
113 # recency cutoff is what keeps arXiv quiet on non-research topics.
114 old = "2017-06-12T12:00:00Z"
115 resp = {"results": [_entry("Do Steph Curry and Klay Thompson Have Hot Hands?", old)]}
116 items = arxiv.parse_arxiv_response(resp, query="Golden State Warriors", today=NOW)
117 assert items == []
118
119
120 def test_unparseable_or_missing_date_is_dropped():
121 resp = {"results": [_entry("No Date", None), _entry("Bad Date", "not-a-date")]}
122 items = arxiv.parse_arxiv_response(resp, query="x", today=NOW)
123 assert items == []
124
125
126 def test_future_dated_entry_is_dropped():
127 future = (NOW + timedelta(days=5)).strftime("%Y-%m-%dT12:00:00Z")
128 resp = {"results": [_entry("Future", future)]}
129 items = arxiv.parse_arxiv_response(resp, query="future", today=NOW)
130 assert items == []
131
132
133 # ---- error / degradation paths ----
134
135 def test_binary_absent_returns_empty(monkeypatch):
136 monkeypatch.setattr(arxiv.shutil, "which", lambda _bin: None)
137 resp = arxiv.search_arxiv("anything", "2026-06-01", "2026-06-27")
138 assert resp["results"] == []
139 assert "error" in resp
140
141
142 def test_empty_topic_returns_empty():
143 assert arxiv.search_arxiv(" ", "2026-06-01", "2026-06-27") == {"results": []}
144
145
146 def test_parse_handles_non_list_results():
147 assert arxiv.parse_arxiv_response({"results": "oops"}, query="x", today=NOW) == []
148 assert arxiv.parse_arxiv_response({}, query="x", today=NOW) == []
149
150
151 def test_quote_only_topic_returns_empty():
152 # A topic of only quote characters cleans to an empty phrase; don't search.
153 assert arxiv.search_arxiv('"', "2026-06-01", "2026-06-27") == {"results": []}
154
155
156 def test_same_day_future_timestamp_is_kept():
157 # A paper announced later the same UTC day yields age_days == -1; the
158 # one-day grace keeps it rather than dropping it as "future".
159 later_today = NOW.replace(hour=23, minute=59).strftime("%Y-%m-%dT%H:%M:00Z")
160 resp = {"results": [_entry("Fresh paper", later_today)]}
161 items = arxiv.parse_arxiv_response(resp, query="fresh paper", today=NOW.replace(hour=1))
162 assert len(items) == 1
163
164
165 class _Proc:
166 def __init__(self, rc, out, err=""):
167 self.returncode, self.stdout, self.stderr = rc, out, err
168
169
170 def test_run_cli_flattens_nested_envelope(monkeypatch):
171 monkeypatch.setattr(arxiv, "_is_available", lambda: True)
172 payload = '{"meta":{},"results":{"entries":[{"title":"X"}]}}'
173 monkeypatch.setattr(arxiv.subproc, "run_with_timeout", lambda cmd, timeout: _Proc(0, payload))
174 resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27")
175 assert resp["results"] == [{"title": "X"}]
176
177
178 def test_run_cli_nonzero_exit_returns_error(monkeypatch):
179 monkeypatch.setattr(arxiv, "_is_available", lambda: True)
180 monkeypatch.setattr(arxiv.subproc, "run_with_timeout", lambda cmd, timeout: _Proc(1, "", "boom\nmore"))
181 resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27")
182 assert resp["results"] == [] and "boom" in resp["error"]
183
184
185 def test_run_cli_bad_json_returns_error(monkeypatch):
186 monkeypatch.setattr(arxiv, "_is_available", lambda: True)
187 monkeypatch.setattr(arxiv.subproc, "run_with_timeout", lambda cmd, timeout: _Proc(0, "not json"))
188 resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27")
189 assert resp["results"] == [] and "error" in resp
190
191
192 def test_empty_stdout_returns_error_without_retry(monkeypatch):
193 monkeypatch.setattr(arxiv, "_is_available", lambda: True)
194 calls = []
195
196 def fake_run(cmd, timeout):
197 calls.append(cmd)
198 return _Proc(0, "")
199
200 monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run)
201 resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27")
202 assert resp == {"results": [], "error": "empty stdout"}
203 assert len(calls) == 1
204
205
206 def test_unrecognized_json_returns_error_without_retry(monkeypatch):
207 monkeypatch.setattr(arxiv, "_is_available", lambda: True)
208 calls = []
209
210 def fake_run(cmd, timeout):
211 calls.append(cmd)
212 return _Proc(0, '{"status":"ok"}')
213
214 monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run)
215 resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27")
216 assert resp == {"results": [], "error": "unrecognized JSON response"}
217 assert len(calls) == 1
218
219
220 def test_recognized_empty_list_retries(monkeypatch):
221 monkeypatch.setattr(arxiv, "_is_available", lambda: True)
222 calls = []
223
224 def fake_run(cmd, timeout):
225 calls.append(cmd)
226 return _Proc(0, "[]")
227
228 monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run)
229 resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27")
230 assert resp == {"results": []}
231 assert len(calls) == 2
232
233
234 # ---- unquoted-retry on zero results (#908) ----
235
236 def test_zero_result_quoted_query_retries_unquoted_and_finds_results(monkeypatch):
237 """A natural-language multi-word topic matches nothing as an exact
238 phrase, but the unquoted retry finds it -- the fix for #908."""
239 monkeypatch.setattr(arxiv, "_is_available", lambda: True)
240 calls = []
241
242 def fake_run(cmd, timeout):
243 calls.append(cmd)
244 query = cmd[cmd.index("--search-query") + 1]
245 if query == 'all:"AI video generation advances"':
246 return _Proc(0, '{"results":{"entries":[]}}')
247 return _Proc(0, '{"results":{"entries":[{"title":"AI video generation advances"}]}}')
248
249 monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run)
250 resp = arxiv.search_arxiv("AI video generation advances", "2026-06-01", "2026-06-27")
251 assert resp["results"] == [{"title": "AI video generation advances"}]
252 assert len(calls) == 2
253 assert 'all:"AI video generation advances"' in calls[0]
254 assert 'all:"AI" AND all:"video" AND all:"generation" AND all:"advances"' in calls[1]
255
256
257 def test_zero_result_quoted_query_retry_also_empty_returns_empty(monkeypatch):
258 monkeypatch.setattr(arxiv, "_is_available", lambda: True)
259 calls = []
260
261 def fake_run(cmd, timeout):
262 calls.append(cmd)
263 return _Proc(0, '{"results":{"entries":[]}}')
264
265 monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run)
266 resp = arxiv.search_arxiv("truly obscure nonsense topic", "2026-06-01", "2026-06-27")
267 assert resp["results"] == []
268 assert "error" not in resp
269 assert len(calls) == 2
270
271
272 def test_real_cli_error_does_not_trigger_unquoted_retry(monkeypatch):
273 """A genuine failure (nonzero exit) must not retry -- only a clean
274 zero-result success should (R6)."""
275 monkeypatch.setattr(arxiv, "_is_available", lambda: True)
276 calls = []
277
278 def fake_run(cmd, timeout):
279 calls.append(cmd)
280 return _Proc(1, "", "boom")
281
282 monkeypatch.setattr(arxiv.subproc, "run_with_timeout", fake_run)
283 resp = arxiv.search_arxiv("topic", "2026-06-01", "2026-06-27")
284 assert resp["results"] == [] and "boom" in resp["error"]
285 assert len(calls) == 1
286
286 lines PYTHON