返回 last30days-skill
test_last_run_state.py
根目录 / tests / test_last_run_state.py
1 import io
2 import json
3 import os
4 import subprocess
5 import sys
6 import tempfile
7 import unittest
8 from contextlib import redirect_stderr, redirect_stdout
9 from pathlib import Path
10 from unittest import mock
11
12 import last30days as cli
13 from lib import schema
14
15 REPO_ROOT = Path(__file__).resolve().parents[1]
16 LAST30DAYS_SCRIPT = REPO_ROOT / "skills" / "last30days" / "scripts" / "last30days.py"
17 SKILL_MD = REPO_ROOT / "skills" / "last30days" / "SKILL.md"
18
19
20 def run_last30days(topic: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
21 return subprocess.run(
22 [sys.executable, str(LAST30DAYS_SCRIPT), topic, "--mock", "--emit=json"],
23 cwd=REPO_ROOT,
24 env=env,
25 capture_output=True,
26 text=True,
27 encoding="utf-8",
28 check=False,
29 )
30
31
32 def _report(topic: str) -> schema.Report:
33 return schema.Report(
34 topic=topic,
35 range_from="2026-05-01",
36 range_to="2026-05-31",
37 generated_at="2026-05-31T00:00:00+00:00",
38 provider_runtime=schema.ProviderRuntime(
39 reasoning_provider="local",
40 planner_model="mock-planner",
41 rerank_model="mock-rerank",
42 ),
43 query_plan=schema.QueryPlan(
44 intent="concept",
45 freshness_mode="balanced_recent",
46 cluster_mode="none",
47 raw_topic=topic,
48 subqueries=[
49 schema.SubQuery(
50 label="primary",
51 search_query=topic,
52 ranking_query=topic,
53 sources=["grounding"],
54 )
55 ],
56 source_weights={"grounding": 1.0},
57 ),
58 clusters=[],
59 ranked_candidates=[],
60 items_by_source={"grounding": []},
61 errors_by_source={},
62 )
63
64
65 def _diag() -> dict[str, object]:
66 return {
67 "available_sources": ["grounding"],
68 "providers": {"google": True, "openai": False, "xai": False},
69 "x_backend": None,
70 "bird_installed": True,
71 "bird_authenticated": False,
72 "bird_username": None,
73 "native_web_backend": "brave",
74 }
75
76
77 class LastRunStateTests(unittest.TestCase):
78 def test_empty_config_override_disables_last_run_write(self):
79 with tempfile.TemporaryDirectory() as tmp:
80 home = Path(tmp) / "home"
81 env = os.environ.copy()
82 env["HOME"] = str(home)
83 env["LAST30DAYS_CONFIG_DIR"] = ""
84
85 result = run_last30days("synthetic eval query", env)
86
87 self.assertEqual(result.returncode, 0, result.stderr)
88 self.assertFalse((home / ".config" / "last30days" / "last-run.json").exists())
89
90 def test_custom_config_override_writes_last_run_to_custom_dir(self):
91 with tempfile.TemporaryDirectory() as tmp:
92 config_dir = Path(tmp) / "custom-config"
93 env = os.environ.copy()
94 env["HOME"] = str(Path(tmp) / "home")
95 env["LAST30DAYS_CONFIG_DIR"] = str(config_dir)
96
97 result = run_last30days("custom config query", env)
98
99 self.assertEqual(result.returncode, 0, result.stderr)
100 payload = json.loads((config_dir / "last-run.json").read_text())
101 self.assertEqual(payload["topic"], "custom config query")
102 self.assertGreaterEqual(payload["total"], 0)
103 self.assertEqual(str(config_dir / "last-report.json"), payload["report_cache"])
104 self.assertTrue((config_dir / "last-report.json").exists())
105
106 def test_last_report_cache_round_trips_single_report(self):
107 with tempfile.TemporaryDirectory() as tmp:
108 config_dir = Path(tmp) / "config"
109 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir):
110 report = _report("OpenClaw")
111 cli._write_last_run("OpenClaw", report)
112 loaded = cli._load_last_report_cache("OpenClaw")
113
114 self.assertIsNotNone(loaded)
115 cached_report, entity_reports, cache_path = loaded
116 self.assertEqual("OpenClaw", cached_report.topic)
117 self.assertIsNone(entity_reports)
118 self.assertEqual(config_dir / "last-report.json", cache_path)
119
120 def test_last_report_cache_expires_after_ttl(self):
121 with tempfile.TemporaryDirectory() as tmp:
122 config_dir = Path(tmp) / "config"
123 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir):
124 cli._write_last_run("OpenClaw", _report("OpenClaw"))
125 cache_path = config_dir / "last-report.json"
126 payload = json.loads(cache_path.read_text(encoding="utf-8"))
127 payload["timestamp"] = "2026-01-01T00:00:00+00:00"
128 cache_path.write_text(json.dumps(payload), encoding="utf-8")
129 loaded = cli._load_last_report_cache("OpenClaw", ttl_seconds=3600)
130
131 self.assertIsNone(loaded)
132
133 def test_last_report_cache_ttl_zero_disables_reuse(self):
134 with tempfile.TemporaryDirectory() as tmp:
135 config_dir = Path(tmp) / "config"
136 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir):
137 cli._write_last_run("OpenClaw", _report("OpenClaw"))
138 loaded = cli._load_last_report_cache("OpenClaw", ttl_seconds=0)
139
140 self.assertIsNone(loaded)
141
142 def test_partial_comparison_cache_does_not_degrade_to_single_report(self):
143 with tempfile.TemporaryDirectory() as tmp:
144 config_dir = Path(tmp) / "config"
145 reports = [("Alpha", _report("Alpha")), ("Beta", _report("Beta"))]
146 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir):
147 cli._write_last_run("Alpha vs Beta", reports[0][1], entity_reports=reports)
148 cache_path = config_dir / "last-report.json"
149 payload = json.loads(cache_path.read_text(encoding="utf-8"))
150 payload["reports"] = payload["reports"][:1]
151 cache_path.write_text(json.dumps(payload), encoding="utf-8")
152 loaded = cli._load_last_report_cache("Alpha vs Beta")
153
154 self.assertIsNone(loaded)
155
156 def test_html_synthesis_reuses_cached_single_report_without_pipeline_run(self):
157 with tempfile.TemporaryDirectory() as tmp:
158 config_dir = Path(tmp) / "config"
159 synthesis_path = Path(tmp) / "synthesis.md"
160 synthesis_path.write_text("# OpenClaw\n\nCached synthesis body.", encoding="utf-8")
161 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir):
162 cli._write_last_run("OpenClaw", _report("OpenClaw"))
163
164 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir), \
165 mock.patch.object(cli.env, "get_config", return_value={}), \
166 mock.patch.object(cli.pipeline, "diagnose", return_value=_diag()), \
167 mock.patch.object(cli.pipeline, "run", side_effect=AssertionError("pipeline should not run")), \
168 mock.patch.object(sys, "argv", [
169 "last30days.py",
170 "OpenClaw",
171 "--emit=html",
172 "--synthesis-file",
173 str(synthesis_path),
174 ]), \
175 mock.patch.dict(os.environ, {"LAST30DAYS_SKIP_PREFLIGHT": "1"}, clear=False):
176 stdout = io.StringIO()
177 stderr = io.StringIO()
178 with redirect_stdout(stdout), redirect_stderr(stderr):
179 rc = cli.main()
180
181 self.assertEqual(0, rc)
182 self.assertIn("Cached synthesis body.", stdout.getvalue())
183 self.assertIn("Reusing cached report data", stderr.getvalue())
184
185 def test_deep_research_bypasses_html_synthesis_cache(self):
186 with tempfile.TemporaryDirectory() as tmp:
187 config_dir = Path(tmp) / "config"
188 synthesis_path = Path(tmp) / "synthesis.md"
189 synthesis_path.write_text("# Cached synthesis", encoding="utf-8")
190 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir):
191 cli._write_last_run("OpenClaw", _report("OpenClaw"))
192
193 fresh_report = _report("OpenClaw")
194 config = {"OPENROUTER_API_KEY": "or-test"}
195 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir), \
196 mock.patch.object(cli.env, "get_config", return_value=config), \
197 mock.patch.object(cli.pipeline, "diagnose", return_value=_diag()), \
198 mock.patch.object(cli.pipeline, "run", return_value=fresh_report) as run_mock, \
199 mock.patch.object(cli.ui, "ProgressDisplay"), \
200 mock.patch.object(cli, "_load_last_report_cache") as cache_mock, \
201 mock.patch.object(sys, "argv", [
202 "last30days.py",
203 "OpenClaw",
204 "--deep-research",
205 "--emit=html",
206 "--synthesis-file",
207 str(synthesis_path),
208 ]), \
209 mock.patch.dict(
210 os.environ,
211 {"LAST30DAYS_SKIP_PREFLIGHT": "1"},
212 clear=False,
213 ):
214 with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
215 rc = cli.main()
216
217 self.assertEqual(0, rc)
218 cache_mock.assert_not_called()
219 run_mock.assert_called_once()
220 self.assertTrue(run_mock.call_args.kwargs["config"]["_deep_research"])
221
222 def test_html_synthesis_reuses_cached_comparison_reports(self):
223 with tempfile.TemporaryDirectory() as tmp:
224 config_dir = Path(tmp) / "config"
225 synthesis_path = Path(tmp) / "synthesis.md"
226 synthesis_path.write_text(
227 "# Alpha vs Beta\n\nCached comparison body.",
228 encoding="utf-8",
229 )
230 reports = [("Alpha", _report("Alpha")), ("Beta", _report("Beta"))]
231 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir):
232 cli._write_last_run("Alpha vs Beta", reports[0][1], entity_reports=reports)
233
234 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir), \
235 mock.patch.object(cli.env, "get_config", return_value={}), \
236 mock.patch.object(cli.pipeline, "diagnose", return_value=_diag()), \
237 mock.patch.object(cli.pipeline, "run", side_effect=AssertionError("pipeline should not run")), \
238 mock.patch.object(sys, "argv", [
239 "last30days.py",
240 "Alpha",
241 "vs",
242 "Beta",
243 "--emit=html",
244 "--synthesis-file",
245 str(synthesis_path),
246 ]), \
247 mock.patch.dict(os.environ, {"LAST30DAYS_SKIP_PREFLIGHT": "1"}, clear=False):
248 stdout = io.StringIO()
249 stderr = io.StringIO()
250 with redirect_stdout(stdout), redirect_stderr(stderr):
251 rc = cli.main()
252
253 self.assertEqual(0, rc)
254 self.assertIn("Cached comparison body.", stdout.getvalue())
255 self.assertIn("last30days · Alpha vs Beta", stdout.getvalue())
256 self.assertIn("Reusing cached report data", stderr.getvalue())
257
258 def test_html_synthesis_warns_and_falls_back_when_cache_topic_misses(self):
259 with tempfile.TemporaryDirectory() as tmp:
260 config_dir = Path(tmp) / "config"
261 synthesis_path = Path(tmp) / "synthesis.md"
262 synthesis_path.write_text("Cached synthesis body.", encoding="utf-8")
263 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir):
264 cli._write_last_run("OpenClaw", _report("OpenClaw"))
265
266 fresh_report = _report("Different Topic")
267 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir), \
268 mock.patch.object(cli.env, "get_config", return_value={}), \
269 mock.patch.object(cli.pipeline, "diagnose", return_value=_diag()), \
270 mock.patch.object(cli.pipeline, "run", return_value=fresh_report) as run_mock, \
271 mock.patch.object(cli.ui, "ProgressDisplay"), \
272 mock.patch.object(sys, "argv", [
273 "last30days.py",
274 "Different",
275 "Topic",
276 "--emit=html",
277 "--synthesis-file",
278 str(synthesis_path),
279 ]), \
280 mock.patch.dict(os.environ, {"LAST30DAYS_SKIP_PREFLIGHT": "1"}, clear=False):
281 stdout = io.StringIO()
282 stderr = io.StringIO()
283 with redirect_stdout(stdout), redirect_stderr(stderr):
284 rc = cli.main()
285
286 self.assertEqual(0, rc)
287 self.assertTrue(run_mock.called)
288 self.assertIn("No matching cached report data", stderr.getvalue())
289 self.assertIn("Cached synthesis body.", stdout.getvalue())
290
291 def test_html_synthesis_falls_back_when_cache_is_stale(self):
292 with tempfile.TemporaryDirectory() as tmp:
293 config_dir = Path(tmp) / "config"
294 synthesis_path = Path(tmp) / "synthesis.md"
295 synthesis_path.write_text("Cached synthesis body.", encoding="utf-8")
296 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir):
297 cli._write_last_run("OpenClaw", _report("OpenClaw"))
298 cache_path = config_dir / "last-report.json"
299 payload = json.loads(cache_path.read_text(encoding="utf-8"))
300 payload["timestamp"] = "2026-01-01T00:00:00+00:00"
301 cache_path.write_text(json.dumps(payload), encoding="utf-8")
302
303 fresh_report = _report("OpenClaw")
304 with mock.patch.object(cli.env, "CONFIG_DIR", config_dir), \
305 mock.patch.object(cli.env, "get_config", return_value={}), \
306 mock.patch.object(cli.pipeline, "diagnose", return_value=_diag()), \
307 mock.patch.object(cli.pipeline, "run", return_value=fresh_report) as run_mock, \
308 mock.patch.object(cli.ui, "ProgressDisplay"), \
309 mock.patch.object(sys, "argv", [
310 "last30days.py",
311 "OpenClaw",
312 "--emit=html",
313 "--synthesis-file",
314 str(synthesis_path),
315 ]), \
316 mock.patch.dict(os.environ, {"LAST30DAYS_SKIP_PREFLIGHT": "1"}, clear=False):
317 stdout = io.StringIO()
318 stderr = io.StringIO()
319 with redirect_stdout(stdout), redirect_stderr(stderr):
320 rc = cli.main()
321
322 self.assertEqual(0, rc)
323 self.assertTrue(run_mock.called)
324 self.assertIn("No matching cached report data", stderr.getvalue())
325 self.assertIn("Cached synthesis body.", stdout.getvalue())
326
327
328 class TestSkillMdFirstRunReference(unittest.TestCase):
329 """Verifies SKILL.md references that exist in the CLI."""
330
331 def test_nux_wizard_not_referenced(self):
332 content = SKILL_MD.read_text(encoding="utf-8")
333 self.assertNotIn(
334 "nux-wizard.md", content,
335 "SKILL.md should not reference the missing nux-wizard.md file",
336 )
337
338 def test_skill_md_references_setup_command(self):
339 content = SKILL_MD.read_text(encoding="utf-8")
340 self.assertIn(
341 "last30days.py setup", content,
342 "SKILL.md should reference the Python setup subcommand",
343 )
344
345 def test_setup_subcommand_dispatches(self):
346 """topic 'setup' must reach setup_wizard, not be swallowed by argparse."""
347 with mock.patch.object(cli.env, "get_config", return_value={}), \
348 mock.patch("lib.setup_wizard.run_auto_setup", return_value={"cookies_found": {}}) as mock_setup, \
349 mock.patch("lib.setup_wizard.write_setup_config") as mock_write, \
350 mock.patch("lib.setup_wizard.get_setup_status_text", return_value="ok"), \
351 mock.patch.object(sys, "argv", ["last30days.py", "setup"]):
352 stderr = io.StringIO()
353 with redirect_stderr(stderr):
354 rc = cli.main()
355 self.assertEqual(0, rc)
356 mock_setup.assert_called_once()
357 mock_write.assert_called_once()
358
359
360
361 if __name__ == "__main__":
362 unittest.main()
363
363 lines PYTHON