223 lines
8.5 KiB
Python
223 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# ///
|
|
"""Tests for recon_kit.py."""
|
|
|
|
import io
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stderr, 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):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
report = Path(tmp) / "report.md"
|
|
report.write_text(REPORT, encoding="utf-8")
|
|
code, result = run(["citations", str(report)])
|
|
self.assertEqual(result["dangling_markers"], [4])
|
|
self.assertEqual(result["orphaned_rows"], [3])
|
|
self.assertNotIn(9, result["markers"]) # fenced content ignored
|
|
self.assertEqual(code, 1)
|
|
|
|
|
|
def run_text(argv, text, name="report.md"):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
f = Path(tmp) / name
|
|
f.write_text(text, encoding="utf-8")
|
|
return run([argv[0], str(f), *argv[1:]])
|
|
|
|
|
|
class CitationsEdgeTest(unittest.TestCase):
|
|
def test_a_bare_number_table_is_not_the_source_appendix(self):
|
|
text = (
|
|
"Growth is fast [1].\n\n| Rank | Vendor |\n| --- | --- |\n| 1 | Acme |\n| 2 | Beta |\n| 3 | Gamma |\n\n"
|
|
"| [n] | Supports |\n| --- | --- |\n| [1] | growth |\n"
|
|
)
|
|
code, result = run_text(["citations"], text)
|
|
self.assertEqual(result["appendix_rows"], [1])
|
|
self.assertTrue(result["ok"])
|
|
self.assertEqual(code, 0)
|
|
|
|
def test_tilde_and_long_backtick_fences(self):
|
|
text = (
|
|
"~~~\n[7] tilde fenced\n~~~\n\n"
|
|
"````\n```\n[8] still fenced after an inner three-backtick line\n```\n````\n\n"
|
|
"Read after the close [1].\n\n| [n] | Supports |\n| --- | --- |\n| [1] | x |\n"
|
|
)
|
|
code, result = run_text(["citations"], text)
|
|
self.assertEqual(result["markers"], [1])
|
|
self.assertTrue(result["ok"])
|
|
|
|
def test_a_fence_nested_in_a_list_item_hides_its_markers(self):
|
|
text = (
|
|
"- Example:\n\n ```\n [5] inside a list-nested fence\n ```\n\n"
|
|
"Cited [1].\n\n| [n] | Supports |\n| --- | --- |\n| [1] | x |\n"
|
|
)
|
|
code, result = run_text(["citations"], text)
|
|
self.assertEqual(result["markers"], [1])
|
|
self.assertTrue(result["ok"])
|
|
|
|
|
|
class TallyTest(unittest.TestCase):
|
|
def test_last_status_wins_per_ref(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
log = Path(tmp) / "memlog.md"
|
|
log.write_text(MEMLOG, encoding="utf-8")
|
|
code, result = run(["tally", str(log)])
|
|
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"},
|
|
]
|
|
)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
f = Path(tmp) / "claims.json"
|
|
f.write_text(claims, encoding="utf-8")
|
|
code, result = run(
|
|
[
|
|
"staleness",
|
|
str(f),
|
|
"--windows",
|
|
'{"size/growth": 18, "pricing": 3}',
|
|
"--today",
|
|
"2026-07-22",
|
|
]
|
|
)
|
|
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)
|
|
|
|
def test_malformed_claims_exit_2(self):
|
|
for payload in ('{"x": 1}', '{"claims": [5]}'):
|
|
err = io.StringIO()
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
f = Path(tmp) / "claims.json"
|
|
f.write_text(payload, encoding="utf-8")
|
|
with redirect_stderr(err), redirect_stdout(io.StringIO()):
|
|
code = main(["staleness", str(f), "--windows", '{"pricing": 3}'])
|
|
self.assertEqual(code, 2, payload)
|
|
self.assertTrue(err.getvalue().startswith("error: "), payload)
|
|
|
|
def test_stdin_is_read_as_utf8_whatever_the_locale(self):
|
|
claims = json.dumps([{"claim": "café prices", "class": "pricing", "pub_date": "2026-06"}], ensure_ascii=False)
|
|
stdin = io.TextIOWrapper(io.BytesIO(claims.encode("utf-8")), encoding="latin-1")
|
|
original = sys.stdin
|
|
sys.stdin = stdin
|
|
try:
|
|
code, result = run(["staleness", "-", "--windows", '{"pricing": 3}', "--today", "2026-07-01"])
|
|
finally:
|
|
sys.stdin = original
|
|
self.assertEqual(result["claims"][0]["claim"], "café prices")
|
|
self.assertEqual(code, 0)
|
|
|
|
|
|
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"])
|
|
self.assertEqual(result["folder"], "research-smb-accounting-saas")
|
|
self.assertEqual(code, 0)
|
|
pattern = "{research_type}-{topic_slug}-{date}"
|
|
code, result = run(
|
|
["slug", "SMB Accounting SaaS", "--type", "market", "--date", "2026-07-22", "--pattern", pattern]
|
|
)
|
|
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):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
report = Path(tmp) / "report.md"
|
|
report.write_text(REPORT, encoding="utf-8")
|
|
code, result = run(["escape-sources", str(report)])
|
|
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)
|
|
|
|
def test_urls_keep_balanced_parentheses(self):
|
|
text = (
|
|
"| [n] | Supports | Publisher |\n| --- | --- | --- |\n"
|
|
"| [1] | a | [Rust](https://en.wikipedia.org/wiki/Rust_(programming_language)) |\n"
|
|
"| [2] | b | https://en.wikipedia.org/wiki/Go_(programming_language) |\n"
|
|
)
|
|
code, result = run_text(["escape-sources"], text)
|
|
self.assertIn('href="https://en.wikipedia.org/wiki/Rust_(programming_language)"', result["html"])
|
|
self.assertIn('href="https://en.wikipedia.org/wiki/Go_(programming_language)"', result["html"])
|
|
self.assertEqual(result["invalid_urls"], [])
|
|
self.assertEqual(code, 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|