| from pathlib import Path | |
| from tower_game.assets import AssetCatalog | |
| from tower_game.registry import SKILLS | |
| from tower_game.render import ( | |
| render_latest_log, | |
| render_sidebar, | |
| render_stage, | |
| render_stage_narration, | |
| skill_card_label, | |
| ) | |
| from tower_game.state import new_game_state | |
| from tower_game.ui import CSS, JS, _tooltip_data | |
| def test_stage_uses_reserved_huds_and_left_facing_enemy(): | |
| state = new_game_state(phase="combat") | |
| state["current_floor"] = 1 | |
| state["enemy_name"] = "Test Enemy" | |
| state["enemy_hp"] = 20 | |
| state["enemy_max_hp"] = 20 | |
| stage = render_stage(state, AssetCatalog.load()) | |
| assert 'class="stage-hud player-stage-hud"' in stage | |
| assert 'class="stage-hud enemy-stage-hud"' in stage | |
| assert stage.index('class="stage-hud player-stage-hud"') < stage.index('class="combatant-wrap player-wrap"') | |
| assert "enemy-sprite faces-left" in stage | |
| assert "scaleX(-1)" not in CSS | |
| def test_stage_renders_element_specific_attack_effects(): | |
| state = new_game_state(phase="combat") | |
| state["visual_event"] = { | |
| "event_id": 1, | |
| "player_animation": "skill", | |
| "player_element": "Lightning", | |
| "enemy_animation": "idle", | |
| "enemy_variant": "", | |
| "enemy_element": "Physical", | |
| } | |
| stage = render_stage(state, AssetCatalog.load()) | |
| assert "attack-fx effect-skill element-lightning" in stage | |
| for element in ( | |
| "physical", | |
| "magical", | |
| "fire", | |
| "water", | |
| "ice", | |
| "lightning", | |
| "earth", | |
| "holy", | |
| "demonic", | |
| ): | |
| assert f".element-{element}::before" in CSS | |
| def test_sidebar_removes_duplicate_skill_descriptions(): | |
| state = new_game_state(phase="combat") | |
| state["active_skills"] = [SKILLS["power_strike"].model_dump()] | |
| state["cooldowns"]["power_strike"] = 2 | |
| sidebar = render_sidebar( | |
| state, | |
| AssetCatalog.load(), | |
| {"power_strike": "18 damage"}, | |
| ) | |
| assert "sidebar-skill" not in sidebar | |
| assert "<h3>Skills</h3>" not in sidebar | |
| assert "Equipment" in sidebar | |
| def test_skill_cards_include_effect_and_cooldown_without_long_description(): | |
| state = new_game_state(phase="combat") | |
| skill = SKILLS["power_strike"].model_dump() | |
| state["cooldowns"]["power_strike"] = 2 | |
| label = skill_card_label(skill, state, "18 damage") | |
| assert "Power Strike" in label | |
| assert "CD: 2T" in label | |
| assert "18 damage" in label | |
| assert skill["description"] not in label | |
| state["cooldowns"].clear() | |
| assert "CD: 1T" in skill_card_label(skill, state, "18 damage") | |
| shadow = SKILLS["shadow_step"].model_dump() | |
| assert "CD:" not in skill_card_label(shadow, state) | |
| assert "CD: 1T" in skill_card_label(skill, state, active=False) | |
| def test_sidebar_renders_effective_equipment_stats_and_shared_card_tokens(): | |
| state = new_game_state(phase="combat") | |
| state["equipment"]["weapon"] = { | |
| "id": "strength_blade", | |
| "name": "Strength Blade", | |
| "slot": "weapon", | |
| "rarity": "Rare", | |
| "asset_id": "weapon_sword_1", | |
| "stat": "strength", | |
| "value": 4, | |
| "element": "Physical", | |
| } | |
| sidebar = render_sidebar(state, AssetCatalog.load(), {}) | |
| assert "STR" in sidebar | |
| assert 'class="stat-bonus">(+4)</small>' in sidebar | |
| assert "Max HP" in sidebar | |
| assert "Max AP" in sidebar | |
| assert "AP Regen" in sidebar | |
| assert "--card-bg:#12101a" in CSS | |
| assert "--deck-bg:#12101ae8" in CSS | |
| assert "--card-border:#30283f" in CSS | |
| assert ".action-deck button {" in CSS | |
| assert "background:var(--card-bg) !important" in CSS | |
| assert "background-image:none !important" in CSS | |
| assert "border:1px solid var(--card-border) !important" in CSS | |
| assert "border-radius:0 !important" in CSS | |
| def test_skill_tooltips_explain_cooldown_and_ap_disabling(): | |
| state = new_game_state(phase="combat") | |
| state["active_skills"] = [SKILLS["power_strike"].model_dump()] | |
| state["cooldowns"]["power_strike"] = 1 | |
| tooltip = _tooltip_data(state, {"power_strike": "18 damage"}) | |
| assert "Ready in 1 turn." in tooltip | |
| state["cooldowns"].clear() | |
| state["current_ap"] = 0 | |
| tooltip = _tooltip_data(state, {"power_strike": "18 damage"}) | |
| assert "Requires 2 AP." in tooltip | |
| def test_tooltip_observer_does_not_watch_its_own_attributes(): | |
| assert "childList:true" in JS | |
| assert "attributes:true" not in JS | |
| assert "button.dataset.tip" in JS | |
| assert "#button-tooltip-data [data-target-id]" in JS | |
| assert "tower-tooltip" in JS | |
| assert "requestAnimationFrame" in JS | |
| def test_integrated_command_panel_uses_explicit_compact_rows(): | |
| source = Path("tower_game/ui.py").read_text(encoding="utf-8") | |
| assert ".action-deck {" in CSS | |
| assert "position:absolute !important" in CSS | |
| assert "grid-template-columns:repeat(2" in CSS | |
| assert ".skills-deck > .styler" in CSS | |
| assert ".submenu-row" in CSS | |
| assert 'elem_classes=["action-deck", "basic-actions-deck"]' in source | |
| assert 'elem_classes=["action-deck", "skills-deck"]' in source | |
| assert "for row_index in range(3)" in source | |
| assert "for column_index in range(2)" in source | |
| assert "item_toggle.click" not in source | |
| assert "skills_toggle.click" not in source | |
| assert "button.click(item_handler" not in source | |
| assert "submenu-grid" not in source | |
| assert "padding-bottom:clamp" in CSS | |
| def test_player_hud_and_results_layer_above_integrated_commands(): | |
| assert ".player-stage-hud { left:3%; bottom:51%; }" in CSS | |
| assert ".stage-overlay" in CSS | |
| assert "z-index:20" in CSS | |
| assert "z-index:8" in CSS | |
| def test_stage_renders_status_icons_enemy_effects_and_accessible_tooltips(): | |
| state = new_game_state(phase="combat") | |
| state["player_statuses"] = {"poison": 2, "atkup": 1} | |
| state["enemy_statuses"] = {"defup": 1, "stun": 1} | |
| state["visual_event"] = { | |
| "event_id": 1, | |
| "player_animation": "idle", | |
| "player_element": "Physical", | |
| "enemy_animation": "grunt", | |
| "enemy_variant": "triple-claw", | |
| "enemy_element": "Physical", | |
| } | |
| stage = render_stage(state, AssetCatalog.load()) | |
| assert "player-status-rack" in stage | |
| assert "enemy-status-rack" in stage | |
| assert "Poison: Takes damage" in stage | |
| assert "Defense Up: Armor is increased" in stage | |
| assert "status-duration" in stage | |
| assert "enemy-effect-grunt" in stage | |
| assert "enemy-variant-triple-claw" in stage | |
| for variant in ( | |
| "silver-slash", | |
| "thrust", | |
| "triple-claw", | |
| "projectile", | |
| "hex-pulse", | |
| "heavy-cleave", | |
| "ground-shockwave", | |
| ): | |
| assert f".enemy-variant-{variant}::before" in CSS | |
| def test_stage_uses_cached_static_urls_and_small_payload(): | |
| state = new_game_state(phase="combat") | |
| state["current_floor"] = 1 | |
| stage = render_stage(state, AssetCatalog.load()) | |
| assert "/gradio_api/file=" in stage | |
| assert "data:image" not in stage | |
| assert len(stage.encode("utf-8")) < 20_000 | |
| def test_normal_turn_narration_timer_is_removed(): | |
| source = Path("tower_game/ui.py").read_text(encoding="utf-8") | |
| assert "gr.Timer" not in source | |
| assert "NARRATION_QUEUE" not in source | |
| assert "update_stage=False" in source | |
| assert "await asyncio.sleep(beat_delay)" in source | |
| assert "3.5" in source and "1.25" in source | |
| def test_stage_narration_is_a_separate_lightweight_overlay(): | |
| state = new_game_state(phase="combat") | |
| state["presentation_line"] = "Player: The blade finds its mark." | |
| narration = render_stage_narration(state) | |
| assert "stage-narration" in narration | |
| assert "The blade finds its mark" in narration | |
| assert "battle-frame" not in narration | |
| def test_latest_turn_feed_has_heading_and_three_labelled_events(): | |
| state = new_game_state(phase="combat") | |
| state["active_turn"] = 4 | |
| state["combat_events"] = [ | |
| {"turn": 3, "actor": "System", "text": "older"}, | |
| {"turn": 4, "actor": "Player", "text": "You strike."}, | |
| {"turn": 4, "actor": "Enemy", "text": "Enemy strikes."}, | |
| {"turn": 4, "actor": "Status", "text": "Poison deals damage."}, | |
| ] | |
| feed = render_latest_log(state) | |
| assert "Turn 4" in feed | |
| assert "older" not in feed | |
| assert "Player" in feed and "Enemy" in feed and "Status" in feed | |
| def test_difficulty_picker_and_percentage_equipment_labels_are_present(): | |
| source = Path("tower_game/ui.py").read_text(encoding="utf-8") | |
| assert 'label="Run Difficulty"' in source | |
| assert '("Easy - forgiving combat and lighter defenses", "easy")' in source | |
| assert '("Normal - stronger enemies and bosses", "normal")' in source | |
| state = new_game_state(phase="combat") | |
| state["equipment"]["armor"] = { | |
| "id": "ward", | |
| "name": "Ward", | |
| "slot": "armor", | |
| "rarity": "Rare", | |
| "asset_id": "armor_1", | |
| "bonuses": [ | |
| {"stat": "armor", "value": 5}, | |
| {"stat": "evasion", "value": 3}, | |
| ], | |
| } | |
| sidebar = render_sidebar(state, AssetCatalog.load(), {}) | |
| assert "+5% Armor" in sidebar | |
| assert "+3% Evasion" in sidebar | |
| def test_skill_scaling_tooltips_and_combat_options_are_present(): | |
| source = Path("tower_game/ui.py").read_text(encoding="utf-8") | |
| state = new_game_state(phase="combat") | |
| state["active_skills"] = [SKILLS["power_strike"].model_dump()] | |
| tooltip = _tooltip_data(state, {"power_strike": "18 damage"}) | |
| assert "Scales with STR" in tooltip | |
| assert "Cinematic - readable 3.5 second rounds" in source | |
| assert "Fast - current 1.25 second rounds" in source | |
| assert "Improves Strike and STR-scaling technique damage" in source | |
| def test_evolution_hides_raw_dials_until_developer_details(): | |
| from tower_game.render import render_evolution | |
| state = new_game_state(phase="evolution_reveal") | |
| state["pending_evolution"] = { | |
| "class_name": "Towerbreaker", | |
| "title": "Stone Answers", | |
| "narrative": "Repeated force becomes law.", | |
| "portrait_id": "player_strength", | |
| "signature_skill_id": "power_strike", | |
| "signature_skill": None, | |
| "dial_changes": [{"dial": "str_scaling_dial", "delta": 0.25}], | |
| } | |
| rendered = render_evolution(state, AssetCatalog.load()) | |
| assert "Strength scaling" in rendered | |
| assert "<summary>Developer Details</summary>" in rendered | |
| assert "<code>str_scaling_dial</code>" in rendered | |