"""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, )