|
8 | 8 | from statemachine.contrib.diagram import DotGraphMachine |
9 | 9 | from statemachine.contrib.diagram import main |
10 | 10 | from statemachine.contrib.diagram import quickchart_write_svg |
| 11 | +from statemachine.contrib.diagram.model import ActionType |
11 | 12 | from statemachine.contrib.diagram.model import StateType |
12 | 13 | from statemachine.contrib.diagram.renderers.dot import DotRenderer |
13 | 14 |
|
@@ -489,6 +490,165 @@ class SM(StateChart): |
489 | 490 | ) |
490 | 491 |
|
491 | 492 |
|
| 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 | + |
492 | 652 | class TestSphinxDirective: |
493 | 653 | """Unit tests for the statemachine-diagram Sphinx directive.""" |
494 | 654 |
|
|
0 commit comments