diff --git a/openproficiency/ProficiencyLevel.py b/openproficiency/ProficiencyLevel.py new file mode 100644 index 0000000..2625989 --- /dev/null +++ b/openproficiency/ProficiencyLevel.py @@ -0,0 +1,109 @@ +"""ProficiencyLevel module for OpenProficiency library.""" + +import json +from typing import Any, Dict, Optional, Union, Set +from .validators import validate_kebab_case + + +class ProficiencyLevel: + """Class representing a proficiency level defined by prerequisite topics.""" + + # Initializers + def __init__( + self, + # Required + id: str, + # Optional + description: Optional[str] = None, + pretopics: Optional[Set[str]] = None, + ): + # Required + self.id = id + # Optional + self.description = description + if pretopics is None: + pretopics = set() + self.pretopics = pretopics + + # Properties + @property + def id(self) -> str: + """Get the proficiency level ID.""" + return self._id + + @id.setter + def id(self, value: str) -> None: + """Set the proficiency level ID. kebab-case""" + validate_kebab_case(value) + self._id = value + + @property + def description(self) -> Optional[str]: + """Get the description.""" + return self._description + + @description.setter + def description(self, value: Optional[Union[str, None]]) -> None: + """Set the description. Max 100 characters.""" + if value is not None and len(value) > 100: + raise ValueError(f"Description must be 100 characters or less. Got {len(value)} characters.") + self._description = value + + # Methods + def add_pretopic(self, pretopic: str) -> None: + """ + Add a pretopic (prerequisite topic) to this proficiency level. + """ + self.pretopics.add(pretopic) + + def add_pretopics(self, pretopics: Set[str]) -> None: + """ + Add multiple pretopics to this proficiency level. + """ + self.pretopics.update(pretopics) + + def remove_pretopic(self, pretopic: str) -> None: + """Remove a pretopic by its ID.""" + self.pretopics.discard(pretopic) + + def to_dict(self) -> Dict[str, Any]: + """Convert ProficiencyLevel to JSON-serializable dictionary.""" + return { + "id": self.id, + "description": self.description, + "pretopics": list(self.pretopics), + } + + def to_json(self) -> str: + """Convert ProficiencyLevel to JSON string.""" + return json.dumps(self.to_dict()) + + # Methods - Static + @staticmethod + def from_dict(data: Dict[str, Any]) -> "ProficiencyLevel": + """Create a ProficiencyLevel instance from a dictionary.""" + return ProficiencyLevel( + id=data["id"], + description=data.get("description", ""), + pretopics=set(data.get("pretopics", [])), + ) + + @staticmethod + def from_json(json_str: str) -> "ProficiencyLevel": + """Create a ProficiencyLevel instance from a JSON string.""" + try: + data = json.loads(json_str) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON: {e}") + return ProficiencyLevel.from_dict(data) + + def __eq__(self, other: Any) -> bool: + """Check equality based and pretopics.""" + if not isinstance(other, ProficiencyLevel): + return False + return set(self.pretopics) == set(other.pretopics) + + # Debugging + def __repr__(self) -> str: + """String representation of ProficiencyLevel.""" + return f"ProficiencyLevel(id='{self.id}', " f"description='{self.description}', " f"pretopics={self.pretopics})" diff --git a/openproficiency/ProficiencyLevelList.py b/openproficiency/ProficiencyLevelList.py new file mode 100644 index 0000000..e2124be --- /dev/null +++ b/openproficiency/ProficiencyLevelList.py @@ -0,0 +1,276 @@ +"""ProficiencyLevelList module for OpenProficiency library.""" + +import json +import re +from datetime import datetime, timezone +from typing import Optional, Dict, Any, Union, List, cast +from .ProficiencyLevel import ProficiencyLevel +from .TopicList import TopicList +from .validators import validate_kebab_case, validate_hostname + + +class ProficiencyLevelList: + """Class representing a collection of proficiency levels with dependencies.""" + + # Initializers + def __init__( + self, + # Required + owner: str, + name: str, + version: str, + timestamp: Union[str, datetime], + certificate: str, + # Optional + description: Optional[str] = None, + levels: Optional[Dict[str, ProficiencyLevel]] = None, + dependencies: Optional[Dict[str, TopicList]] = None, + ): + # Required + self.owner = owner + self.name = name + self.version = version + self.timestamp = timestamp + self.certificate = certificate + + # Optional + self.description = description + self.levels = levels or {} + self.dependencies = dependencies or {} + + # Properties + @property + def owner(self) -> str: + """Get the owner name.""" + return self._owner + + @owner.setter + def owner(self, value: str) -> None: + """Set the owner with hostname validation. Format: hostname, Ex: `example.com`""" + validate_hostname(value) + self._owner = value + + @property + def name(self) -> str: + """Get the ProficiencyLevelList name. Format: kebab-case""" + return self._name + + @name.setter + def name(self, value: str) -> None: + """Set the ProficiencyLevelList name with kebab-case validation.""" + validate_kebab_case(value) + self._name = value + + @property + def version(self) -> Union[str, None]: + """Get the semantic version of the ProficiencyLevelList.""" + return self._version + + @version.setter + def version(self, value: Union[str, None]) -> None: + """Set the semantic version with X.Y.Z format validation.""" + if value is not None and not re.match(r"^\d+\.\d+\.\d+$", value): + raise ValueError(f"Invalid version format: '{value}'. Must be semantic versioning (X.Y.Z)") + self._version = value + + @property + def timestamp(self) -> datetime: + """Get the timestamp as a datetime object.""" + return self._timestamp + + @timestamp.setter + def timestamp(self, value: Union[datetime, str, None]) -> None: + """Set the timestamp from a string or datetime object.""" + if value is None: + self._timestamp = datetime.now(timezone.utc) + elif isinstance(value, datetime): + self._timestamp = value + elif isinstance(value, str): + self._timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + else: + raise ValueError("Invalid timestamp format. Must be a datetime object or ISO 8601 string.") + + @property + def full_name(self) -> str: + """Get the full name of the ProficiencyLevelList in 'owner/name@version' format.""" + full_name = f"{self.owner}/{self.name}" + if self.version: + full_name += f"@{self.version}" + return full_name + + # Methods + def add_level(self, level: ProficiencyLevel, validate: bool = True) -> None: + """ + Add a proficiency level to this list. + Validates that all pretopics reference valid topics in dependencies. + """ + # Check for duplicate ID + if level.id in self.levels: + raise ValueError(f"A proficiency level with ID '{level.id}' already exists in this list") + + # Validate pretopics + if validate: + self._validate_pretopics(level) + + # Add the level + self.levels[level.id] = level + + def add_dependency(self, namespace: str, topic_list: TopicList) -> None: + """ + Add an imported TopicList as a dependency. + """ + # Validate namespace format (kebab-case) + validate_kebab_case(namespace) + + # Check for duplicate namespace + if namespace in self.dependencies: + raise ValueError(f"A dependency with namespace '{namespace}' already exists in this list") + + # Add the dependency + self.dependencies[namespace] = topic_list + + def _validate_pretopics(self, level: ProficiencyLevel) -> None: + """ + Validate that all pretopics in a level reference valid topics in dependencies. + Pretopics must be in format 'namespace.topic-id'. + """ + errors: List[str] = [] + + for pretopic in level.pretopics: + # Parse namespace and topic ID + if "." not in pretopic: + errors.append( + f"Pretopic '{pretopic}' in level '{level.id}' is not in " + "namespace notation format (expected 'namespace.topic-id')" + ) + continue + + parts = pretopic.split(".", 1) + namespace = parts[0] + topic_id = parts[1] + + # Check if namespace exists in dependencies + if namespace not in self.dependencies: + errors.append( + f"Pretopic '{pretopic}' in level '{level.id}' references unknown " f"namespace '{namespace}'" + ) + continue + + # Check if topic exists in the TopicList + topic_list = self.dependencies[namespace] + if topic_list.get_topic(topic_id) is None: + errors.append( + f"Pretopic '{pretopic}' in level '{level.id}' references " + f"non-existent topic '{topic_id}' in namespace '{namespace}'" + ) + + # If there are any errors, raise them all together + if errors: + error_message = "; ".join(errors) + raise ValueError(error_message) + + def to_dict(self) -> Dict[str, Any]: + """ + Export the ProficiencyLevelList to a dictionary. + """ + # Create dictionary + data: Dict[str, Any] = { + "owner": self.owner, + "name": self.name, + "version": self.version, + "timestamp": self.timestamp.isoformat(), + "certificate": self.certificate, + "proficiency-levels": {}, + "dependencies": {}, + } + + # Add description if set + if self.description is not None: + data["description"] = self.description + + # Add dependencies + for namespace, topic_list in self.dependencies.items(): + data["dependencies"][namespace] = topic_list.full_name + + # Add each level + for level_id, level in self.levels.items(): + data["proficiency-levels"][level_id] = level.to_dict() + + return data + + def to_json(self) -> str: + """Convert ProficiencyLevelList to JSON string.""" + return json.dumps(self.to_dict()) + + # Methods - Class + @staticmethod + def from_dict(data: Dict[str, Any]) -> "ProficiencyLevelList": + """ + Create a ProficiencyLevelList from a dictionary. + Optionally provide TopicList objects for dependencies. + """ + # Create empty ProficiencyLevelList + level_list = ProficiencyLevelList( + owner=data["owner"], + name=data["name"], + description=data.get("description", None), + version=data["version"], + timestamp=data["timestamp"], + certificate=data["certificate"], + ) + + # Add dependencies if provided + dependencies = cast(Dict[str, str], data.get("dependencies", {})) + for namespace, topic_list_full_name in dependencies.items(): + # Extract from full name like 'example.com/math-topics@1.0.0' + owner = topic_list_full_name.split("/")[0] + name = topic_list_full_name.split("/")[1].split("@")[0] + version = topic_list_full_name.split("@")[1] + # Load topic list + # TODO: In the future this should use the url to retrieve the list + # and get metadata from the list's json. + topic_list = TopicList( + owner=owner, + name=name, + version=version, + ) + # Assign to namespace + level_list.dependencies[namespace] = topic_list + + # Add each level + levels = cast(Dict[str, Any], data.get("proficiency-levels", {})) + for level_id, level_data in levels.items(): + if isinstance(level_data, dict): + level_dict = cast(Dict[str, Any], level_data) + level = ProficiencyLevel( + id=level_id, + description=level_dict.get("description"), + pretopics=set(level_dict.get("pretopics", [])), + ) + level_list.add_level(level, validate=False) + + return level_list + + @staticmethod + def from_json(json_data: str) -> "ProficiencyLevelList": + """ + Load a ProficiencyLevelList from JSON string. + Optionally provide TopicList objects for dependencies. + """ + # Verify input is json string + try: + data = json.loads(json_data) + except TypeError: + raise TypeError("Unable to import. 'json_data' must be a JSON string") + except Exception as e: + raise e + + return ProficiencyLevelList.from_dict(data) + + # Debugging + def __repr__(self) -> str: + """String representation of ProficiencyLevelList.""" + return ( + f"ProficiencyLevelList(owner='{self.owner}', name='{self.name}', " + f"levels_count={len(self.levels)}, dependencies_count={len(self.dependencies)})" + ) diff --git a/openproficiency/__init__.py b/openproficiency/__init__.py index 8a52fbd..990d089 100644 --- a/openproficiency/__init__.py +++ b/openproficiency/__init__.py @@ -4,3 +4,5 @@ from .TopicList import TopicList from .ProficiencyScore import ProficiencyScore, ProficiencyScoreName from .TranscriptEntry import TranscriptEntry +from .ProficiencyLevel import ProficiencyLevel +from .ProficiencyLevelList import ProficiencyLevelList diff --git a/tests/ProficiencyLevelList_test.py b/tests/ProficiencyLevelList_test.py new file mode 100644 index 0000000..84aeb34 --- /dev/null +++ b/tests/ProficiencyLevelList_test.py @@ -0,0 +1,1005 @@ +"""Tests for the ProficiencyLevelList class.""" + +import json +from datetime import datetime +from typing import Any, Dict +from openproficiency import ( + ProficiencyLevelList, + ProficiencyLevel, + TopicList, + Topic, +) + + +class TestProficiencyLevelList: + + # Initializers + def test_init_required_params(self): + """Create a level list with required params.""" + + # Arrange + owner = "example.com" + name = "proficiency-levels" + version = "1.0.0" + timestamp = "2025-01-15T10:30:00+00:00" + certificate = "https://example.com/cert.pem" + + # Act + level_list = ProficiencyLevelList( + owner=owner, + name=name, + version=version, + timestamp=timestamp, + certificate=certificate, + ) + + # Assert + assert level_list.owner == owner + assert level_list.name == name + assert level_list.version == version + assert level_list.certificate == certificate + assert level_list.levels == {} + assert level_list.dependencies == {} + + def test_init_with_description(self): + """Create a level list with description field.""" + + # Arrange + owner = "example.com" + name = "proficiency-levels" + version = "1.0.0" + timestamp = "2025-01-15T10:30:00+00:00" + certificate = "https://example.com/cert.pem" + description = "Standard proficiency levels for the platform" + + # Act + level_list = ProficiencyLevelList( + owner=owner, + name=name, + version=version, + timestamp=timestamp, + certificate=certificate, + description=description, + ) + + # Assert + assert level_list.owner == owner + assert level_list.name == name + assert level_list.description == description + + def test_init_with_version(self): + """Create a level list with custom version.""" + + # Arrange + owner = "example.com" + name = "proficiency-levels" + version = "1.2.0" + timestamp = "2025-01-15T10:30:00+00:00" + certificate = "https://example.com/cert.pem" + + # Act + level_list = ProficiencyLevelList( + owner=owner, + name=name, + version=version, + timestamp=timestamp, + certificate=certificate, + ) + + # Assert + assert level_list.version == version + + def test_init_with_timestamp(self): + """Create a level list with custom timestamp.""" + + # Arrange + owner = "example.com" + name = "proficiency-levels" + version = "1.0.0" + timestamp = "2025-01-15T10:30:00+00:00" + certificate = "https://example.com/cert.pem" + + # Act + level_list = ProficiencyLevelList( + owner=owner, + name=name, + version=version, + timestamp=timestamp, + certificate=certificate, + ) + + # Assert + assert isinstance(level_list.timestamp, datetime) + assert level_list.timestamp.isoformat() == timestamp + + def test_init_with_certificate(self): + """Create a level list with certificate field.""" + + # Arrange + owner = "example.com" + name = "proficiency-levels" + version = "1.0.0" + timestamp = "2025-01-15T10:30:00+00:00" + certificate = "https://example.com/cert.pem" + + # Act + level_list = ProficiencyLevelList( + owner=owner, + name=name, + version=version, + timestamp=timestamp, + certificate=certificate, + ) + + # Assert + assert level_list.certificate == certificate + + # Properties - Owner + def test_owner_validation(self): + """Test that owner is validated as hostname.""" + + # Act & Assert + try: + ProficiencyLevelList( + owner="not a hostname!", + name="test", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + assert False, "Expected ValueError for invalid hostname" + except ValueError as e: + assert "hostname" in str(e).lower() + + # Properties - Name + def test_name_validation(self): + """Test that name is validated as kebab-case.""" + + # Act & Assert + try: + ProficiencyLevelList( + owner="example.com", + name="InvalidName", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + assert False, "Expected ValueError for invalid kebab-case" + except ValueError as e: + assert "kebab-case" in str(e).lower() + + def test_name_change(self): + """Test changing name after creation.""" + + # Arrange + level_list = ProficiencyLevelList( + owner="example.com", + name="original-name", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + + # Act + level_list.name = "new-name" + + # Assert + assert level_list.name == "new-name" + + # Properties - Version + def test_version_validation(self): + """Test that version must be semantic versioning.""" + + # Act & Assert + try: + ProficiencyLevelList( + owner="example.com", + name="test", + version="1.2", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + assert False, "Expected ValueError for invalid version" + except ValueError as e: + assert "semantic versioning" in str(e).lower() + + def test_version_set_valid(self): + """Test setting a valid version.""" + + # Arrange + level_list = ProficiencyLevelList( + owner="example.com", + name="test", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + + # Act + level_list.version = "2.3.1" + + # Assert + assert level_list.version == "2.3.1" + + # Properties - Timestamp + def test_timestamp_from_iso_string(self): + """Test setting timestamp from ISO string.""" + + # Arrange + iso_string = "2025-06-15T14:30:00+00:00" + + # Act + level_list = ProficiencyLevelList( + owner="example.com", + name="test", + version="1.0.0", + timestamp=iso_string, + certificate="cert", + ) + + # Assert + assert level_list.timestamp.isoformat() == iso_string + + def test_timestamp_from_z_format(self): + """Test setting timestamp from Z-format string.""" + + # Arrange + z_string = "2025-06-15T14:30:00Z" + + # Act + level_list = ProficiencyLevelList( + owner="example.com", + name="test", + version="1.0.0", + timestamp=z_string, + certificate="cert", + ) + + # Assert + assert level_list.timestamp.isoformat() == "2025-06-15T14:30:00+00:00" + + def test_timestamp_from_datetime(self): + """Test setting timestamp from datetime object.""" + + # Arrange + dt = datetime(2025, 6, 15, 14, 30, 0) + + # Act + level_list = ProficiencyLevelList( + owner="example.com", + name="test", + version="1.0.0", + timestamp=dt, + certificate="cert", + ) + + # Assert + assert level_list.timestamp == dt + + # Properties - Full Name + def test_full_name_without_version(self): + """Test full name when version is set (version is required).""" + + # Arrange + level_list = ProficiencyLevelList( + owner="example.com", + name="test-levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + + # Act & Assert + assert level_list.full_name == "example.com/test-levels@1.0.0" + + def test_full_name_with_version(self): + """Test full name with version.""" + + # Arrange + level_list = ProficiencyLevelList( + owner="example.com", + name="test-levels", + version="2.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + + # Act & Assert + assert level_list.full_name == "example.com/test-levels@2.0.0" + + # Methods - Add Level + def test_add_level(self): + """Test adding a proficiency level without pretopics.""" + + # Arrange + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level = ProficiencyLevel( + id="beginner", + description="Beginner level", + ) + + # Act + level_list.add_level(level) + + # Assert + assert "beginner" in level_list.levels + assert level_list.levels["beginner"] == level + + def test_add_level_with_pretopics_and_dependencies(self): + """Test adding a level with valid pretopics.""" + + # Arrange + math_topic_list = TopicList( + owner="example.com", + name="math-topics", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + math_topic_list.add_topic(Topic(id="addition")) + math_topic_list.add_topic(Topic(id="subtraction")) + + level_list = ProficiencyLevelList( + owner="example.com", + name="math-proficiency-levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + dependencies={ + "math": math_topic_list, + }, + ) + + level = ProficiencyLevel( + id="beginner", + description="Beginner level", + pretopics={ + "math.addition", + "math.subtraction", + }, + ) + + # Act + level_list.add_level(level) + + # Assert + assert "beginner" in level_list.levels + assert level_list.levels["beginner"] == level + + def test_add_level_duplicate_id_raises_error(self): + """Test that adding a level with duplicate ID raises error.""" + + # Arrange + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level1 = ProficiencyLevel(id="beginner") + level2 = ProficiencyLevel(id="beginner") + + level_list.add_level(level1) + + # Act & Assert + try: + level_list.add_level(level2) + assert False, "Expected ValueError for duplicate level ID" + except ValueError as e: + assert "already exists" in str(e) + + def test_add_level_invalid_pretopic_format(self): + """Test that pretopic not in namespace notation raises error.""" + + # Arrange + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level = ProficiencyLevel( + id="beginner", + pretopics={"invalid-pretopic"}, + ) + + # Act & Assert + try: + level_list.add_level(level) + assert False, "Expected ValueError for invalid pretopic format" + except ValueError as e: + assert "namespace notation" in str(e).lower() + + def test_add_level_missing_dependency_namespace(self): + """Test that pretopic referencing missing namespace raises error.""" + + # Arrange + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level = ProficiencyLevel( + id="beginner", + pretopics={"math.addition"}, + ) + + # Act & Assert + try: + level_list.add_level(level) + assert False, "Expected ValueError for missing namespace" + except ValueError as e: + assert "unknown namespace" in str(e).lower() + + def test_add_level_topic_not_in_dependency(self): + """Test that pretopic referencing non-existent topic raises error.""" + + # Arrange + topic_list = TopicList( + owner="example.com", + name="math-topics", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + topic_list.add_topic(Topic(id="addition")) + + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level_list.add_dependency("math", topic_list) + + level = ProficiencyLevel( + id="beginner", + pretopics={"math.multiplication"}, + ) + + # Act & Assert + try: + level_list.add_level(level) + assert False, "Expected ValueError for non-existent topic" + except ValueError as e: + assert "non-existent topic" in str(e).lower() + + # Methods - Add Dependency + def test_add_dependency(self): + """Test adding a topic list as dependency.""" + + # Arrange + topic_list = TopicList( + owner="example.com", + name="math-topics", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + + # Act + level_list.add_dependency("math", topic_list) + + # Assert + assert "math" in level_list.dependencies + assert level_list.dependencies["math"] == topic_list + + def test_add_dependency_invalid_namespace(self): + """Test that invalid namespace format raises error.""" + + # Arrange + topic_list = TopicList( + owner="example.com", + name="math-topics", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + + # Act & Assert + try: + level_list.add_dependency("InvalidNamespace", topic_list) + assert False, "Expected ValueError for invalid namespace" + except ValueError as e: + assert "kebab-case" in str(e).lower() + + def test_add_dependency_duplicate_namespace(self): + """Test that duplicate namespace raises error.""" + + # Arrange + topic_list1 = TopicList( + owner="example.com", + name="math-topics", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + topic_list2 = TopicList( + owner="example.com", + name="science-topics", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level_list.add_dependency("namespace", topic_list1) + + # Act & Assert + try: + level_list.add_dependency("namespace", topic_list2) + assert False, "Expected ValueError for duplicate namespace" + except ValueError as e: + assert "already exists" in str(e) + + # Methods - to_dict + def test_to_dict_basic(self): + """Test converting to dictionary with basic metadata.""" + + # Arrange + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="https://example.com/cert.pem", + description="Test levels", + ) + + # Act + data = level_list.to_dict() + + # Assert + assert data["owner"] == "example.com" + assert data["name"] == "levels" + assert data["description"] == "Test levels" + assert data["version"] == "1.0.0" + assert data["certificate"] == "https://example.com/cert.pem" + assert "timestamp" in data + assert data["proficiency-levels"] == {} + + def test_to_dict_with_levels(self): + """Test converting to dictionary with levels.""" + + # Arrange + math_topic_list = TopicList( + owner="example.com", + name="math-topics", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + math_topic_list.add_topic(Topic(id="addition")) + math_topic_list.add_topic(Topic(id="subtraction")) + math_topic_list.add_topic(Topic(id="multiplication")) + math_topic_list.add_topic(Topic(id="division")) + + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + dependencies={ + "math": math_topic_list, + }, + levels={ + "beginner": ProficiencyLevel( + id="beginner", + description="Beginner level", + pretopics={ + "math.addition", + "math.subtraction", + }, + ), + "intermediate": ProficiencyLevel( + id="intermediate", + description="Intermediate level", + pretopics={ + "math.multiplication", + "math.division", + }, + ), + }, + ) + + # Act + data = level_list.to_dict() + + # Assert + assert len(data["proficiency-levels"]) == 2 + assert "beginner" in data["proficiency-levels"] + assert "intermediate" in data["proficiency-levels"] + + assert data["proficiency-levels"]["beginner"]["id"] == "beginner" + assert data["proficiency-levels"]["beginner"]["description"] == "Beginner level" + assert "math.addition" in data["proficiency-levels"]["beginner"]["pretopics"] + assert "math.subtraction" in data["proficiency-levels"]["beginner"]["pretopics"] + + assert data["proficiency-levels"]["intermediate"]["id"] == "intermediate" + assert data["proficiency-levels"]["intermediate"]["description"] == "Intermediate level" + assert "math.multiplication" in data["proficiency-levels"]["intermediate"]["pretopics"] + assert "math.division" in data["proficiency-levels"]["intermediate"]["pretopics"] + + def test_to_dict_with_dependencies(self): + """Test converting to dictionary with dependencies.""" + + # Arrange + topic_list = TopicList( + owner="example.com", + name="math-topics", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level_list.add_dependency("math", topic_list) + + # Act + data = level_list.to_dict() + + # Assert + assert "dependencies" in data + assert "math" in data["dependencies"] + assert data["dependencies"]["math"] == "example.com/math-topics@1.0.0" + + # Methods - to_json + def test_to_json(self): + """Test converting to JSON string.""" + + # Arrange + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level_list.add_level(ProficiencyLevel(id="beginner")) + + # Act + json_str = level_list.to_json() + + # Assert + data = json.loads(json_str) + assert data["owner"] == "example.com" + assert data["name"] == "levels" + assert "beginner" in data["proficiency-levels"] + + # Methods - from_dict + def test_from_dict_simple(self): + """Test creating from dictionary with minimal details.""" + + # Arrange + data: Dict[str, Any] = { + "owner": "example.com", + "name": "levels", + "description": "Test levels", + "version": "1.0.0", + "timestamp": "2025-01-15T10:30:00+00:00", + "certificate": "--- cert ---", + } + + # Act + level_list = ProficiencyLevelList.from_dict(data) + + # Assert + assert level_list.owner == "example.com" + assert level_list.name == "levels" + assert level_list.description == "Test levels" + assert level_list.version == "1.0.0" + assert level_list.timestamp.isoformat() == "2025-01-15T10:30:00+00:00" + assert level_list.certificate == "--- cert ---" + + def test_from_dict_levels_dependencies(self): + """Test creating from dictionary with levels and dependencies.""" + + # Arrange + data: Dict[str, Any] = { + "owner": "example.com", + "name": "levels", + "version": "1.0.0", + "timestamp": "2025-01-15T10:30:00+00:00", + "certificate": "cert", + "proficiency-levels": { + "beginner": { + "description": "Beginner level", + "pretopics": [], + }, + "advanced": { + "description": "Advanced level", + "pretopics": [], + }, + }, + "dependencies": { + "math": "example.com/math-topics@1.0.0", + }, + } + + # Act + level_list = ProficiencyLevelList.from_dict(data) + + # Assert + assert len(level_list.levels) == 2 + assert "beginner" in level_list.levels + assert "advanced" in level_list.levels + assert level_list.levels["beginner"].description == "Beginner level" + + assert "math" in level_list.dependencies + assert level_list.dependencies["math"].owner == "example.com" + assert level_list.dependencies["math"].name == "math-topics" + assert level_list.dependencies["math"].version == "1.0.0" + + # Methods - from_json + def test_from_json_basic(self): + """Test creating from JSON string.""" + + # Arrange + json_str = json.dumps( + { + "owner": "example.com", + "name": "levels", + "description": "Test levels", + "version": "1.0.0", + "timestamp": "2025-01-15T10:30:00+00:00", + "certificate": "--- cert ---", + } + ) + + # Act + level_list = ProficiencyLevelList.from_json(json_str) + + # Assert + assert level_list.owner == "example.com" + assert level_list.name == "levels" + assert level_list.description == "Test levels" + assert level_list.version == "1.0.0" + assert level_list.timestamp.isoformat() == "2025-01-15T10:30:00+00:00" + assert level_list.certificate == "--- cert ---" + + def test_from_json_with_levels(self): + """Test creating from JSON string with levels and dependencies.""" + + # Arrange + json_str = json.dumps( + { + "owner": "example.com", + "name": "levels", + "version": "1.0.0", + "timestamp": "2025-01-15T10:30:00+00:00", + "certificate": "cert", + "proficiency-levels": { + "beginner": { + "description": "Beginner level", + "pretopics": [], + }, + "advanced": { + "description": "Advanced level", + "pretopics": [], + }, + }, + "dependencies": { + "math": "example.com/math-topics@1.0.0", + }, + } + ) + + # Act + level_list = ProficiencyLevelList.from_json(json_str) + + # Assert + assert len(level_list.levels) == 2 + assert "beginner" in level_list.levels + assert "advanced" in level_list.levels + assert level_list.levels["beginner"].description == "Beginner level" + + assert "math" in level_list.dependencies + assert level_list.dependencies["math"].owner == "example.com" + assert level_list.dependencies["math"].name == "math-topics" + assert level_list.dependencies["math"].version == "1.0.0" + + def test_from_json_invalid_input(self): + """Test that non-JSON input raises error.""" + + # Arrange + json_str = "not a json string" + + # Act + result = None + try: + ProficiencyLevelList.from_json(json_data=json_str) + except Exception as e: + result = e + + # Assert + assert isinstance(result, json.JSONDecodeError) + + # Methods - Round trip (to_dict -> from_dict) + def test_round_trip_to_dict_from_dict(self): + """Test converting to dict and back preserves data.""" + + # Arrange + topic_list = TopicList( + owner="example.com", + name="math-topics", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + topic_list.add_topic(Topic(id="addition")) + topic_list.add_topic(Topic(id="subtraction")) + + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + description="Math proficiency levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="https://example.com/cert.pem", + levels={ + "beginner": ProficiencyLevel( + id="beginner", + description="Beginner level", + pretopics={ + "math.addition", + }, + ), + "intermediate": ProficiencyLevel( + id="intermediate", + description="Intermediate level", + pretopics={ + "math.subtraction", + }, + ), + }, + dependencies={ + "math": topic_list, + }, + ) + + # Act + data = level_list.to_dict() + result = ProficiencyLevelList.from_dict(data) + + # Assert + assert result.owner == level_list.owner + assert result.name == level_list.name + assert result.description == level_list.description + assert result.version == level_list.version + assert result.timestamp == level_list.timestamp + assert result.certificate == level_list.certificate + + assert len(result.dependencies) == 1 + assert "math" in result.dependencies + assert result.dependencies["math"].full_name == topic_list.full_name + + assert len(result.levels) == 2 + assert result.levels["beginner"].description == "Beginner level" + assert "math.addition" in result.levels["beginner"].pretopics + assert result.levels["intermediate"].description == "Intermediate level" + assert "math.subtraction" in result.levels["intermediate"].pretopics + + def test_round_trip_to_json_from_json(self): + """Test converting to JSON and back preserves data.""" + + # Arrange + topic_list = TopicList( + owner="example.com", + name="math-topics", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + topic_list.add_topic(Topic(id="addition")) + topic_list.add_topic(Topic(id="subtraction")) + + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + description="Math proficiency levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="https://example.com/cert.pem", + dependencies={ + "math": topic_list, + }, + ) + level_list.add_level( + ProficiencyLevel( + id="beginner", + description="Beginner level", + pretopics={ + "math.addition", + }, + ) + ) + level_list.add_level( + ProficiencyLevel( + id="intermediate", + description="Intermediate level", + pretopics={ + "math.subtraction", + }, + ) + ) + + # Act + json_str = level_list.to_json() + result = ProficiencyLevelList.from_json(json_str) + round_trip_json = result.to_json() + + # Assert + data = json.loads(json_str) + round_trip_data = json.loads(round_trip_json) + assert round_trip_data == data + + # Debugging + def test_repr(self): + """Test string representation.""" + + # Arrange + topic_list = TopicList( + owner="example.com", + name="math-topics", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + topic_list.add_topic(Topic(id="addition")) + + level_list = ProficiencyLevelList( + owner="example.com", + name="levels", + version="1.0.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert", + ) + level_list.add_dependency("math", topic_list) + level_list.add_level(ProficiencyLevel(id="beginner")) + level_list.add_level(ProficiencyLevel(id="advanced")) + + # Act + repr_str = repr(level_list) + + # Assert + assert "example.com" in repr_str + assert "levels" in repr_str + assert "levels_count=2" in repr_str + assert "dependencies_count=1" in repr_str diff --git a/tests/ProficiencyLevel_test.py b/tests/ProficiencyLevel_test.py new file mode 100644 index 0000000..43c5411 --- /dev/null +++ b/tests/ProficiencyLevel_test.py @@ -0,0 +1,427 @@ +"""Tests for the ProficiencyLevel class.""" + +import json +from openproficiency import ProficiencyLevel + + +class TestProficiencyLevel: + + # Initializers + def test_init_required_params(self): + """Create a proficiency level with required fields only.""" + + # Arrange + id = "beginner" + + # Act + level = ProficiencyLevel(id=id) + + # Assert + assert level.id == id + assert level.description is None + assert level.pretopics == set() + + def test_init_optional_params(self): + """Test creating a proficiency level with optional parameters.""" + + # Arrange + id = "intermediate" + description = "Intermediate level of proficiency" + pretopics = {"topic-1", "topic-2", "topic-3"} + + # Act + level = ProficiencyLevel( + id=id, + description=description, + pretopics=pretopics, + ) + + # Assert + assert level.id == id + assert level.description == description + assert len(level.pretopics) == 3 + assert "topic-1" in level.pretopics + assert "topic-2" in level.pretopics + assert "topic-3" in level.pretopics + + # Properties - ID + def test_id(self): + """Test that ID setter works.""" + + # Arrange + level = ProficiencyLevel(id="level-1") + new_id = "beginner" + + # Act + level.id = new_id + + # Assert + assert level.id == new_id + + # Properties - Description + def test_description(self): + """Test that valid descriptions are accepted.""" + + # Arrange + level = ProficiencyLevel(id="beginner") + desc = "Short description" + + # Act + level.description = desc + + # Assert + assert level.description == desc + + def test_description_too_long(self): + """Test that descriptions over 100 characters raise ValueError.""" + + # Arrange + level = ProficiencyLevel(id="beginner") + long_description = "a" * 101 + + # Act & Assert + try: + level.description = long_description + assert False, "Expected ValueError for description too long" + except ValueError as e: + assert "100 characters" in str(e) + + def test_description_init_too_long(self): + """Test that init with too-long description raises ValueError.""" + + # Act & Assert + try: + ProficiencyLevel(id="beginner", description="a" * 101) + assert False, "Expected ValueError for description too long" + except ValueError as e: + assert "100 characters" in str(e) + + # Methods - Pretopics + def test_add_pretopic_string(self): + """Test adding a pretopic as a string.""" + + # Arrange + level = ProficiencyLevel(id="beginner") + pretopic_id = "git-basics" + + # Act + level.add_pretopic(pretopic_id) + + # Assert + assert pretopic_id in level.pretopics + + def test_add_pretopic_with_existing_pretopics(self): + """Test adding a pretopic when level already has pretopics.""" + + # Arrange + level = ProficiencyLevel(id="intermediate", pretopics={"existing-topic"}) + new_pretopic = "new-topic" + + # Act + level.add_pretopic(new_pretopic) + + # Assert + assert new_pretopic in level.pretopics + assert "existing-topic" in level.pretopics + + def test_add_pretopic_no_duplicates(self): + """Test that duplicate pretopics are not added.""" + + # Arrange + level = ProficiencyLevel(id="beginner") + pretopic_id = "git-basics" + + # Act + level.add_pretopic(pretopic_id) + level.add_pretopic(pretopic_id) + + # Assert + assert len(level.pretopics) == 1 + assert pretopic_id in level.pretopics + + def test_add_pretopics_no_duplicates(self): + """Test adding multiple pretopics.""" + + # Arrange + level = ProficiencyLevel(id="intermediate") + pretopic_ids = {"git-basics", "cli-basics", "version-control"} + + # Act + level.add_pretopics(pretopic_ids) + + # Assert + assert len(level.pretopics) == 3 + for pretopic_id in pretopic_ids: + assert pretopic_id in level.pretopics + + def test_add_pretopics_to_existing(self): + """Test adding multiple pretopics to an existing set.""" + + # Arrange + level = ProficiencyLevel(id="intermediate", pretopics={"existing-topic"}) + new_pretopics = {"git-basics", "cli-basics"} + + # Act + level.add_pretopics(new_pretopics) + + # Assert + assert "existing-topic" in level.pretopics + assert "git-basics" in level.pretopics + assert "cli-basics" in level.pretopics + assert len(level.pretopics) == 3 + + def test_remove_pretopic(self): + """Test removing a pretopic.""" + + # Arrange + level = ProficiencyLevel(id="intermediate", pretopics={"git-basics", "cli-basics"}) + + # Act + level.remove_pretopic("git-basics") + + # Assert + assert "git-basics" not in level.pretopics + assert "cli-basics" in level.pretopics + + def test_remove_pretopic_nonexistent(self): + """Test removing a pretopic that doesn't exist.""" + + # Arrange + level = ProficiencyLevel(id="intermediate", pretopics={"git-basics"}) + + # Act + level.remove_pretopic("nonexistent-topic") + + # Assert + assert level.pretopics == {"git-basics"} + + # Methods - to_dict and to_json + def test_to_dict(self): + """Test conversion to dictionary.""" + + # Arrange + level = ProficiencyLevel( + id="intermediate", + description="Intermediate level", + pretopics={"git-basics", "cli-basics"}, + ) + + # Act + result = level.to_dict() + + # Assert + assert isinstance(result, dict) + assert result["id"] == "intermediate" + assert result["description"] == "Intermediate level" + assert isinstance(result["pretopics"], list) + assert "git-basics" in result["pretopics"] + assert "cli-basics" in result["pretopics"] + + def test_to_dict_empty_pretopics(self): + """Test to_dict with empty pretopics.""" + + # Arrange + level = ProficiencyLevel(id="beginner", description="Beginner") + + # Act + result = level.to_dict() + + # Assert + assert result["pretopics"] == [] + + def test_to_json(self): + """Test conversion to JSON string.""" + + # Arrange + level = ProficiencyLevel( + id="intermediate", + description="Intermediate level", + pretopics={"git-basics", "cli-basics"}, + ) + + # Act + json_str = level.to_json() + + # Assert + assert isinstance(json_str, str) + parsed = json.loads(json_str) + assert parsed["id"] == "intermediate" + assert parsed["description"] == "Intermediate level" + assert isinstance(parsed["pretopics"], list) + assert "git-basics" in parsed["pretopics"] + assert "cli-basics" in parsed["pretopics"] + + # Methods - from_dict and from_json + def test_from_dict_all_fields(self): + """Test creating ProficiencyLevel from dict with all fields.""" + + # Arrange + data = { + "id": "intermediate", + "description": "Intermediate level", + "pretopics": ["git-basics", "cli-basics"], + } + + # Act + level = ProficiencyLevel.from_dict(data) + + # Assert + assert level.id == "intermediate" + assert level.description == "Intermediate level" + assert level.pretopics == {"git-basics", "cli-basics"} + + def test_from_dict_minimal_fields(self): + """Test creating ProficiencyLevel from dict with only required fields.""" + + # Arrange + data = {"id": "beginner"} + + # Act + level = ProficiencyLevel.from_dict(data) + + # Assert + assert level.id == "beginner" + assert level.description == "" + assert level.pretopics == set() + + def test_from_json_valid(self): + """Test creating ProficiencyLevel from valid JSON string.""" + + # Arrange + json_data = { + "id": "intermediate", + "description": "Intermediate level", + "pretopics": ["git-basics"], + } + json_str = json.dumps(json_data) + + # Act + level = ProficiencyLevel.from_json(json_str) + + # Assert + assert level.id == "intermediate" + assert level.description == "Intermediate level" + assert level.pretopics == {"git-basics"} + + def test_from_json_invalid(self): + """Test that invalid JSON raises ValueError.""" + + # Arrange + invalid_json = '{"id": "intermediate", invalid json}' + + # Act & Assert + result = None + try: + ProficiencyLevel.from_json(invalid_json) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "Invalid" in str(result) + assert "JSON" in str(result) + + # Round-trip tests + def test_to_dict_from_dict_roundtrip(self): + """Test that to_dict and from_dict are inverse operations.""" + + # Arrange + original = ProficiencyLevel( + id="advanced", + description="Advanced level of proficiency", + pretopics={"topic-1", "topic-2", "topic-3"}, + ) + + # Act + dict_data = original.to_dict() + restored = ProficiencyLevel.from_dict(dict_data) + + # Assert + assert restored == original + + def test_to_json_from_json_roundtrip(self): + """Test that to_json and from_json are inverse operations.""" + + # Arrange + original = ProficiencyLevel( + id="expert", + description="Expert level", + pretopics={"advanced-topic-1", "advanced-topic-2"}, + ) + + # Act + json_str = original.to_json() + restored = ProficiencyLevel.from_json(json_str) + + # Assert + assert restored == original + + # Equality and Representation + def test_equality_same_pretopics(self): + """Test equality when pretopics are the same.""" + # Arrange + pretopics = {"topic-1", "topic-2"} + + # Act + levelA = ProficiencyLevel(id="intermediate", description="level 1", pretopics=pretopics) + levelB = ProficiencyLevel(id="intermediate", description="beginner", pretopics=pretopics) + + # Assert + assert levelA == levelB + + def test_equality_different_pretopics(self): + """Test equality when pretopics are the different.""" + # Arrange + pretopicsA = {"topic-1", "topic-2"} + pretopicsB = {"topic-2", "topic-3"} + + # Act + levelA = ProficiencyLevel(id="intermediate", description="level 1", pretopics=pretopicsA) + levelB = ProficiencyLevel(id="intermediate", description="beginner", pretopics=pretopicsB) + + # Assert + assert levelA != levelB + + def test_repr(self): + """Test string representation.""" + + # Arrange + level = ProficiencyLevel( + id="intermediate", + description="Intermediate level", + pretopics={"topic-1", "topic-2"}, + ) + + # Act + repr_str = repr(level) + + # Assert + assert "ProficiencyLevel" in repr_str + assert "intermediate" in repr_str + assert "Intermediate level" in repr_str + assert "topic-1" in repr_str or "pretopics" in repr_str + + # Integration tests + def test_complete_workflow(self): + """Test a complete workflow with a proficiency level.""" + + # Arrange + # Create a proficiency level with prerequisites + level = ProficiencyLevel( + id="intermediate", + description="Intermediate proficiency", + pretopics={"beginner", "advanced-basics"}, + ) + + # Act + # Prepare for JSON export + json_str = level.to_json() + + # Restore from JSON + restored = ProficiencyLevel.from_json(json_str) + + # Assert + assert restored.id == "intermediate" + assert "beginner" in restored.pretopics + assert "advanced-basics" in restored.pretopics + assert len(restored.pretopics) == 2