From 9257852c3e3bfdc798920db182c15667ada32f98 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 9 Apr 2026 07:53:10 -0500 Subject: [PATCH] feat: add refractor-test execute phase with step-by-step reporting Co-Authored-By: Claude Opus 4.6 (1M context) --- cogs/dev_tools.py | 153 +++++++++++++++++++++++++++++++++++++++- tests/test_dev_tools.py | 147 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 2 deletions(-) diff --git a/cogs/dev_tools.py b/cogs/dev_tools.py index 87b17e4..9101ae8 100644 --- a/cogs/dev_tools.py +++ b/cogs/dev_tools.py @@ -245,8 +245,157 @@ class DevToolsCog(commands.Cog): opposing_player_id: int, num_plays: int, ): - """Execute phase placeholder — implemented in Task 5.""" - raise NotImplementedError("Execute phase not yet implemented") + """Execute the refractor integration test chain. + + Creates synthetic game data, runs the real refractor pipeline, + and reports pass/fail at each step. Stops on first failure. + """ + results = [] + game_id = None + + # Remove the "Executing..." field + if len(embed.fields) > 1: + embed.remove_field(len(embed.fields) - 1) + + # Helper to update the embed with current results + async def update_embed( + final: bool = False, view: discord.ui.View | None = None + ): + results_text = "\n".join(results) + # Remove old results field if present, add new one + while len(embed.fields) > 1: + embed.remove_field(len(embed.fields) - 1) + embed.add_field(name="Results", value=results_text, inline=False) + kwargs = {"embed": embed} + if view is not None: + kwargs["view"] = view + await interaction.edit_original_response(**kwargs) + + try: + # Step 1: Create game + game_data = build_game_data(team_id=team_id, season=CURRENT_SEASON) + game_resp = await db_post("games", payload=game_data) + game_id = game_resp["id"] + results.append(f"✅ Game created (#{game_id})") + await update_embed() + except Exception as e: + results.append(f"❌ Game creation failed: {e}") + await update_embed() + return + + try: + # Step 2: Create plays + if card_type == "batter": + plays = build_batter_plays( + game_id, player_id, team_id, opposing_player_id, num_plays + ) + else: + plays = build_pitcher_plays( + game_id, player_id, team_id, opposing_player_id, num_plays + ) + await db_post("plays", payload={"plays": plays}) + results.append(f"✅ {num_plays} plays inserted") + await update_embed() + except Exception as e: + results.append(f"❌ Play insertion failed: {e}") + await update_embed( + final=True, view=CleanupView(interaction.user.id, game_id, embed) + ) + return + + try: + # Step 3: Create pitcher decision + pitcher_id = opposing_player_id if card_type == "batter" else player_id + decision_data = build_decision_data( + game_id, pitcher_id, team_id, CURRENT_SEASON + ) + await db_post("decisions", payload=decision_data) + results.append("✅ Pitcher decision inserted") + await update_embed() + except Exception as e: + results.append(f"❌ Decision insertion failed: {e}") + await update_embed( + final=True, view=CleanupView(interaction.user.id, game_id, embed) + ) + return + + try: + # Step 4: Update season stats + stats_resp = await db_post(f"season-stats/update-game/{game_id}") + if stats_resp and stats_resp.get("skipped"): + results.append("⚠️ Season stats skipped (already processed)") + else: + updated = stats_resp.get("updated", "?") if stats_resp else "?" + results.append(f"✅ Season stats updated ({updated} players)") + await update_embed() + except Exception as e: + results.append(f"❌ Season stats update failed: {e}") + results.append("⏭️ Skipped: evaluate-game (depends on season stats)") + await update_embed( + final=True, view=CleanupView(interaction.user.id, game_id, embed) + ) + return + + try: + # Step 5: Evaluate refractor + eval_resp = await db_post(f"refractor/evaluate-game/{game_id}") + tier_ups = eval_resp.get("tier_ups", []) if eval_resp else [] + + if tier_ups: + for tu in tier_ups: + old_t = tu.get("old_tier", "?") + new_t = tu.get("new_tier", "?") + variant = tu.get("variant_created", "") + results.append(f"✅ Tier-up detected! T{old_t} → T{new_t}") + if variant: + results.append(f"✅ Variant card created (variant: {variant})") + else: + evaluated = eval_resp.get("evaluated", "?") if eval_resp else "?" + results.append(f"⚠️ No tier-up detected (evaluated {evaluated} cards)") + await update_embed() + except Exception as e: + results.append(f"❌ Evaluate-game failed: {e}") + await update_embed( + final=True, view=CleanupView(interaction.user.id, game_id, embed) + ) + return + + # Step 6: Trigger card render (if tier-up) + if tier_ups: + for tu in tier_ups: + variant = tu.get("variant_created") + if not variant: + continue + try: + today = date.today().isoformat() + render_resp = await db_get( + f"players/{tu['player_id']}/{card_type_key}card/{today}/{variant}", + none_okay=True, + ) + if render_resp: + results.append("✅ Card rendered + S3 upload triggered") + img_url = ( + render_resp + if isinstance(render_resp, str) + else render_resp.get("image_url") + ) + if ( + img_url + and isinstance(img_url, str) + and img_url.startswith("http") + ): + embed.set_image(url=img_url) + else: + results.append( + "⚠️ Card render returned no data (may still be processing)" + ) + except Exception as e: + results.append(f"⚠️ Card render failed (non-fatal): {e}") + + # Final update with cleanup buttons + await update_embed( + final=True, view=CleanupView(interaction.user.id, game_id, embed) + ) async def setup(bot: commands.Bot): diff --git a/tests/test_dev_tools.py b/tests/test_dev_tools.py index 334bc3b..c36ba34 100644 --- a/tests/test_dev_tools.py +++ b/tests/test_dev_tools.py @@ -239,3 +239,150 @@ class TestRefractorTestSetup: mock_exec.assert_not_called() call_kwargs = mock_interaction.edit_original_response.call_args[1] assert "not found" in call_kwargs["content"].lower() + + +class TestRefractorTestExecute: + """Test the execution phase: API calls, step-by-step reporting, + stop-on-failure behavior.""" + + @pytest.fixture + def mock_interaction(self): + interaction = AsyncMock(spec=discord.Interaction) + interaction.user = MagicMock() + interaction.user.id = 12345 + interaction.response = AsyncMock() + interaction.edit_original_response = AsyncMock() + return interaction + + @pytest.fixture + def mock_bot(self): + return MagicMock(spec=commands.Bot) + + @pytest.fixture + def base_embed(self): + embed = discord.Embed(title="Refractor Integration Test") + embed.add_field(name="Setup", value="test setup", inline=False) + embed.add_field(name="", value="⏳ Executing...", inline=False) + return embed + + async def test_successful_batter_flow(self, mock_interaction, mock_bot, base_embed): + """Full happy path: game created, plays inserted, stats updated, + tier-up detected, card rendered.""" + from cogs.dev_tools import DevToolsCog + + cog = DevToolsCog(mock_bot) + + game_response = {"id": 42} + plays_response = {"count": 3} + decisions_response = {"count": 1} + stats_response = {"updated": 1, "skipped": False} + eval_response = { + "evaluated": 1, + "tier_ups": [ + { + "player_id": 100, + "team_id": 31, + "player_name": "Mike Trout", + "old_tier": 0, + "new_tier": 1, + "current_value": 45, + "track_name": "Batter Track", + "variant_created": "abc123", + } + ], + } + + with ( + patch("cogs.dev_tools.db_post", new_callable=AsyncMock) as mock_post, + patch("cogs.dev_tools.db_get", new_callable=AsyncMock) as mock_get, + ): + mock_post.side_effect = [ + game_response, # POST games + plays_response, # POST plays + decisions_response, # POST decisions + stats_response, # POST season-stats/update-game + eval_response, # POST refractor/evaluate-game + ] + mock_get.return_value = {"image_url": "https://s3.example.com/card.png"} + + await cog._execute_refractor_test( + interaction=mock_interaction, + embed=base_embed, + player_id=100, + player_name="Mike Trout", + team_id=31, + card_type="batter", + card_type_key="batting", + opposing_player_id=200, + num_plays=3, + ) + + assert mock_interaction.edit_original_response.call_count >= 1 + final_call = mock_interaction.edit_original_response.call_args_list[-1] + final_embed = final_call[1]["embed"] + result_text = "\n".join(f.value for f in final_embed.fields if f.value) + assert "✅" in result_text + assert "game" in result_text.lower() + + async def test_stops_on_game_creation_failure( + self, mock_interaction, mock_bot, base_embed + ): + """If game creation fails, stop immediately and show error.""" + from cogs.dev_tools import DevToolsCog + + cog = DevToolsCog(mock_bot) + + with patch( + "cogs.dev_tools.db_post", + new_callable=AsyncMock, + side_effect=Exception("500 Server Error"), + ): + await cog._execute_refractor_test( + interaction=mock_interaction, + embed=base_embed, + player_id=100, + player_name="Mike Trout", + team_id=31, + card_type="batter", + card_type_key="batting", + opposing_player_id=200, + num_plays=3, + ) + + final_call = mock_interaction.edit_original_response.call_args_list[-1] + final_embed = final_call[1]["embed"] + result_text = "\n".join(f.value for f in final_embed.fields if f.value) + assert "❌" in result_text + + async def test_no_tierup_still_reports_success( + self, mock_interaction, mock_bot, base_embed + ): + """If evaluate-game returns no tier-ups, report it clearly.""" + from cogs.dev_tools import DevToolsCog + + cog = DevToolsCog(mock_bot) + + with patch("cogs.dev_tools.db_post", new_callable=AsyncMock) as mock_post: + mock_post.side_effect = [ + {"id": 42}, # game + {"count": 3}, # plays + {"count": 1}, # decisions + {"updated": 1, "skipped": False}, # stats + {"evaluated": 1, "tier_ups": []}, # no tier-ups + ] + await cog._execute_refractor_test( + interaction=mock_interaction, + embed=base_embed, + player_id=100, + player_name="Mike Trout", + team_id=31, + card_type="batter", + card_type_key="batting", + opposing_player_id=200, + num_plays=3, + ) + + final_call = mock_interaction.edit_original_response.call_args_list[-1] + final_embed = final_call[1]["embed"] + result_text = "\n".join(f.value for f in final_embed.fields if f.value) + assert "no tier-up" in result_text.lower()