返回 last30days-skill
test_slug_filename_length.py
根目录 / tests / test_slug_filename_length.py
1 # ruff: noqa: E402
2 """Tests for slug truncation on long topics.
3
4 `save_output()` slugifies the entire query/topic string into the save
5 filename with no truncation. On macOS (and most filesystems), filenames
6 are capped at 255 bytes; a topic longer than ~230 characters produced a
7 slug that exceeded that limit, and `Path.write_text()` raised
8 `OSError: [Errno 63] File name too long` *after* research had already
9 completed, discarding the gathered results.
10
11 `slugify()` now truncates long slugs to a safe length and appends a short
12 hash of the full value so distinct long topics still map to distinct,
13 deterministic filenames.
14 """
15
16 from __future__ import annotations
17
18 import importlib.util
19 import os
20 import shutil
21 import subprocess
22 import sys
23 import tempfile
24 import unittest
25 from pathlib import Path
26
27 REPO_ROOT = Path(__file__).resolve().parents[1]
28
29
30 def _engine_path() -> Path:
31 return REPO_ROOT / "skills" / "last30days" / "scripts" / "last30days.py"
32
33
34 def _load_engine_module():
35 spec = importlib.util.spec_from_file_location("last30days_engine", _engine_path())
36 module = importlib.util.module_from_spec(spec)
37 sys.modules[spec.name] = module
38 try:
39 spec.loader.exec_module(module)
40 except SystemExit:
41 pass
42 return module
43
44
45 class SlugifyUnitTests(unittest.TestCase):
46 """Pin the truncation/hash contract directly on `slugify()`."""
47
48 @classmethod
49 def setUpClass(cls) -> None:
50 cls.engine = _load_engine_module()
51
52 def test_short_topic_is_unchanged(self) -> None:
53 self.assertEqual(self.engine.slugify("OpenAI"), "openai")
54 self.assertEqual(self.engine.slugify("hello world"), "hello-world")
55
56 def test_long_topic_slug_fits_filesystem_limit(self) -> None:
57 long_topic = "multi model comparison " * 25 # ~575 chars
58 slug = self.engine.slugify(long_topic)
59 filename = f"{slug}-raw.md"
60 self.assertLess(
61 len(filename.encode("utf-8")), 255,
62 msg=f"Filename {len(filename.encode('utf-8'))} bytes — exceeds macOS 255-byte limit",
63 )
64
65 def test_long_topic_slug_is_deterministic(self) -> None:
66 long_topic = "multi model comparison " * 25
67 self.assertEqual(self.engine.slugify(long_topic), self.engine.slugify(long_topic))
68
69 def test_distinct_long_topics_produce_distinct_slugs(self) -> None:
70 base = "multi model comparison " * 25
71 self.assertNotEqual(self.engine.slugify(base), self.engine.slugify(base + "extra"))
72
73 def test_long_slug_actually_writable_on_disk(self) -> None:
74 long_topic = "multi model comparison " * 25
75 slug = self.engine.slugify(long_topic)
76 with tempfile.TemporaryDirectory() as tmp:
77 path = Path(tmp) / f"{slug}-raw.md"
78 path.write_text("test", encoding="utf-8")
79 self.assertTrue(path.exists())
80
81
82 class SaveOutputLongTopicIntegrationTests(unittest.TestCase):
83 """End-to-end: a long topic must not crash the save path after research completes."""
84
85 def setUp(self) -> None:
86 self.tmp = Path(tempfile.mkdtemp(prefix="l30d-slug-length-"))
87
88 def tearDown(self) -> None:
89 shutil.rmtree(self.tmp, ignore_errors=True)
90
91 def test_long_topic_saves_without_oserror(self) -> None:
92 long_topic = "multi model comparison across providers and pricing tiers " * 8 # ~480 chars
93 cmd = [
94 sys.executable,
95 str(_engine_path()),
96 long_topic,
97 "--mock",
98 "--emit=md",
99 "--save-dir",
100 str(self.tmp),
101 ]
102 env = {**os.environ, "LAST30DAYS_SKIP_PREFLIGHT": "1"}
103 result = subprocess.run(
104 cmd,
105 capture_output=True,
106 text=True,
107 env=env,
108 encoding="utf-8",
109 errors="replace",
110 check=False,
111 )
112 self.assertEqual(result.returncode, 0, msg=result.stderr)
113 self.assertNotIn("File name too long", result.stderr)
114 files = sorted(self.tmp.glob("*.md"))
115 self.assertGreaterEqual(
116 len(files), 1,
117 msg=f"No file saved for long topic. stderr: {result.stderr}",
118 )
119
120
121 if __name__ == "__main__":
122 unittest.main()
123
123 lines PYTHON