Selectively port source hashing, locked manifest/memory writes, path-aware link lint and graph analysis, and revision-checked staging from upstream 4f457e154653756b04802d5aeb25c20f42b4770c. Record source hashes and exclusions. Preserve local archive/catalog citations, curated manifest metadata, config location, file-index and MCP behavior. Wire maintenance guidance into four skills without migrating vault contents or changing running services. Validation: 609 tests passed, including existing MCP/file-asset regressions and imported upstream maintenance tests. Read-only vault status left shared metadata hashes unchanged. Wiki MCP remains inactive; orders MCP is active.
173 lines
5.9 KiB
Python
173 lines
5.9 KiB
Python
"""Tests for bi-temporal page validity."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from obsidian_wiki.lint import lint_vault
|
|
from obsidian_wiki.temporal import (
|
|
is_current,
|
|
parse_date,
|
|
superseded_target,
|
|
validity_window,
|
|
)
|
|
|
|
|
|
def _page(vault: Path, relpath: str, *, extra: str = "", summary: str = "A page.") -> Path:
|
|
path = vault / relpath
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
lines = [
|
|
"---",
|
|
f"title: {path.stem}",
|
|
"category: references",
|
|
"tags: [test]",
|
|
"sources: [manual]",
|
|
"created: 2026-01-01",
|
|
"updated: 2026-01-01",
|
|
f"summary: {summary}",
|
|
]
|
|
if extra:
|
|
lines.append(extra)
|
|
lines += ["---", f"# {path.stem}"]
|
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return path
|
|
|
|
|
|
# --- parsing ---------------------------------------------------------------
|
|
|
|
def test_parse_date_accepts_dates_timestamps_and_empty() -> None:
|
|
assert parse_date("2026-04-01") == date(2026, 4, 1)
|
|
assert parse_date("2026-04-01T09:30:00+00:00") == date(2026, 4, 1)
|
|
assert parse_date("2026-04-01T09:30:00Z") == date(2026, 4, 1)
|
|
assert parse_date(' "2026-04-01" ') == date(2026, 4, 1)
|
|
assert parse_date("") is None
|
|
assert parse_date(None) is None
|
|
|
|
|
|
def test_parse_date_rejects_nonsense() -> None:
|
|
with pytest.raises(ValueError):
|
|
parse_date("last tuesday")
|
|
|
|
|
|
def test_validity_window_rejects_inverted_window() -> None:
|
|
with pytest.raises(ValueError, match="precedes valid_from"):
|
|
validity_window({"valid_from": "2026-05-01", "valid_until": "2026-04-01"})
|
|
|
|
|
|
def test_superseded_target_unwraps_wikilinks_aliases_and_paths() -> None:
|
|
assert superseded_target("[[gateway-envoy]]") == "gateway-envoy"
|
|
assert superseded_target('"[[references/gateway-envoy.md|Envoy]]"') == "gateway-envoy"
|
|
assert superseded_target("[[gateway-envoy#Routes]]") == "gateway-envoy"
|
|
assert superseded_target("") == ""
|
|
|
|
|
|
# --- currency --------------------------------------------------------------
|
|
|
|
def test_page_without_temporal_fields_is_always_current() -> None:
|
|
assert is_current({}, date(1999, 1, 1)) is True
|
|
|
|
|
|
def test_valid_until_is_inclusive_and_valid_from_is_a_floor() -> None:
|
|
window = {"valid_from": "2026-04-01", "valid_until": "2026-06-30"}
|
|
assert is_current(window, date(2026, 3, 31)) is False # not yet true
|
|
assert is_current(window, date(2026, 4, 1)) is True
|
|
assert is_current(window, date(2026, 6, 30)) is True # last day counts
|
|
assert is_current(window, date(2026, 7, 1)) is False # no longer true
|
|
|
|
|
|
def test_malformed_dates_count_as_current_so_retrieval_never_silently_drops() -> None:
|
|
assert is_current({"valid_until": "whenever"}, date(2026, 6, 1)) is True
|
|
|
|
|
|
# --- retrieval -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- lint ------------------------------------------------------------------
|
|
|
|
def test_lint_fails_on_malformed_temporal_metadata(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "references/inverted.md",
|
|
extra="valid_from: 2026-05-01\nvalid_until: 2026-04-01")
|
|
_page(vault, "references/garbage.md", extra="valid_until: soon")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
issues = {item["page"] for item in report["findings"]["temporal_errors"]}
|
|
assert issues == {"references/inverted.md", "references/garbage.md"}
|
|
assert report["status"] == "fail"
|
|
|
|
|
|
def test_lint_reports_dangling_superseded_by(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "references/old.md", extra="superseded_by: \"[[does-not-exist]]\"")
|
|
_page(vault, "references/self.md", extra="superseded_by: \"[[self]]\"")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["temporal_errors"] == []
|
|
assert report["findings"]["superseded_dangling"] == [
|
|
{"page": "references/old.md", "target": "does-not-exist", "issue": "missing_target"},
|
|
{"page": "references/self.md", "target": "self", "issue": "self_reference"},
|
|
]
|
|
# A bracketed value is a wikilink like any other, so the pre-existing
|
|
# broken_links check fires too and the vault fails — same as a typed
|
|
# `relationships:` target pointing at nothing.
|
|
assert report["findings"]["broken_links"] == [
|
|
{"page": "references/old.md", "target": "does-not-exist"}
|
|
]
|
|
assert report["status"] == "fail"
|
|
|
|
|
|
def test_lint_warns_on_a_dangling_unbracketed_superseded_by(tmp_path: Path) -> None:
|
|
"""Written as a plain name it isn't a wikilink, so only the specific
|
|
finding fires and the vault warns rather than fails."""
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "references/old.md", extra="superseded_by: does-not-exist")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["broken_links"] == []
|
|
assert report["findings"]["superseded_dangling"] == [
|
|
{"page": "references/old.md", "target": "does-not-exist", "issue": "missing_target"}
|
|
]
|
|
assert report["status"] == "warn"
|
|
|
|
|
|
def test_lint_passes_a_valid_supersession_chain(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "references/old.md",
|
|
extra="valid_until: 2026-03-31\nsuperseded_by: \"[[new]]\"")
|
|
_page(vault, "references/new.md", extra="valid_from: 2026-04-01")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["temporal_errors"] == []
|
|
assert report["findings"]["superseded_dangling"] == []
|
|
|
|
|
|
# --- CLI -------------------------------------------------------------------
|
|
|
|
def _run(*args: str) -> subprocess.CompletedProcess[str]:
|
|
env = os.environ.copy()
|
|
env["PYTHONPATH"] = str(Path(__file__).parents[1])
|
|
return subprocess.run(
|
|
[sys.executable, "-m", "obsidian_wiki.cli", *args],
|
|
capture_output=True, check=False, text=True, env=env,
|
|
)
|