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>
This commit is contained in:
257
tests/unit/infospace/test_composition.py
Normal file
257
tests/unit/infospace/test_composition.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user