Files
김경종 72dad72703
Tests / Hermetic test suite (push) Has been cancelled
Tests / Skill frontmatter validation (push) Has been cancelled
add claude-obsidian
2026-05-28 10:57:16 +09:00

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