Files
markitect-main/tests/unit/infospace/test_composition.py
tegwick b76d6d38c1 feat(infospace): add composition model for discipline binding (S2.6)
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>
2026-02-19 02:03:54 +01:00

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