"""Tests for the phase-2 prompt builder (P1.4).""" from __future__ import annotations import hashlib import json from dataclasses import fields from pathlib import Path from pf1e_simulator.combat import Action, Observation from pf1e_simulator.nl_policy import KindValue from pf1e_simulator.nl_policy import get_args as _get_args from pf1e_simulator.prompt import PromptPack from pf1e_simulator.prompt_builder import ( PromptBuilder, _render_examples, _supported_kinds, action_schema, render_candidates, ) _FIXTURE_PACK = Path(__file__).resolve().parent / "fixtures" / "prompt_pack" _FIXTURE_PACK_FS = Path(__file__).resolve().parent / "fixtures" / "prompt_pack_fs" _FIXTURE_VERSION = "0.1.0-test" _FIXTURE_PROMPT_NAME = "nl_default" 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 test_supported_kinds_filters_unsupported() -> None: kinds = _supported_kinds() assert "swift" not in kinds assert "free" not in kinds assert "full_round" not in kinds # 27 kinds remain assert len(kinds) == 27 # Order preserved from source Literal assert kinds == ( "attack", "move", "wait", "immediate", "full_attack", "charge", "withdraw", "5foot_step", "cast_spell", "maneuver", "channel_energy", "hex", "deed", "spell_combat", "inspire_courage", "retrieve_weapon", "repair_weapon", "activate_power_attack", "delay", "ready", "total_defense", "fight_defensively", "stand_up", "drop_prone", "aid_another", "run", "hide", ) def test_action_schema_structure() -> None: schema = action_schema() assert schema["type"] == "object" assert schema["required"] == ["kind"] assert schema["additionalProperties"] is False props = schema["properties"] # Properties match Action fields exactly action_field_names = {f.name for f in fields(Action)} assert set(props.keys()) == action_field_names # Kind enum is the filtered 27 kind_prop = props["kind"] assert kind_prop["type"] == "string" assert kind_prop["enum"] == list(_supported_kinds()) def test_action_schema_spot_check_fragments() -> None: schema = action_schema() props = schema["properties"] # target_id accepts either a string or null assert props["target_id"] == {"type": ["string", "null"]} # destination is a nullable two-int pair (prefixItems + min/maxItems) dest = props["destination"] assert dest["type"] == ["array", "null"] assert dest["prefixItems"] == [{"type": "integer"}, {"type": "integer"}] assert dest["minItems"] == 2 assert dest["maxItems"] == 2 # channel_exclude: string[] (array of strings) ch_ex = props["channel_exclude"] assert ch_ex["type"] == "array" assert ch_ex["items"] == {"type": "string"} # cast_defensively is a boolean field assert props["cast_defensively"] == {"type": "boolean"} # tumble is a boolean field assert props["tumble"] == {"type": "boolean"} # channel_mode: nullable enum ch_mode = props["channel_mode"] assert ch_mode["type"] == ["string", "null"] assert ch_mode["enum"] == ["heal", "harm"] # aid_mode: nullable enum aid = props["aid_mode"] assert aid["type"] == ["string", "null"] assert aid["enum"] == ["attack", "ac"] # trick_condition: nullable enum (6 values) tc = props["trick_condition"] assert tc["type"] == ["string", "null"] assert tc["enum"] == ["dazzled", "blinded", "deafened", "entangled", "shaken", "sickened"] # ready_trigger: nullable enum (4 values) rt = props["ready_trigger"] assert rt["type"] == ["string", "null"] assert rt["enum"] == ["enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally"] # metamagic: string[] meta = props["metamagic"] assert meta["type"] == "array" assert meta["items"] == {"type": "string"} # class_ability is a nullable string field assert props["class_ability"] == {"type": ["string", "null"]} def test_render_candidates() -> None: # Single action with payload rendered = render_candidates((Action(kind="move", destination=(2, 3)),)) assert rendered == ('0. move {"destination":[2,3]}',) # Action with no payload (all defaults/None) rendered = render_candidates((Action(kind="total_defense"),)) assert rendered == ("0. total_defense",) # Action with target_id rendered = render_candidates((Action(kind="attack", target_id="orc"),)) assert rendered == ('0. attack {"target_id":"orc"}',) # Empty menu assert render_candidates(()) == () # Multiple actions rendered = render_candidates(( Action(kind="attack", target_id="a"), Action(kind="move", destination=(1, 1)), )) assert rendered == ( '0. attack {"target_id":"a"}', '1. move {"destination":[1,1]}', ) def test_prompt_builder_build_deterministic() -> None: builder = _builder() obs = _obs() prompt1 = builder.build(obs) prompt2 = builder.build(obs) assert prompt1 == prompt2 def test_prompt_builder_sections_present() -> None: builder = _builder() obs = _obs() prompt = builder.build(obs) # Template content assert "tactical director" in prompt assert "battle state" in prompt # Sections assert "## observation\n" in prompt assert "## plausible actions\n" in prompt assert "## action schema\n" in prompt # Schema is valid JSON and contains kind enum schema_part = prompt.split("## action schema\n")[1] parsed = json.loads(schema_part) assert parsed["properties"]["kind"]["enum"] == list(_supported_kinds()) def test_prompt_version_format() -> None: builder = _builder() pv = builder.prompt_version(_FIXTURE_PROMPT_NAME) assert pv.startswith(f"{_FIXTURE_VERSION}:{_FIXTURE_PROMPT_NAME}:") digest = pv.split(":")[-1] assert len(digest) == 12 assert all(c in "0123456789abcdef" for c in digest) # Cross-check against actual pack content pack = _pack() content = pack.get(_FIXTURE_PROMPT_NAME) expected_digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:12] assert digest == expected_digest def test_prompt_builder_custom_prompt_name() -> None: builder = _builder() obs = _obs() # nl_default is the only fixture, but the API accepts any name prompt = builder.build(obs, prompt_name=_FIXTURE_PROMPT_NAME) assert "tactical director" in prompt def test_prompt_version_delegates_to_pack() -> None: builder = _builder() pack = _pack() assert builder.prompt_version(_FIXTURE_PROMPT_NAME) == pack.prompt_version(_FIXTURE_PROMPT_NAME) def test_action_schema_cross_pin_with_nl_policy_kindvalue() -> None: """The kind enum in the schema must match nl_policy.KindValue exactly.""" schema = action_schema() assert schema["properties"]["kind"]["enum"] == list(_get_args(KindValue)) # ---- few-shot examples (P5.2) ---------------------------------------------- _FS_PACK = PromptPack.load(_FIXTURE_PACK_FS) def _fs_builder() -> PromptBuilder: return PromptBuilder(pack=_FS_PACK) def test_render_examples_block_format() -> None: """Few-shot pairs render as numbered observation-to-response blocks.""" rendered = _render_examples((_FS_PACK.examples(_FIXTURE_PROMPT_NAME)[0],)) assert rendered == ( "## few-shot examples\n" "### example 1\n" "observation:\n" '{"combatant": "goblin", "distance_ft": 10, "enemy": "orc", "round": 1}\n' "\n" "response:\n" '[{"kind": "attack", "target_id": "orc"}]' ) def test_build_without_examples_unchanged() -> None: """Packs without examples keep the pre-few-shot bit-for-bit section order.""" plain = _builder().build(_obs()) assert "## few-shot examples" not in plain # Template, then observation, then candidates, then schema — in order. sections = [ "## observation\n", "## plausible actions\n", "## action schema\n", ] positions = [plain.index(section) for section in sections] assert positions == sorted(positions) # The template comes first, unmodified. assert plain.startswith("You are the tactical director") def test_build_with_examples_inserts_between_template_and_observation() -> None: """The few-shot section sits between the template and the observation.""" prompt = _fs_builder().build(_obs()) assert "## few-shot examples\n" in prompt assert prompt.index("## few-shot examples\n") < prompt.index("## observation\n") # Template still leads the prompt. assert prompt.startswith("You are the tactical director") # Observation/candidates/schema keep their relative order. assert prompt.index("## observation\n") < prompt.index("## plausible actions\n") assert prompt.index("## plausible actions\n") < prompt.index("## action schema\n") def test_build_with_examples_includes_example_content() -> None: """The rendered example content is embedded verbatim (sorted keys).""" prompt = _fs_builder().build(_obs()) assert '{"combatant": "goblin", "distance_ft": 10, "enemy": "orc", "round": 1}' in prompt assert '[{"kind": "attack", "target_id": "orc"}]' in prompt assert "### example 1" in prompt def test_empty_examples_array_no_section(tmp_path: Path) -> None: """An empty examples array yields no few-shot section (not an error).""" root = tmp_path / "emptyfs" root.mkdir(parents=True, exist_ok=True) (root / "version.txt").write_text("1.0.0", encoding="utf-8") (root / "nl_default.md").write_text("T", encoding="utf-8") (root / "nl_default.examples.json").write_text("[]", encoding="utf-8") pack = PromptPack.load(root) assert pack.examples("nl_default") == () prompt = PromptBuilder(pack=pack).build(_obs()) assert "## few-shot examples" not in prompt