Discipline resolution, viability checking, entity access, stale mapping detection, and binding management. CLI commands: bind-discipline, disciplines, stale-mappings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
258 lines
9.9 KiB
Python
258 lines
9.9 KiB
Python
"""
|
|
Tests for infospace composition model (S2.6).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from markitect.infospace.composition import (
|
|
DisciplineStatus,
|
|
StaleMappingInfo,
|
|
bind_discipline,
|
|
check_discipline_status,
|
|
compute_discipline_digests,
|
|
find_stale_mappings,
|
|
get_discipline_entities,
|
|
load_discipline_config,
|
|
resolve_discipline_path,
|
|
)
|
|
from markitect.infospace.config import (
|
|
DisciplineBinding,
|
|
InfospaceConfig,
|
|
TopicConfig,
|
|
ViabilityThreshold,
|
|
save_infospace_config,
|
|
)
|
|
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _create_discipline(tmp_path: Path, name: str = "test-discipline") -> Path:
|
|
"""Create a minimal discipline infospace directory."""
|
|
disc_dir = tmp_path / name
|
|
disc_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
disc_config = InfospaceConfig(
|
|
topic=TopicConfig(name=name.replace("-", " ").title(), domain="Testing"),
|
|
viability={"coverage_ratio": ViabilityThreshold(metric="coverage_ratio", min=0.5)},
|
|
)
|
|
save_infospace_config(disc_config, disc_dir / "infospace.yaml")
|
|
|
|
# Create some entities
|
|
entities_dir = disc_dir / "output" / "entities"
|
|
entities_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
for slug in ["concept_a", "concept_b", "concept-c"]:
|
|
title = slug.replace("-", " ").title()
|
|
(entities_dir / f"{slug}.md").write_text(
|
|
f"# {title}\n\n## Definition\n\nA test concept for {slug}.\n\n"
|
|
f"## Source Chapter\n\nch01\n\n## Domain\n\nTesting\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
return disc_dir
|
|
|
|
|
|
def _parent_config(tmp_path: Path, disc_path: str = "") -> InfospaceConfig:
|
|
"""Create a parent infospace config."""
|
|
return InfospaceConfig(
|
|
topic=TopicConfig(name="Parent", domain="Testing"),
|
|
disciplines=[DisciplineBinding(name="Test Discipline", path=disc_path)]
|
|
if disc_path
|
|
else [],
|
|
)
|
|
|
|
|
|
# ── resolve_discipline_path ─────────────────────────────────────────
|
|
|
|
|
|
class TestResolveDisciplinePath:
|
|
def test_relative_path(self, tmp_path):
|
|
disc_dir = _create_discipline(tmp_path)
|
|
binding = DisciplineBinding(name="test", path="test-discipline")
|
|
result = resolve_discipline_path(binding, tmp_path)
|
|
assert result is not None
|
|
assert result == disc_dir.resolve()
|
|
|
|
def test_absolute_path(self, tmp_path):
|
|
disc_dir = _create_discipline(tmp_path)
|
|
binding = DisciplineBinding(name="test", path=str(disc_dir))
|
|
result = resolve_discipline_path(binding, tmp_path / "other")
|
|
assert result is not None
|
|
assert result == disc_dir.resolve()
|
|
|
|
def test_missing_path(self, tmp_path):
|
|
binding = DisciplineBinding(name="test", path="nonexistent")
|
|
assert resolve_discipline_path(binding, tmp_path) is None
|
|
|
|
def test_empty_path(self, tmp_path):
|
|
binding = DisciplineBinding(name="test", path="")
|
|
assert resolve_discipline_path(binding, tmp_path) is None
|
|
|
|
|
|
# ── load_discipline_config ──────────────────────────────────────────
|
|
|
|
|
|
class TestLoadDisciplineConfig:
|
|
def test_loads_config(self, tmp_path):
|
|
disc_dir = _create_discipline(tmp_path)
|
|
binding = DisciplineBinding(name="test", path="test-discipline")
|
|
config = load_discipline_config(binding, tmp_path)
|
|
assert config is not None
|
|
assert config.topic.domain == "Testing"
|
|
|
|
def test_missing_config_file(self, tmp_path):
|
|
(tmp_path / "no-config").mkdir()
|
|
binding = DisciplineBinding(name="test", path="no-config")
|
|
assert load_discipline_config(binding, tmp_path) is None
|
|
|
|
def test_missing_directory(self, tmp_path):
|
|
binding = DisciplineBinding(name="test", path="gone")
|
|
assert load_discipline_config(binding, tmp_path) is None
|
|
|
|
|
|
# ── check_discipline_status ─────────────────────────────────────────
|
|
|
|
|
|
class TestCheckDisciplineStatus:
|
|
def test_valid_discipline(self, tmp_path):
|
|
_create_discipline(tmp_path)
|
|
binding = DisciplineBinding(name="Test Discipline", path="test-discipline")
|
|
status = check_discipline_status(binding, tmp_path)
|
|
assert status.exists
|
|
assert status.has_config
|
|
assert status.entity_count == 3
|
|
assert status.error == ""
|
|
|
|
def test_missing_discipline(self, tmp_path):
|
|
binding = DisciplineBinding(name="Missing", path="nope")
|
|
status = check_discipline_status(binding, tmp_path)
|
|
assert not status.exists
|
|
assert "not found" in status.error.lower()
|
|
|
|
def test_no_config(self, tmp_path):
|
|
(tmp_path / "bare").mkdir()
|
|
binding = DisciplineBinding(name="Bare", path="bare")
|
|
status = check_discipline_status(binding, tmp_path)
|
|
assert status.exists
|
|
assert not status.has_config
|
|
|
|
def test_viable_with_metrics(self, tmp_path):
|
|
disc_dir = _create_discipline(tmp_path)
|
|
# Write metrics that meet the threshold
|
|
metrics_dir = disc_dir / "output" / "metrics"
|
|
metrics_dir.mkdir(parents=True, exist_ok=True)
|
|
(metrics_dir / "metrics.yaml").write_text(
|
|
yaml.safe_dump({"coverage_ratio": 0.8}), encoding="utf-8"
|
|
)
|
|
binding = DisciplineBinding(name="Test", path="test-discipline")
|
|
status = check_discipline_status(binding, tmp_path)
|
|
assert status.is_viable
|
|
|
|
def test_not_viable_below_threshold(self, tmp_path):
|
|
disc_dir = _create_discipline(tmp_path)
|
|
metrics_dir = disc_dir / "output" / "metrics"
|
|
metrics_dir.mkdir(parents=True, exist_ok=True)
|
|
(metrics_dir / "metrics.yaml").write_text(
|
|
yaml.safe_dump({"coverage_ratio": 0.2}), encoding="utf-8"
|
|
)
|
|
binding = DisciplineBinding(name="Test", path="test-discipline")
|
|
status = check_discipline_status(binding, tmp_path)
|
|
assert not status.is_viable
|
|
|
|
def test_to_dict(self, tmp_path):
|
|
_create_discipline(tmp_path)
|
|
binding = DisciplineBinding(name="Test", path="test-discipline")
|
|
status = check_discipline_status(binding, tmp_path)
|
|
d = status.to_dict()
|
|
assert d["name"] == "Test"
|
|
assert d["exists"] is True
|
|
assert d["entity_count"] == 3
|
|
|
|
|
|
# ── get_discipline_entities ─────────────────────────────────────────
|
|
|
|
|
|
class TestGetDisciplineEntities:
|
|
def test_returns_entities(self, tmp_path):
|
|
_create_discipline(tmp_path)
|
|
binding = DisciplineBinding(name="Test", path="test-discipline")
|
|
entities = get_discipline_entities(binding, tmp_path)
|
|
assert len(entities) == 3
|
|
slugs = {e.slug for e in entities}
|
|
assert "concept_a" in slugs
|
|
|
|
def test_missing_discipline(self, tmp_path):
|
|
binding = DisciplineBinding(name="Test", path="nope")
|
|
assert get_discipline_entities(binding, tmp_path) == []
|
|
|
|
|
|
# ── compute_discipline_digests ──────────────────────────────────────
|
|
|
|
|
|
class TestComputeDisciplineDigests:
|
|
def test_returns_digests(self, tmp_path):
|
|
_create_discipline(tmp_path)
|
|
binding = DisciplineBinding(name="Test", path="test-discipline")
|
|
digests = compute_discipline_digests(binding, tmp_path)
|
|
assert len(digests) == 3
|
|
assert "concept_a" in digests
|
|
assert isinstance(digests["concept_a"], str)
|
|
assert len(digests["concept_a"]) == 12
|
|
|
|
|
|
# ── find_stale_mappings ─────────────────────────────────────────────
|
|
|
|
|
|
class TestFindStaleMappings:
|
|
def test_no_references(self, tmp_path):
|
|
cfg = _parent_config(tmp_path, disc_path="test-discipline")
|
|
assert find_stale_mappings(cfg, tmp_path) == []
|
|
|
|
def test_no_stale(self, tmp_path):
|
|
_create_discipline(tmp_path)
|
|
cfg = _parent_config(tmp_path, disc_path="test-discipline")
|
|
refs = {"entity_x": ["concept_a", "concept_b"]}
|
|
stale = find_stale_mappings(cfg, tmp_path, mapping_references=refs)
|
|
assert stale == []
|
|
|
|
def test_detects_stale(self, tmp_path):
|
|
_create_discipline(tmp_path)
|
|
cfg = _parent_config(tmp_path, disc_path="test-discipline")
|
|
refs = {"entity_x": ["concept_a", "deleted_concept"]}
|
|
stale = find_stale_mappings(cfg, tmp_path, mapping_references=refs)
|
|
assert len(stale) == 1
|
|
assert stale[0].entity_slug == "entity_x"
|
|
assert stale[0].discipline_entity == "deleted_concept"
|
|
|
|
def test_stale_to_dict(self):
|
|
info = StaleMappingInfo(
|
|
entity_slug="e1", discipline_entity="d1", reason="gone"
|
|
)
|
|
d = info.to_dict()
|
|
assert d["entity_slug"] == "e1"
|
|
|
|
|
|
# ── bind_discipline ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestBindDiscipline:
|
|
def test_adds_binding(self, tmp_path):
|
|
_create_discipline(tmp_path)
|
|
cfg = InfospaceConfig(topic=TopicConfig(name="Parent"))
|
|
status = bind_discipline(cfg, name="Test", path="test-discipline", root=tmp_path)
|
|
assert status.exists
|
|
assert len(cfg.disciplines) == 1
|
|
assert cfg.disciplines[0].name == "Test"
|
|
|
|
def test_duplicate_rejected(self, tmp_path):
|
|
_create_discipline(tmp_path)
|
|
cfg = _parent_config(tmp_path, disc_path="test-discipline")
|
|
status = bind_discipline(cfg, name="Test Discipline", path="x", root=tmp_path)
|
|
assert "already bound" in status.error
|