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