chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,395 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for skill utilities."""
|
||||
|
||||
import builtins
|
||||
import io
|
||||
import sys
|
||||
from unittest import mock
|
||||
import zipfile
|
||||
|
||||
from google.adk.skills import list_skills_in_dir
|
||||
from google.adk.skills import list_skills_in_gcs_dir as _list_skills_in_gcs_dir
|
||||
from google.adk.skills import load_skill_from_dir as _load_skill_from_dir
|
||||
from google.adk.skills import load_skill_from_gcs_dir as _load_skill_from_gcs_dir
|
||||
from google.adk.skills._utils import _load_skill_from_zip_bytes
|
||||
from google.adk.skills._utils import _read_skill_properties
|
||||
from google.adk.skills._utils import _validate_skill_dir
|
||||
import pytest
|
||||
|
||||
|
||||
def test__load_skill_from_dir(tmp_path):
|
||||
"""Tests loading a skill from a directory."""
|
||||
skill_dir = tmp_path / "test-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
skill_md_content = """---
|
||||
name: test-skill
|
||||
description: Test description
|
||||
---
|
||||
Test instructions
|
||||
"""
|
||||
(skill_dir / "SKILL.md").write_text(skill_md_content)
|
||||
|
||||
# Create references
|
||||
ref_dir = skill_dir / "references"
|
||||
ref_dir.mkdir()
|
||||
(ref_dir / "ref1.md").write_text("ref1 content")
|
||||
|
||||
# Create assets
|
||||
assets_dir = skill_dir / "assets"
|
||||
assets_dir.mkdir()
|
||||
(assets_dir / "asset1.txt").write_text("asset1 content")
|
||||
|
||||
# Create scripts
|
||||
scripts_dir = skill_dir / "scripts"
|
||||
scripts_dir.mkdir()
|
||||
(scripts_dir / "script1.sh").write_text("echo hello")
|
||||
|
||||
skill = _load_skill_from_dir(skill_dir)
|
||||
|
||||
assert skill.name == "test-skill"
|
||||
assert skill.description == "Test description"
|
||||
assert skill.instructions == "Test instructions"
|
||||
assert skill.resources.get_reference("ref1.md") == "ref1 content"
|
||||
assert skill.resources.get_asset("asset1.txt") == "asset1 content"
|
||||
assert skill.resources.get_script("script1.sh").src == "echo hello"
|
||||
|
||||
|
||||
def test_allowed_tools_yaml_key(tmp_path):
|
||||
"""Tests that allowed-tools YAML key loads correctly."""
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
skill_md = """---
|
||||
name: my-skill
|
||||
description: A skill
|
||||
allowed-tools: "some-tool-*"
|
||||
---
|
||||
Instructions here
|
||||
"""
|
||||
(skill_dir / "SKILL.md").write_text(skill_md)
|
||||
|
||||
skill = _load_skill_from_dir(skill_dir)
|
||||
assert skill.frontmatter.allowed_tools == "some-tool-*"
|
||||
|
||||
|
||||
def test_name_directory_mismatch(tmp_path):
|
||||
"""Tests that name-directory mismatch raises ValueError."""
|
||||
skill_dir = tmp_path / "wrong-dir"
|
||||
skill_dir.mkdir()
|
||||
|
||||
skill_md = """---
|
||||
name: my-skill
|
||||
description: A skill
|
||||
---
|
||||
Body
|
||||
"""
|
||||
(skill_dir / "SKILL.md").write_text(skill_md)
|
||||
|
||||
with pytest.raises(ValueError, match="does not match directory"):
|
||||
_load_skill_from_dir(skill_dir)
|
||||
|
||||
|
||||
def test_validate_skill_dir_valid(tmp_path):
|
||||
"""Tests validate_skill_dir with a valid skill."""
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
skill_md = """---
|
||||
name: my-skill
|
||||
description: A skill
|
||||
---
|
||||
Body
|
||||
"""
|
||||
(skill_dir / "SKILL.md").write_text(skill_md)
|
||||
|
||||
problems = _validate_skill_dir(skill_dir)
|
||||
assert problems == []
|
||||
|
||||
|
||||
def test_validate_skill_dir_missing_dir(tmp_path):
|
||||
"""Tests validate_skill_dir with missing directory."""
|
||||
problems = _validate_skill_dir(tmp_path / "nonexistent")
|
||||
assert len(problems) == 1
|
||||
assert "does not exist" in problems[0]
|
||||
|
||||
|
||||
def test_validate_skill_dir_missing_skill_md(tmp_path):
|
||||
"""Tests validate_skill_dir with missing SKILL.md."""
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
problems = _validate_skill_dir(skill_dir)
|
||||
assert len(problems) == 1
|
||||
assert "SKILL.md not found" in problems[0]
|
||||
|
||||
|
||||
def test_validate_skill_dir_name_mismatch(tmp_path):
|
||||
"""Tests validate_skill_dir catches name-directory mismatch."""
|
||||
skill_dir = tmp_path / "wrong-dir"
|
||||
skill_dir.mkdir()
|
||||
|
||||
skill_md = """---
|
||||
name: my-skill
|
||||
description: A skill
|
||||
---
|
||||
Body
|
||||
"""
|
||||
(skill_dir / "SKILL.md").write_text(skill_md)
|
||||
|
||||
problems = _validate_skill_dir(skill_dir)
|
||||
assert any("does not match" in p for p in problems)
|
||||
|
||||
|
||||
def test_validate_skill_dir_unknown_fields(tmp_path):
|
||||
"""Tests validate_skill_dir detects unknown frontmatter fields."""
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
skill_md = """---
|
||||
name: my-skill
|
||||
description: A skill
|
||||
unknown-field: something
|
||||
---
|
||||
Body
|
||||
"""
|
||||
(skill_dir / "SKILL.md").write_text(skill_md)
|
||||
|
||||
problems = _validate_skill_dir(skill_dir)
|
||||
assert any("Unknown frontmatter" in p for p in problems)
|
||||
|
||||
|
||||
def test__read_skill_properties(tmp_path):
|
||||
"""Tests read_skill_properties basic usage."""
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
skill_md = """---
|
||||
name: my-skill
|
||||
description: A cool skill
|
||||
license: MIT
|
||||
---
|
||||
Body content
|
||||
"""
|
||||
(skill_dir / "SKILL.md").write_text(skill_md)
|
||||
|
||||
fm = _read_skill_properties(skill_dir)
|
||||
assert fm.name == "my-skill"
|
||||
assert fm.description == "A cool skill"
|
||||
assert fm.license == "MIT"
|
||||
|
||||
|
||||
@mock.patch("google.cloud.storage.Client")
|
||||
def test__list_skills_in_gcs_dir(mock_client_class):
|
||||
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client_class.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.bucket.return_value = mock_bucket
|
||||
|
||||
mock_iterator = mock.MagicMock()
|
||||
mock_iterator.prefixes = ["skills/my-skill/"]
|
||||
mock_bucket.list_blobs.return_value = mock_iterator
|
||||
|
||||
mock_blob = mock.MagicMock()
|
||||
mock_blob.exists.return_value = True
|
||||
mock_blob.download_as_text.return_value = (
|
||||
"---\nname: my-skill\ndescription: A skill\n---\nBody"
|
||||
)
|
||||
mock_bucket.blob.return_value = mock_blob
|
||||
|
||||
skills = _list_skills_in_gcs_dir("my-bucket", "skills/")
|
||||
assert "my-skill" in skills
|
||||
assert skills["my-skill"].name == "my-skill"
|
||||
|
||||
|
||||
@mock.patch("google.cloud.storage.Client")
|
||||
@mock.patch("logging.warning")
|
||||
def test__list_skills_in_gcs_dir_skips_invalid(
|
||||
mock_logging_warning, mock_client_class
|
||||
):
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client_class.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.bucket.return_value = mock_bucket
|
||||
|
||||
mock_iterator = mock.MagicMock()
|
||||
mock_iterator.prefixes = ["skills/invalid-skill/", "skills/valid-skill/"]
|
||||
mock_bucket.list_blobs.return_value = mock_iterator
|
||||
|
||||
def mock_blob_side_effect(path):
|
||||
m = mock.MagicMock()
|
||||
m.exists.return_value = True
|
||||
if "invalid-skill" in path:
|
||||
m.download_as_text.return_value = "invalid yaml content"
|
||||
else:
|
||||
m.download_as_text.return_value = (
|
||||
"---\nname: valid-skill\ndescription: A skill\n---\nBody"
|
||||
)
|
||||
return m
|
||||
|
||||
mock_bucket.blob.side_effect = mock_blob_side_effect
|
||||
|
||||
skills = _list_skills_in_gcs_dir("my-bucket", "skills/")
|
||||
assert "valid-skill" in skills
|
||||
assert "invalid-skill" not in skills
|
||||
|
||||
# Verify warning was logged for the invalid skill
|
||||
mock_logging_warning.assert_called_once()
|
||||
args, _ = mock_logging_warning.call_args
|
||||
assert "Skipping invalid skill" in args[0]
|
||||
assert args[1] == "invalid-skill"
|
||||
assert args[2] == "my-bucket"
|
||||
|
||||
|
||||
@mock.patch("google.cloud.storage.Client")
|
||||
def test__load_skill_from_gcs_dir(mock_client_class):
|
||||
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client_class.return_value = mock_client
|
||||
mock_bucket = mock.MagicMock()
|
||||
mock_client.bucket.return_value = mock_bucket
|
||||
|
||||
def mock_blob_side_effect(path):
|
||||
m = mock.MagicMock()
|
||||
if path.endswith("SKILL.md"):
|
||||
m.exists.return_value = True
|
||||
m.download_as_text.return_value = (
|
||||
"---\nname: my-skill\ndescription: Test description\n---\nTest"
|
||||
" instructions"
|
||||
)
|
||||
else:
|
||||
m.exists.return_value = False
|
||||
return m
|
||||
|
||||
mock_bucket.blob.side_effect = mock_blob_side_effect
|
||||
|
||||
# For resources
|
||||
def list_blobs_side_effect(prefix=None):
|
||||
if prefix.endswith("references/"):
|
||||
m = mock.MagicMock()
|
||||
m.name = prefix + "ref1.md"
|
||||
m.download_as_text.return_value = "ref1 content"
|
||||
return [m]
|
||||
return []
|
||||
|
||||
mock_bucket.list_blobs.side_effect = list_blobs_side_effect
|
||||
|
||||
skill = _load_skill_from_gcs_dir("my-bucket", "skills/my-skill/")
|
||||
|
||||
assert skill.name == "my-skill"
|
||||
assert skill.description == "Test description"
|
||||
assert skill.instructions == "Test instructions"
|
||||
# Using dict access for reference
|
||||
assert skill.resources.get_reference("ref1.md") == "ref1 content"
|
||||
|
||||
|
||||
def test_list_skills_in_dir(tmp_path):
|
||||
"""Tests listing skills in a directory."""
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
|
||||
# Valid skill 1
|
||||
skill1_dir = skills_dir / "skill1"
|
||||
skill1_dir.mkdir()
|
||||
(skill1_dir / "SKILL.md").write_text(
|
||||
"---\nname: skill1\ndescription: desc1\n---\nbody"
|
||||
)
|
||||
|
||||
# Valid skill 2
|
||||
skill2_dir = skills_dir / "skill2"
|
||||
skill2_dir.mkdir()
|
||||
(skill2_dir / "SKILL.md").write_text(
|
||||
"---\nname: skill2\ndescription: desc2\n---\nbody"
|
||||
)
|
||||
|
||||
# Invalid skill: missing SKILL.md
|
||||
(skills_dir / "invalid-no-md").mkdir()
|
||||
|
||||
# Invalid skill: invalid YAML
|
||||
invalid_yaml_dir = skills_dir / "invalid-yaml"
|
||||
invalid_yaml_dir.mkdir()
|
||||
(invalid_yaml_dir / "SKILL.md").write_text("---\ninvalid: yaml: :\n---\nbody")
|
||||
|
||||
# Invalid skill: name mismatch
|
||||
mismatch_dir = skills_dir / "mismatch"
|
||||
mismatch_dir.mkdir()
|
||||
(mismatch_dir / "SKILL.md").write_text(
|
||||
"---\nname: other-name\ndescription: desc\n---\nbody"
|
||||
)
|
||||
|
||||
skills = list_skills_in_dir(skills_dir)
|
||||
|
||||
assert len(skills) == 2
|
||||
assert "skill1" in skills
|
||||
assert "skill2" in skills
|
||||
assert skills["skill1"].name == "skill1"
|
||||
assert skills["skill2"].name == "skill2"
|
||||
|
||||
|
||||
def test_list_skills_in_dir_missing_base_path(tmp_path):
|
||||
"""Tests list_skills_in_dir with missing base directory."""
|
||||
|
||||
skills = list_skills_in_dir(tmp_path / "nonexistent")
|
||||
assert skills == {}
|
||||
|
||||
|
||||
def test__load_skill_from_zip_bytes():
|
||||
"""Tests loading a skill directly from in-memory zip file bytes."""
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w") as z:
|
||||
z.writestr(
|
||||
"SKILL.md",
|
||||
"---\nname: my-skill\ndescription: A skill\n---\nBody instructions",
|
||||
)
|
||||
z.writestr("references/ref1.md", "ref1 content")
|
||||
z.writestr("scripts/script1.sh", "echo hello")
|
||||
|
||||
skill = _load_skill_from_zip_bytes(zip_buffer.getvalue())
|
||||
assert skill.frontmatter.name == "my-skill"
|
||||
assert skill.frontmatter.description == "A skill"
|
||||
assert skill.instructions == "Body instructions"
|
||||
assert skill.resources.get_reference("ref1.md") == "ref1 content"
|
||||
assert skill.resources.get_script("script1.sh").src == "echo hello"
|
||||
|
||||
|
||||
def test__list_skills_in_gcs_dir_import_error():
|
||||
"""Tests list_skills_in_gcs_dir raises ImportError when storage missing."""
|
||||
real_import = builtins.__import__
|
||||
|
||||
def mock_import(name, globals=None, locals=None, fromlist=(), level=0):
|
||||
if name == "google.cloud" and "storage" in (fromlist or ()):
|
||||
raise ImportError("No module named 'google.cloud.storage'")
|
||||
return real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
with mock.patch("builtins.__import__", mock_import):
|
||||
with pytest.raises(ImportError, match="google-cloud-storage is required"):
|
||||
_list_skills_in_gcs_dir("my-bucket", "skills/")
|
||||
|
||||
|
||||
def test__load_skill_from_gcs_dir_import_error():
|
||||
"""Tests load_skill_from_gcs_dir raises ImportError when storage missing."""
|
||||
real_import = builtins.__import__
|
||||
|
||||
def mock_import(name, globals=None, locals=None, fromlist=(), level=0):
|
||||
if name == "google.cloud" and "storage" in (fromlist or ()):
|
||||
raise ImportError("No module named 'google.cloud.storage'")
|
||||
return real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
with mock.patch("builtins.__import__", mock_import):
|
||||
with pytest.raises(ImportError, match="google-cloud-storage is required"):
|
||||
_load_skill_from_gcs_dir("my-bucket", "skills/my-skill/")
|
||||
@@ -0,0 +1,252 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for skill models."""
|
||||
|
||||
from google.adk.features import FeatureName
|
||||
from google.adk.features._feature_registry import temporary_feature_override
|
||||
from google.adk.skills import models
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
|
||||
|
||||
def test_frontmatter():
|
||||
"""Tests Frontmatter model."""
|
||||
frontmatter = models.Frontmatter(
|
||||
name="test-skill",
|
||||
description="Test description",
|
||||
license="Apache 2.0",
|
||||
compatibility="test",
|
||||
allowed_tools="test",
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
assert frontmatter.name == "test-skill"
|
||||
assert frontmatter.description == "Test description"
|
||||
assert frontmatter.license == "Apache 2.0"
|
||||
assert frontmatter.compatibility == "test"
|
||||
assert frontmatter.allowed_tools == "test"
|
||||
assert frontmatter.metadata == {"key": "value"}
|
||||
|
||||
|
||||
def test_resources():
|
||||
"""Tests Resources model."""
|
||||
resources = models.Resources(
|
||||
references={"ref1": "ref content"},
|
||||
assets={"asset1": "asset content"},
|
||||
scripts={"script1": models.Script(src="print('hello')")},
|
||||
)
|
||||
assert resources.get_reference("ref1") == "ref content"
|
||||
assert resources.get_asset("asset1") == "asset content"
|
||||
assert resources.get_script("script1").src == "print('hello')"
|
||||
assert resources.get_reference("ref2") is None
|
||||
assert resources.get_asset("asset2") is None
|
||||
assert resources.get_script("script2") is None
|
||||
assert resources.list_references() == ["ref1"]
|
||||
assert resources.list_assets() == ["asset1"]
|
||||
assert resources.list_scripts() == ["script1"]
|
||||
|
||||
|
||||
def test_skill_properties():
|
||||
"""Tests Skill model."""
|
||||
frontmatter = models.Frontmatter(
|
||||
name="my-skill", description="my description"
|
||||
)
|
||||
skill = models.Skill(frontmatter=frontmatter, instructions="do this")
|
||||
assert skill.name == "my-skill"
|
||||
assert skill.description == "my description"
|
||||
|
||||
|
||||
def test_script_to_string():
|
||||
"""Tests Script model."""
|
||||
script = models.Script(src="print('hello')")
|
||||
assert str(script) == "print('hello')"
|
||||
|
||||
|
||||
# --- Name validation tests ---
|
||||
|
||||
|
||||
def test_name_too_long():
|
||||
with pytest.raises(ValidationError, match="at most 64 characters"):
|
||||
models.Frontmatter(name="a" * 65, description="desc")
|
||||
|
||||
|
||||
def test_name_uppercase_rejected():
|
||||
with pytest.raises(ValidationError, match="lowercase kebab-case"):
|
||||
models.Frontmatter(name="My-Skill", description="desc")
|
||||
|
||||
|
||||
def test_name_leading_hyphen():
|
||||
with pytest.raises(ValidationError, match="lowercase kebab-case"):
|
||||
models.Frontmatter(name="-my-skill", description="desc")
|
||||
|
||||
|
||||
def test_name_trailing_hyphen():
|
||||
with pytest.raises(ValidationError, match="lowercase kebab-case"):
|
||||
models.Frontmatter(name="my-skill-", description="desc")
|
||||
|
||||
|
||||
def test_name_consecutive_hyphens():
|
||||
with pytest.raises(ValidationError, match="lowercase kebab-case"):
|
||||
models.Frontmatter(name="my--skill", description="desc")
|
||||
|
||||
|
||||
def test_name_underscore_rejected_by_default():
|
||||
with pytest.raises(ValidationError, match="lowercase kebab-case"):
|
||||
models.Frontmatter(name="my_skill", description="desc")
|
||||
|
||||
|
||||
def test_name_valid_underscore_preserved_with_flag():
|
||||
with temporary_feature_override(FeatureName.SNAKE_CASE_SKILL_NAME, True):
|
||||
fm = models.Frontmatter(name="my_skill", description="desc")
|
||||
assert fm.name == "my_skill"
|
||||
|
||||
|
||||
def test_name_invalid_chars_ampersand():
|
||||
with pytest.raises(
|
||||
ValidationError, match="name must be lowercase kebab-case"
|
||||
):
|
||||
models.Frontmatter(name="skill&name", description="desc")
|
||||
|
||||
|
||||
def test_name_mixed_delimiters_rejected_by_default():
|
||||
with pytest.raises(
|
||||
ValidationError, match="name must be lowercase kebab-case"
|
||||
):
|
||||
models.Frontmatter(name="my-skill_1", description="desc")
|
||||
|
||||
|
||||
def test_name_mixed_delimiters_rejected_with_flag():
|
||||
with temporary_feature_override(FeatureName.SNAKE_CASE_SKILL_NAME, True):
|
||||
with pytest.raises(
|
||||
ValidationError, match="Mixing hyphens and underscores is not allowed"
|
||||
):
|
||||
models.Frontmatter(name="my-skill_1", description="desc")
|
||||
|
||||
|
||||
def test_name_valid_passes():
|
||||
fm = models.Frontmatter(name="my-skill-2", description="desc")
|
||||
assert fm.name == "my-skill-2"
|
||||
|
||||
|
||||
def test_name_single_word():
|
||||
fm = models.Frontmatter(name="skill", description="desc")
|
||||
assert fm.name == "skill"
|
||||
|
||||
|
||||
# --- Description validation tests ---
|
||||
|
||||
|
||||
def test_description_empty():
|
||||
with pytest.raises(ValidationError, match="must not be empty"):
|
||||
models.Frontmatter(name="my-skill", description="")
|
||||
|
||||
|
||||
def test_description_too_long():
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="at most 1024 characters. Description length: 1025",
|
||||
):
|
||||
models.Frontmatter(name="my-skill", description="x" * 1025)
|
||||
|
||||
|
||||
# --- Compatibility validation tests ---
|
||||
|
||||
|
||||
def test_compatibility_too_long():
|
||||
with pytest.raises(ValidationError, match="at most 500 characters"):
|
||||
models.Frontmatter(
|
||||
name="my-skill", description="desc", compatibility="c" * 501
|
||||
)
|
||||
|
||||
|
||||
# --- Extra field rejected ---
|
||||
|
||||
|
||||
def test_extra_field_allowed():
|
||||
fm = models.Frontmatter.model_validate({
|
||||
"name": "my-skill",
|
||||
"description": "desc",
|
||||
"unknown_field": "value",
|
||||
})
|
||||
assert fm.name == "my-skill"
|
||||
|
||||
|
||||
# --- allowed-tools alias ---
|
||||
|
||||
|
||||
def test_allowed_tools_alias_via_model_validate():
|
||||
fm = models.Frontmatter.model_validate({
|
||||
"name": "my-skill",
|
||||
"description": "desc",
|
||||
"allowed-tools": "tool-pattern",
|
||||
})
|
||||
assert fm.allowed_tools == "tool-pattern"
|
||||
|
||||
|
||||
def test_allowed_tools_serialization_alias():
|
||||
fm = models.Frontmatter(
|
||||
name="my-skill", description="desc", allowed_tools="tool-pattern"
|
||||
)
|
||||
dumped = fm.model_dump(by_alias=True)
|
||||
assert "allowed-tools" in dumped
|
||||
assert dumped["allowed-tools"] == "tool-pattern"
|
||||
|
||||
|
||||
def test_metadata_adk_additional_tools_list():
|
||||
fm = models.Frontmatter.model_validate({
|
||||
"name": "my-skill",
|
||||
"description": "desc",
|
||||
"metadata": {"adk_additional_tools": ["tool1", "tool2"]},
|
||||
})
|
||||
assert fm.metadata["adk_additional_tools"] == ["tool1", "tool2"]
|
||||
|
||||
|
||||
def test_metadata_adk_additional_tools_rejected_as_string():
|
||||
with pytest.raises(
|
||||
ValidationError, match="adk_additional_tools must be a list of strings"
|
||||
):
|
||||
models.Frontmatter.model_validate({
|
||||
"name": "my-skill",
|
||||
"description": "desc",
|
||||
"metadata": {"adk_additional_tools": "tool1 tool2"},
|
||||
})
|
||||
|
||||
|
||||
def test_metadata_adk_additional_tools_invalid_type():
|
||||
with pytest.raises(
|
||||
ValidationError, match="adk_additional_tools must be a list of strings"
|
||||
):
|
||||
models.Frontmatter.model_validate({
|
||||
"name": "my-skill",
|
||||
"description": "desc",
|
||||
"metadata": {"adk_additional_tools": 123},
|
||||
})
|
||||
|
||||
|
||||
def test_metadata_adk_inject_state_bool():
|
||||
fm = models.Frontmatter.model_validate({
|
||||
"name": "my-skill",
|
||||
"description": "desc",
|
||||
"metadata": {"adk_inject_state": True},
|
||||
})
|
||||
assert fm.metadata["adk_inject_state"] is True
|
||||
|
||||
|
||||
def test_metadata_adk_inject_state_rejected_as_string():
|
||||
with pytest.raises(ValidationError, match="adk_inject_state must be a bool"):
|
||||
models.Frontmatter.model_validate({
|
||||
"name": "my-skill",
|
||||
"description": "desc",
|
||||
"metadata": {"adk_inject_state": "true"},
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit tests for prompt."""
|
||||
|
||||
from google.adk.skills import models
|
||||
from google.adk.skills import prompt
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPrompt:
|
||||
|
||||
def test_format_skills_as_xml(self):
|
||||
skills = [
|
||||
models.Frontmatter(name="skill1", description="desc1"),
|
||||
models.Frontmatter(name="skill2", description="desc2"),
|
||||
]
|
||||
xml = prompt.format_skills_as_xml(skills)
|
||||
|
||||
assert "<name>\nskill1\n</name>" in xml
|
||||
assert "<description>\ndesc1\n</description>" in xml
|
||||
assert "<location>" not in xml
|
||||
assert "<name>\nskill2\n</name>" in xml
|
||||
assert "<description>\ndesc2\n</description>" in xml
|
||||
assert xml.startswith("<available_skills>")
|
||||
assert xml.endswith("</available_skills>")
|
||||
|
||||
def test_format_skills_as_xml_empty(self):
|
||||
xml = prompt.format_skills_as_xml([])
|
||||
assert xml == "<available_skills>\n</available_skills>"
|
||||
|
||||
def test_format_skills_as_xml_escaping(self):
|
||||
skills = [
|
||||
models.Frontmatter(name="my-skill", description="desc<ription>"),
|
||||
]
|
||||
xml = prompt.format_skills_as_xml(skills)
|
||||
assert "my-skill" in xml
|
||||
assert "desc<ription>" in xml
|
||||
Reference in New Issue
Block a user