Merge pull request 'fix/position-validation-lineup' (#10) from fix/position-validation-lineup into main
All checks were successful
Build Docker Image / build (push) Successful in 1m12s

Reviewed-on: #10
This commit is contained in:
cal 2026-02-11 04:15:44 +00:00
commit df97b76294
5 changed files with 340 additions and 132 deletions

View File

@ -1 +1 @@
1.8.5
1.9.0

View File

@ -15,7 +15,7 @@ from sqlmodel import func, or_
from api_calls import db_get
from command_logic.logic_gameplay import bunts, chaos, complete_game, defender_dropdown_view, doubles, flyballs, frame_checks, get_full_roster_from_sheets, checks_log_interaction, complete_play, get_scorebug_embed, groundballs, hit_by_pitch, homeruns, is_game_over, lineouts, manual_end_game, new_game_checks, new_game_conflicts, popouts, read_lineup, relief_pitcher_dropdown_view, select_ai_reliever, show_defense_cards, singles, starting_pitcher_dropdown_view, steals, strikeouts, sub_batter_dropdown_view, substitute_player, triples, undo_play, update_game_settings, walks, xchecks, activate_last_play
from play_lock import release_play_lock, safe_play_lock
from command_logic.play_context import locked_play
from dice import ab_roll
from exceptions import *
import gauntlets
@ -1180,10 +1180,7 @@ class Gameplay(commands.Cog):
@app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME)
async def end_game_command(self, interaction: discord.Interaction):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='end-game')
with safe_play_lock(session, this_play):
# await interaction.edit_original_response(content='Let\'s see, I didn\'t think this game was over...')
async with locked_play(session, interaction, 'end-game') as (this_game, owner_team, this_play):
await manual_end_game(session, interaction, this_game, current_play=this_play)
group_substitution = app_commands.Group(name='substitute', description='Make a substitution in active game')
@ -1268,200 +1265,185 @@ class Gameplay(commands.Cog):
@group_log.command(name='flyball', description='Flyballs: a, b, ballpark, bq, c')
async def log_flyball(self, interaction: discord.Interaction, flyball_type: Literal['a', 'b', 'ballpark', 'b?', 'c']):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log flyball')
async with locked_play(session, interaction, 'log flyball') as (this_game, owner_team, this_play):
logger.info(f'log flyball {flyball_type} - this_play: {this_play}')
this_play = await flyballs(session, interaction, this_play, flyball_type)
logger.info(f'log flyball {flyball_type} - this_play: {this_play}')
this_play = await flyballs(session, interaction, this_play, flyball_type)
await self.complete_and_post_play(
session,
interaction,
this_play,
buffer_message='Flyball logged' if this_play.starting_outs + this_play.outs < 3 and ((this_play.on_second and flyball_type in ['b', 'ballpark']) or (this_play.on_third and flyball_type == 'b?')) else None
)
await self.complete_and_post_play(
session,
interaction,
this_play,
buffer_message='Flyball logged' if this_play.starting_outs + this_play.outs < 3 and ((this_play.on_second and flyball_type in ['b', 'ballpark']) or (this_play.on_third and flyball_type == 'b?')) else None
)
@group_log.command(name='frame-pitch', description=f'Walk/strikeout split; determined by home plate umpire')
async def log_frame_check(self, interaction: discord.Interaction):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log frame-check')
async with locked_play(session, interaction, 'log frame-check') as (this_game, owner_team, this_play):
logger.info(f'log frame-check - this_play: {this_play}')
this_play = await frame_checks(session, interaction, this_play)
logger.info(f'log frame-check - this_play: {this_play}')
this_play = await frame_checks(session, interaction, this_play)
await self.complete_and_post_play(
session,
interaction,
this_play,
buffer_message='Frame check logged'
)
await self.complete_and_post_play(
session,
interaction,
this_play,
buffer_message='Frame check logged'
)
@group_log.command(name='lineout', description='Lineouts: one out, ballpark, max outs')
async def log_lineout(self, interaction: discord.Interaction, lineout_type: Literal['one-out', 'ballpark', 'max-outs']):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log lineout')
async with locked_play(session, interaction, 'log lineout') as (this_game, owner_team, this_play):
logger.info(f'log lineout - this_play: {this_play}')
this_play = await lineouts(session, interaction, this_play, lineout_type)
logger.info(f'log lineout - this_play: {this_play}')
this_play = await lineouts(session, interaction, this_play, lineout_type)
await self.complete_and_post_play(session, interaction, this_play, buffer_message='Lineout logged' if this_play.on_base_code > 3 else None)
await self.complete_and_post_play(session, interaction, this_play, buffer_message='Lineout logged' if this_play.on_base_code > 3 else None)
@group_log.command(name='single', description='Singles: *, **, ballpark, uncapped')
async def log_single(
self, interaction: discord.Interaction, single_type: Literal['*', '**', 'ballpark', 'uncapped']):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log single')
async with locked_play(session, interaction, 'log single') as (this_game, owner_team, this_play):
logger.info(f'log single {single_type} - this_play: {this_play}')
this_play = await singles(session, interaction, this_play, single_type)
logger.info(f'log single {single_type} - this_play: {this_play}')
this_play = await singles(session, interaction, this_play, single_type)
await self.complete_and_post_play(session, interaction, this_play, buffer_message='Single logged' if ((this_play.on_first or this_play.on_second) and single_type == 'uncapped') else None)
await self.complete_and_post_play(session, interaction, this_play, buffer_message='Single logged' if ((this_play.on_first or this_play.on_second) and single_type == 'uncapped') else None)
@group_log.command(name='double', description='Doubles: **, ***, uncapped')
async def log_double(self, interaction: discord.Interaction, double_type: Literal['**', '***', 'uncapped']):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log double')
async with locked_play(session, interaction, 'log double') as (this_game, owner_team, this_play):
logger.info(f'log double {double_type} - this_play: {this_play}')
this_play = await doubles(session, interaction, this_play, double_type)
logger.info(f'log double {double_type} - this_play: {this_play}')
this_play = await doubles(session, interaction, this_play, double_type)
await self.complete_and_post_play(session, interaction, this_play, buffer_message='Double logged' if (this_play.on_first and double_type == 'uncapped') else None)
await self.complete_and_post_play(session, interaction, this_play, buffer_message='Double logged' if (this_play.on_first and double_type == 'uncapped') else None)
@group_log.command(name='triple', description='Triples: no sub-types')
async def log_triple(self, interaction: discord.Interaction):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log triple')
async with locked_play(session, interaction, 'log triple') as (this_game, owner_team, this_play):
logger.info(f'log triple - this_play: {this_play}')
this_play = await triples(session, interaction, this_play)
logger.info(f'log triple - this_play: {this_play}')
this_play = await triples(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
@group_log.command(name='homerun', description='Home Runs: ballpark, no-doubt')
async def log_homerun(self, interaction: discord.Interaction, homerun_type: Literal['ballpark', 'no-doubt']):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log homerun')
async with locked_play(session, interaction, 'log homerun') as (this_game, owner_team, this_play):
logger.info(f'log homerun {homerun_type} - this_play: {this_play}')
this_play = await homeruns(session, interaction, this_play, homerun_type)
logger.info(f'log homerun {homerun_type} - this_play: {this_play}')
this_play = await homeruns(session, interaction, this_play, homerun_type)
await self.complete_and_post_play(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
@group_log.command(name='walk', description='Walks: unintentional (default), intentional')
async def log_walk(self, interaction: discord.Interaction, walk_type: Literal['unintentional', 'intentional'] = 'unintentional'):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log walk')
async with locked_play(session, interaction, 'log walk') as (this_game, owner_team, this_play):
logger.info(f'log walk {walk_type} - this_play: {this_play}')
this_play = await walks(session, interaction, this_play, walk_type)
logger.info(f'log walk {walk_type} - this_play: {this_play}')
this_play = await walks(session, interaction, this_play, walk_type)
await self.complete_and_post_play(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
@group_log.command(name='strikeout', description='Strikeout')
async def log_strikeout(self, interaction: discord.Interaction):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log strikeout')
async with locked_play(session, interaction, 'log strikeout') as (this_game, owner_team, this_play):
logger.info(f'log strikeout - this_play: {this_play}')
this_play = await strikeouts(session, interaction, this_play)
logger.info(f'log strikeout - this_play: {this_play}')
this_play = await strikeouts(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
@group_log.command(name='popout', description='Popout')
async def log_popout(self, interaction: discord.Interaction):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log popout')
async with locked_play(session, interaction, 'log popout') as (this_game, owner_team, this_play):
logger.info(f'log popout - this_play: {this_play}')
this_play = await popouts(session, interaction, this_play)
logger.info(f'log popout - this_play: {this_play}')
this_play = await popouts(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
@group_log.command(name='groundball', description='Groundballs: a, b, c')
async def log_groundball(self, interaction: discord.Interaction, groundball_type: Literal['a', 'b', 'c']):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name=f'log groundball {groundball_type}')
async with locked_play(session, interaction, f'log groundball {groundball_type}') as (this_game, owner_team, this_play):
logger.info(f'log groundball {groundball_type} - this_play: {this_play}')
this_play = await groundballs(session, interaction, this_play, groundball_type)
logger.info(f'log groundball {groundball_type} - this_play: {this_play}')
this_play = await groundballs(session, interaction, this_play, groundball_type)
await self.complete_and_post_play(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
@group_log.command(name='hit-by-pitch', description='Hit by pitch: batter to first; runners advance if forced')
async def log_hit_by_pitch(self, interaction: discord.Interaction):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log hit-by-pitch')
async with locked_play(session, interaction, 'log hit-by-pitch') as (this_game, owner_team, this_play):
logger.info(f'log hit-by-pitch - this_play: {this_play}')
this_play = await hit_by_pitch(session, interaction, this_play)
logger.info(f'log hit-by-pitch - this_play: {this_play}')
this_play = await hit_by_pitch(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
@group_log.command(name='chaos', description='Chaos: wild-pitch, passed-ball, balk, pickoff')
async def log_chaos(self, interaction: discord.Interaction, chaos_type: Literal['wild-pitch', 'passed-ball', 'balk', 'pickoff']):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log hit-by-pitch')
async with locked_play(session, interaction, 'log chaos') as (this_game, owner_team, this_play):
if this_play.on_base_code == 0:
await interaction.edit_original_response(
content=f'There cannot be chaos when the bases are empty.'
)
return
if this_play.on_base_code == 0:
await interaction.edit_original_response(
content=f'There cannot be chaos when the bases are empty.'
)
return
logger.info(f'log chaos - this_play: {this_play}')
this_play = await chaos(session, interaction, this_play, chaos_type)
logger.info(f'log chaos - this_play: {this_play}')
this_play = await chaos(session, interaction, this_play, chaos_type)
await self.complete_and_post_play(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
@group_log.command(name='bunt', description='Bunts: sacrifice, bad, popout, double-play, defense')
async def log_sac_bunt(self, interaction: discord.Interaction, bunt_type: Literal['sacrifice', 'bad', 'popout', 'double-play', 'defense']):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log bunt')
async with locked_play(session, interaction, 'log bunt') as (this_game, owner_team, this_play):
if this_play.on_base_code == 0:
await interaction.edit_original_response(
content=f'You cannot bunt when the bases are empty.'
)
return
elif this_play.starting_outs == 2:
await interaction.edit_original_response(
content=f'You cannot bunt with two outs.'
)
return
if this_play.on_base_code == 0:
await interaction.edit_original_response(
content=f'You cannot bunt when the bases are empty.'
)
return
elif this_play.starting_outs == 2:
await interaction.edit_original_response(
content=f'You cannot bunt with two outs.'
)
return
logger.info(f'log bunt - this_play: {this_play}')
this_play = await bunts(session, interaction, this_play, bunt_type)
logger.info(f'log bunt - this_play: {this_play}')
this_play = await bunts(session, interaction, this_play, bunt_type)
await self.complete_and_post_play(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
@group_log.command(name='stealing', description='Running: stolen-base, caught-stealing')
@app_commands.describe(to_base='Base the runner is advancing to; 2 for 2nd, 3 for 3rd, 4 for Home')
async def log_stealing(self, interaction: discord.Interaction, running_type: Literal['stolen-base', 'caught-stealing', 'steal-plus-overthrow'], to_base: Literal[2, 3, 4]):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log stealing')
async with locked_play(session, interaction, 'log stealing') as (this_game, owner_team, this_play):
if (to_base == 2 and this_play.on_first is None) or (to_base == 3 and this_play.on_second is None) or (to_base == 4 and this_play.on_third is None):
logger.info(f'Illegal steal attempt')
await interaction.edit_original_response(
content=f'I don\'t see a runner there.'
)
return
if (to_base == 2 and this_play.on_first is None) or (to_base == 3 and this_play.on_second is None) or (to_base == 4 and this_play.on_third is None):
logger.info(f'Illegal steal attempt')
await interaction.edit_original_response(
content=f'I don\'t see a runner there.'
)
return
if (to_base == 3 and this_play.on_third is not None) or (to_base == 2 and this_play.on_second is not None):
logger.info(f'Stealing runner is blocked')
if to_base == 3:
content = f'{this_play.on_second.player.name} is blocked by {this_play.on_third.player.name}'
else:
content = f'{this_play.on_first.player.name} is blocked by {this_play.on_second.player.name}'
if (to_base == 3 and this_play.on_third is not None) or (to_base == 2 and this_play.on_second is not None):
logger.info(f'Stealing runner is blocked')
if to_base == 3:
content = f'{this_play.on_second.player.name} is blocked by {this_play.on_third.player.name}'
else:
content = f'{this_play.on_first.player.name} is blocked by {this_play.on_second.player.name}'
await interaction.edit_original_response(
content=content
)
return
await interaction.edit_original_response(
content=content
)
return
logger.info(f'log stealing - this_play: {this_play}')
this_play = await steals(session, interaction, this_play, running_type, to_base)
logger.info(f'log stealing - this_play: {this_play}')
this_play = await steals(session, interaction, this_play, running_type, to_base)
await self.complete_and_post_play(session, interaction, this_play)
await self.complete_and_post_play(session, interaction, this_play)
@group_log.command(name='xcheck', description='Defender makes an x-check')
@app_commands.choices(position=[
@ -1477,23 +1459,23 @@ class Gameplay(commands.Cog):
])
async def log_xcheck_command(self, interaction: discord.Interaction, position: Choice[str]):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log xcheck')
async with locked_play(session, interaction, 'log xcheck') as (this_game, owner_team, this_play):
logger.info(f'log xcheck - this_play: {this_play}')
this_play = await xchecks(session, interaction, this_play, position.value)
logger.info(f'log xcheck - this_play: {this_play}')
this_play = await xchecks(session, interaction, this_play, position.value)
await self.complete_and_post_play(session, interaction, this_play, buffer_message='X-Check logged')
await self.complete_and_post_play(session, interaction, this_play, buffer_message='X-Check logged')
@group_log.command(name='undo-play', description='Roll back most recent play from the log')
async def log_undo_play_command(self, interaction: discord.Interaction):
with Session(engine) as session:
this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='log undo-play')
async with locked_play(session, interaction, 'log undo-play') as (this_game, owner_team, this_play):
logger.info(f'log undo-play - this_play: {this_play}')
original_play = this_play
this_play = undo_play(session, this_play)
original_play.locked = False # prevent finally from committing deleted row
logger.info(f'log undo-play - this_play: {this_play}')
this_play = undo_play(session, this_play)
await self.post_play(session, interaction, this_play)
await self.post_play(session, interaction, this_play)
group_show = app_commands.Group(name='show-card', description='Display the player card for an active player')

View File

View File

@ -0,0 +1,64 @@
"""
Async context manager for play locking. Wraps checks_log_interaction()
and guarantees lock release on exception, early return, or normal exit.
This module sits above play_lock and logic_gameplay to avoid circular imports.
"""
import logging
from contextlib import asynccontextmanager
from sqlmodel import Session
from play_lock import release_play_lock
from command_logic.logic_gameplay import checks_log_interaction
logger = logging.getLogger("discord_app")
@asynccontextmanager
async def locked_play(session: Session, interaction, command_name: str):
"""
Async context manager that acquires a play lock via checks_log_interaction
and guarantees release when the block exits.
Usage:
with Session(engine) as session:
async with locked_play(session, interaction, 'log flyball') as (game, team, play):
play = await flyballs(session, interaction, play, flyball_type)
await self.complete_and_post_play(session, interaction, play, ...)
Behavior:
- If checks_log_interaction raises (PlayLockedException, etc.), no lock
was acquired and the exception propagates naturally.
- If complete_play() was called, play.complete=True, so finally is a no-op.
- If the block raises an exception, the lock is released and the exception re-raised.
- If the block returns early (e.g. validation failure), the lock is released.
"""
# If this raises, no lock was acquired - exception propagates naturally
this_game, owner_team, this_play = await checks_log_interaction(
session, interaction, command_name=command_name, lock_play=True
)
try:
yield this_game, owner_team, this_play
except Exception as e:
if this_play.locked and not this_play.complete:
logger.error(
f"Exception in '{command_name}' on play {this_play.id}, releasing lock: {e}"
)
try:
release_play_lock(session, this_play)
except Exception as release_err:
logger.error(f"Failed to release lock on play {this_play.id}: {release_err}")
raise
finally:
# Catches early returns (the primary bug fix)
# No-op if complete_play() already set complete=True and locked=False
if this_play.locked and not this_play.complete:
logger.warning(
f"Play {this_play.id} still locked after '{command_name}', "
f"releasing (likely early return)"
)
try:
release_play_lock(session, this_play)
except Exception as release_err:
logger.error(f"Failed to force release lock on play {this_play.id}: {release_err}")

View File

@ -11,6 +11,7 @@ Tests the play locking system including:
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from command_logic.logic_gameplay import checks_log_interaction, release_play_lock, safe_play_lock
from command_logic.play_context import locked_play
from exceptions import PlayLockedException
@ -315,3 +316,164 @@ def test_safe_play_lock_allows_normal_completion(mock_session):
pass
# Completed play should not be unlocked by context manager
# --- locked_play context manager tests ---
@pytest.mark.asyncio
async def test_locked_play_releases_on_exception(mock_session, mock_interaction, mock_game, mock_team):
"""
Verify locked_play releases the lock when an exception is raised inside the block.
This ensures that if command processing throws (e.g. a dice roll function errors),
the play lock is released so the game isn't permanently stuck.
"""
mock_play = MagicMock()
mock_play.id = 700
mock_play.locked = True
mock_play.complete = False
def fake_release(session, play):
play.locked = False
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
return_value=(mock_game, mock_team, mock_play),
):
with patch("command_logic.play_context.release_play_lock", side_effect=fake_release) as mock_release:
with pytest.raises(ValueError):
async with locked_play(mock_session, mock_interaction, "log test") as (g, t, p):
raise ValueError("processing error")
# Called once in except block; finally sees locked=False and skips
mock_release.assert_called_once_with(mock_session, mock_play)
@pytest.mark.asyncio
async def test_locked_play_releases_on_early_return(mock_session, mock_interaction, mock_game, mock_team):
"""
Verify locked_play releases the lock when the block exits normally without
calling complete_play (i.e. an early return from validation).
This is the primary bug fix commands like log_chaos, log_sac_bunt, and
log_stealing had early returns after lock acquisition that left the lock stuck.
"""
mock_play = MagicMock()
mock_play.id = 701
mock_play.locked = True
mock_play.complete = False
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
return_value=(mock_game, mock_team, mock_play),
):
with patch("command_logic.play_context.release_play_lock") as mock_release:
async with locked_play(mock_session, mock_interaction, "log bunt") as (g, t, p):
# Simulate early return: block exits without calling complete_play
pass
# Lock should be released because play.locked=True and play.complete=False
mock_release.assert_called_once_with(mock_session, mock_play)
@pytest.mark.asyncio
async def test_locked_play_skips_release_when_complete(mock_session, mock_interaction, mock_game, mock_team):
"""
Verify locked_play does NOT attempt to release the lock when complete_play()
has already run (play.complete=True, play.locked=False).
This is the normal success path complete_play handles the lock release,
so the context manager should be a no-op.
"""
mock_play = MagicMock()
mock_play.id = 702
mock_play.locked = True
mock_play.complete = False
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
return_value=(mock_game, mock_team, mock_play),
):
with patch("command_logic.play_context.release_play_lock") as mock_release:
async with locked_play(mock_session, mock_interaction, "log single") as (g, t, p):
# Simulate complete_play() having been called
mock_play.locked = False
mock_play.complete = True
# Should NOT attempt release since play is already complete
mock_release.assert_not_called()
@pytest.mark.asyncio
async def test_locked_play_propagates_checks_exception(mock_session, mock_interaction):
"""
Verify that when checks_log_interaction raises PlayLockedException (because
the play is already locked by another command), the exception propagates
without any cleanup attempt the lock was never ours.
"""
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
side_effect=PlayLockedException("Play is already being processed. Please wait."),
):
with patch("command_logic.play_context.release_play_lock") as mock_release:
with pytest.raises(PlayLockedException):
async with locked_play(mock_session, mock_interaction, "log walk") as (g, t, p):
pass # Should never reach here
# No release attempted — lock was never acquired
mock_release.assert_not_called()
@pytest.mark.asyncio
async def test_locked_play_reraises_original_exception(mock_session, mock_interaction, mock_game, mock_team):
"""
Verify that the original exception is preserved and re-raised after
the lock is released. The caller (global error handler) should see
the real error, not a lock-release error.
"""
mock_play = MagicMock()
mock_play.id = 704
mock_play.locked = True
mock_play.complete = False
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
return_value=(mock_game, mock_team, mock_play),
):
with patch("command_logic.play_context.release_play_lock"):
with pytest.raises(RuntimeError, match="dice roll failed"):
async with locked_play(mock_session, mock_interaction, "log xcheck") as (g, t, p):
raise RuntimeError("dice roll failed")
@pytest.mark.asyncio
async def test_locked_play_handles_release_failure(mock_session, mock_interaction, mock_game, mock_team):
"""
Verify that if release_play_lock itself fails (e.g. DB connection lost),
the original exception is still propagated rather than being masked by
the release failure.
"""
mock_play = MagicMock()
mock_play.id = 705
mock_play.locked = True
mock_play.complete = False
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
return_value=(mock_game, mock_team, mock_play),
):
with patch(
"command_logic.play_context.release_play_lock",
side_effect=Exception("DB connection lost"),
):
# The original ValueError should propagate, not the release failure
with pytest.raises(ValueError, match="original error"):
async with locked_play(mock_session, mock_interaction, "log chaos") as (g, t, p):
raise ValueError("original error")