from __future__ import annotations import copy import json from pathlib import Path import pytest from gcmd_classifier.errors import ( DuplicateCanonicalPathError, DuplicateUUIDError, InvalidHierarchyTransitionError, MalformedHierarchyError, VocabularyLookupError, ) from gcmd_classifier.vocabulary import build_vocabulary_index, calculate_file_hash, load_vocabulary FIXTURE_PATH = Path("tests/fixtures/gcmd_hierarchy_small.json") FULL_HIERARCHY_PATH = Path("data/gcmd_hierarchy.json") def load_fixture_data() -> dict: return json.loads(FIXTURE_PATH.read_text()) def build_fixture_index(): return load_vocabulary(FIXTURE_PATH) def test_recursive_traversal_and_variable_depth_branches() -> None: index = build_fixture_index() assert len(index) == 10 assert index.root_level == "Category" assert index.root_name == "EARTH SCIENCE" assert index.children_of(None) == ("topic-atmosphere", "topic-oceans") assert index.children_of("term-weather-events") == () assert index.get("vl3-carbon-dioxide-profiles").level == "Variable_Level_3" def test_uuid_preservation_and_record_fields() -> None: index = build_fixture_index() record = index.get("vl3-carbon-dioxide-profiles") assert record.UUID == "vl3-carbon-dioxide-profiles" assert record.name == "CARBON DIOXIDE PROFILES" assert record.level == "Variable_Level_3" assert record.topic == "ATMOSPHERE" assert record.term == "ATMOSPHERIC CHEMISTRY" assert record.path_components == ( "ATMOSPHERE", "ATMOSPHERIC CHEMISTRY", "CARBON", "CARBON DIOXIDE", "CARBON DIOXIDE PROFILES", ) assert ( record.canonical_path == "ATMOSPHERE > ATMOSPHERIC CHEMISTRY > CARBON > CARBON DIOXIDE > " "CARBON DIOXIDE PROFILES" ) assert record.parent_uuid == "vl2-carbon-dioxide" assert record.parent_name == "CARBON DIOXIDE" assert record.child_uuids == () assert record.has_children is False assert record.assignable is True assert record.definition == "Vertical carbon dioxide profiles." assert record.vocabulary_version == calculate_file_hash(FIXTURE_PATH) def test_topic_term_and_variable_records_are_assignable() -> None: index = build_fixture_index() topic = index.get("topic-atmosphere") term = index.get("term-atmospheric-chemistry") internal_variable = index.get("vl1-atmosphere-carbon") leaf_variable = index.get("vl2-methane") assert topic.level == "Topic" assert term.level == "Term" assert internal_variable.level == "Variable_Level_1" assert leaf_variable.level == "Variable_Level_2" assert topic.assignable is True assert term.assignable is True assert internal_variable.assignable is True assert leaf_variable.assignable is True assert internal_variable.has_children is True assert leaf_variable.has_children is False def test_root_category_is_non_assignable_context() -> None: index = build_fixture_index() assert "EARTH SCIENCE" not in index.records_by_path assert all(record.level != "Category" for record in index.records_by_uuid.values()) assert index.parent_of("topic-atmosphere") is None assert index.get("topic-atmosphere").parent_name == "EARTH SCIENCE" def test_optional_and_missing_definitions() -> None: index = build_fixture_index() assert index.get("topic-atmosphere").definition == "Atmospheric science." assert index.get("vl2-methane").definition is None def test_canonical_path_excludes_root_category() -> None: index = build_fixture_index() assert index.get("topic-atmosphere").canonical_path == "ATMOSPHERE" assert index.get("term-weather-events").canonical_path == "ATMOSPHERE > WEATHER EVENTS" assert not index.get("topic-atmosphere").canonical_path.startswith("EARTH SCIENCE") def test_identical_names_in_different_branches_are_allowed_when_paths_differ() -> None: index = build_fixture_index() atmosphere_carbon = index.get("vl1-atmosphere-carbon") ocean_carbon = index.get("vl1-ocean-carbon") assert atmosphere_carbon.name == ocean_carbon.name == "CARBON" assert atmosphere_carbon.canonical_path != ocean_carbon.canonical_path def test_duplicate_uuid_detection() -> None: data = load_fixture_data() data["children"][1]["UUID"] = "topic-atmosphere" with pytest.raises(DuplicateUUIDError): build_vocabulary_index(data, vocabulary_version="test") def test_duplicate_canonical_path_detection() -> None: data = load_fixture_data() duplicate = copy.deepcopy(data["children"][0]["children"][1]) duplicate["UUID"] = "term-weather-events-duplicate" data["children"][0]["children"].append(duplicate) with pytest.raises(DuplicateCanonicalPathError): build_vocabulary_index(data, vocabulary_version="test") @pytest.mark.parametrize("field", ["level", "name"]) def test_missing_required_fields(field: str) -> None: data = load_fixture_data() del data["children"][0][field] with pytest.raises(MalformedHierarchyError): build_vocabulary_index(data, vocabulary_version="test") def test_unexpected_uuidless_non_root_node() -> None: data = load_fixture_data() del data["children"][0]["UUID"] with pytest.raises(MalformedHierarchyError): build_vocabulary_index(data, vocabulary_version="test") def test_valid_hierarchy_level_transitions() -> None: index = build_fixture_index() assert index.get("topic-atmosphere").child_uuids == ( "term-atmospheric-chemistry", "term-weather-events", ) assert index.get("term-atmospheric-chemistry").child_uuids == ("vl1-atmosphere-carbon",) assert index.get("vl1-atmosphere-carbon").child_uuids == ( "vl2-carbon-dioxide", "vl2-methane", ) assert index.get("vl2-carbon-dioxide").child_uuids == ("vl3-carbon-dioxide-profiles",) def test_invalid_hierarchy_level_transition() -> None: data = load_fixture_data() data["children"][0]["children"][0]["level"] = "Variable_Level_1" with pytest.raises(InvalidHierarchyTransitionError): build_vocabulary_index(data, vocabulary_version="test") def test_parent_and_direct_child_lookup() -> None: index = build_fixture_index() assert index.parent_of("vl2-carbon-dioxide") == "vl1-atmosphere-carbon" assert index.children_of("vl1-atmosphere-carbon") == ( "vl2-carbon-dioxide", "vl2-methane", ) with pytest.raises(VocabularyLookupError): index.parent_of("missing-uuid") def test_topic_to_term_lookup() -> None: index = build_fixture_index() terms = index.terms_for_topic("topic-atmosphere") assert tuple(term.UUID for term in terms) == ( "term-atmospheric-chemistry", "term-weather-events", ) with pytest.raises(VocabularyLookupError): index.terms_for_topic("term-weather-events") def test_term_to_variable_lookup() -> None: index = build_fixture_index() variables = index.variables_for_parent("term-atmospheric-chemistry") assert tuple(variable.UUID for variable in variables) == ("vl1-atmosphere-carbon",) assert index.variables_for_parent("vl2-methane") == () with pytest.raises(VocabularyLookupError): index.variables_for_parent("topic-atmosphere") def test_ancestor_and_descendant_checks() -> None: index = build_fixture_index() assert index.is_ancestor("topic-atmosphere", "vl3-carbon-dioxide-profiles") is True assert index.is_descendant("vl3-carbon-dioxide-profiles", "topic-atmosphere") is True assert index.is_ancestor("term-weather-events", "vl3-carbon-dioxide-profiles") is False assert index.ancestors_of("vl3-carbon-dioxide-profiles") == ( "vl2-carbon-dioxide", "vl1-atmosphere-carbon", "term-atmospheric-chemistry", "topic-atmosphere", ) def test_vocabulary_file_hash_stability() -> None: assert calculate_file_hash(FIXTURE_PATH) == calculate_file_hash(FIXTURE_PATH) assert build_fixture_index().vocabulary_version == calculate_file_hash(FIXTURE_PATH) def test_source_file_immutability() -> None: before = FULL_HIERARCHY_PATH.read_bytes() load_vocabulary(FULL_HIERARCHY_PATH) after = FULL_HIERARCHY_PATH.read_bytes() assert after == before def test_every_current_topic_and_term_has_uuid() -> None: index = load_vocabulary(FULL_HIERARCHY_PATH) assert all(topic.UUID for topic in index.topics()) assert all(term.UUID for topic in index.topics() for term in index.terms_for_topic(topic.UUID)) def test_full_data_smoke_counts_for_current_vocabulary() -> None: index = load_vocabulary(FULL_HIERARCHY_PATH) counts = index.count_by_level() assert counts["Topic"] == 14 assert counts["Term"] == 144 assert counts["Variable_Level_1"] == 1368 assert counts["Variable_Level_2"] == 1456 assert counts["Variable_Level_3"] == 553 assert len(index) == 3535