| 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 |