tabras / tests /test_generator.py
Codex
Trim prefetch to 1 pack, log model-pack failures, JSON-constrained cards, rotate fallback names
b483ca7
Raw
History Blame Contribute Delete
29.8 kB
from typing import Any
import subprocess
import pytest
from budget import Card, CardSpec, EffectPlan, cost_card
from generator import (
CodexCardClient,
LlamaCppCardClient,
MiniCPMCardClient,
balance_json_closers,
card_context,
codex_pack_prompt,
deck_summary,
draft_direction,
draft_need,
extract_json_object,
generate_card,
generate_pack,
generate_llamacpp_card,
generic_card_name,
generator_payload,
llamacpp_card_prompt,
llama_candidate_need,
llamacpp_retry_prompt,
normalize_card_response,
pack_payload,
parse_llamacpp_card_text,
parse_card_payload,
parse_effect_plan,
parse_pack_payload,
repair_llamacpp_card,
primitive_guidance,
school_flavor_mismatch,
school_imagery_rule,
school_identity,
theme_name_leak,
distinct_name,
)
from primitives import Effect
class FakeClient:
# Initialize the fake client with a fixed raw response.
def __init__(self, raw: dict[str, Any]) -> None:
self.raw = raw
self.payload: dict[str, Any] | None = None
# Capture the payload and return a fixed model response.
def create_card(self, payload: dict[str, Any]) -> dict[str, Any]:
self.payload = payload
return self.raw
class FakePackClient:
# Initialize the fake pack client with a fixed raw response.
def __init__(self, raw: dict[str, Any]) -> None:
self.raw = raw
self.payload: dict[str, Any] | None = None
# Capture the payload and return a fixed model pack response.
def create_pack(self, payload: dict[str, Any]) -> dict[str, Any]:
self.payload = payload
return self.raw
class FakeChat:
# Initialize the fake chat client with fixed text.
def __init__(self, text: str | list[str]) -> None:
self.responses = [text] if isinstance(text, str) else text
self.calls: list[tuple[str, str]] = []
# Capture prompts and return fixed text.
def complete(self, system: str, user: str) -> str:
self.calls.append((system, user))
return self.responses[min(len(self.calls) - 1, len(self.responses) - 1)]
# Build one card for generator context tests.
def context_card() -> Card:
return cost_card(CardSpec("Spark", 1, "fire", "wuxia", (EffectPlan("deal"),)))
# Build one direct card for summary tests.
def direct_card(name: str, school: str, effects: tuple[Effect, ...]) -> Card:
return Card(name, 1, school, "wuxia", effects) # type: ignore[arg-type]
# Verify generator payload includes deck context and tool schema.
def test_generator_payload_is_deck_aware() -> None:
payload = generator_payload("fire", "wuxia", [context_card()], [context_card()])
assert payload["allowed_primitives"] == ("deal", "burn", "bomb", "scaling", "draw", "energy", "block", "conditional")
assert payload["current_deck"][0]["rules_text"] == "Deal 2 damage."
assert payload["current_deck_summary"] == {"deal": 1}
assert payload["draft_anchors"][0]["name"] == "Spark"
assert payload["draft_anchor_summary"] == {"deal": 1}
assert "fast pressure" in payload["draft_direction"]
assert "races with direct pressure" in payload["school_identity"]
assert "burn for damage over turns" in payload["primitive_guidance"]
assert "Fire" in payload["draft_need"]
assert payload["tool_schema"]["name"] == "create_card"
# Verify school identity text captures class mechanics.
def test_school_identity() -> None:
assert "shield charge" in school_identity("earth")
assert "plain deal is secondary" in school_identity("earth")
# Verify primitive guidance maps school ideas to primitive ids.
def test_primitive_guidance() -> None:
assert "scaling to spend shield charge" in primitive_guidance("earth")
# Verify draft direction is inferred from anchor primitives.
def test_draft_direction() -> None:
anchors = [direct_card("Vuln", "ice", (Effect("vulnerable", amount=1),))]
assert "multi_hit" in draft_direction("ice", anchors)
assert "No anchor cards" in draft_direction("fire", [])
# Verify llama.cpp candidate need points at missing scaling.
def test_llama_candidate_need_prefers_missing_scaling() -> None:
payload = pack_payload("earth", "wuxia", [direct_card("Block", "earth", (Effect("block", amount=1),))], 2, 3)
assert "primitive_id scaling" in llama_candidate_need(payload, {})
# Verify llama.cpp candidate need points at missing block.
def test_llama_candidate_need_prefers_missing_block() -> None:
deck = [direct_card("Scale", "earth", (Effect("scaling", amount=1, condition="shield_charge"),))]
payload = pack_payload("earth", "wuxia", deck, 2, 3)
assert "primitive_id block" in llama_candidate_need(payload, {})
# Verify llama.cpp candidate need points at missing multi-hit.
def test_llama_candidate_need_prefers_missing_multi_hit() -> None:
deck = [direct_card("Vuln", "ice", (Effect("vulnerable", amount=1),))]
payload = pack_payload("ice", "wuxia", deck, 2, 3)
assert "primitive_id multi_hit" in llama_candidate_need(payload, {})
# Verify llama.cpp candidate need points at missing initiative.
def test_llama_candidate_need_prefers_missing_initiative() -> None:
deck = [
direct_card("Vuln A", "ice", (Effect("vulnerable", amount=1),)),
direct_card("Vuln B", "ice", (Effect("vulnerable", amount=1),)),
direct_card("Hit", "ice", (Effect("multi_hit", amount=1),)),
]
payload = pack_payload("ice", "wuxia", deck, 2, 3)
assert "primitive_id initiative" in llama_candidate_need(payload, {})
# Verify llama.cpp candidate need avoids repeated deal when covered.
def test_llama_candidate_need_avoids_repeated_deal() -> None:
deck = [
direct_card("Block", "earth", (Effect("block", amount=1),)),
direct_card("Scaling", "earth", (Effect("scaling", amount=1, condition="shield_charge"),)),
direct_card("Deal A", "earth", (Effect("deal", amount=1),)),
direct_card("Deal B", "earth", (Effect("deal", amount=1),)),
direct_card("Deal C", "earth", (Effect("deal", amount=1),)),
direct_card("Deal D", "earth", (Effect("deal", amount=1),)),
]
payload = pack_payload("earth", "wuxia", deck, 2, 3)
assert "primitive_id block" in llama_candidate_need(payload, {"deal": 1, "scaling": 1})
# Verify Fire pack generation explores non-deal primitives after one damage card.
def test_llama_candidate_need_diversifies_fire_pack() -> None:
payload = pack_payload("fire", "wuxia", [], 2, 3)
assert "primitive_id burn" in llama_candidate_need(payload, {"deal": 1})
assert "primitive_id bomb" in llama_candidate_need(payload, {"deal": 1, "burn": 1})
assert "primitive_id draw" in llama_candidate_need(payload, {"deal": 1, "burn": 1, "bomb": 1})
# Verify llama.cpp retry prompt names exact primitive requirements.
def test_llamacpp_retry_prompt_mentions_scaling() -> None:
payload = pack_payload("earth", "wuxia", [], 2, 3)
prompt = llamacpp_retry_prompt(payload, "explore primitive_id scaling")
assert "primitive_id exactly \"block\"" in prompt
assert "primitive_id exactly \"scaling\"" in prompt
assert "primitive_id exactly \"multi_hit\"" in prompt
assert "primitive_id exactly \"initiative\"" in prompt
# Verify deck summary counts all costed card effects.
def test_deck_summary_counts_effects() -> None:
deck = [
direct_card("Wall", "earth", (Effect("block", amount=3), Effect("ward", amount=1))),
direct_card("Payoff", "earth", (Effect("scaling", amount=2),)),
]
assert deck_summary(deck) == {"block": 1, "scaling": 1, "ward": 1}
# Verify Earth draft need shifts toward payoff after protection.
def test_earth_draft_need_prefers_payoff_when_protected() -> None:
deck = [
direct_card("Ward A", "earth", (Effect("ward", amount=1),)),
direct_card("Ward B", "earth", (Effect("ward", amount=1),)),
direct_card("Block", "earth", (Effect("block", amount=1),)),
direct_card("Scaling", "earth", (Effect("scaling", amount=1, condition="shield_charge"),)),
]
assert "payoff" in draft_need("earth", deck)
# Verify Earth asks for scaling when block exists without payoff.
def test_earth_draft_need_prefers_scaling_after_block() -> None:
deck = [direct_card("Block", "earth", (Effect("block", amount=1),))]
assert "scaling" in draft_need("earth", deck)
# Verify Earth asks for block when payoff exists without enough charge.
def test_earth_draft_need_prefers_block_after_scaling() -> None:
deck = [direct_card("Scale", "earth", (Effect("scaling", amount=1, condition="shield_charge"),))]
assert "add block" in draft_need("earth", deck)
# Verify Earth stops asking for plain damage once deal is covered.
def test_earth_draft_need_moves_away_from_deal() -> None:
deck = [
direct_card("Block", "earth", (Effect("block", amount=1),)),
direct_card("Scaling", "earth", (Effect("scaling", amount=1, condition="shield_charge"),)),
direct_card("Deal A", "earth", (Effect("deal", amount=1),)),
direct_card("Deal B", "earth", (Effect("deal", amount=1),)),
direct_card("Deal C", "earth", (Effect("deal", amount=1),)),
direct_card("Deal D", "earth", (Effect("deal", amount=1),)),
]
assert "add block" in draft_need("earth", deck)
# Verify Ice asks for multi-hit after vulnerability setup.
def test_ice_draft_need_prefers_multi_hit_after_vulnerable() -> None:
deck = [direct_card("Vuln", "ice", (Effect("vulnerable", amount=1),))]
assert "multi_hit" in draft_need("ice", deck)
# Verify Ice asks for initiative after setup and payoff exist.
def test_ice_draft_need_prefers_initiative_after_setup_payoff() -> None:
deck = [
direct_card("Vuln A", "ice", (Effect("vulnerable", amount=1),)),
direct_card("Vuln B", "ice", (Effect("vulnerable", amount=1),)),
direct_card("Hit", "ice", (Effect("multi_hit", amount=1),)),
]
assert "initiative" in draft_need("ice", deck)
# Verify pack payload includes cost and requested pack size.
def test_pack_payload_includes_draft_context() -> None:
payload = pack_payload("fire", "wuxia", [context_card()], cost=3, pack_size=3)
assert payload["cost"] == 3
assert payload["pack_size"] == 3
assert payload["pack_schema"]["required"] == ["cards"]
# Verify card batch payload includes a cost schedule.
def test_cards_payload_includes_cost_schedule() -> None:
from generator import cards_payload
payload = cards_payload("fire", "wuxia", [context_card()], (2, 3))
assert payload["costs"] == (2, 3)
assert payload["pack_size"] == 2
assert "immediate Deal" in payload["school_bias"]
# Verify card context exposes compact fixed rules text.
def test_card_context_is_compact() -> None:
assert card_context(context_card()) == {"name": "Spark", "cost": 1, "rules_text": "Deal 2 damage."}
# Verify model-authored magnitudes are ignored by engine costing.
def test_generate_card_ignores_model_numbers() -> None:
client = FakeClient(
{
"name": "Inferno Lie",
"flavor": "The model says 999.",
"art_prompt": "flame",
"effects": [{"primitive_id": "deal", "weight": 1, "amount": 999}],
}
)
card = generate_card(client, "fire", "wuxia", [context_card()], cost=2)
assert client.payload is not None
assert client.payload["current_deck"][0]["name"] == "Spark"
assert card.effects[0].amount == 4
assert "999" not in card.rules_text()
# Verify pack generation costs every returned candidate.
def test_generate_pack_returns_costed_candidates() -> None:
client = FakePackClient(
{
"cards": [
{"name": "A", "effects": [{"primitive_id": "deal"}]},
{"name": "B", "effects": [{"primitive_id": "burn"}]},
{"name": "C", "effects": [{"primitive_id": "bomb"}]},
]
}
)
pack = generate_pack(client, "fire", "wuxia", [context_card()], cost=2)
assert client.payload is not None
assert client.payload["current_deck"][0]["name"] == "Spark"
assert [card.name for card in pack] == ["A", "B", "C"]
assert pack[0].rules_text() == "Deal 4 damage."
# Verify multi-card generation costs cards against their own costs.
def test_generate_cards_returns_costed_batch() -> None:
from generator import generate_cards
client = FakePackClient(
{
"cards": [
{"name": "A", "effects": [{"primitive_id": "deal"}]},
{"name": "B", "effects": [{"primitive_id": "burn"}]},
]
}
)
cards = generate_cards(client, "fire", "wuxia", [context_card()], (2, 3))
assert [card.rules_text() for card in cards] == ["Deal 4 damage.", "Burn 3 damage for 2 turns."]
# Verify multi-card generation rejects wrong-size output.
def test_generate_cards_rejects_wrong_size() -> None:
from generator import generate_cards
client = FakePackClient({"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]})
with pytest.raises(ValueError, match="wrong size"):
generate_cards(client, "fire", "wuxia", [], (2, 3))
# Verify pack generation rejects wrong-size model output.
def test_generate_pack_rejects_wrong_size() -> None:
client = FakePackClient({"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]})
with pytest.raises(ValueError, match="wrong size"):
generate_pack(client, "fire", "wuxia", [], cost=2)
# Verify pack generation trims extra model output.
def test_generate_pack_trims_extra_cards() -> None:
client = FakePackClient(
{
"cards": [
{"name": "A", "effects": [{"primitive_id": "deal"}]},
{"name": "B", "effects": [{"primitive_id": "burn"}]},
{"name": "C", "effects": [{"primitive_id": "bomb"}]},
{"name": "D", "effects": [{"primitive_id": "draw"}]},
]
}
)
pack = generate_pack(client, "fire", "wuxia", [], cost=2)
assert [card.name for card in pack] == ["A", "B", "C"]
# Verify invalid school output is rejected after parsing.
def test_parse_card_payload_still_goes_through_budget_validation() -> None:
raw = {"name": "Off School", "effects": [{"primitive_id": "bomb"}]}
spec = parse_card_payload(raw, "ice", "wuxia", 1)
with pytest.raises(ValueError, match="not allowed"):
cost_card(spec)
# Verify pack parsing uses engine costing.
def test_parse_pack_payload_costs_cards() -> None:
pack = parse_pack_payload({"cards": [{"name": "A", "effects": [{"primitive_id": "deal", "amount": 999}]}]}, "fire", "wuxia", 1)
assert pack[0].rules_text() == "Deal 2 damage."
# Verify invalid model weights normalize to the smallest legal weight.
def test_parse_effect_plan_clamps_weight() -> None:
assert parse_effect_plan({"primitive_id": "deal", "weight": 0}).weight == 1
# Verify Codex prompt includes constraints and deck context.
def test_codex_pack_prompt_mentions_engine_owned_numbers() -> None:
prompt = codex_pack_prompt(pack_payload("fire", "wuxia", [context_card()], 2, 3))
assert "strict JSON only" in prompt
assert "engine owns all final numbers" in prompt
assert "meaningfully different" in prompt
assert "Class identity" in prompt
assert "Primitive guidance" in prompt
assert "Draft anchor cards" in prompt
assert "Draft direction" in prompt
assert "Deck primitive counts" in prompt
assert "Draft need" in prompt
assert "Return exactly 3 cards" in prompt
assert "Spark" in prompt
# Verify Codex prompt supports batch cost schedules.
def test_codex_pack_prompt_mentions_cost_schedule() -> None:
from generator import cards_payload
prompt = codex_pack_prompt(cards_payload("fire", "wuxia", [], (2, 3)))
assert "Card costs by index: [2, 3]" in prompt
# Verify JSON extraction tolerates surrounding model text.
def test_extract_json_object() -> None:
assert extract_json_object("text {\"cards\": []} more") == "{\"cards\": []}"
with pytest.raises(ValueError, match="JSON object"):
extract_json_object("no json")
# Verify JSON extraction ignores later objects in chatty model output.
def test_extract_json_object_uses_first_balanced_object() -> None:
text = 'first {"name": "A", "effects": []} then {"name": "B"}'
assert extract_json_object(text) == '{"name": "A", "effects": []}'
# Verify JSON extraction repairs missing closing delimiters.
def test_extract_json_object_repairs_missing_closer() -> None:
assert extract_json_object('{"cards": [{"name": "A"}]') == '{"cards": [{"name": "A"}]}'
# Verify JSON closer balancing ignores braces inside strings.
def test_balance_json_closers_ignores_strings() -> None:
assert balance_json_closers('{"text": "}"}') == '{"text": "}"}'
# Verify Codex client reads the final-message file.
def test_codex_client_invokes_subprocess(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
calls: list[dict[str, Any]] = []
# Fake subprocess.run by writing the requested output file.
def fake_run(command: tuple[str, ...], **kwargs: Any) -> subprocess.CompletedProcess[str]:
output_path = command[command.index("--output-last-message") + 1]
calls.append({"command": command, "kwargs": kwargs})
with open(output_path, "w") as output:
output.write('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}')
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("generator.subprocess.run", fake_run)
client = CodexCardClient(command=("codex", "exec", "-"), cwd=str(tmp_path))
raw = client.create_pack(pack_payload("fire", "wuxia", [], 1, 1))
assert raw["cards"][0]["name"] == "A"
assert "--output-last-message" in calls[0]["command"]
assert calls[0]["kwargs"]["cwd"] == str(tmp_path)
# Verify single-card Codex calls reuse the pack path.
def test_codex_client_create_card_uses_pack(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
# Fake subprocess.run by writing one valid card.
def fake_run(command: tuple[str, ...], **_: Any) -> subprocess.CompletedProcess[str]:
output_path = command[command.index("--output-last-message") + 1]
with open(output_path, "w") as output:
output.write('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}')
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("generator.subprocess.run", fake_run)
client = CodexCardClient(command=("codex", "exec", "-"), cwd=str(tmp_path))
raw = client.create_card(generator_payload("fire", "wuxia", []))
assert raw["name"] == "A"
# Verify MiniCPM card client uses local chat completions.
def test_minicpm_card_client() -> None:
chat = FakeChat('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}')
client = MiniCPMCardClient(chat) # type: ignore[arg-type]
raw = client.create_pack(pack_payload("fire", "wuxia", [], 1, 1))
assert raw["cards"][0]["name"] == "A"
assert "strict JSON" in chat.calls[0][0]
assert "Allowed primitives" in chat.calls[0][1]
# Verify MiniCPM single-card calls reuse the pack path.
def test_minicpm_card_client_create_card() -> None:
chat = FakeChat('{"cards": [{"name": "A", "effects": [{"primitive_id": "deal"}]}]}')
client = MiniCPMCardClient(chat) # type: ignore[arg-type]
assert client.create_card(generator_payload("fire", "wuxia", []))["name"] == "A"
# Verify llama.cpp card prompt asks for one compact card.
def test_llamacpp_card_prompt() -> None:
payload = pack_payload("fire", "wuxia", [context_card()], 2, 3)
prompt = llamacpp_card_prompt(payload, [{"name": "A", "effects": [{"primitive_id": "burn"}]}])
assert "Create exactly one Tabras card" in prompt
assert "Fit the school identity" in prompt
assert "Primitive guidance" in prompt
assert "Current draft need" in prompt
assert "This candidate should" in prompt
assert "Already in this pack" in prompt
assert "race plan" not in prompt
assert "\"burn\": 1" in prompt
assert "Spark" in prompt
# Verify llama.cpp card client builds packs from concurrent single-card calls,
# one model call per card, with duplicate names de-duplicated after the fact.
def test_llamacpp_card_client() -> None:
chat = FakeChat('{"name": "A", "effects": [{"primitive_id": "deal"}]}')
client = LlamaCppCardClient(chat) # type: ignore[arg-type]
raw = client.create_pack(pack_payload("fire", "wuxia", [], 1, 3))
assert [card["name"] for card in raw["cards"]] == ["A", "Cinder Warrant", "Ashen Oath"]
assert len(chat.calls) == 3
# Verify llama.cpp card generation retries missed candidate needs.
def test_generate_llamacpp_card_retries_missed_need() -> None:
payload = pack_payload("earth", "wuxia", [direct_card("Block", "earth", (Effect("block", amount=1),))], 2, 3)
chat = FakeChat(
[
'{"name": "Plain", "effects": [{"primitive_id": "deal"}]}',
'{"earth_tabras": {"name": "Granite Vow", "effects": [{"primitive_id": "scaling"}]}}',
]
)
card = generate_llamacpp_card(chat, payload, [])
assert card["name"] == "Granite Vow"
assert card["effects"] == [{"primitive_id": "scaling", "weight": 1}]
assert len(chat.calls) == 2
# Verify generic name detection flags mechanic words and empty names.
def test_generic_card_name() -> None:
assert generic_card_name("")
assert generic_card_name("Fire Deal")
assert generic_card_name("Ice Deal 2")
assert generic_card_name("Vulnerable Anime School")
assert generic_card_name("Fast Burner")
assert generic_card_name("Flaming Fire")
assert generic_card_name("Pressureflare")
assert generic_card_name("Pressure Clock 2")
assert not generic_card_name("Vow of Cinders")
# Verify theme labels are rejected from in-world card names.
def test_theme_name_leak() -> None:
assert theme_name_leak("Vulnerable Anime School", "anime school")
assert not theme_name_leak("Glass Edict", "anime school")
# Verify card prompts carry the naming rules.
def test_prompts_ban_mechanic_names() -> None:
payload = pack_payload("fire", "wuxia", [], 2, 3)
assert "Never use these words in the name" in llamacpp_card_prompt(payload, [])
assert "Never use these words in the name" in llamacpp_retry_prompt(payload, "fit the draft need")
assert "glacier" in school_imagery_rule("fire")
assert "glacier" in llamacpp_card_prompt(payload, [])
# Verify llama.cpp card generation retries hyper-generic names.
def test_generate_llamacpp_card_retries_generic_name() -> None:
payload = pack_payload("fire", "wuxia", [], 2, 3)
chat = FakeChat(
[
'{"name": "Fire Deal", "effects": [{"primitive_id": "deal"}]}',
'{"name": "Vow of Cinders", "effects": [{"primitive_id": "deal"}]}',
]
)
card = generate_llamacpp_card(chat, payload, [])
assert card["name"] == "Vow of Cinders"
assert len(chat.calls) == 2
# Verify failed Fire retries repair to a non-deal primitive when pack needs variety.
def test_generate_llamacpp_card_repairs_to_needed_fire_primitive() -> None:
payload = pack_payload("fire", "wuxia", [], 2, 3)
chat = FakeChat('{"name": "Fire Deal", "effects": [{"primitive_id": "deal"}]}')
card = generate_llamacpp_card(chat, payload, [{"name": "A", "effects": [{"primitive_id": "deal"}]}])
assert card["effects"] == [{"primitive_id": "burn", "weight": 1}]
assert card["name"] == "Cinder Warrant"
# Verify screenshot-style generic names retry into authored names.
def test_generate_llamacpp_card_retries_theme_and_mechanic_names() -> None:
payload = pack_payload("ice", "anime school", [], 3, 3)
chat = FakeChat(
[
'{"name": "Vulnerable Anime School", "effects": [{"primitive_id": "multi_hit"}]}',
'{"name": "Glass Edict", "effects": [{"primitive_id": "multi_hit"}]}',
]
)
card = generate_llamacpp_card(chat, payload, [])
assert card["name"] == "Glass Edict"
assert len(chat.calls) == 2
# Verify off-school imagery is rejected and retried.
def test_generate_llamacpp_card_retries_off_school_imagery() -> None:
payload = pack_payload("fire", "wuxia", [], 2, 3)
chat = FakeChat(
[
'{"name": "Howling Glacier", "art_prompt": "frozen snow spell", "effects": [{"primitive_id": "deal"}]}',
'{"name": "Vow of Cinders", "art_prompt": "cinder oath spell", "effects": [{"primitive_id": "deal"}]}',
]
)
card = generate_llamacpp_card(chat, payload, [])
assert card["name"] == "Vow of Cinders"
assert len(chat.calls) == 2
# Verify school imagery validation catches Fire cards with Ice names.
def test_school_flavor_mismatch() -> None:
assert school_flavor_mismatch("fire", {"name": "Howling Glacier"})
assert not school_flavor_mismatch("fire", {"name": "Vow of Cinders"})
# Verify llama.cpp repair fills required schema fields.
def test_repair_llamacpp_card_fills_missing_fields() -> None:
payload = pack_payload("fire", "wuxia", [], 1, 3)
raw = {"school": "fire", "effects": [{"primitive_id": "deal", "weight": 0}, {"primitive_id": "burn"}]}
card = repair_llamacpp_card(raw, payload, [])
assert card["name"] == "Cinder Warrant"
assert card["flavor"] == ""
assert card["art_prompt"] == "wuxia fire card art"
assert card["effects"] == [{"primitive_id": "deal", "weight": 1}, {"primitive_id": "burn", "weight": 1}]
# Verify repair strips off-school imagery before costing.
def test_repair_llamacpp_card_replaces_off_school_name() -> None:
payload = pack_payload("fire", "wuxia", [], 1, 3)
raw = {"name": "Howling Glacier", "art_prompt": "frozen snow spell", "effects": [{"primitive_id": "deal"}]}
card = repair_llamacpp_card(raw, payload, [])
assert card["name"] == "Cinder Warrant"
assert "frozen" not in card["art_prompt"]
# Verify llama.cpp repair replaces a duplicate name with a fresh pool name (no number).
def test_repair_llamacpp_card_uniquifies_duplicate_name() -> None:
payload = pack_payload("fire", "wuxia", [], 1, 3)
raw = {"name": "Firepress", "effects": [{"primitive_id": "deal"}]}
card = repair_llamacpp_card(raw, payload, [{"name": "Firepress"}])
assert card["name"] == "Cinder Warrant"
assert not card["name"][-1].isdigit()
# Verify a collision draws a fresh pool name instead of a numeric suffix.
def test_distinct_name() -> None:
assert distinct_name("A", set(), "fire") == "A"
assert distinct_name("A", {"A"}, "fire") == "Cinder Warrant"
assert distinct_name("A", {"A", "Cinder Warrant"}, "ice") == "Glass Edict"
# Verify llama.cpp repair drops invalid primitives.
def test_repair_llamacpp_card_drops_invalid_primitives() -> None:
payload = pack_payload("ice", "wuxia", [], 1, 3)
card = repair_llamacpp_card({"effects": [{"primitive_id": "bomb"}]}, payload, [])
assert card["effects"] == [{"primitive_id": "deal", "weight": 1}]
# Verify one-card normalization accepts pack-shaped responses.
def test_normalize_card_response() -> None:
assert normalize_card_response({"cards": [{"name": "A"}]}) == {"name": "A"}
# Verify one-card normalization unwraps named wrapper objects.
def test_normalize_card_response_unwraps_wrapper() -> None:
raw = {"earth_tabras": {"name": "Earth Tabras", "effects": [{"primitive_id": "scaling"}]}}
assert normalize_card_response(raw) == {"name": "Earth Tabras", "effects": [{"primitive_id": "scaling"}]}
# Verify one-card normalization unwraps MiniCPM shape wrappers.
def test_normalize_card_response_unwraps_shape() -> None:
raw = {"shape": {"name": "Shield of Scale", "effects": [{"primitive_id": "scaling"}]}, "school": "earth"}
assert normalize_card_response(raw) == {"name": "Shield of Scale", "effects": [{"primitive_id": "scaling"}]}
# Verify malformed llama.cpp card JSON becomes a legal fallback card.
def test_parse_llamacpp_card_text_falls_back() -> None:
payload = pack_payload("earth", "wuxia", [], 1, 3)
card = parse_llamacpp_card_text('{"name" "Broken"}', payload, [])
assert card["name"] == "Stone Covenant"
assert card["effects"] == [{"primitive_id": "deal", "weight": 1}]
# Verify Codex client reports subprocess failures.
def test_codex_client_reports_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
# Fake subprocess.run with a failed Codex result.
def fake_run(command: tuple[str, ...], **_: Any) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(command, 1, "", "blocked")
monkeypatch.setattr("generator.subprocess.run", fake_run)
client = CodexCardClient(command=("codex", "exec", "-"), cwd=str(tmp_path))
with pytest.raises(RuntimeError, match="blocked"):
client.create_pack(pack_payload("fire", "wuxia", [], 1, 1))
# Verify quick mode accepts the first card without a corrective retry.
def test_generate_llamacpp_card_quick_skips_retry() -> None:
payload = pack_payload("fire", "wuxia", [], 2, 3)
payload["quick"] = True
chat = FakeChat('{"name": "Fire Deal", "effects": [{"primitive_id": "deal"}]}')
card = generate_llamacpp_card(chat, payload, [])
assert card["name"] == "Cinder Warrant"
assert len(chat.calls) == 1
# Verify generate_pack threads the quick flag into the client payload.
def test_generate_pack_threads_quick_flag() -> None:
card = {"name": "A", "flavor": "", "art_prompt": "", "effects": [{"primitive_id": "deal"}]}
client = FakePackClient({"cards": [card, dict(card, name="B"), dict(card, name="C")]})
generate_pack(client, "fire", "wuxia", [], 1, quick=True)
assert client.payload["quick"] is True