From 69240c013d572a0f9d82094b21306bc8ac133414 Mon Sep 17 00:00:00 2001 From: Greyson Lalonde Date: Thu, 5 Mar 2026 19:33:31 -0500 Subject: [PATCH] test: add skills unit and integration tests --- lib/crewai/tests/skills/__init__.py | 0 .../skills/fixtures/invalid-name/SKILL.md | 4 + .../skills/fixtures/minimal-skill/SKILL.md | 4 + .../skills/fixtures/valid-skill/SKILL.md | 24 +++ .../fixtures/valid-skill/assets/config.json | 1 + .../fixtures/valid-skill/references/guide.md | 3 + .../fixtures/valid-skill/scripts/setup.sh | 2 + lib/crewai/tests/skills/test_integration.py | 78 +++++++++ lib/crewai/tests/skills/test_loader.py | 159 +++++++++++++++++ lib/crewai/tests/skills/test_models.py | 106 ++++++++++++ lib/crewai/tests/skills/test_parser.py | 161 ++++++++++++++++++ lib/crewai/tests/skills/test_validation.py | 89 ++++++++++ 12 files changed, 631 insertions(+) create mode 100644 lib/crewai/tests/skills/__init__.py create mode 100644 lib/crewai/tests/skills/fixtures/invalid-name/SKILL.md create mode 100644 lib/crewai/tests/skills/fixtures/minimal-skill/SKILL.md create mode 100644 lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md create mode 100644 lib/crewai/tests/skills/fixtures/valid-skill/assets/config.json create mode 100644 lib/crewai/tests/skills/fixtures/valid-skill/references/guide.md create mode 100644 lib/crewai/tests/skills/fixtures/valid-skill/scripts/setup.sh create mode 100644 lib/crewai/tests/skills/test_integration.py create mode 100644 lib/crewai/tests/skills/test_loader.py create mode 100644 lib/crewai/tests/skills/test_models.py create mode 100644 lib/crewai/tests/skills/test_parser.py create mode 100644 lib/crewai/tests/skills/test_validation.py diff --git a/lib/crewai/tests/skills/__init__.py b/lib/crewai/tests/skills/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/crewai/tests/skills/fixtures/invalid-name/SKILL.md b/lib/crewai/tests/skills/fixtures/invalid-name/SKILL.md new file mode 100644 index 000000000..ce075ee0a --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/invalid-name/SKILL.md @@ -0,0 +1,4 @@ +--- +name: Invalid--Name +description: This skill has an invalid name. +--- diff --git a/lib/crewai/tests/skills/fixtures/minimal-skill/SKILL.md b/lib/crewai/tests/skills/fixtures/minimal-skill/SKILL.md new file mode 100644 index 000000000..2efe9b9ea --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/minimal-skill/SKILL.md @@ -0,0 +1,4 @@ +--- +name: minimal-skill +description: A minimal skill with only required fields. +--- diff --git a/lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md b/lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md new file mode 100644 index 000000000..bc262184d --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md @@ -0,0 +1,24 @@ +--- +name: valid-skill +description: A complete test skill with all optional directories. +license: Apache-2.0 +compatibility: crewai>=0.1.0 +metadata: + author: test + version: "1.0" +allowed_tools: + - web-search + - file-read +--- + +## Instructions + +This skill provides comprehensive instructions for the agent. + +### Usage + +Follow these steps to use the skill effectively. + +### Notes + +Additional context for the agent. diff --git a/lib/crewai/tests/skills/fixtures/valid-skill/assets/config.json b/lib/crewai/tests/skills/fixtures/valid-skill/assets/config.json new file mode 100644 index 000000000..76519fa8c --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/valid-skill/assets/config.json @@ -0,0 +1 @@ +{"key": "value"} diff --git a/lib/crewai/tests/skills/fixtures/valid-skill/references/guide.md b/lib/crewai/tests/skills/fixtures/valid-skill/references/guide.md new file mode 100644 index 000000000..8ef68aeb6 --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/valid-skill/references/guide.md @@ -0,0 +1,3 @@ +# Reference Guide + +This is a reference document for the skill. diff --git a/lib/crewai/tests/skills/fixtures/valid-skill/scripts/setup.sh b/lib/crewai/tests/skills/fixtures/valid-skill/scripts/setup.sh new file mode 100644 index 000000000..1178a039d --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/valid-skill/scripts/setup.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo "setup" diff --git a/lib/crewai/tests/skills/test_integration.py b/lib/crewai/tests/skills/test_integration.py new file mode 100644 index 000000000..6159a56dc --- /dev/null +++ b/lib/crewai/tests/skills/test_integration.py @@ -0,0 +1,78 @@ +"""Integration tests for the skills system.""" + +from pathlib import Path + +import pytest + +from crewai.skills.loader import activate_skill, discover_skills, format_skill_context +from crewai.skills.models import DisclosureLevel + + +def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path: + """Helper to create a skill directory with SKILL.md.""" + skill_dir = parent / name + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Skill {name}\n---\n{body}" + ) + return skill_dir + + +class TestSkillDiscoveryAndActivation: + """End-to-end tests for discover + activate workflow.""" + + def test_discover_and_activate(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "my-skill", body="Use this skill.") + skills = discover_skills(tmp_path) + assert len(skills) == 1 + assert skills[0].disclosure_level == DisclosureLevel.METADATA + + activated = activate_skill(skills[0]) + assert activated.disclosure_level == DisclosureLevel.INSTRUCTIONS + assert activated.instructions == "Use this skill." + + context = format_skill_context(activated) + assert "## Skill: my-skill" in context + assert "Use this skill." in context + + def test_filter_by_skill_names(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "alpha") + _create_skill_dir(tmp_path, "beta") + _create_skill_dir(tmp_path, "gamma") + + all_skills = discover_skills(tmp_path) + wanted = {"alpha", "gamma"} + filtered = [s for s in all_skills if s.name in wanted] + assert {s.name for s in filtered} == {"alpha", "gamma"} + + def test_full_fixture_skill(self) -> None: + fixtures = Path(__file__).parent / "fixtures" + valid_dir = fixtures / "valid-skill" + if not valid_dir.exists(): + pytest.skip("Fixture not found") + + skills = discover_skills(fixtures) + valid_skills = [s for s in skills if s.name == "valid-skill"] + assert len(valid_skills) == 1 + + skill = valid_skills[0] + assert skill.frontmatter.license == "Apache-2.0" + assert skill.frontmatter.allowed_tools == ["web-search", "file-read"] + + activated = activate_skill(skill) + assert "Instructions" in (activated.instructions or "") + + def test_multiple_search_paths(self, tmp_path: Path) -> None: + path_a = tmp_path / "a" + path_a.mkdir() + _create_skill_dir(path_a, "skill-a") + + path_b = tmp_path / "b" + path_b.mkdir() + _create_skill_dir(path_b, "skill-b") + + all_skills = [] + for search_path in [path_a, path_b]: + all_skills.extend(discover_skills(search_path)) + names = {s.name for s in all_skills} + assert names == {"skill-a", "skill-b"} diff --git a/lib/crewai/tests/skills/test_loader.py b/lib/crewai/tests/skills/test_loader.py new file mode 100644 index 000000000..3cbb56a24 --- /dev/null +++ b/lib/crewai/tests/skills/test_loader.py @@ -0,0 +1,159 @@ +"""Tests for skills/loader.py.""" + +from pathlib import Path + +from crewai.skills.loader import ( + activate_skill, + discover_skills, + format_skill_context, + load_resources, +) +from crewai.skills.models import DisclosureLevel, Skill, SkillFrontmatter +from crewai.skills.parser import load_skill_metadata + + +def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path: + """Helper to create a skill directory with SKILL.md.""" + skill_dir = parent / name + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Skill {name}\n---\n{body}" + ) + return skill_dir + + +class TestDiscoverSkills: + """Tests for discover_skills.""" + + def test_finds_valid_skills(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "alpha") + _create_skill_dir(tmp_path, "beta") + skills = discover_skills(tmp_path) + names = {s.name for s in skills} + assert names == {"alpha", "beta"} + + def test_skips_dirs_without_skill_md(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "valid") + (tmp_path / "no-skill").mkdir() + skills = discover_skills(tmp_path) + assert len(skills) == 1 + assert skills[0].name == "valid" + + def test_skips_invalid_skills(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "good-skill") + bad_dir = tmp_path / "bad-skill" + bad_dir.mkdir() + (bad_dir / "SKILL.md").write_text( + "---\nname: Wrong-Name\ndescription: bad\n---\n" + ) + skills = discover_skills(tmp_path) + assert len(skills) == 1 + + def test_empty_directory(self, tmp_path: Path) -> None: + skills = discover_skills(tmp_path) + assert skills == [] + + def test_nonexistent_path(self, tmp_path: Path) -> None: + skills = discover_skills(tmp_path / "nonexistent") + assert skills == [] + + def test_sorted_by_name(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "zebra") + _create_skill_dir(tmp_path, "alpha") + skills = discover_skills(tmp_path) + assert [s.name for s in skills] == ["alpha", "zebra"] + + +class TestActivateSkill: + """Tests for activate_skill.""" + + def test_promotes_to_instructions(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "my-skill", body="Instructions.") + skill = load_skill_metadata(tmp_path / "my-skill") + activated = activate_skill(skill) + assert activated.disclosure_level == DisclosureLevel.INSTRUCTIONS + assert activated.instructions == "Instructions." + + def test_idempotent(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "my-skill") + skill = load_skill_metadata(tmp_path / "my-skill") + activated = activate_skill(skill) + again = activate_skill(activated) + assert again is activated + + +class TestLoadResources: + """Tests for load_resources.""" + + def test_promotes_to_resources(self, tmp_path: Path) -> None: + skill_dir = _create_skill_dir(tmp_path, "my-skill") + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.sh").write_text("#!/bin/bash") + skill = load_skill_metadata(skill_dir) + full = load_resources(skill) + assert full.disclosure_level == DisclosureLevel.RESOURCES + + +class TestFormatSkillContext: + """Tests for format_skill_context.""" + + def test_metadata_level(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="A skill") + skill = Skill( + frontmatter=fm, path=tmp_path, disclosure_level=DisclosureLevel.METADATA + ) + ctx = format_skill_context(skill) + assert "## Skill: test-skill" in ctx + assert "A skill" in ctx + + def test_instructions_level(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="A skill") + skill = Skill( + frontmatter=fm, + path=tmp_path, + disclosure_level=DisclosureLevel.INSTRUCTIONS, + instructions="Do these things.", + ) + ctx = format_skill_context(skill) + assert "## Skill: test-skill" in ctx + assert "Do these things." in ctx + + def test_no_instructions_at_instructions_level(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="A skill") + skill = Skill( + frontmatter=fm, + path=tmp_path, + disclosure_level=DisclosureLevel.INSTRUCTIONS, + instructions=None, + ) + ctx = format_skill_context(skill) + assert ctx == "## Skill: test-skill\nA skill" + + def test_resources_level(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="A skill") + skill = Skill( + frontmatter=fm, + path=tmp_path, + disclosure_level=DisclosureLevel.RESOURCES, + instructions="Do things.", + resource_files={ + "scripts": ["run.sh"], + "assets": ["data.json", "config.yaml"], + }, + ) + ctx = format_skill_context(skill) + assert "### Available Resources" in ctx + assert "**assets/**: data.json, config.yaml" in ctx + assert "**scripts/**: run.sh" in ctx + + def test_resources_level_empty_files(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="A skill") + skill = Skill( + frontmatter=fm, + path=tmp_path, + disclosure_level=DisclosureLevel.RESOURCES, + instructions="Do things.", + resource_files={}, + ) + ctx = format_skill_context(skill) + assert "### Available Resources" not in ctx diff --git a/lib/crewai/tests/skills/test_models.py b/lib/crewai/tests/skills/test_models.py new file mode 100644 index 000000000..1433e708e --- /dev/null +++ b/lib/crewai/tests/skills/test_models.py @@ -0,0 +1,106 @@ +"""Tests for skills/models.py.""" + +from pathlib import Path + +import pytest + +from crewai.skills.models import DisclosureLevel, Skill, SkillFrontmatter + + +class TestDisclosureLevel: + """Tests for DisclosureLevel enum.""" + + def test_ordering(self) -> None: + assert DisclosureLevel.METADATA < DisclosureLevel.INSTRUCTIONS + assert DisclosureLevel.INSTRUCTIONS < DisclosureLevel.RESOURCES + + def test_values(self) -> None: + assert DisclosureLevel.METADATA == 1 + assert DisclosureLevel.INSTRUCTIONS == 2 + assert DisclosureLevel.RESOURCES == 3 + + +class TestSkillFrontmatter: + """Tests for SkillFrontmatter model.""" + + def test_required_fields(self) -> None: + fm = SkillFrontmatter(name="my-skill", description="A test skill") + assert fm.name == "my-skill" + assert fm.description == "A test skill" + assert fm.license is None + assert fm.metadata is None + assert fm.allowed_tools is None + + def test_all_fields(self) -> None: + fm = SkillFrontmatter( + name="web-search", + description="Search the web", + license="Apache-2.0", + compatibility="crewai>=0.1.0", + metadata={"author": "test"}, + allowed_tools=["browser"], + ) + assert fm.license == "Apache-2.0" + assert fm.metadata == {"author": "test"} + assert fm.allowed_tools == ["browser"] + + def test_frozen(self) -> None: + fm = SkillFrontmatter(name="my-skill", description="desc") + with pytest.raises(Exception): + fm.name = "other" # type: ignore[misc] + + def test_invalid_name_rejected(self) -> None: + with pytest.raises(ValueError): + SkillFrontmatter(name="Invalid--Name", description="bad") + + +class TestSkill: + """Tests for Skill model.""" + + def test_properties(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="desc") + skill = Skill(frontmatter=fm, path=tmp_path / "test-skill") + assert skill.name == "test-skill" + assert skill.description == "desc" + assert skill.disclosure_level == DisclosureLevel.METADATA + + def test_resource_dirs(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "test-skill" + skill_dir.mkdir() + fm = SkillFrontmatter(name="test-skill", description="desc") + skill = Skill(frontmatter=fm, path=skill_dir) + assert skill.scripts_dir == skill_dir / "scripts" + assert skill.references_dir == skill_dir / "references" + assert skill.assets_dir == skill_dir / "assets" + + def test_has_dirs_false(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "test-skill" + skill_dir.mkdir() + fm = SkillFrontmatter(name="test-skill", description="desc") + skill = Skill(frontmatter=fm, path=skill_dir) + assert not skill.has_scripts() + assert not skill.has_references() + assert not skill.has_assets() + + def test_has_dirs_true(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "test-skill" + skill_dir.mkdir() + (skill_dir / "scripts").mkdir() + (skill_dir / "references").mkdir() + (skill_dir / "assets").mkdir() + fm = SkillFrontmatter(name="test-skill", description="desc") + skill = Skill(frontmatter=fm, path=skill_dir) + assert skill.has_scripts() + assert skill.has_references() + assert skill.has_assets() + + def test_with_disclosure_level(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="desc") + skill = Skill(frontmatter=fm, path=tmp_path) + promoted = skill.with_disclosure_level( + DisclosureLevel.INSTRUCTIONS, + instructions="Do this.", + ) + assert promoted.disclosure_level == DisclosureLevel.INSTRUCTIONS + assert promoted.instructions == "Do this." + assert skill.disclosure_level == DisclosureLevel.METADATA diff --git a/lib/crewai/tests/skills/test_parser.py b/lib/crewai/tests/skills/test_parser.py new file mode 100644 index 000000000..394e3d624 --- /dev/null +++ b/lib/crewai/tests/skills/test_parser.py @@ -0,0 +1,161 @@ +"""Tests for skills/parser.py.""" + +from pathlib import Path + +import pytest + +from crewai.skills.models import DisclosureLevel +from crewai.skills.parser import ( + SkillParseError, + load_skill_instructions, + load_skill_metadata, + load_skill_resources, + parse_frontmatter, + parse_skill_md, +) + + +class TestParseFrontmatter: + """Tests for parse_frontmatter.""" + + def test_valid_frontmatter_and_body(self) -> None: + content = "---\nname: test\ndescription: A test\n---\n\nBody text here." + fm, body = parse_frontmatter(content) + assert fm["name"] == "test" + assert fm["description"] == "A test" + assert body == "Body text here." + + def test_empty_body(self) -> None: + content = "---\nname: test\ndescription: A test\n---" + fm, body = parse_frontmatter(content) + assert fm["name"] == "test" + assert body == "" + + def test_missing_opening_delimiter(self) -> None: + with pytest.raises(SkillParseError, match="must start with"): + parse_frontmatter("name: test\n---\nBody") + + def test_missing_closing_delimiter(self) -> None: + with pytest.raises(SkillParseError, match="missing closing"): + parse_frontmatter("---\nname: test\n") + + def test_invalid_yaml(self) -> None: + with pytest.raises(SkillParseError, match="Invalid YAML"): + parse_frontmatter("---\n: :\n bad: [yaml\n---\nBody") + + def test_triple_dash_in_body(self) -> None: + content = "---\nname: test\ndescription: desc\n---\n\nBody with --- inside." + fm, body = parse_frontmatter(content) + assert "---" in body + + def test_unicode_content(self) -> None: + content = "---\nname: test\ndescription: Beschreibung\n---\n\nUnicode: \u00e4\u00f6\u00fc\u00df" + fm, body = parse_frontmatter(content) + assert fm["description"] == "Beschreibung" + assert "\u00e4\u00f6\u00fc\u00df" in body + + def test_non_mapping_frontmatter(self) -> None: + with pytest.raises(SkillParseError, match="must be a YAML mapping"): + parse_frontmatter("---\n- item1\n- item2\n---\nBody") + + +class TestParseSkillMd: + """Tests for parse_skill_md.""" + + def test_valid_file(self, tmp_path: Path) -> None: + skill_md = tmp_path / "SKILL.md" + skill_md.write_text( + "---\nname: my-skill\ndescription: desc\n---\nInstructions here." + ) + fm, body = parse_skill_md(skill_md) + assert fm.name == "my-skill" + assert body == "Instructions here." + + def test_file_not_found(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + parse_skill_md(tmp_path / "nonexistent" / "SKILL.md") + + +class TestLoadSkillMetadata: + """Tests for load_skill_metadata.""" + + def test_valid_skill(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test skill\n---\nBody" + ) + skill = load_skill_metadata(skill_dir) + assert skill.name == "my-skill" + assert skill.disclosure_level == DisclosureLevel.METADATA + assert skill.instructions is None + + def test_directory_name_mismatch(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "wrong-name" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test skill\n---\n" + ) + with pytest.raises(ValueError, match="does not match"): + load_skill_metadata(skill_dir) + + +class TestLoadSkillInstructions: + """Tests for load_skill_instructions.""" + + def test_promotes_to_instructions(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test\n---\nFull body." + ) + skill = load_skill_metadata(skill_dir) + promoted = load_skill_instructions(skill) + assert promoted.disclosure_level == DisclosureLevel.INSTRUCTIONS + assert promoted.instructions == "Full body." + + def test_idempotent(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test\n---\nBody." + ) + skill = load_skill_metadata(skill_dir) + promoted = load_skill_instructions(skill) + again = load_skill_instructions(promoted) + assert again is promoted + + +class TestLoadSkillResources: + """Tests for load_skill_resources.""" + + def test_catalogs_resources(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test\n---\nBody." + ) + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.sh").write_text("#!/bin/bash") + (skill_dir / "assets").mkdir() + (skill_dir / "assets" / "data.json").write_text("{}") + + skill = load_skill_metadata(skill_dir) + full = load_skill_resources(skill) + assert full.disclosure_level == DisclosureLevel.RESOURCES + assert full.instructions == "Body." + assert full.resource_files is not None + assert "scripts" in full.resource_files + assert "run.sh" in full.resource_files["scripts"] + assert "assets" in full.resource_files + assert "data.json" in full.resource_files["assets"] + + def test_no_resource_dirs(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test\n---\nBody." + ) + skill = load_skill_metadata(skill_dir) + full = load_skill_resources(skill) + assert full.resource_files == {} diff --git a/lib/crewai/tests/skills/test_validation.py b/lib/crewai/tests/skills/test_validation.py new file mode 100644 index 000000000..95cc3135f --- /dev/null +++ b/lib/crewai/tests/skills/test_validation.py @@ -0,0 +1,89 @@ +"""Tests for skills/validation.py.""" + +from pathlib import Path + +import pytest + +from crewai.skills.validation import ( + MAX_SKILL_NAME_LENGTH, + validate_directory_name, + validate_skill_name, +) + + +class TestValidateSkillName: + """Tests for validate_skill_name.""" + + def test_simple_name(self) -> None: + assert validate_skill_name("web-search") == "web-search" + + def test_single_word(self) -> None: + assert validate_skill_name("search") == "search" + + def test_numeric(self) -> None: + assert validate_skill_name("tool3") == "tool3" + + def test_all_digits(self) -> None: + assert validate_skill_name("123") == "123" + + def test_single_char(self) -> None: + assert validate_skill_name("a") == "a" + + def test_max_length(self) -> None: + name = "a" * MAX_SKILL_NAME_LENGTH + assert validate_skill_name(name) == name + + def test_multi_hyphen_segments(self) -> None: + assert validate_skill_name("my-cool-skill") == "my-cool-skill" + + def test_empty_raises(self) -> None: + with pytest.raises(ValueError, match="must not be empty"): + validate_skill_name("") + + def test_too_long_raises(self) -> None: + name = "a" * (MAX_SKILL_NAME_LENGTH + 1) + with pytest.raises(ValueError, match="at most"): + validate_skill_name(name) + + def test_uppercase_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid skill name"): + validate_skill_name("MySkill") + + def test_leading_hyphen_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid skill name"): + validate_skill_name("-skill") + + def test_trailing_hyphen_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid skill name"): + validate_skill_name("skill-") + + def test_consecutive_hyphens_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid skill name"): + validate_skill_name("my--skill") + + def test_underscore_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid skill name"): + validate_skill_name("my_skill") + + def test_space_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid skill name"): + validate_skill_name("my skill") + + def test_special_chars_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid skill name"): + validate_skill_name("skill@v1") + + +class TestValidateDirectoryName: + """Tests for validate_directory_name.""" + + def test_matching_names(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + validate_directory_name(skill_dir, "my-skill") + + def test_mismatched_names(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "other-name" + skill_dir.mkdir() + with pytest.raises(ValueError, match="does not match"): + validate_directory_name(skill_dir, "my-skill")