350 lines
16 KiB
Python
350 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""test_wiki_mode.py — hermetic tests for scripts/wiki-mode.py.
|
|
|
|
Covers config load/save round-trip, all 4 modes' routing, slugification, ID
|
|
minting, and the default-to-generic fallback when .vault-meta/mode.json is
|
|
absent. No network, no LLM, no ollama. Pure stdlib + subprocess.
|
|
|
|
Usage:
|
|
python3 tests/test_wiki_mode.py
|
|
"""
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
HELPER = ROOT / "scripts" / "wiki-mode.py"
|
|
|
|
spec = importlib.util.spec_from_file_location("wiki_mode", HELPER)
|
|
wm = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(wm)
|
|
|
|
|
|
class Fail(SystemExit):
|
|
pass
|
|
|
|
|
|
def assert_eq(label, expected, actual):
|
|
if expected != actual:
|
|
raise Fail(f"FAIL {label}: expected {expected!r}, got {actual!r}")
|
|
print(f"OK {label}")
|
|
|
|
|
|
def assert_true(label, cond, hint=""):
|
|
if not cond:
|
|
raise Fail(f"FAIL {label}{(': ' + hint) if hint else ''}")
|
|
print(f"OK {label}")
|
|
|
|
|
|
# ─── Default-to-generic when no config file ──────────────────────────────────
|
|
def test_load_config_defaults_to_generic_when_absent():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
with mock.patch.object(wm, "MODE_PATH", Path(tmp) / "nonexistent.json"):
|
|
cfg = wm.load_config()
|
|
assert_eq("absent config → mode=generic", "generic", cfg["mode"])
|
|
assert_eq("schema_version present", 1, cfg["schema_version"])
|
|
assert_true("all 4 mode configs present",
|
|
set(cfg["config"].keys()) == {"lyt", "para", "zettelkasten", "generic"})
|
|
|
|
|
|
# ─── Config save → load round-trip ───────────────────────────────────────────
|
|
def test_save_load_roundtrip():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
mode_path = Path(tmp) / "mode.json"
|
|
with mock.patch.object(wm, "MODE_PATH", mode_path), \
|
|
mock.patch.object(wm, "META_DIR", Path(tmp)):
|
|
cfg = wm.load_config()
|
|
cfg["mode"] = "lyt"
|
|
cfg["configured_at"] = "2026-05-17T00:00:00Z"
|
|
wm.save_config(cfg)
|
|
assert_true("mode.json written", mode_path.is_file())
|
|
cfg2 = wm.load_config()
|
|
assert_eq("round-trip mode", "lyt", cfg2["mode"])
|
|
assert_eq("round-trip configured_at", "2026-05-17T00:00:00Z", cfg2["configured_at"])
|
|
|
|
|
|
# ─── Corrupted mode.json falls back to generic with warning ──────────────────
|
|
def test_corrupted_config_falls_back_to_generic():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
mode_path = Path(tmp) / "mode.json"
|
|
mode_path.write_text("{ this is not valid json", encoding="utf-8")
|
|
with mock.patch.object(wm, "MODE_PATH", mode_path):
|
|
cfg = wm.load_config()
|
|
assert_eq("corrupted config → mode=generic", "generic", cfg["mode"])
|
|
|
|
|
|
# ─── Mode=generic routing matches v1.7 conventions ──────────────────────────
|
|
def test_generic_routing():
|
|
cfg = dict(wm.DEFAULT_CONFIG)
|
|
cfg["mode"] = "generic"
|
|
assert_eq("generic source",
|
|
"wiki/sources/Karpathy-2025-essay.md",
|
|
wm.route_path("generic", "source", "Karpathy 2025 essay", cfg))
|
|
assert_eq("generic entity preserves case",
|
|
"wiki/entities/Andrej Karpathy.md",
|
|
wm.route_path("generic", "entity", "Andrej Karpathy", cfg))
|
|
assert_eq("generic concept",
|
|
"wiki/concepts/Compounding Vault.md",
|
|
wm.route_path("generic", "concept", "Compounding Vault", cfg))
|
|
assert_eq("generic session",
|
|
"wiki/sessions/v1-8-launch-prep.md",
|
|
wm.route_path("generic", "session", "v1.8 launch prep", cfg))
|
|
|
|
|
|
# ─── Mode=lyt routing: all atomic notes flat under wiki/notes/ ──────────────
|
|
def test_lyt_routing():
|
|
cfg = dict(wm.DEFAULT_CONFIG)
|
|
cfg["mode"] = "lyt"
|
|
src = wm.route_path("lyt", "source", "Karpathy essay", cfg)
|
|
ent = wm.route_path("lyt", "entity", "Andrej Karpathy", cfg)
|
|
con = wm.route_path("lyt", "concept", "Compounding Vault", cfg)
|
|
assert_true("lyt source goes to notes/", src.startswith("wiki/notes/"), hint=src)
|
|
assert_true("lyt entity goes to notes/", ent.startswith("wiki/notes/"), hint=ent)
|
|
assert_true("lyt concept goes to notes/", con.startswith("wiki/notes/"), hint=con)
|
|
|
|
|
|
# ─── Mode=para routing: actionability-based folders ─────────────────────────
|
|
def test_para_routing():
|
|
cfg = dict(wm.DEFAULT_CONFIG)
|
|
cfg["mode"] = "para"
|
|
src = wm.route_path("para", "source", "Karpathy essay", cfg)
|
|
ent = wm.route_path("para", "entity", "Andrej Karpathy", cfg)
|
|
sess = wm.route_path("para", "session", "v1.8 prep", cfg)
|
|
res = wm.route_path("para", "research", "compounding-vault", cfg)
|
|
assert_true("para source → resources/incoming/", src.startswith("wiki/resources/incoming/"), hint=src)
|
|
assert_true("para entity → resources/people/", ent.startswith("wiki/resources/people/"), hint=ent)
|
|
assert_true("para session → projects/inbox/", sess.startswith("wiki/projects/inbox/"), hint=sess)
|
|
assert_true("para research → resources/<topic>/", "wiki/resources/compounding-vault/" in res, hint=res)
|
|
|
|
|
|
# ─── Mode=zettelkasten routing: flat, timestamp-prefixed ────────────────────
|
|
def test_zettelkasten_routing():
|
|
cfg = dict(wm.DEFAULT_CONFIG)
|
|
cfg["mode"] = "zettelkasten"
|
|
p = wm.route_path("zettelkasten", "source", "Karpathy essay", cfg)
|
|
# Format: wiki/<20-digit-timestamp-with-microseconds>-<slug>.md
|
|
assert_true("zettel path starts with wiki/", p.startswith("wiki/"), hint=p)
|
|
assert_true("zettel no subfolders", p.count("/") == 1, hint=p)
|
|
fname = p.rsplit("/", 1)[1]
|
|
parts = fname.split("-", 1)
|
|
# v1.8.1 fix: IDs are 20 digits (YYYYMMDDHHMMSSffffff) for collision resistance
|
|
assert_true("zettel ID is 20 digits", parts[0].isdigit() and len(parts[0]) == 20, hint=fname)
|
|
|
|
|
|
# ─── Zettel ID format ───────────────────────────────────────────────────────
|
|
def test_mint_zettel_id_format():
|
|
zid = wm.mint_zettel_id()
|
|
# 14 (YYYYMMDDHHMMSS) + 6 (microseconds) = 20 digits
|
|
assert_true("zettel ID is 20-digit string", len(zid) == 20 and zid.isdigit(), hint=zid)
|
|
|
|
|
|
def test_mint_zettel_id_collision_resistance():
|
|
"""v1.8.1 fix: rapid back-to-back mint calls produce DIFFERENT IDs.
|
|
Microsecond suffix ensures two calls within the same second are distinct.
|
|
"""
|
|
ids = [wm.mint_zettel_id() for _ in range(10)]
|
|
assert_eq("zettel IDs all distinct (10 rapid calls)", 10, len(set(ids)))
|
|
|
|
|
|
def test_slugify_extended_unicode():
|
|
"""v1.8.1 fix: explicit test coverage for CJK + Cyrillic (verifier LOW).
|
|
The slugify function preserves any Unicode word character; only ASCII
|
|
punctuation and emoji get stripped/converted.
|
|
"""
|
|
assert_eq("CJK preserved", "日本語の文書", wm.slugify("日本語の文書"))
|
|
assert_eq("Cyrillic with space", "Привет-мир", wm.slugify("Привет мир"))
|
|
assert_eq("Mixed scripts", "Hello-мир-café", wm.slugify("Hello мир café"))
|
|
# Emoji is stripped (not in \w); surrounding text joined by single hyphen
|
|
assert_eq("Emoji becomes single hyphen between words", "Test-emoji",
|
|
wm.slugify("Test 🎉 emoji"))
|
|
|
|
|
|
# ─── Slugify handles unicode + special chars ────────────────────────────────
|
|
def test_slugify():
|
|
# Case is PRESERVED to match v1.7 entity/concept filing conventions.
|
|
assert_eq("ascii slug", "Karpathy-2025-essay", wm.slugify("Karpathy 2025 essay"))
|
|
assert_eq("unicode preserved", "café-résumé", wm.slugify("café résumé"))
|
|
# Periods become hyphens (so v1.7 → v1-7, not v17)
|
|
assert_eq("dots become hyphens", "v1-7-launch-prep", wm.slugify("v1.7 launch! prep?"))
|
|
assert_eq("empty → 'untitled'", "untitled", wm.slugify(""))
|
|
|
|
|
|
# ─── Path-traversal hardening (v1.8.2): entity/concept names cannot escape ──
|
|
def test_safe_name_strips_path_separators():
|
|
"""v1.8.2 fix: names that intentionally preserve case (entity, concept)
|
|
must not allow path traversal via '../', leading '/', backslashes, NULs,
|
|
or control characters. Spaces and case are still preserved.
|
|
"""
|
|
assert_eq("traversal '../' stripped", "etcpasswd", wm.safe_name("../../../etc/passwd"))
|
|
assert_eq("leading '/' stripped", "etcpasswd", wm.safe_name("/etc/passwd"))
|
|
assert_eq("backslash stripped", "etcpasswd", wm.safe_name("..\\..\\etc\\passwd"))
|
|
assert_eq("NUL stripped", "foobar", wm.safe_name("foo\x00bar"))
|
|
assert_eq("control chars stripped", "foobar", wm.safe_name("foo\x01\x02bar"))
|
|
assert_eq("leading dot stripped (no hidden files)", "hidden", wm.safe_name(".hidden"))
|
|
assert_eq("leading hyphen stripped (no flag escapes)", "flag", wm.safe_name("-flag"))
|
|
assert_eq("spaces + case preserved", "Andrej Karpathy", wm.safe_name("Andrej Karpathy"))
|
|
assert_eq("empty after strip → 'untitled'", "untitled", wm.safe_name("/"))
|
|
|
|
|
|
def test_route_path_blocks_traversal_for_generic_entity_and_concept():
|
|
"""The end-to-end route must not allow the returned path to escape vault root."""
|
|
import os
|
|
cfg = dict(wm.DEFAULT_CONFIG); cfg["mode"] = "generic"
|
|
vault = os.path.abspath(".")
|
|
for content_type, malicious in [
|
|
("entity", "../../../etc/passwd"),
|
|
("concept", "/etc/passwd"),
|
|
("entity", "..\\..\\..\\Windows\\System32"),
|
|
("research","../escape"),
|
|
]:
|
|
p = wm.route_path("generic", content_type, malicious, cfg)
|
|
abs_p = os.path.abspath(p)
|
|
assert_true(f"generic {content_type}({malicious!r}) stays inside vault",
|
|
abs_p.startswith(vault + os.sep), hint=f"got {abs_p}")
|
|
|
|
|
|
def test_route_path_blocks_traversal_for_para_entity_and_concept():
|
|
import os
|
|
cfg = dict(wm.DEFAULT_CONFIG); cfg["mode"] = "para"
|
|
vault = os.path.abspath(".")
|
|
for content_type, malicious in [
|
|
("entity", "../../../etc/passwd"),
|
|
("concept", "/etc/shadow"),
|
|
]:
|
|
p = wm.route_path("para", content_type, malicious, cfg)
|
|
abs_p = os.path.abspath(p)
|
|
assert_true(f"para {content_type}({malicious!r}) stays inside vault",
|
|
abs_p.startswith(vault + os.sep), hint=f"got {abs_p}")
|
|
|
|
|
|
# ─── CLI --mode preview override (v1.8.2) ───────────────────────────────────
|
|
def test_cli_route_mode_override_previews_without_writing():
|
|
"""`route --mode lyt source X` must return an lyt path even when current
|
|
mode is generic, and must NOT modify .vault-meta/mode.json."""
|
|
before = subprocess.run([sys.executable, str(HELPER), "get"],
|
|
capture_output=True, text=True, timeout=5).stdout.strip()
|
|
result = subprocess.run(
|
|
[sys.executable, str(HELPER), "route", "--mode", "lyt", "source", "Preview Test"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
assert_eq("cli route --mode rc=0", 0, result.returncode)
|
|
path = result.stdout.strip()
|
|
assert_true("preview returns lyt notes/ path",
|
|
path.startswith("wiki/notes/"), hint=path)
|
|
after = subprocess.run([sys.executable, str(HELPER), "get"],
|
|
capture_output=True, text=True, timeout=5).stdout.strip()
|
|
assert_eq("current mode unchanged by preview", before, after)
|
|
|
|
|
|
def test_cli_route_mode_override_rejects_invalid():
|
|
result = subprocess.run(
|
|
[sys.executable, str(HELPER), "route", "--mode", "bogus", "source", "X"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
assert_true("preview rejects bogus mode", result.returncode != 0,
|
|
hint=f"rc={result.returncode}")
|
|
|
|
|
|
# ─── Invalid content type raises ───────────────────────────────────────────
|
|
def test_invalid_content_type_raises():
|
|
cfg = dict(wm.DEFAULT_CONFIG)
|
|
try:
|
|
wm.route_path("generic", "garbage", "x", cfg)
|
|
raise Fail("expected SystemExit(4) for invalid type")
|
|
except SystemExit as e:
|
|
assert_eq("invalid type → exit 4", 4, e.code)
|
|
|
|
|
|
# ─── CLI subprocess: `wiki-mode.py get` returns mode string ─────────────────
|
|
def test_cli_get_returns_mode():
|
|
"""End-to-end CLI test via subprocess; uses the actual vault's mode (or generic if absent)."""
|
|
result = subprocess.run(
|
|
[sys.executable, str(HELPER), "get"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
assert_eq("cli get rc=0", 0, result.returncode)
|
|
mode = result.stdout.strip()
|
|
assert_true("cli get returns one of 4 modes",
|
|
mode in ("generic", "lyt", "para", "zettelkasten"), hint=mode)
|
|
|
|
|
|
# ─── CLI subprocess: `wiki-mode.py id` returns 14-digit timestamp ───────────
|
|
def test_cli_id_returns_timestamp():
|
|
result = subprocess.run(
|
|
[sys.executable, str(HELPER), "id"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
assert_eq("cli id rc=0", 0, result.returncode)
|
|
zid = result.stdout.strip()
|
|
assert_true("cli id is 20-digit", len(zid) == 20 and zid.isdigit(), hint=zid)
|
|
|
|
|
|
# ─── CLI subprocess: `wiki-mode.py route source NAME` returns a path ────────
|
|
def test_cli_route_returns_path():
|
|
result = subprocess.run(
|
|
[sys.executable, str(HELPER), "route", "source", "Test Source"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
assert_eq("cli route rc=0", 0, result.returncode)
|
|
path = result.stdout.strip()
|
|
assert_true("cli route returns wiki-rooted path",
|
|
path.startswith("wiki/"), hint=path)
|
|
assert_true("cli route returns .md path", path.endswith(".md"), hint=path)
|
|
|
|
|
|
# ─── CLI subprocess: invalid mode rejected ──────────────────────────────────
|
|
def test_cli_set_rejects_invalid_mode():
|
|
result = subprocess.run(
|
|
[sys.executable, str(HELPER), "set", "bogus"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
assert_true("cli set rejects invalid mode", result.returncode != 0,
|
|
hint=f"rc={result.returncode}")
|
|
|
|
|
|
# ─── CLI subprocess: templates listing returns all 6 ───────────────────────
|
|
def test_cli_templates_lists_six():
|
|
result = subprocess.run(
|
|
[sys.executable, str(HELPER), "templates"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
assert_eq("cli templates rc=0", 0, result.returncode)
|
|
lines = [l for l in result.stdout.strip().split("\n") if l]
|
|
assert_eq("cli templates returns 6 paths", 6, len(lines))
|
|
|
|
|
|
def main():
|
|
print("=== test_wiki_mode.py ===")
|
|
test_load_config_defaults_to_generic_when_absent()
|
|
test_save_load_roundtrip()
|
|
test_corrupted_config_falls_back_to_generic()
|
|
test_generic_routing()
|
|
test_lyt_routing()
|
|
test_para_routing()
|
|
test_zettelkasten_routing()
|
|
test_mint_zettel_id_format()
|
|
test_mint_zettel_id_collision_resistance()
|
|
test_slugify()
|
|
test_slugify_extended_unicode()
|
|
test_safe_name_strips_path_separators()
|
|
test_route_path_blocks_traversal_for_generic_entity_and_concept()
|
|
test_route_path_blocks_traversal_for_para_entity_and_concept()
|
|
test_cli_route_mode_override_previews_without_writing()
|
|
test_cli_route_mode_override_rejects_invalid()
|
|
test_invalid_content_type_raises()
|
|
test_cli_get_returns_mode()
|
|
test_cli_id_returns_timestamp()
|
|
test_cli_route_returns_path()
|
|
test_cli_set_rejects_invalid_mode()
|
|
test_cli_templates_lists_six()
|
|
print("\nAll wiki-mode tests passed.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|