Skip to content

Commit c5458df

Browse files
committed
test: reach 100% branch coverage for diagram package
Add tests for extract.py (deep history type, internal transitions with and without actions, bidirectional compound detection, invalid input, initial state fallback), dot.py (compound labels with actions, internal action format, targetless transitions, non-bidirectional anchors), and __main__.py (via runpy). Mark unreachable branch in extract.py (history states never have substates) with pragma: no cover, and add `if __name__` to coverage exclusions in pyproject.toml.
1 parent cc20e85 commit c5458df

3 files changed

Lines changed: 162 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ exclude_lines = [
116116
"raise AssertionError",
117117
"raise NotImplementedError",
118118
"if TYPE_CHECKING",
119+
'if __name__ == "__main__"',
119120
]
120121

121122
[tool.coverage.html]

statemachine/contrib/diagram/extract.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def _extract_all_transitions(states, getter) -> List[DiagramTransition]:
131131
result.extend(_extract_all_transitions(state.states, getter))
132132
for history_state in getattr(state, "history", []):
133133
result.extend(_extract_transitions_from_state(history_state, getter))
134-
if history_state.states:
134+
if history_state.states: # pragma: no cover
135135
result.extend(_extract_all_transitions(history_state.states, getter))
136136
return result
137137

tests/test_contrib_diagram.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from statemachine.contrib.diagram import DotGraphMachine
99
from statemachine.contrib.diagram import main
1010
from statemachine.contrib.diagram import quickchart_write_svg
11+
from statemachine.contrib.diagram.model import ActionType
1112
from statemachine.contrib.diagram.model import StateType
1213
from statemachine.contrib.diagram.renderers.dot import DotRenderer
1314

@@ -489,6 +490,165 @@ class SM(StateChart):
489490
)
490491

491492

