Files
2026-07-13 13:12:33 +08:00

428 lines
14 KiB
Python

"""Parser validation tests for the new user_input step kind (PR1, design §5.2)."""
from __future__ import annotations
import pytest
from opensquilla.skills.meta.parser import MetaPlanError, parse_meta_plan
from opensquilla.skills.meta.types import ClarifyStepConfig
from opensquilla.skills.types import SkillLayer, SkillSpec
def _spec(steps: list[dict]) -> SkillSpec:
"""Build a minimal meta-kind SkillSpec for parser tests."""
return SkillSpec(
name="test-skill",
description="",
layer=SkillLayer.BUNDLED,
always=False,
triggers=["test trigger"],
content="",
kind="meta",
meta_priority=0,
composition_raw={"steps": steps},
)
def test_user_input_kind_is_accepted_by_parser():
spec = _spec([
{
"id": "collect",
"kind": "user_input",
"skill": "collect",
"clarify": {
"mode": "form",
"fields": [
{"name": "destination", "type": "string", "required": True},
],
},
},
])
plan = parse_meta_plan(spec)
assert plan is not None
assert plan.steps[0].kind == "user_input"
assert isinstance(plan.steps[0].clarify_config, ClarifyStepConfig)
assert plan.steps[0].clarify_config.fields[0].name == "destination"
def test_user_input_accepts_localized_intro_and_prompts():
spec = _spec([
{
"id": "collect",
"kind": "user_input",
"skill": "collect",
"clarify": {
"mode": "form",
"intro": "补充信息 / Add details",
"intro_zh": "请补充信息。",
"intro_en": "Please add details.",
"fields": [
{
"name": "topic",
"type": "string",
"required": True,
"prompt": "主题 / Topic",
"prompt_zh": "主题",
"prompt_en": "Topic",
},
],
},
},
])
plan = parse_meta_plan(spec)
assert plan is not None
cfg = plan.steps[0].clarify_config
assert cfg is not None
assert cfg.intro_by_language == {
"zh": "请补充信息。",
"en": "Please add details.",
}
assert cfg.fields[0].prompt_by_language == {
"zh": "主题",
"en": "Topic",
}
def test_user_input_splits_legacy_bilingual_intro_and_prompts():
spec = _spec([
{
"id": "collect",
"kind": "user_input",
"skill": "collect",
"clarify": {
"mode": "form",
"intro": "请补充信息 / Please add details",
"fields": [
{
"name": "topic",
"type": "string",
"required": True,
"prompt": "主题 / Topic",
},
],
},
},
])
plan = parse_meta_plan(spec)
assert plan is not None
cfg = plan.steps[0].clarify_config
assert cfg is not None
assert cfg.intro_by_language == {
"zh": "请补充信息",
"en": "Please add details",
}
assert cfg.fields[0].prompt_by_language == {
"zh": "主题",
"en": "Topic",
}
def test_user_input_parser_appends_generic_additional_notes_field():
spec = _spec([
{
"id": "collect",
"kind": "user_input",
"skill": "collect",
"clarify": {
"mode": "form",
"fields": [
{"name": "destination", "type": "string", "required": True},
],
},
},
])
plan = parse_meta_plan(spec)
assert plan is not None
cfg = plan.steps[0].clarify_config
assert cfg is not None
notes = cfg.fields[-1]
assert notes.name == "additional_notes"
assert notes.type == "string"
assert notes.required is False
assert notes.max_chars == 2000
assert "备注" in notes.prompt_by_language["zh"]
assert "Additional" in notes.prompt_by_language["en"]
def test_user_input_requires_clarify_block():
spec = _spec([{"id": "collect", "kind": "user_input", "skill": "collect"}])
with pytest.raises(MetaPlanError, match="user_input.*requires.*clarify"):
parse_meta_plan(spec)
def _user_input_spec(**clarify_overrides) -> SkillSpec:
"""Minimal user_input step with the given clarify overrides."""
base_clarify = {
"mode": "form",
"fields": [{"name": "x", "type": "string", "required": True}],
}
base_clarify.update(clarify_overrides)
return _spec([
{"id": "collect", "kind": "user_input", "skill": "collect", "clarify": base_clarify},
])
# Rule 1: mode must be "form" or "chat"; default "form"
def test_rule01_mode_defaults_to_form():
spec = _spec([{
"id": "c", "kind": "user_input", "skill": "c",
"clarify": {"fields": [{"name": "x", "type": "string"}]},
}])
plan = parse_meta_plan(spec)
assert plan.steps[0].clarify_config.mode == "form"
def test_rule01_unknown_mode_rejected():
with pytest.raises(MetaPlanError, match="clarify.mode"):
parse_meta_plan(_user_input_spec(mode="dialog"))
# Rule 2: field name regex
def test_rule02_field_name_regex_rejects_uppercase():
with pytest.raises(MetaPlanError, match=r"name.*must match"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "BAD", "type": "string"}],
))
def test_rule02_field_names_must_be_unique():
with pytest.raises(MetaPlanError, match="duplicate name"):
parse_meta_plan(_user_input_spec(
fields=[
{"name": "x", "type": "string"},
{"name": "x", "type": "int"},
],
))
# Rule 3: type whitelist
def test_rule03_type_must_be_in_whitelist():
with pytest.raises(MetaPlanError, match="type 'float'"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "float"}],
))
# Rule 4: enum requires choices
def test_rule04_enum_requires_choices():
with pytest.raises(MetaPlanError, match="enum type requires non-empty choices"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "enum"}],
))
def test_rule04_enum_choices_must_be_unique():
with pytest.raises(MetaPlanError, match="choices must be unique"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "enum", "choices": ["a", "a"]}],
))
# Rule 5: int min/max
def test_rule05_int_min_must_not_exceed_max():
with pytest.raises(MetaPlanError, match=r"min=10.*max=1"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "int", "min": 10, "max": 1}],
))
def test_rule05_min_max_only_for_int():
with pytest.raises(MetaPlanError, match="min/max only valid"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "string", "min": 1}],
))
# Rule 6: string max_chars range
def test_rule06_string_max_chars_in_range():
with pytest.raises(MetaPlanError, match="max_chars must be an int in"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "string", "max_chars": 5000}],
))
def test_rule06_max_chars_only_for_string():
with pytest.raises(MetaPlanError, match="max_chars only valid"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "int", "max_chars": 10}],
))
# Rule 7: required and default mutually exclusive
def test_rule07_required_and_default_mutually_exclusive():
with pytest.raises(MetaPlanError, match="required=true and default are mutually exclusive"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "string", "required": True, "default": "y"}],
))
# Rule 8: default must pass per-type validation
def test_rule08_enum_default_must_be_in_choices():
with pytest.raises(MetaPlanError, match=r"default 'big' not in choices"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "enum",
"choices": ["small", "mid"], "default": "big"}],
))
def test_rule08_int_default_must_be_int():
with pytest.raises(MetaPlanError, match="default for int field must be an int"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "int", "default": "five"}],
))
def test_rule08_int_default_below_min_rejected():
with pytest.raises(MetaPlanError, match="default 0 is below min=1"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "int", "min": 1, "max": 10, "default": 0}],
))
def test_rule08_int_default_above_max_rejected():
with pytest.raises(MetaPlanError, match="default 11 is above max=10"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "int", "min": 1, "max": 10, "default": 11}],
))
def test_rule08_string_default_exceeding_max_chars_rejected():
with pytest.raises(MetaPlanError, match="default length .* exceeds max_chars"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "string", "max_chars": 5,
"default": "longer than five"}],
))
def test_rule08_int_default_must_not_be_bool():
"""In Python, isinstance(True, int) is True. Catch this explicitly so
`default: true` on an int field cannot silently coerce."""
with pytest.raises(MetaPlanError, match="default for int field must be an int"):
parse_meta_plan(_user_input_spec(
fields=[{"name": "x", "type": "int", "default": True}],
))
# Defensive: required must be a real boolean (not 'false' string, etc.)
def test_required_must_be_real_bool_truthy_string_rejected():
"""A literal 'false' string in YAML decodes to a string, not False.
The parser explicitly rejects implicit string-to-bool coercion."""
spec = _spec([
{
"id": "collect", "kind": "user_input", "skill": "collect",
"clarify": {
"mode": "form",
"fields": [{"name": "x", "type": "string", "required": "false"}],
},
},
])
with pytest.raises(MetaPlanError, match="required.*must be a boolean"):
parse_meta_plan(spec)
# Rule 9: skip_if must be a string AND must compile_expression at parse time
def test_rule09_skip_if_must_be_string():
with pytest.raises(MetaPlanError, match="skip_if must be a string"):
parse_meta_plan(_user_input_spec(skip_if=123))
def test_rule09_skip_if_compiles_at_parse_time():
"""Spec §5.2 rule 9: skip_if must compile via _JINJA_ENV.compile_expression.
Malformed Jinja is rejected before SkillSpec ever loads."""
with pytest.raises(MetaPlanError, match="skip_if failed to compile"):
parse_meta_plan(_user_input_spec(skip_if="this is not jinja {{"))
def test_rule09_valid_skip_if_passes():
plan = parse_meta_plan(_user_input_spec(
skip_if="'destination=' in outputs.classify",
))
assert plan.steps[0].clarify_config.skip_if == "'destination=' in outputs.classify"
# Rule 10: cancel_keywords list of non-empty strings; normalized to lowercase
def test_rule10_cancel_keywords_normalized_lowercase():
plan = parse_meta_plan(_user_input_spec(cancel_keywords=["Cancel", "STOP", " 取消 "]))
assert plan.steps[0].clarify_config.cancel_keywords == ("cancel", "stop", "取消")
def test_rule10_cancel_keywords_rejects_empty_string():
with pytest.raises(MetaPlanError, match="list of non-empty strings"):
parse_meta_plan(_user_input_spec(cancel_keywords=["", "stop"]))
# Rule 11: timeout_hours range
def test_rule11_timeout_hours_lower_bound():
with pytest.raises(MetaPlanError, match=r"in \[1, 168\]"):
parse_meta_plan(_user_input_spec(timeout_hours=0))
def test_rule11_timeout_hours_upper_bound():
with pytest.raises(MetaPlanError, match=r"in \[1, 168\]"):
parse_meta_plan(_user_input_spec(timeout_hours=169))
# Rule 12: field count caps
def test_rule12_form_mode_caps_at_12_fields():
fields = [
{"name": f"f{i}", "type": "string", "required": True}
for i in range(13)
]
with pytest.raises(MetaPlanError, match="max for mode='form'"):
parse_meta_plan(_user_input_spec(fields=fields))
def test_rule12_chat_mode_caps_at_4_fields():
fields = [
{"name": f"f{i}", "type": "string", "required": True}
for i in range(5)
]
with pytest.raises(MetaPlanError, match="max for mode='chat'"):
parse_meta_plan(_user_input_spec(mode="chat", fields=fields))
# Rule 13: prompt/intro stored verbatim — no escape at parse time
def test_rule13_intro_stored_verbatim_with_angle_brackets():
plan = parse_meta_plan(_user_input_spec(intro="<b>important</b>"))
assert plan.steps[0].clarify_config.intro == "<b>important</b>"
# Rule 14: nl_extract boolean + nl_extract_tier
def test_rule14_nl_extract_default_false():
plan = parse_meta_plan(_user_input_spec())
assert plan.steps[0].clarify_config.nl_extract is False
assert plan.steps[0].clarify_config.nl_extract_tier == ""
def test_rule14_nl_extract_must_be_bool():
with pytest.raises(MetaPlanError, match="nl_extract.*must be a boolean"):
parse_meta_plan(_user_input_spec(nl_extract="yes"))
def test_rule14_nl_extract_tier_ignored_when_extract_false():
plan = parse_meta_plan(_user_input_spec(nl_extract=False, nl_extract_tier="t3"))
# spec §5.2 rule 14: tier is ignored when nl_extract is false
assert plan.steps[0].clarify_config.nl_extract_tier == ""
def test_rule14_nl_extract_tier_kept_when_extract_true():
plan = parse_meta_plan(_user_input_spec(nl_extract=True, nl_extract_tier="t1"))
assert plan.steps[0].clarify_config.nl_extract is True
assert plan.steps[0].clarify_config.nl_extract_tier == "t1"
# Cross-cutting: 'clarify' on a non-user_input step is rejected
def test_clarify_block_rejected_on_non_user_input_step():
spec = _spec([
{
"id": "x", "kind": "agent", "skill": "summarize",
"clarify": {"mode": "form", "fields": [{"name": "x", "type": "string"}]},
},
])
with pytest.raises(MetaPlanError, match="'clarify' only valid for kind=user_input"):
parse_meta_plan(spec)