feat(phase2): P1c
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
"""Tests for the natural-language policy (P1.3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pf1e_simulator.client import FakeLLMClient, ResponseCache, ScriptExhaustedError
|
||||
from pf1e_simulator.combat import Action, Observation
|
||||
from pf1e_simulator.nl_policy import (
|
||||
NLPolicy,
|
||||
NLPolicyError,
|
||||
fallback_default,
|
||||
parse_actions,
|
||||
)
|
||||
from pf1e_simulator.prompt import PromptPack
|
||||
from pf1e_simulator.prompt_builder import PromptBuilder
|
||||
|
||||
_FIXTURE_PACK = Path(__file__).resolve().parent / "fixtures" / "prompt_pack"
|
||||
|
||||
|
||||
def _obs() -> Observation:
|
||||
"""Minimal valid observation for deterministic tests."""
|
||||
return Observation(
|
||||
round=1,
|
||||
acting_combatant=0,
|
||||
combatants=(
|
||||
{"id": "goblin", "side": "players", "active": True, "pos": (1, 1)},
|
||||
),
|
||||
pairs=(),
|
||||
terrain=((None,),),
|
||||
elevation=((0,),),
|
||||
)
|
||||
|
||||
|
||||
def _pack() -> PromptPack:
|
||||
return PromptPack.load(_FIXTURE_PACK)
|
||||
|
||||
|
||||
def _builder() -> PromptBuilder:
|
||||
return PromptBuilder(pack=_pack())
|
||||
|
||||
|
||||
def _make_client(script: list[str], *, builder: PromptBuilder, **kwargs) -> FakeLLMClient:
|
||||
"""Create a FakeLLMClient with the correct prompt_version from the builder."""
|
||||
return FakeLLMClient(
|
||||
script=script,
|
||||
prompt_version=builder.prompt_version("nl_default"),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _make_policy(client: FakeLLMClient, builder: PromptBuilder) -> NLPolicy:
|
||||
return NLPolicy(client=client, builder=builder)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def builder() -> PromptBuilder:
|
||||
"""A builder over the fixture pack, shared by the NLPolicy integration tests."""
|
||||
return _builder()
|
||||
|
||||
|
||||
# ---- parse_actions tests -----------------------------------------------------
|
||||
|
||||
def test_parse_actions_ok() -> None:
|
||||
reply = '[{"kind": "move", "destination": [2, 3]}]'
|
||||
actions = parse_actions(reply)
|
||||
assert actions == (Action(kind="move", destination=(2, 3)),)
|
||||
|
||||
|
||||
def test_parse_actions_empty_array() -> None:
|
||||
assert parse_actions("[]") == ()
|
||||
|
||||
|
||||
def test_parse_actions_invalid_json() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"reply is not valid JSON"):
|
||||
parse_actions("not json")
|
||||
|
||||
|
||||
def test_parse_actions_not_a_list() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"reply must be a JSON array of action objects"):
|
||||
parse_actions('{"kind": "attack"}')
|
||||
|
||||
|
||||
def test_parse_actions_unknown_field() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"unknown action field\(s\): bogus"):
|
||||
parse_actions('[{"kind": "attack", "bogus": 1}]')
|
||||
|
||||
|
||||
def test_parse_actions_missing_kind() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"missing or unknown 'kind'"):
|
||||
parse_actions('[{"target_id": "orc"}]')
|
||||
|
||||
|
||||
def test_parse_actions_unknown_kind() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"missing or unknown 'kind'"):
|
||||
parse_actions('[{"kind": "dance"}]')
|
||||
|
||||
|
||||
def test_parse_actions_channel_mode_invalid() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"'channel_mode' must be one of: heal, harm"):
|
||||
parse_actions('[{"kind": "channel_energy", "channel_mode": "loud"}]')
|
||||
|
||||
|
||||
def test_parse_actions_destination_invalid_not_pair() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"'destination' must be a \[row, col\] pair"):
|
||||
parse_actions('[{"kind": "move", "destination": [2]}]')
|
||||
|
||||
|
||||
def test_parse_actions_destination_invalid_strings() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"'destination' must be a \[row, col\] pair"):
|
||||
parse_actions('[{"kind": "move", "destination": ["2", "3"]}]')
|
||||
|
||||
|
||||
def test_parse_actions_aid_mode_invalid() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"'aid_mode' must be one of: attack, ac"):
|
||||
parse_actions('[{"kind": "aid_another", "aid_mode": "damage"}]')
|
||||
|
||||
|
||||
def test_parse_actions_trick_condition_invalid() -> None:
|
||||
with pytest.raises(
|
||||
NLPolicyError,
|
||||
match=(
|
||||
r"'trick_condition' must be one of: dazzled, blinded, "
|
||||
r"deafened, entangled, shaken, sickened"
|
||||
),
|
||||
):
|
||||
parse_actions('[{"kind": "cast_spell", "trick_condition": "nauseated"}]')
|
||||
|
||||
|
||||
def test_parse_actions_ready_trigger_invalid() -> None:
|
||||
with pytest.raises(
|
||||
NLPolicyError,
|
||||
match=(
|
||||
r"'ready_trigger' must be one of: enemy_enters_reach, "
|
||||
r"enemy_casts, enemy_attacks, enemy_hits_ally"
|
||||
),
|
||||
):
|
||||
parse_actions('[{"kind": "ready", "ready_trigger": "enemy_sneezed"}]')
|
||||
|
||||
|
||||
def test_parse_actions_channel_exclude_not_list() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"'channel_exclude' must be a list of strings"):
|
||||
parse_actions('[{"kind": "channel_energy", "channel_exclude": "orc"}]')
|
||||
|
||||
|
||||
def test_parse_actions_channel_exclude_ok() -> None:
|
||||
actions = parse_actions('[{"kind": "channel_energy", "channel_exclude": ["orc"]}]')
|
||||
assert actions[0].channel_exclude == ("orc",)
|
||||
|
||||
|
||||
def test_parse_actions_tumble_not_bool() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"'tumble' must be a boolean"):
|
||||
parse_actions('[{"kind": "attack", "tumble": "yes"}]')
|
||||
|
||||
|
||||
def test_parse_actions_cast_defensively_not_bool() -> None:
|
||||
with pytest.raises(NLPolicyError, match=r"'cast_defensively' must be a boolean"):
|
||||
parse_actions('[{"kind": "cast_spell", "cast_defensively": 1}]')
|
||||
|
||||
|
||||
def test_parse_actions_metamagic_ok() -> None:
|
||||
actions = parse_actions('[{"kind": "cast_spell", "metamagic": ["Empower", "Maximize"]}]')
|
||||
assert actions[0].metamagic == ("Empower", "Maximize")
|
||||
|
||||
|
||||
# ---- fallback_default tests --------------------------------------------------
|
||||
|
||||
def test_fallback_default_prefers_full_attack() -> None:
|
||||
menu = (
|
||||
Action(kind="total_defense"),
|
||||
Action(kind="full_attack", target_id="orc"),
|
||||
Action(kind="move"),
|
||||
)
|
||||
assert fallback_default(menu) == (Action(kind="full_attack", target_id="orc"),)
|
||||
|
||||
|
||||
def test_fallback_default_prefers_attack_over_charge() -> None:
|
||||
menu = (
|
||||
Action(kind="charge", target_id="orc"),
|
||||
Action(kind="attack", target_id="orc"),
|
||||
)
|
||||
assert fallback_default(menu) == (Action(kind="attack", target_id="orc"),)
|
||||
|
||||
|
||||
def test_fallback_default_prefers_charge_over_move() -> None:
|
||||
menu = (
|
||||
Action(kind="move", target_id="orc"),
|
||||
Action(kind="charge", target_id="orc"),
|
||||
)
|
||||
assert fallback_default(menu) == (Action(kind="charge", target_id="orc"),)
|
||||
|
||||
|
||||
def test_fallback_default_falls_back_to_first() -> None:
|
||||
menu = (Action(kind="total_defense"), Action(kind="delay"))
|
||||
assert fallback_default(menu) == (Action(kind="total_defense"),)
|
||||
|
||||
|
||||
def test_fallback_default_empty_menu_returns_wait() -> None:
|
||||
assert fallback_default(()) == (Action(kind="wait"),)
|
||||
|
||||
|
||||
# ---- NLPolicy integration tests ---------------------------------------------
|
||||
|
||||
def test_nl_policy_parse_ok(builder: PromptBuilder) -> None:
|
||||
"""Parse OK path with valid JSON from FakeLLMClient."""
|
||||
script = ['[{"kind": "move", "destination": [2, 3]}]']
|
||||
client = _make_client(script, builder=builder)
|
||||
policy = _make_policy(client, builder)
|
||||
|
||||
actions = policy(_obs())
|
||||
assert actions == (Action(kind="move", destination=(2, 3)),)
|
||||
assert client.stats.calls == 1
|
||||
|
||||
|
||||
def test_nl_policy_retry_then_succeeds() -> None:
|
||||
"""One bad reply, then a good one → parsed successfully."""
|
||||
builder = _builder()
|
||||
script = ["not json", '[{"kind": "move", "destination": [2, 3]}]']
|
||||
client = _make_client(script, builder=builder)
|
||||
policy = _make_policy(client, builder)
|
||||
|
||||
actions = policy(_obs())
|
||||
assert actions == (Action(kind="move", destination=(2, 3)),)
|
||||
assert client.stats.calls == 2
|
||||
|
||||
|
||||
def test_nl_policy_retry_then_fallback() -> None:
|
||||
"""Two bad replies → fallback used."""
|
||||
builder = _builder()
|
||||
script = ["not json", "not json"]
|
||||
client = _make_client(script, builder=builder)
|
||||
policy = _make_policy(client, builder)
|
||||
|
||||
# Minimal obs menu is (total_defense, delay)
|
||||
actions = policy(_obs())
|
||||
# fallback_default picks first decisive action, but menu has only defensive
|
||||
assert actions == (Action(kind="total_defense"),)
|
||||
assert client.stats.calls == 2
|
||||
|
||||
|
||||
def test_nl_policy_prompt_version_mismatch() -> None:
|
||||
"""Client default prompt_version vs builder pack version → ValueError."""
|
||||
builder = _builder()
|
||||
# Use default prompt_version ("fixture:fake:0") which differs from pack
|
||||
client = FakeLLMClient(script=['[{"kind": "wait"}]'])
|
||||
with pytest.raises(ValueError, match=r"prompt_version"):
|
||||
_make_policy(client, builder)
|
||||
|
||||
|
||||
def test_nl_policy_script_exhausted_propagates() -> None:
|
||||
"""ScriptExhaustedError from FakeLLMClient propagates (not caught)."""
|
||||
builder = _builder()
|
||||
client = _make_client([], builder=builder)
|
||||
policy = _make_policy(client, builder)
|
||||
|
||||
with pytest.raises(ScriptExhaustedError):
|
||||
policy(_obs())
|
||||
|
||||
|
||||
def test_nl_policy_shared_cache_hit() -> None:
|
||||
"""Two clients sharing a ResponseCache: second call hits cache."""
|
||||
builder = _builder()
|
||||
cache = ResponseCache()
|
||||
# First client has one scripted response
|
||||
client1 = _make_client(['[{"kind": "wait"}]'], builder=builder, cache=cache)
|
||||
# Second client has NO script; relies on cache hit
|
||||
client2 = _make_client([], builder=builder, cache=cache)
|
||||
policy1 = _make_policy(client1, builder)
|
||||
policy2 = _make_policy(client2, builder)
|
||||
|
||||
obs = _obs()
|
||||
actions1 = policy1(obs)
|
||||
actions2 = policy2(obs)
|
||||
|
||||
assert actions2 == actions1 == (Action(kind="wait"),)
|
||||
# Second call was a cache hit (no ScriptExhaustedError)
|
||||
assert client2.stats.cache_hits == 1
|
||||
assert client1.stats.cache_misses == 1
|
||||
Reference in New Issue
Block a user