493+
class TestExtract:
494+
"""Tests for extract.py edge cases."""
495+
496+
def test_deep_history_state_type(self):
497+
"""Deep history state is correctly typed in the extracted graph."""
498+
from statemachine.contrib.diagram.extract import extract
499+
500+
from tests.machines.showcase_deep_history import DeepHistorySC
501+
502+
graph = extract(DeepHistorySC)
503+
# Find the history state in the outer compound's children
504+
outer = next(s for s in graph.states if s.id == "outer")
505+
h_state = next(s for s in outer.children if s.type == StateType.HISTORY_DEEP)
506+
assert h_state is not None
507+
508+
def test_internal_transition_actions_extracted(self):
509+
"""Internal transitions with actions are extracted into state actions."""
510+
from statemachine.contrib.diagram.extract import extract
511+
512+
from tests.machines.showcase_internal import InternalSC
513+
514+
graph = extract(InternalSC)
515+
monitoring = next(s for s in graph.states if s.id == "monitoring")
516+
internal_actions = [a for a in monitoring.actions if a.type == ActionType.INTERNAL]
517+
assert len(internal_actions) >= 1
518+
assert any("check" in a.body for a in internal_actions)
519+
520+
def test_internal_transition_skipped_in_bidirectional(self):
521+
"""Internal transitions are skipped in _collect_bidirectional_compound_ids."""
522+
from statemachine.contrib.diagram.extract import extract
523+
524+
class SM(StateChart):
525+
class parent(State.Compound, name="Parent"):
526+
child1 = State(initial=True)
527+
child2 = State(final=True)
528+
529+
def log(self): ...
530+
531+
check = child1.to.itself(internal=True, on="log")
532+
go = child1.to(child2)
533+
534+
start = State(initial=True)
535+
end = State(final=True)
536+
537+
enter = start.to(parent)
538+
finish = parent.to(end)
539+
540+
graph = extract(SM)
541+
# parent has both incoming and outgoing, so it should be bidirectional
542+
assert "parent" in graph.bidirectional_compound_ids
543+
544+
def test_internal_transition_without_action(self):
545+
"""Internal transition without on action has no internal action in diagram."""
546+
from statemachine.contrib.diagram.extract import extract
547+
548+
class SM(StateChart):
549+
s1 = State(initial=True)
550+
s2 = State(final=True)
551+
552+
noop = s1.to.itself(internal=True)
553+
go = s1.to(s2)
554+
555+
graph = extract(SM)
556+
s1 = next(s for s in graph.states if s.id == "s1")
557+
internal_actions = [a for a in s1.actions if a.type == ActionType.INTERNAL]
558+
assert internal_actions == []
559+
560+
def test_extract_invalid_type_raises(self):
561+
"""extract() raises TypeError for invalid input."""
562+
from statemachine.contrib.diagram.extract import extract
563+
564+
with pytest.raises(TypeError, match="Expected a StateChart"):
565+
extract("not a machine") # type: ignore[arg-type]
566+
567+
def test_resolve_initial_fallback(self):
568+
"""When no explicit initial, first candidate gets is_initial=True."""
569+
from statemachine.contrib.diagram.extract import _resolve_initial_states
570+
from statemachine.contrib.diagram.model import DiagramState
571+
572+
states = [
573+
DiagramState(id="a", name="A", type=StateType.REGULAR),
574+
DiagramState(id="b", name="B", type=StateType.REGULAR),
575+
]
576+
_resolve_initial_states(states)
577+
assert states[0].is_initial is True
578+
579+
580+
class TestDotRendererEdgeCases:
581+
"""Tests for dot.py edge cases."""
582+
583+
def test_compound_state_with_actions_label(self):
584+
"""Compound state with entry/exit actions renders action rows in label."""
585+
586+
class SM(StateChart):
587+
class parent(State.Compound, name="Parent"):
588+
child = State(initial=True)
589+
590+
def on_enter_parent(self): ...
591+
592+
start = State(initial=True)
593+
enter = start.to(parent)
594+
595+
dot = DotGraphMachine(SM)().to_string()
596+
# The compound label should contain the entry action
597+
assert "entry" in dot.lower() or "on_enter_parent" in dot
598+
599+
def test_internal_action_format(self):
600+
"""Internal action uses body directly (no 'entry /' prefix)."""
601+
renderer = DotRenderer()
602+
from statemachine.contrib.diagram.model import DiagramAction
603+
604+
action = DiagramAction(type=ActionType.INTERNAL, body="check / log_status")
605+
result = renderer._format_action(action)
606+
assert result == "check / log_status"
607+
608+
def test_targetless_transition_self_loop(self):
609+
"""Transition with no target falls back to source as destination."""
610+
from statemachine.contrib.diagram.model import DiagramTransition
611+
612+
transition = DiagramTransition(source="s1", targets=[], event="tick")
613+
renderer = DotRenderer()
614+
renderer._compound_ids = set()
615+
edges = renderer._create_edges(transition)
616+
assert len(edges) == 1
617+
# With no targets, target_ids becomes [None], and dst becomes source
618+
assert edges[0].obj_dict["points"][1] == "s1"
619+
620+
def test_compound_edge_anchor_non_bidirectional(self):
621+
"""Non-bidirectional compound state uses generic _anchor node."""
622+
renderer = DotRenderer()
623+
renderer._compound_bidir_ids = {"other"}
624+
result = renderer._compound_edge_anchor("my_state", "out")
625+
assert result == "my_state_anchor"
626+
627+
628+
class TestDiagramMainModule:
629+
"""Tests for __main__.py."""
630+
631+
def test_main_module_execution(self, tmp_path):
632+
"""python -m statemachine.contrib.diagram works."""
633+
import runpy
634+
635+
out = tmp_path / "sm.svg"
636+
with mock.patch(
637+
"sys.argv",
638+
[
639+
"statemachine.contrib.diagram",
640+
"tests.examples.traffic_light_machine.TrafficLightMachine",
641+
str(out),
642+
],
643+
):
644+
with pytest.raises(SystemExit) as exc_info:
645+
runpy.run_module(
646+
"statemachine.contrib.diagram", run_name="__main__", alter_sys=True
647+
)
648+
assert exc_info.value.code is None
649+
assert out.exists()
650+
651+
492652
class TestSphinxDirective:
493653
"""Unit tests for the statemachine-diagram Sphinx directive."""
494654

0 commit comments

Comments
 (0)