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

345 lines
12 KiB
Python

"""Deterministic reply parser tests (PR4, design §9.3 + §10)."""
from __future__ import annotations
from opensquilla.skills.meta.clarify_text import parse_clarify_reply
from opensquilla.skills.meta.types import ClarifyField, ClarifyStepConfig
def _schema(*fields: ClarifyField) -> ClarifyStepConfig:
return ClarifyStepConfig(mode="form", fields=tuple(fields))
# ── Mode 1: key:value lines ──
def test_key_value_simple():
schema = _schema(
ClarifyField(name="destination", type="string", required=True),
ClarifyField(name="days", type="int", required=True, min=1, max=14),
)
fields, errors = parse_clarify_reply(
"destination: Tokyo\ndays: 5", schema, surface="cli",
)
assert errors == []
assert fields == {"destination": "Tokyo", "days": 5}
def test_key_value_fullwidth_colon():
schema = _schema(ClarifyField(name="destination", type="string", required=True))
fields, errors = parse_clarify_reply(
"destination:东京", schema, surface="im",
)
assert errors == []
assert fields == {"destination": "东京"}
def test_key_value_cjk_value():
schema = _schema(ClarifyField(name="destination", type="string", required=True))
fields, errors = parse_clarify_reply(
"destination: 上海五日游", schema, surface="cli",
)
assert errors == []
assert fields == {"destination": "上海五日游"}
def test_key_value_unknown_key_rejected():
schema = _schema(ClarifyField(name="destination", type="string", required=True))
fields, errors = parse_clarify_reply(
"destination: Tokyo\nbogus: x", schema, surface="cli",
)
assert fields == {}
assert any("bogus" in e for e in errors)
def test_key_value_partial_missing_required():
schema = _schema(
ClarifyField(name="destination", type="string", required=True),
ClarifyField(name="days", type="int", required=True),
)
fields, errors = parse_clarify_reply(
"destination: Tokyo", schema, surface="cli",
)
assert fields == {}
assert any("days" in e and "required" in e.lower() for e in errors)
def test_key_value_extra_whitespace_tolerated():
schema = _schema(ClarifyField(name="destination", type="string", required=True))
fields, errors = parse_clarify_reply(
" destination : Tokyo ", schema, surface="cli",
)
assert errors == []
assert fields == {"destination": "Tokyo"}
# ── Mode 2: numbered lines ──
def test_numbered_lines_paren():
schema = _schema(
ClarifyField(name="destination", type="string", required=True),
ClarifyField(name="days", type="int", required=True),
)
fields, errors = parse_clarify_reply(
"1) Tokyo\n2) 5", schema, surface="cli",
)
assert errors == []
assert fields == {"destination": "Tokyo", "days": 5}
def test_numbered_lines_dot():
schema = _schema(
ClarifyField(name="destination", type="string", required=True),
ClarifyField(name="days", type="int", required=True),
)
fields, errors = parse_clarify_reply(
"1. Tokyo\n2. 5", schema, surface="cli",
)
assert errors == []
assert fields == {"destination": "Tokyo", "days": 5}
def test_numbered_out_of_range():
schema = _schema(ClarifyField(name="x", type="string", required=True))
fields, errors = parse_clarify_reply("5) bogus", schema, surface="cli")
assert fields == {}
assert any("5" in e for e in errors)
# ── Mode 3: positional ──
def test_positional_simple():
schema = _schema(
ClarifyField(name="destination", type="string", required=True),
ClarifyField(name="days", type="int", required=True),
)
fields, errors = parse_clarify_reply(
"Tokyo\n5", schema, surface="cli",
)
assert errors == []
assert fields == {"destination": "Tokyo", "days": 5}
def test_positional_fewer_lines_than_fields():
schema = _schema(
ClarifyField(name="destination", type="string", required=True),
ClarifyField(name="days", type="int", required=True),
)
fields, errors = parse_clarify_reply("Tokyo", schema, surface="cli")
assert fields == {}
assert any("days" in e and "required" in e.lower() for e in errors)
def test_positional_more_lines_than_fields():
# Two-field schema where the second line genuinely overflows.
schema = _schema(
ClarifyField(name="x", type="string", required=True),
ClarifyField(name="y", type="int", required=True),
)
fields, errors = parse_clarify_reply(
"Tokyo\n42\nextra", schema, surface="cli",
)
assert fields == {}
assert any("too many" in e.lower() for e in errors)
def test_positional_multiline_collapses_into_single_string_field():
# When the schema is a single string field (the catch-all pattern
# used by free-form review steps), additional lines should be
# joined into the same field rather than reported as overflow.
schema = _schema(ClarifyField(name="x", type="string", required=True))
fields, errors = parse_clarify_reply(
"Tokyo\nextra", schema, surface="cli",
)
assert errors == []
assert fields == {"x": "Tokyo\nextra"}
# ── Hybrid rejection ──
def test_hybrid_key_value_and_positional_rejected():
schema = _schema(
ClarifyField(name="destination", type="string", required=True),
ClarifyField(name="days", type="int", required=True),
)
fields, errors = parse_clarify_reply(
"destination: Tokyo\n5", schema, surface="cli",
)
assert fields == {}
assert any("mixed" in e.lower() for e in errors)
# ── Per-field validation ──
def test_int_field_non_numeric_rejected():
schema = _schema(ClarifyField(name="days", type="int", required=True, min=1, max=14))
fields, errors = parse_clarify_reply("days: abc", schema, surface="cli")
assert fields == {}
assert any("integer" in e.lower() for e in errors)
def test_int_field_below_min_rejected():
schema = _schema(ClarifyField(name="days", type="int", required=True, min=1, max=14))
fields, errors = parse_clarify_reply("days: 0", schema, surface="cli")
assert fields == {}
assert any("min" in e.lower() for e in errors)
def test_int_field_above_max_rejected():
schema = _schema(ClarifyField(name="days", type="int", required=True, min=1, max=14))
fields, errors = parse_clarify_reply("days: 100", schema, surface="cli")
assert fields == {}
assert any("max" in e.lower() for e in errors)
def test_enum_field_invalid_rejected():
schema = _schema(
ClarifyField(name="budget", type="enum", required=True,
choices=("budget", "mid", "premium")),
)
fields, errors = parse_clarify_reply("budget: luxury", schema, surface="cli")
assert fields == {}
assert any("budget" in e and "luxury" in e for e in errors)
def test_enum_field_valid():
schema = _schema(
ClarifyField(name="budget", type="enum", required=True,
choices=("budget", "mid", "premium")),
)
fields, errors = parse_clarify_reply("budget: mid", schema, surface="cli")
assert errors == []
assert fields == {"budget": "mid"}
def test_string_field_exceeds_max_chars_rejected():
schema = _schema(
ClarifyField(name="notes", type="string", required=True, max_chars=10),
)
fields, errors = parse_clarify_reply(
"notes: " + ("x" * 50), schema, surface="cli",
)
assert fields == {}
assert any("max_chars" in e or "length" in e.lower() for e in errors)
def test_bool_field_true_variants():
schema = _schema(ClarifyField(name="b", type="bool", required=True))
for s in ("true", "True", "yes", "1"):
fields, errors = parse_clarify_reply(f"b: {s}", schema, surface="cli")
assert errors == [], s
assert fields == {"b": True}, s
def test_bool_field_false_variants():
schema = _schema(ClarifyField(name="b", type="bool", required=True))
for s in ("false", "False", "no", "0"):
fields, errors = parse_clarify_reply(f"b: {s}", schema, surface="cli")
assert errors == [], s
assert fields == {"b": False}, s
def test_bool_field_invalid_rejected():
schema = _schema(ClarifyField(name="b", type="bool", required=True))
fields, errors = parse_clarify_reply("b: maybe", schema, surface="cli")
assert fields == {}
assert any("bool" in e.lower() for e in errors)
# ── Optional fields ──
def test_optional_field_with_default_omitted():
schema = _schema(
ClarifyField(name="destination", type="string", required=True),
ClarifyField(name="budget", type="enum",
choices=("budget", "mid"), default="mid"),
)
fields, errors = parse_clarify_reply("destination: Tokyo", schema, surface="cli")
assert errors == []
assert fields == {"destination": "Tokyo"}
def test_optional_field_without_default_omitted_is_ok():
schema = _schema(
ClarifyField(name="destination", type="string", required=True),
ClarifyField(name="notes", type="string", required=False),
)
fields, errors = parse_clarify_reply("destination: Tokyo", schema, surface="cli")
assert errors == []
assert fields == {"destination": "Tokyo"}
# ── Edge cases ──
def test_empty_reply_with_required_fields_errors():
schema = _schema(ClarifyField(name="x", type="string", required=True))
fields, errors = parse_clarify_reply("", schema, surface="cli")
assert fields == {}
assert errors
def test_whitespace_only_reply_with_required_fields_errors():
schema = _schema(ClarifyField(name="x", type="string", required=True))
fields, errors = parse_clarify_reply(" \n\n ", schema, surface="cli")
assert fields == {}
assert errors
def test_empty_value_after_colon():
schema = _schema(ClarifyField(name="x", type="string", required=True))
fields, errors = parse_clarify_reply("x:", schema, surface="cli")
assert fields == {}
assert any("x" in e and ("empty" in e.lower() or "required" in e.lower()) for e in errors)
# ── F4: CJK + conversational bool synonyms ──
import pytest # noqa: E402 — kept at section boundary for grouping
@pytest.mark.parametrize(
"value,expected",
[
("好", True),
("对", True),
("嗯", True),
("可以", True),
("确认", True),
("没问题", True),
("ok", True),
("OK", True),
("不", False),
("不要", False),
("不行", False),
("不用", False),
("算了", False),
# Pre-existing literals must still work.
("是", True),
("yes", True),
("否", False),
("no", False),
],
)
def test_bool_field_accepts_cjk_and_conversational_synonyms(value, expected) -> None:
"""F4: chat-mode users naturally answer bool prompts with 好 / 对 /
可以 / 算了 / 不用. Before this contract those replies fell through
to "not a bool" and triggered a re-prompt, surfacing as the
"information not understood" symptom. ``_TRUE_VALUES`` /
``_FALSE_VALUES`` in ``clarify_text.py`` must accept the full
conversational set; ``clarify_form.py`` mirrors the same table so
multi-surface skills do not diverge."""
schema = _schema(ClarifyField(name="ok", type="bool", required=True))
fields, errors = parse_clarify_reply(f"ok: {value}", schema, surface="cli")
assert errors == [], f"unexpected errors for {value!r}: {errors}"
assert fields == {"ok": expected}
def test_bool_field_rejects_unknown_token() -> None:
"""F4 negative control: the synonym tables must NOT silently
coerce arbitrary CJK or English words to True/False. Only the
enumerated synonym set is accepted."""
schema = _schema(ClarifyField(name="ok", type="bool", required=True))
fields, errors = parse_clarify_reply("ok: maybe", schema, surface="cli")
assert fields == {}
assert errors, "expected a parse error for an unknown bool token"