Skip to content

Commit 4d29187

Browse files
committed
test: cover remaining branch partials in invoke, callbacks, and statemachine
Move visit condition tests to test_callbacks.py and invalid state value test to test_statemachine.py. Add tests for async cancelled-during-execution, sync error-after-cancel, and spawn_pending_async empty paths. Add pragma: no branch to IInvoke Protocol method body (coverage.py limitation with Protocol classes).
1 parent 608bdbe commit 4d29187

4 files changed

Lines changed: 161 additions & 1 deletion

File tree

statemachine/invoke.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class IInvoke(Protocol):
4444
Optionally implement ``on_cancel()`` for cleanup when the state is exited.
4545
"""
4646

47-
def run(self, ctx: "InvokeContext") -> Any: ...
47+
def run(self, ctx: "InvokeContext") -> Any: ... # pragma: no branch
4848

4949

5050
class _InvokeCallableWrapper:

tests/test_callbacks.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44
from statemachine.callbacks import CallbackGroup
5+
from statemachine.callbacks import CallbacksExecutor
56
from statemachine.callbacks import CallbackSpec
67
from statemachine.callbacks import CallbackSpecList
78
from statemachine.callbacks import CallbacksRegistry
@@ -351,3 +352,35 @@ class ExampleStateMachine(StateChart):
351352
match="Error on transition start from Created to Started when resolving callbacks",
352353
):
353354
ExampleStateMachine()
355+
356+
357+
class TestVisitConditionFalse:
358+
"""visit/async_visit skip callbacks whose condition returns False."""
359+
360+
def test_visit_skips_when_condition_is_false(self):
361+
visited = []
362+
spec = CallbackSpec(
363+
"never_called",
364+
group=CallbackGroup.INVOKE,
365+
is_convention=True,
366+
cond=lambda *a, **kw: False,
367+
)
368+
executor = CallbacksExecutor()
369+
executor.add("test_key", spec, lambda: lambda **kw: True)
370+
371+
executor.visit(lambda cb, *a, **kw: visited.append(str(cb)))
372+
assert visited == []
373+
374+
async def test_async_visit_skips_when_condition_is_false(self):
375+
visited = []
376+
spec = CallbackSpec(
377+
"never_called",
378+
group=CallbackGroup.INVOKE,
379+
is_convention=True,
380+
cond=lambda *a, **kw: False,
381+
)
382+
executor = CallbacksExecutor()
383+
executor.add("test_key", spec, lambda: lambda **kw: True)
384+
385+
await executor.async_visit(lambda cb, *a, **kw: visited.append(str(cb)))
386+
assert visited == []

tests/test_invoke.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,3 +880,110 @@ async def async_visitor(callback, **kwargs):
880880

881881
await executor.async_visit(async_visitor)
882882
assert visited == ["dummy"]
883+
884+
885+
class TestIInvokeProtocolRun:
886+
"""IInvoke.run() protocol method can be called on a concrete implementation."""
887+
888+
def test_protocol_run_is_callable(self):
889+
"""Verify that calling run() on a concrete IInvoke instance works."""
890+
891+
class ConcreteInvoker:
892+
def run(self, ctx):
893+
return "concrete_result"
894+
895+
invoker: IInvoke = ConcreteInvoker()
896+
result = invoker.run(None)
897+
assert result == "concrete_result"
898+
899+
900+
class TestSpawnPendingAsyncEmpty:
901+
"""spawn_pending_async with nothing pending is a no-op."""
902+
903+
async def test_spawn_pending_async_no_pending(self, sm_runner):
904+
class SM(StateChart):
905+
idle = State(initial=True)
906+
active = State(final=True)
907+
go = idle.to(active)
908+
909+
sm = await sm_runner.start(SM)
910+
# Directly call spawn_pending_async with empty pending list
911+
await sm._engine._invoke_manager.spawn_pending_async()
912+
913+
914+
class TestInvokeAsyncCancelledDuringExecution:
915+
"""Async handler completes or errors after state was already exited."""
916+
917+
async def test_success_after_cancel(self):
918+
"""Handler returns successfully but ctx.cancelled is already set."""
919+
from tests.conftest import SMRunner
920+
921+
class SM(StateChart):
922+
loading = State(initial=True)
923+
stopped = State(final=True)
924+
cancel = loading.to(stopped)
925+
926+
def on_invoke_loading(self, ctx=None, **kwargs):
927+
if ctx is None:
928+
return
929+
# Simulate: cancelled is set during execution but we still return
930+
ctx.cancelled.set()
931+
return "should_be_ignored"
932+
933+
sm_runner = SMRunner(is_async=True)
934+
sm = await sm_runner.start(SM)
935+
await sm_runner.sleep(0.2)
936+
await sm_runner.processing_loop(sm)
937+
938+
# The done.invoke event should NOT have been sent (cancelled)
939+
assert "loading" in sm.configuration_values
940+
941+
async def test_error_after_cancel(self):
942+
"""Handler raises but ctx.cancelled is already set — error is swallowed."""
943+
from tests.conftest import SMRunner
944+
945+
class SM(StateChart):
946+
loading = State(initial=True)
947+
error_state = State(final=True)
948+
error_execution = loading.to(error_state)
949+
950+
def on_invoke_loading(self, ctx=None, **kwargs):
951+
if ctx is None:
952+
return
953+
# Simulate: cancelled during execution, then error
954+
ctx.cancelled.set()
955+
raise ValueError("should be ignored")
956+
957+
sm_runner = SMRunner(is_async=True)
958+
sm = await sm_runner.start(SM)
959+
await sm_runner.sleep(0.2)
960+
await sm_runner.processing_loop(sm)
961+
962+
# The error.execution event should NOT have been sent (cancelled)
963+
assert "loading" in sm.configuration_values
964+
965+
966+
class TestSyncInvokeErrorAfterCancel:
967+
"""Sync handler errors after state was already exited."""
968+
969+
async def test_sync_error_after_cancel(self):
970+
"""Sync handler raises but ctx.cancelled is set — error.execution not sent."""
971+
from tests.conftest import SMRunner
972+
973+
class SM(StateChart):
974+
loading = State(initial=True)
975+
error_state = State(final=True)
976+
error_execution = loading.to(error_state)
977+
978+
def on_invoke_loading(self, ctx=None, **kwargs):
979+
if ctx is None:
980+
return
981+
ctx.cancelled.set()
982+
raise ValueError("should be ignored")
983+
984+
sm_runner = SMRunner(is_async=False)
985+
sm = await sm_runner.start(SM)
986+
await sm_runner.sleep(0.2)
987+
await sm_runner.processing_loop(sm)
988+
989+
assert "loading" in sm.configuration_values

tests/test_statemachine.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,3 +709,23 @@ def is_blocked(self):
709709

710710
sm = MyMachine()
711711
assert [e.id for e in sm.enabled_events()] == ["go"]
712+
713+
714+
class TestInvalidStateValueNonNone:
715+
"""current_state raises InvalidStateValue when state value is non-None but invalid."""
716+
717+
def test_invalid_non_none_state_value(self):
718+
import warnings
719+
720+
class SM(StateChart):
721+
idle = State(initial=True)
722+
active = State(final=True)
723+
go = idle.to(active)
724+
725+
sm = SM()
726+
# Bypass setter validation by writing directly to the model attribute
727+
setattr(sm.model, sm.state_field, "nonexistent_state")
728+
with warnings.catch_warnings():
729+
warnings.simplefilter("ignore", DeprecationWarning)
730+
with pytest.raises(exceptions.InvalidStateValue):
731+
_ = sm.current_state

0 commit comments

Comments
 (0)