tabras / tests /test_ui.py
Codex
Pre-bake backbone card art (instant, no shimmer) + stop warming face-down enemy hand
a33d5b8
Raw
History Blame Contribute Delete
20.2 kB
import threading
import time
import forge
from ui import (
CARD_PANEL_COUNT,
board_html,
boss_thought_lines,
card_html,
choose_draft_card,
collect_ready_pack,
choose_draft_card_steps,
draft_screen_html,
enemy_school_for,
fallback_pack,
finish_opening_draft,
log_html,
make_pack,
mana_html,
new_run,
new_run_shell,
pass_turn,
pass_turn_steps,
pending_tokens_html,
play_hand_card,
prefetch_pack_limit,
queue_next_pack,
refresh_art,
)
# Build a run state that has finished drafting and entered battle.
def battle_state(seed: int = 2):
state = new_run("Ada", "dark fantasy", "fire", seed=seed)
for _ in range(9):
state = choose_draft_card(state, 0)
return state
# Verify enemy school rotation is deterministic.
def test_enemy_school_for() -> None:
assert enemy_school_for("fire") == "earth"
assert enemy_school_for("earth") == "ice"
assert enemy_school_for("ice") == "fire"
# Verify fallback packs produce three costed card panels.
def test_fallback_pack() -> None:
pack = fallback_pack("earth", "dark fantasy", 3)
assert len(pack) == CARD_PANEL_COUNT
assert any("banked shield charge" in card.rules_text() for card in pack)
# Verify fallback Fire packs vary instead of repeating the same visible names.
def test_fire_fallback_pack_rotates_names() -> None:
first = fallback_pack("fire", "dark fantasy", 2)
second = fallback_pack("fire", "dark fantasy", 3, current_deck=first)
assert {card.name for card in first}.isdisjoint({card.name for card in second})
assert all("card" not in card.art_prompt.lower() for card in first + second)
# Verify model pack failures are visible in server logs before fallback.
def test_make_pack_logs_model_failure(capsys) -> None:
pack = make_pack(BrokenPackClient(), None, "fire", "dark fantasy", (), 1)
captured = capsys.readouterr()
assert len(pack) == CARD_PANEL_COUNT
assert "Tabras model pack failed" in captured.err
assert "RuntimeError: bad json" in captured.err
# Verify a new run starts in draft state with three clickable cards.
def test_new_run_starts_draft() -> None:
state = new_run("Ada", "anime", "ice", seed=1)
assert state.player_name == "Ada"
assert state.enemy_school == "fire"
assert state.duel is None
html = draft_screen_html(state)
assert html.count("tabras-card") == CARD_PANEL_COUNT
assert all(f"draft-btn-{index}" in html for index in range(CARD_PANEL_COUNT))
assert "Deck 6/15" in html
# Verify a new run shell shows loading instead of unfinished starter cards.
def test_new_run_shell_shows_loading() -> None:
state = new_run_shell("Ada", "anime", "ice", seed=1)
html = draft_screen_html(state)
assert "loading-bar" in html
assert "Starter Deck" not in html
assert "starter-card" not in html
assert "draft-btn" not in html
# Verify queued first packs render a loading bar instead of blocking.
def test_queue_next_pack_shows_loading() -> None:
forge.reset()
client = ForgePackClient()
state = queue_next_pack(new_run_shell("Ada", "anime", "ice", seed=1), client)
html = draft_screen_html(state)
assert "loading-bar" in html
assert "Forging your first draft pack" in html
assert "draft-btn" not in html
# Verify a ready background pack replaces the loading state.
def test_collect_ready_pack_installs_pack(monkeypatch) -> None:
monkeypatch.setattr("ui.MIN_DRAFT_LOADING_SECONDS", 0.0)
forge.reset()
client = ForgePackClient()
state = queue_next_pack(new_run_shell("Ada", "anime", "ice", seed=1), client)
assert "loading-bar" in draft_screen_html(state)
forge.drain()
state = collect_ready_pack(state, client)
html = draft_screen_html(state)
assert "loading-bar" not in html
assert html.count("draft-card") == CARD_PANEL_COUNT
assert "draft-btn-0" in html
# Verify draft packs reveal text before queued art settles.
def test_collect_ready_pack_reveals_before_art(monkeypatch) -> None:
monkeypatch.setattr("ui.MIN_DRAFT_LOADING_SECONDS", 0.0)
forge.reset()
client = ForgePackClient()
art_client = GatedArtClient()
state = queue_next_pack(new_run_shell("Ada", "anime", "ice", seed=1), client, art_client)
assert "loading-bar" in draft_screen_html(state)
forge.drain()
for _ in range(20):
time.sleep(0.01)
state = collect_ready_pack(state, client, art_client)
if state.current_pack:
break
html = draft_screen_html(state)
assert "draft-btn-0" in html
assert "pending-art" in html
assert all(card.art_uri == "" for card in state.current_pack)
art_client.released.set()
forge.drain()
state = refresh_art(state)
assert all(card.art_uri for card in state.current_pack)
# Verify opening draft completion replaces the starter view with clickable picks.
def test_finish_opening_draft_deals_first_pack() -> None:
state = finish_opening_draft(new_run_shell("Ada", "anime", "ice", seed=1))
html = draft_screen_html(state)
assert "Starter Deck" not in html
assert html.count("draft-card") == CARD_PANEL_COUNT
assert "draft-btn-0" in html
# Verify choosing nine draft cards starts combat.
def test_choose_draft_card_starts_battle() -> None:
state = battle_state()
assert state.duel is not None
assert len(state.player_deck) == 15
assert len(state.enemy_deck) == 15
assert "Boss" in state.duel.enemy.name
assert draft_screen_html(state) == ""
# Verify the board renders heroes, mana, hand, and end-turn control.
def test_board_html_shows_battle_state() -> None:
state = battle_state()
html = board_html(state)
assert "hp-gem" in html
assert "mana-bar" in html
assert "hand-fan" in html
assert "end-turn-btn" in html
assert html.count("hand-card") >= len(state.duel.player.hand)
# Verify the board is empty before battle.
def test_board_html_empty_outside_battle() -> None:
assert board_html(None) == ""
assert board_html(new_run("Ada", "anime", "ice", seed=1)) == ""
# Verify picking a draft card first fades the old pack, then deals the next.
def test_draft_pick_fades_between_packs() -> None:
state = new_run("Ada", "anime", "ice", seed=1)
old_pack = state.current_pack
steps = list(choose_draft_card_steps(state, 1))
fading, fresh = steps[0], steps[1]
assert fading.pack_fading == 1
assert fading.current_pack == old_pack
html = draft_screen_html(fading)
assert html.count("fading") == 2
assert "picked" in html
assert "draft-btn" not in html
assert fresh.pack_fading == -1
assert "draft-btn-0" in draft_screen_html(fresh)
# Verify playing a card never ends the turn; only END TURN brings the boss out.
def test_playing_card_keeps_turn() -> None:
state = battle_state()
state.duel.player.energy = sum(card.cost for card in state.duel.player.hand)
for _ in range(len(state.duel.player.hand)):
state = play_hand_card(state, 0)
assert all(owner != "Boss" for owner, _ in state.showcase)
assert state.duel.round_number == battle_state().duel.round_number
# Verify the battlefield drops END TURN and the your-turn banner when the duel ends.
def test_battlefield_quiets_after_winner() -> None:
state = battle_state()
live = board_html(state)
assert "END TURN" in live and "your turn" in live
state.duel.enemy.hp = 0
over = board_html(state)
assert "END TURN" not in over
assert "your turn" not in over
assert "duel over" in over
# Verify playing a hand card consumes it and logs the play.
def test_play_hand_card_plays_and_logs() -> None:
state = battle_state()
state.duel.player.energy = max(card.cost for card in state.duel.player.hand)
before = len(state.duel.player.hand)
played = play_hand_card(state, 0)
assert any("plays" in line for line in played.log)
assert len(played.duel.player.hand) <= before
# Verify an unaffordable card is refused with a log line.
def test_play_hand_card_refuses_expensive_card() -> None:
state = battle_state()
expensive = next((i for i, card in enumerate(state.duel.player.hand) if card.cost > state.duel.player.energy), None)
if expensive is None:
return
played = play_hand_card(state, expensive)
assert "needs" in played.log[-1]
assert len(played.duel.player.hand) == len(state.duel.player.hand)
# Verify out-of-range plays leave the state unchanged.
def test_play_hand_card_ignores_bad_index() -> None:
state = battle_state()
assert play_hand_card(state, 99) is state
assert play_hand_card(state, -1) is state
# Verify passing advances the battle log.
def test_pass_turn_updates_log() -> None:
state = battle_state(seed=4)
passed = pass_turn(state)
assert any("passes" in line for line in passed.log)
# Verify played cards land in the showcase with an owner label.
def test_showcase_records_plays() -> None:
state = battle_state()
state.duel.player.energy = max(card.cost for card in state.duel.player.hand)
played = play_hand_card(state, 0)
assert played.showcase[0][0] == "Ada"
html = board_html(played)
assert "played-card" in html
assert "You played" in html
# Verify a round turnover step announces the round and initiative on a darkened board.
def test_round_splash_announces_initiative() -> None:
state = battle_state()
steps = list(pass_turn_steps(state))
splash = next(step for step in steps if step.round_flash)
html = board_html(splash)
assert f"ROUND {splash.round_flash}" in html
assert "YOU GO FIRST" in html or "BOSS GOES FIRST" in html
assert "splash-initiative" in html
# Verify the boss takes a visible thinking step before playing.
def test_boss_turn_shows_thinking() -> None:
state = battle_state(seed=4)
for _ in range(3):
steps = list(pass_turn_steps(state))
thinking = [step for step in steps if step.boss_thinking]
if thinking:
assert "boss-thinking" in board_html(thinking[0])
assert "The boss" in board_html(thinking[0])
assert "Boss is scheming" not in board_html(thinking[0])
return
state = steps[-1]
raise AssertionError("boss never took a visible thinking step")
# Verify boss thinking exposes public tactical trace beats.
def test_boss_thought_lines_show_public_trace() -> None:
from game import PendingEffect
state = battle_state(seed=4)
state.duel.enemy.hp = 7
state.duel.enemy.energy = 5
state.duel.pending.append(PendingEffect("bomb", state.duel.player.name, state.duel.enemy.name, 5, delay=1))
thoughts = boss_thought_lines(state)
assert thoughts[0] == "The boss realizes a 5-damage bomb lands in 2 turns."
assert "looks at its health: 7 HP" in thoughts[1]
assert "without revealing them" in thoughts[2]
assert all(card.name not in thoughts[2] for card in state.duel.enemy.hand)
# Verify boss trace frames use short delays to keep turns responsive.
def test_frame_delay_shortens_boss_trace() -> None:
from dataclasses import replace
from app import frame_delay
state = battle_state(seed=4)
assert frame_delay(replace(state, boss_thinking=True)) == 0.35
assert frame_delay(state) == 0.45
# Verify boss cards stream in one step at a time, newest marked fresh.
def test_boss_plays_stream_stepwise() -> None:
state = battle_state(seed=4)
for _ in range(3):
steps = list(pass_turn_steps(state))
counts = [len(step.showcase) for step in steps if step.showcase]
if counts:
assert counts == sorted(counts)
final = next(step for step in reversed(steps) if step.showcase)
assert board_html(final).count("play-slot fresh") == 1
return
state = steps[-1]
raise AssertionError("boss never played a card")
# Verify playable hand cards launch through the animated play handler.
def test_hand_cards_use_play_handler() -> None:
state = battle_state()
state.duel.player.energy = max(card.cost for card in state.duel.player.hand)
assert "tabrasPlay('hand-btn-0', this)" in board_html(state)
# Verify play log entries are structured with owner, card, and effect text.
def test_log_html_styles_plays_and_rounds() -> None:
state = battle_state()
state.duel.player.energy = max(card.cost for card in state.duel.player.hand)
html = log_html(play_hand_card(state, 0))
assert "log-play log-you" in html
assert "log-owner" in html
assert "log-round" in html
# Verify boss plays join the showcase after the player passes.
def test_showcase_includes_boss_plays() -> None:
state = battle_state(seed=4)
for _ in range(3):
state = pass_turn(state)
assert any(owner == "Boss" for owner, _ in state.showcase)
# Verify per-step damage flashes sum to the action's total HP loss.
def test_hp_flash_tracks_damage() -> None:
state = battle_state(seed=4)
for _ in range(8):
hp_before = state.duel.player.hp
steps = list(pass_turn_steps(state))
flashes = [step for step in steps if step.hp_flash[0] > 0]
assert sum(step.hp_flash[0] for step in steps) == max(0, hp_before - steps[-1].duel.player.hp)
if flashes:
assert "dmg-pop" in board_html(flashes[0])
return
state = steps[-1]
raise AssertionError("boss never dealt damage in eight rounds")
# Verify unplayable hand cards render dimmed without a click handler.
def test_hand_fan_marks_unplayable_cards() -> None:
state = battle_state()
player = state.duel.player
if all(card.cost <= player.energy for card in player.hand):
player.energy = 0
html = board_html(state)
assert "unplayable" in html
# Verify mana crystals reflect energy against the round cap.
def test_mana_html_fills_to_energy() -> None:
state = battle_state()
player = state.duel.player
html = mana_html(player, state.duel.round_number)
assert html.count("mana filled") == player.energy
assert f"{player.energy}/" in html
# Verify pending bombs render as telegraphed tokens.
def test_pending_tokens_show_bombs() -> None:
state = battle_state()
duel = state.duel
from game import PendingEffect
duel.pending.append(PendingEffect("bomb", duel.player.name, duel.enemy.name, 9, delay=2))
html = pending_tokens_html(duel, duel.enemy.name)
assert "bomb in 3" in html
assert pending_tokens_html(duel, duel.player.name) == ""
# Verify the winner banner appears when the duel ends.
def test_winner_banner_on_victory() -> None:
state = battle_state()
state.duel.enemy.hp = 0
html = board_html(state)
assert "VICTORY" in html
assert "restart-btn" in html
assert "game-over" in html
# Verify the boss chooser is built once and falls back without a model.
def test_ui_boss_chooser_cached_fallback(monkeypatch) -> None:
import ui
monkeypatch.delenv("TABRAS_AI_BOSS", raising=False)
monkeypatch.setattr(ui, "_boss_chooser_cache", None)
chooser = ui.ui_boss_chooser()
assert ui.ui_boss_chooser() is chooser
state = battle_state(seed=4)
from play import choose_enemy_card
assert chooser(state.duel, state.duel.enemy) == choose_enemy_card(state.duel, state.duel.enemy)
# Verify the draft banner survives the final fade frame after pick nine.
def test_draft_banner_after_final_pick() -> None:
state = new_run("Ada", "anime", "ice", seed=1)
for _ in range(8):
state = choose_draft_card(state, 0)
final_steps = list(choose_draft_card_steps(state, 0))
fade = final_steps[0]
assert fade.pack_fading == 0
assert "Deck complete" in draft_screen_html(fade)
assert final_steps[-1].duel is not None
# Verify the log renders newest-first for bottom-anchored scroll.
def test_log_html_newest_first() -> None:
state = battle_state()
html = log_html(state)
assert html.startswith("<div class='log-scroll'>")
newest = state.log[-1]
assert html.index(newest[:20]) < html.index(state.log[0][:10])
assert log_html(None) == ""
class ForgePackClient:
# Thread-safe fake pack client with distinct card names per call.
def __init__(self) -> None:
self.lock = threading.Lock()
self.calls = 0
# Return one synthetic pack, numbering cards across calls.
def create_pack(self, payload):
with self.lock:
self.calls += 1
base = self.calls * 10
card = lambda i: {"name": f"Forged {base + i}", "flavor": "x", "art_prompt": "y", "effects": [{"primitive_id": "deal", "weight": 1}]}
return {"cards": [card(i) for i in range(payload["pack_size"])]}
class BrokenPackClient:
# Raise like a model response that cannot be parsed or costed.
def create_pack(self, payload):
raise RuntimeError("bad json")
class FakeArtClient:
# Return a stable URI for each generated art prompt.
def create_art(self, prompt: str) -> str:
return f"uri:{prompt}"
class GatedArtClient:
# Wait until the test releases the image job.
def __init__(self) -> None:
self.released = threading.Event()
# Return a stable URI after the gate opens.
def create_art(self, prompt: str) -> str:
self.released.wait(1)
return f"uri:{prompt}"
# Verify a new run pre-forges candidate packs and the boss deck in the background.
def test_new_run_warms_forge(monkeypatch) -> None:
monkeypatch.delenv("TABRAS_PREFETCH_PACKS", raising=False)
forge.reset()
client = ForgePackClient()
state = new_run("Ada", "anime", "ice", client, seed=3)
forge.drain()
# pack 1 + three speculative branch packs + nine boss-deck picks (the boss deck
# now forges from the start, one card per pick, on the slow lane).
assert client.calls == 13
picked = choose_draft_card(state, 0, client)
assert all(card.name.startswith("Forged") for card in picked.current_pack)
# Verify the speculative draft fanout can be tuned without crashing.
def test_prefetch_pack_limit_env(monkeypatch) -> None:
monkeypatch.setenv("TABRAS_PREFETCH_PACKS", "0")
assert prefetch_pack_limit() == 0
monkeypatch.setenv("TABRAS_PREFETCH_PACKS", "bad")
assert prefetch_pack_limit() == CARD_PANEL_COUNT
# Verify battle start consumes the pre-forged boss deck.
def test_battle_uses_forged_boss_deck() -> None:
forge.reset()
client = ForgePackClient()
state = new_run("Ada", "anime", "ice", client, seed=3)
for _ in range(9):
state = choose_draft_card(state, 0, client)
assert state.duel is not None
assert any(card.name.startswith("Forged") for card in state.enemy_deck)
# Verify generated card art renders into the card face.
def test_card_html_renders_generated_art() -> None:
card = fallback_pack("fire", "dark fantasy", 2)[0]
html = card_html(card.__class__(**{**card.__dict__, "art_uri": "data:image/png;base64,abc"}))
assert "card-art generated" in html
assert "data:image/png;base64,abc" in html
# Verify draft packs can be illustrated through the art client.
def test_new_run_illustrates_draft_cards() -> None:
forge.reset()
client = ForgePackClient()
art_client = GatedArtClient()
state = new_run("Ada", "anime", "ice", client, seed=3, art_client=art_client)
assert all(card.art_uri == "" for card in state.current_pack)
art_client.released.set()
forge.drain()
state = refresh_art(state)
assert all(card.art_uri.startswith("uri:") for card in state.current_pack)
# Verify battle start queues art for visible combat cards.
def test_battle_warms_visible_card_art() -> None:
forge.reset()
client = ForgePackClient()
art_client = FakeArtClient()
state = new_run("Ada", "anime", "ice", client, seed=3, art_client=art_client)
for _ in range(9):
state = choose_draft_card(state, 0, client, art_client)
forge.drain()
state = refresh_art(state)
duel = state.duel
# The player's hand is visible and fully illustrated; the enemy hand renders
# face-down (card backs), so its art is intentionally not warmed.
assert all(card.art_uri for card in duel.player.hand)