145 lines
4.8 KiB
Python
145 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# ///
|
|
"""Tests for recon_kit.py."""
|
|
|
|
import io
|
|
import json
|
|
import sys
|
|
import unittest
|
|
from contextlib import redirect_stdout
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from recon_kit import add_months, main, parse_date, slugify
|
|
|
|
REPORT = """---
|
|
title: 'market research: widgets'
|
|
---
|
|
|
|
# Report
|
|
|
|
The market is growing fast [1] and pricing clusters at $20 [2].
|
|
An uncited assertion sits here [4].
|
|
|
|
```
|
|
[9] inside a fence never counts
|
|
```
|
|
|
|
## Source appendix
|
|
|
|
| [n] | Supports | Publisher | Pub date | Accessed | Confidence |
|
|
| --- | --- | --- | --- | --- | --- |
|
|
| [1] | market growth | [Gartner](https://example.com/g) | 2026-01 | 2026-07-01 | high |
|
|
| [2] | pricing | [Acme](javascript:alert(1)) | 2026-05 | 2026-07-01 | medium |
|
|
| [3] | never cited | https://example.com/x | 2025-11 | 2026-07-01 | low |
|
|
"""
|
|
|
|
MEMLOG = """---
|
|
topic: widgets
|
|
updated: 2026-07-22T10:00
|
|
---
|
|
|
|
- (decision) plan approved
|
|
- (source) round 1 batch: 4 sources
|
|
- (claim) ref=[1] status=verified class=size/growth pub=2026-01 — market growing 12% CAGR
|
|
- (claim) ref=[2] status=unverified class=pricing pub=2026-05 — pricing clusters at $20
|
|
- (claim) ref=[2] status=verified class=pricing pub=2026-05 — confirmed by second source
|
|
- (claim) status=unverified class=behavior pub=2025-03 — users churn at day 8
|
|
- (event) dimension 1 complete
|
|
"""
|
|
|
|
|
|
def run(argv):
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
code = main(argv)
|
|
return code, json.loads(buf.getvalue())
|
|
|
|
|
|
class CitationsTest(unittest.TestCase):
|
|
def test_cross_check(self):
|
|
report = Path(__file__).parent / "_report.md"
|
|
report.write_text(REPORT, encoding="utf-8")
|
|
try:
|
|
code, result = run(["citations", str(report)])
|
|
finally:
|
|
report.unlink()
|
|
self.assertEqual(result["dangling_markers"], [4])
|
|
self.assertEqual(result["orphaned_rows"], [3])
|
|
self.assertNotIn(9, result["markers"]) # fenced content ignored
|
|
self.assertEqual(code, 1)
|
|
|
|
|
|
class TallyTest(unittest.TestCase):
|
|
def test_last_status_wins_per_ref(self):
|
|
log = Path(__file__).parent / "_memlog.md"
|
|
log.write_text(MEMLOG, encoding="utf-8")
|
|
try:
|
|
code, result = run(["tally", str(log)])
|
|
finally:
|
|
log.unlink()
|
|
self.assertEqual(result["by_type"]["claim"], 4)
|
|
self.assertEqual(result["claims"], {"unverified": 1, "verified": 2})
|
|
self.assertEqual(result["claims_total"], 3) # ref=[2] counted once
|
|
self.assertEqual(code, 0)
|
|
|
|
|
|
class StalenessTest(unittest.TestCase):
|
|
def test_dates(self):
|
|
self.assertEqual(parse_date("2026-01"), date(2026, 1, 1))
|
|
self.assertEqual(add_months(date(2026, 1, 31), 1), date(2026, 2, 28))
|
|
|
|
def test_windows(self):
|
|
claims = json.dumps([
|
|
{"claim": "sizing", "class": "size/growth", "pub_date": "2024-06"},
|
|
{"claim": "pricing", "class": "pricing", "pub_date": "2026-06"},
|
|
{"claim": "odd", "class": "unmapped", "pub_date": "2026-06"},
|
|
])
|
|
f = Path(__file__).parent / "_claims.json"
|
|
f.write_text(claims, encoding="utf-8")
|
|
try:
|
|
code, result = run([
|
|
"staleness", str(f),
|
|
"--windows", '{"size/growth": 18, "pricing": 3}',
|
|
"--today", "2026-07-22",
|
|
])
|
|
finally:
|
|
f.unlink()
|
|
self.assertEqual(result["stale_count"], 1) # sizing recheck 2025-12 < today
|
|
self.assertEqual(result["earliest_recheck"], "2025-12-01")
|
|
self.assertEqual(result["no_window_classes"], ["unmapped"])
|
|
self.assertEqual(code, 1)
|
|
|
|
|
|
class SlugTest(unittest.TestCase):
|
|
def test_deterministic_folder(self):
|
|
self.assertEqual(slugify("Créme Brûlée: AI Tools!"), "creme-brulee-ai-tools")
|
|
code, result = run(["slug", "SMB Accounting SaaS", "--type", "market",
|
|
"--date", "2026-07-22"])
|
|
self.assertEqual(result["folder"], "market-smb-accounting-saas-2026-07-22")
|
|
self.assertEqual(code, 0)
|
|
|
|
|
|
class EscapeSourcesTest(unittest.TestCase):
|
|
def test_escaping_and_url_validation(self):
|
|
report = Path(__file__).parent / "_report.md"
|
|
report.write_text(REPORT, encoding="utf-8")
|
|
try:
|
|
code, result = run(["escape-sources", str(report)])
|
|
finally:
|
|
report.unlink()
|
|
self.assertEqual(result["rows"], 3)
|
|
self.assertTrue(any(u.startswith("javascript:") for u in result["invalid_urls"]))
|
|
self.assertNotIn("javascript:", result["html"]) # never linked
|
|
self.assertIn('href="https://example.com/g"', result["html"])
|
|
self.assertIn('id="src-1"', result["html"])
|
|
self.assertEqual(code, 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|