SkillWiki / skillwiki /tests /test_models.py
Apollo-universe
HF Spaces deploy: initial orphan branch (no binary history)
a9df8b1
Raw
History Blame Contribute Delete
21.6 kB
"""Phase 1 核心数据模型测试套件。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict
import pytest
from skillos.models import (
EdgeType,
ExecutionStatus,
ExperienceSourceType,
ExperienceUnit,
GraphStats,
MetaSkillCategory,
Skill,
SkillEdge,
SkillExecutionRecord,
SkillEvaluation,
SkillGraphNode,
SkillImplementation,
SkillInterface,
SkillMetrics,
SkillProposal,
SkillProposalStatus,
SkillProvenance,
SkillState,
SkillSubgraph,
SkillTestCase,
SkillType,
TrajectoryStep,
)
# ===========================================================================
# Skill Model Tests
# ===========================================================================
class TestSkillModel:
def test_minimal_skill_creation(self):
skill = Skill(name="click_element")
assert skill.name == "click_element"
assert skill.skill_type == SkillType.ATOMIC
assert skill.state == SkillState.DRAFT
assert skill.version == "1.0.0"
assert skill.skill_id # UUID 自动生成
def test_display_name_auto_set(self):
skill = Skill(name="fill_form")
assert skill.display_name == "Fill Form"
def test_display_name_custom(self):
skill = Skill(name="fill_form", display_name="Fill Structured Form")
assert skill.display_name == "Fill Structured Form"
def test_name_validation_snake_case(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
Skill(name="FillForm") # 不是 snake_case
with pytest.raises(ValidationError):
Skill(name="fill-form") # 连字符不允许
with pytest.raises(ValidationError):
Skill(name="123skill") # 数字开头
def test_name_valid_snake_case(self):
skill = Skill(name="fill_form_v2")
assert skill.name == "fill_form_v2"
def test_tags_normalized(self):
skill = Skill(name="test_skill", tags=[" Web ", "FORM", "input"])
assert skill.tags == ["web", "form", "input"]
def test_meta_skill_requires_category(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
Skill(name="strategic_skill", skill_type=SkillType.STRATEGIC)
def test_meta_skill_with_category(self):
skill = Skill(
name="lifecycle_manager",
skill_type=SkillType.STRATEGIC,
meta_category=MetaSkillCategory.LIFECYCLE,
)
assert skill.meta_category == MetaSkillCategory.LIFECYCLE
def test_non_meta_skill_cannot_have_category(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
Skill(
name="atomic_skill",
skill_type=SkillType.ATOMIC,
meta_category=MetaSkillCategory.LIFECYCLE,
)
def test_version_pattern(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
Skill(name="test_skill", version="v1.0")
with pytest.raises(ValidationError):
Skill(name="test_skill", version="1.0")
def test_granularity_level_range(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
Skill(name="test_skill", granularity_level=0)
with pytest.raises(ValidationError):
Skill(name="test_skill", granularity_level=6)
def test_state_transition_valid(self):
skill = Skill(name="test_skill", state=SkillState.DRAFT)
skill.transition_to(SkillState.VERIFIED)
assert skill.state == SkillState.VERIFIED
def test_state_transition_invalid(self):
skill = Skill(name="test_skill", state=SkillState.DRAFT)
with pytest.raises(ValueError, match="非法状态转换"):
skill.transition_to(SkillState.RELEASED) # 必须先 VERIFIED
def test_state_transition_to_released_sets_timestamp(self):
skill = Skill(name="test_skill", state=SkillState.VERIFIED)
skill.transition_to(SkillState.RELEASED)
assert skill.released_at is not None
def test_state_transition_to_deprecated_sets_timestamp(self):
skill = Skill(name="test_skill", state=SkillState.RELEASED)
skill.transition_to(SkillState.DEPRECATED)
assert skill.deprecated_at is not None
def test_is_usable(self):
skill = Skill(name="test_skill", state=SkillState.RELEASED)
assert skill.is_usable()
skill2 = Skill(name="test_skill2", state=SkillState.DEGRADED)
assert skill2.is_usable()
skill3 = Skill(name="test_skill3", state=SkillState.DRAFT)
assert not skill3.is_usable()
def test_bump_version_patch(self):
skill = Skill(name="test_skill", version="1.2.3")
skill.bump_version("patch")
assert skill.version == "1.2.4"
def test_bump_version_minor(self):
skill = Skill(name="test_skill", version="1.2.3")
skill.bump_version("minor")
assert skill.version == "1.3.0"
def test_bump_version_major(self):
skill = Skill(name="test_skill", version="1.2.3")
skill.bump_version("major")
assert skill.version == "2.0.0"
def test_record_execution_success(self):
skill = Skill(name="test_skill")
skill.record_execution(success=True, latency_ms=100.0)
assert skill.metrics.usage_count == 1
assert skill.metrics.success_count == 1
assert skill.metrics.failure_count == 0
assert skill.metrics.avg_latency_ms == 100.0
def test_record_execution_failure(self):
skill = Skill(name="test_skill")
skill.record_execution(success=False, latency_ms=50.0)
assert skill.metrics.failure_count == 1
assert skill.metrics.success_count == 0
def test_record_execution_avg_latency(self):
skill = Skill(name="test_skill")
skill.record_execution(success=True, latency_ms=100.0)
skill.record_execution(success=True, latency_ms=200.0)
assert skill.metrics.avg_latency_ms == pytest.approx(150.0)
def test_success_rate_calculation(self):
skill = Skill(name="test_skill")
skill.record_execution(success=True, latency_ms=10.0)
skill.record_execution(success=True, latency_ms=10.0)
skill.record_execution(success=False, latency_ms=10.0)
assert skill.metrics.success_rate == pytest.approx(2 / 3)
def test_success_rate_zero_executions(self):
skill = Skill(name="test_skill")
assert skill.metrics.success_rate == 0.0
def test_to_graph_node(self):
skill = Skill(name="test_skill", state=SkillState.RELEASED)
node = skill.to_graph_node()
assert node["skill_id"] == skill.skill_id
assert node["name"] == "test_skill"
assert node["state"] == "S4"
assert "success_rate" in node
assert "usage_count" in node
def test_default_evaluation_contract(self):
skill = Skill(name="schema_v02_skill")
assert skill.evaluation.verifier_specs == []
assert skill.evaluation.test_case_refs == []
assert skill.evaluation.benchmark_task_ids == []
def test_skill_evaluation_normalizes_refs(self):
evaluation = SkillEvaluation(
verifier_specs=[{"type": "json_equals", "path": "output.success", "value": True}],
test_case_refs=[" case-1 ", ""],
benchmark_task_ids=[" task-1 ", "task-2"],
validation_summary="passes fixed demo verifier",
)
assert evaluation.test_case_refs == ["case-1"]
assert evaluation.benchmark_task_ids == ["task-1", "task-2"]
assert evaluation.verifier_specs[0]["type"] == "json_equals"
def test_skill_evaluation_rejects_blank_verifier_type(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
SkillEvaluation(verifier_specs=[{"type": " "}])
def test_skill_evaluation_accepts_backlog_alias(self):
evaluation = SkillEvaluation.model_validate({"test_cases_refs": ["case-from-backlog"]})
assert evaluation.test_case_refs == ["case-from-backlog"]
# ===========================================================================
# SkillInterface Tests
# ===========================================================================
class TestSkillInterface:
def test_default_interface(self):
iface = SkillInterface()
assert iface.input_schema == {}
assert iface.preconditions == []
def test_full_interface(self):
iface = SkillInterface(
input_schema={"type": "object", "properties": {"x": {"type": "string"}}},
preconditions=["条件1"],
postconditions=["结果1"],
side_effects=["副作用1"],
)
assert len(iface.preconditions) == 1
assert len(iface.side_effects) == 1
# ===========================================================================
# SkillImplementation Tests
# ===========================================================================
class TestSkillImplementation:
def test_code_implementation(self):
impl = SkillImplementation(code="print('hello')")
assert impl.code == "print('hello')"
def test_prompt_implementation(self):
impl = SkillImplementation(prompt_template="Do {action}")
assert impl.prompt_template == "Do {action}"
def test_sub_skill_implementation(self):
impl = SkillImplementation(sub_skill_ids=["id1", "id2"])
assert len(impl.sub_skill_ids) == 2
def test_tool_call_implementation(self):
impl = SkillImplementation(tool_calls=["browser.click"])
assert impl.tool_calls == ["browser.click"]
def test_empty_implementation_raises(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
SkillImplementation() # 三者都为空
# ===========================================================================
# SkillMetrics Tests
# ===========================================================================
class TestSkillMetrics:
def test_total_executions(self):
m = SkillMetrics(success_count=7, failure_count=3)
assert m.total_executions == 10
def test_success_rate(self):
m = SkillMetrics(success_count=9, failure_count=1)
assert m.success_rate == pytest.approx(0.9)
def test_success_rate_no_executions(self):
m = SkillMetrics()
assert m.success_rate == 0.0
# ===========================================================================
# Graph Model Tests
# ===========================================================================
class TestSkillEdge:
def test_valid_edge(self):
edge = SkillEdge(
source_id="skill-a",
target_id="skill-b",
edge_type=EdgeType.DEPENDS_ON,
)
assert edge.source_id == "skill-a"
assert edge.edge_type == EdgeType.DEPENDS_ON
def test_self_loop_raises(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
SkillEdge(source_id="same", target_id="same", edge_type=EdgeType.DEPENDS_ON)
def test_empty_id_raises(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
SkillEdge(source_id="", target_id="skill-b", edge_type=EdgeType.DEPENDS_ON)
def test_weight_range(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
SkillEdge(source_id="a", target_id="b", edge_type=EdgeType.SIMILAR_TO, weight=1.5)
def test_to_neo4j_props(self):
edge = SkillEdge(
source_id="a", target_id="b",
edge_type=EdgeType.COMPOSES_WITH,
weight=0.8,
)
props = edge.to_neo4j_props()
assert props["weight"] == 0.8
assert "edge_id" in props
class TestSkillSubgraph:
def _make_node(self, skill_id: str, name: str) -> SkillGraphNode:
return SkillGraphNode(
skill_id=skill_id,
name=name,
version="1.0.0",
skill_type=SkillType.ATOMIC,
state=SkillState.RELEASED,
)
def test_add_node(self):
sg = SkillSubgraph()
node = self._make_node("id1", "skill_a")
sg.add_node(node)
assert "id1" in sg.nodes
def test_add_edge_valid(self):
sg = SkillSubgraph()
sg.add_node(self._make_node("id1", "skill_a"))
sg.add_node(self._make_node("id2", "skill_b"))
edge = SkillEdge(source_id="id1", target_id="id2", edge_type=EdgeType.DEPENDS_ON)
sg.add_edge(edge)
assert len(sg.edges) == 1
def test_add_edge_missing_node_raises(self):
sg = SkillSubgraph()
sg.add_node(self._make_node("id1", "skill_a"))
edge = SkillEdge(source_id="id1", target_id="id_missing", edge_type=EdgeType.DEPENDS_ON)
with pytest.raises(ValueError):
sg.add_edge(edge)
def test_get_roots(self):
sg = SkillSubgraph()
sg.add_node(self._make_node("root", "root_skill"))
sg.add_node(self._make_node("child", "child_skill"))
edge = SkillEdge(source_id="child", target_id="root", edge_type=EdgeType.DEPENDS_ON)
sg.add_edge(edge)
roots = sg.get_roots()
assert "child" in roots
assert "root" not in roots
def test_topological_sort(self):
sg = SkillSubgraph()
sg.add_node(self._make_node("a", "skill_a"))
sg.add_node(self._make_node("b", "skill_b"))
sg.add_node(self._make_node("c", "skill_c"))
sg.add_edge(SkillEdge(source_id="b", target_id="a", edge_type=EdgeType.DEPENDS_ON))
sg.add_edge(SkillEdge(source_id="c", target_id="b", edge_type=EdgeType.DEPENDS_ON))
order = sg.topological_sort()
assert order.index("a") < order.index("b") < order.index("c")
def test_topological_sort_cycle_raises(self):
sg = SkillSubgraph()
sg.add_node(self._make_node("a", "skill_a"))
sg.add_node(self._make_node("b", "skill_b"))
sg.add_edge(SkillEdge(source_id="a", target_id="b", edge_type=EdgeType.DEPENDS_ON))
sg.add_edge(SkillEdge(source_id="b", target_id="a", edge_type=EdgeType.DEPENDS_ON))
with pytest.raises(ValueError, match="环"):
sg.topological_sort()
def test_to_dict(self):
sg = SkillSubgraph(name="test_subgraph")
sg.add_node(self._make_node("id1", "skill_a"))
d = sg.to_dict()
assert d["name"] == "test_subgraph"
assert d["node_count"] == 1
assert d["edge_count"] == 0
# ===========================================================================
# Experience Model Tests
# ===========================================================================
class TestTrajectoryStep:
def test_valid_step(self):
step = TrajectoryStep(
step_index=0,
action_type="click",
action_target="#submit-btn",
)
assert step.step_index == 0
assert step.success is True
def test_step_with_state(self):
step = TrajectoryStep(
step_index=1,
action_type="type",
action_value="hello",
state_before={"form_filled": False},
state_after={"form_filled": True},
)
assert step.state_after["form_filled"] is True
class TestExperienceUnit:
def test_minimal_experience(self):
exp = ExperienceUnit(source_type=ExperienceSourceType.BROWSER_TRAJECTORY)
assert not exp.is_processed
assert exp.step_count == 0
def test_experience_with_steps(self):
steps = [
TrajectoryStep(step_index=i, action_type="click")
for i in range(3)
]
exp = ExperienceUnit(
source_type=ExperienceSourceType.BROWSER_TRAJECTORY,
steps=steps,
)
assert exp.step_count == 3
def test_mark_processed(self):
exp = ExperienceUnit(source_type=ExperienceSourceType.DOCUMENTATION)
exp.mark_processed(["skill-id-1", "skill-id-2"])
assert exp.is_processed
assert exp.processed_at is not None
assert len(exp.extracted_skill_ids) == 2
def test_tags_normalized(self):
exp = ExperienceUnit(
source_type=ExperienceSourceType.MANUAL_INPUT,
tags=[" WEB ", "FORM"],
)
assert exp.tags == ["web", "form"]
class TestSkillProposal:
def test_pending_proposal(self):
proposal = SkillProposal(
source_experience_id="exp-123",
proposed_name="fill_form",
proposed_description="填写表单",
)
assert proposal.status == SkillProposalStatus.PENDING
def test_accept_proposal(self):
proposal = SkillProposal(
source_experience_id="exp-123",
proposed_name="fill_form",
proposed_description="填写表单",
)
proposal.accept("skill-456")
assert proposal.status == SkillProposalStatus.ACCEPTED
assert proposal.generated_skill_id == "skill-456"
def test_reject_proposal(self):
proposal = SkillProposal(
source_experience_id="exp-123",
proposed_name="fill_form",
proposed_description="填写表单",
)
proposal.reject("与已有 Skill 重复")
assert proposal.status == SkillProposalStatus.REJECTED
assert "重复" in proposal.rejection_reason
def test_merge_proposal(self):
proposal = SkillProposal(
source_experience_id="exp-123",
proposed_name="fill_form",
proposed_description="填写表单",
)
proposal.merge_into("existing-skill-id")
assert proposal.status == SkillProposalStatus.MERGED
assert proposal.merged_into_skill_id == "existing-skill-id"
class TestSkillExecutionRecord:
def test_start_execution(self):
record = SkillExecutionRecord(skill_id="skill-1", skill_version="1.0.0")
record.start()
assert record.status == ExecutionStatus.RUNNING
assert record.started_at is not None
def test_complete_execution(self):
record = SkillExecutionRecord(skill_id="skill-1", skill_version="1.0.0")
record.start()
record.complete({"result": "ok"}, {"state": "done"})
assert record.status == ExecutionStatus.SUCCESS
assert record.output_data == {"result": "ok"}
assert record.latency_ms is not None
assert record.latency_ms >= 0
def test_fail_execution(self):
record = SkillExecutionRecord(skill_id="skill-1", skill_version="1.0.0")
record.start()
record.fail("元素未找到", "ElementNotFoundError")
assert record.status == ExecutionStatus.FAILED
assert record.error_type == "ElementNotFoundError"
assert record.latency_ms is not None
# ===========================================================================
# ORM Mapper Tests (no DB required)
# ===========================================================================
class TestORMMappers:
def test_skill_to_orm_and_back(self):
from skillos.storage.postgres_db import orm_to_skill, skill_to_orm
original = Skill(
name="test_skill",
version="2.1.0",
description="测试 Skill",
skill_type=SkillType.FUNCTIONAL,
domain="web",
state=SkillState.RELEASED,
tags=["web", "test"],
interface=SkillInterface(
input_schema={"type": "object"},
preconditions=["条件1"],
),
implementation=SkillImplementation(
code="print('test')",
tool_calls=["playwright"],
),
)
orm = skill_to_orm(original)
assert orm.skill_id == original.skill_id
assert orm.name == "test_skill"
assert orm.skill_type == "functional"
assert orm.state == "S4"
assert json.loads(orm.tags) == ["web", "test"]
restored = orm_to_skill(orm)
assert restored.skill_id == original.skill_id
assert restored.name == original.name
assert restored.skill_type == SkillType.FUNCTIONAL
assert restored.state == SkillState.RELEASED
assert restored.tags == ["web", "test"]
assert restored.interface.preconditions == ["条件1"]
assert restored.implementation is not None
assert restored.implementation.code == "print('test')"
def test_skill_to_orm_no_implementation(self):
from skillos.storage.postgres_db import skill_to_orm
skill = Skill(name="no_impl_skill")
orm = skill_to_orm(skill)
assert orm.implementation_json is None
def test_skill_to_orm_with_metrics(self):
from skillos.storage.postgres_db import orm_to_skill, skill_to_orm
skill = Skill(name="metrics_skill")
skill.record_execution(success=True, latency_ms=150.0)
skill.record_execution(success=False, latency_ms=200.0)
orm = skill_to_orm(skill)
assert orm.usage_count == 2
assert orm.success_count == 1
assert orm.failure_count == 1
restored = orm_to_skill(orm)
assert restored.metrics.usage_count == 2
assert restored.metrics.success_rate == pytest.approx(0.5)