From b3ad279db804c1ce9071cbf2b2296e9bc3ddacba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Thom=C3=A4?= Date: Thu, 6 Jun 2024 20:27:29 +0200 Subject: [PATCH] chore: cleanup / more tests --- godot-state-charts.csproj | 6 +++ .../wandering_frog/wandering_frog.gd | 2 +- manual/manual.md | 12 ++++- tests/framework/state_chart_test_base.gd | 8 +++- ...matic_transition_leaving_compound_state.gd | 25 ++++++++++ ...est_automatic_transition_on_state_enter.gd | 2 +- tests/test_explicit_event_consumption.gd | 36 ++++++++++++++ tests/test_history_state_as_initial_state.gd | 19 ++++++++ tests/test_history_state_simple.gd | 2 +- tests/test_immediate_condition_state.gd | 47 +++++++++++++++++++ tests/test_parallel_state_event_handling.gd | 30 ++++++++++++ ...st_property_changes_immediately_visible.gd | 36 ++++++++++++++ 12 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 tests/test_automatic_transition_leaving_compound_state.gd create mode 100644 tests/test_explicit_event_consumption.gd create mode 100644 tests/test_history_state_as_initial_state.gd create mode 100644 tests/test_immediate_condition_state.gd create mode 100644 tests/test_parallel_state_event_handling.gd create mode 100644 tests/test_property_changes_immediately_visible.gd diff --git a/godot-state-charts.csproj b/godot-state-charts.csproj index e255488..9faf2de 100644 --- a/godot-state-charts.csproj +++ b/godot-state-charts.csproj @@ -266,11 +266,17 @@ + + + + + + diff --git a/godot_state_charts_examples/random_transitions/wandering_frog/wandering_frog.gd b/godot_state_charts_examples/random_transitions/wandering_frog/wandering_frog.gd index 6b39213..f027b79 100644 --- a/godot_state_charts_examples/random_transitions/wandering_frog/wandering_frog.gd +++ b/godot_state_charts_examples/random_transitions/wandering_frog/wandering_frog.gd @@ -19,7 +19,7 @@ func _on_walk_state_entered(): # While we are in walk state... -func _on_walk_state_physics_processing(delta): +func _on_walk_state_physics_processing(_delta): # set a new velocity velocity = _direction * SPEED # and move into the given direction diff --git a/manual/manual.md b/manual/manual.md index 1c2d1a8..e67078c 100644 --- a/manual/manual.md +++ b/manual/manual.md @@ -301,7 +301,7 @@ The signal is only emitted when the transition is taken, not when it is pending. #### Automatic transitions -It is possible to have transitions with an empty _Event_ field. These transitions will be evaluated whenever you enter a state, send an event or set an expression property (see [expression guards](#expression-guards)). This is useful for modeling [condition states](https://statecharts.dev/glossary/condition-state.html) or react to changes in expression properties. Usually you will put a guard on such an automatic transition to make sure it is only taken when a certain condition is met. +It is possible to have transitions with an empty _Event_ field. These transitions will be evaluated whenever you change a state, send an event or set an expression property (see [expression guards](#expression-guards)). This is useful for modeling [condition states](https://statecharts.dev/glossary/condition-state.html) or react to changes in expression properties. Usually you will put a guard on such an automatic transition to make sure it is only taken when a certain condition is met. ![Alt text](immediate_transition.png) @@ -552,6 +552,16 @@ Godot has a very nice built-in comment field named "Editor Description". Use thi Usually you don't need to worry too much about the order in which state changes are processed but there are some instances where it is important to know the order in which events are processed. The following will give you an overview on the inner workings and the order in which events are processed. +#### Generic event handling rules + +The state chart reacts to these events: + +- an explicit event was sent to the state chart node using the `send_event` function. +- an expression property was changed using the `set_expression_property` function. +- +Whenever an event occurs, the state chart will try to find transitions that react to this event. Only transitions in states that are currently active will be considered. Transitions will be checked in a depth-first manner. So the innermost transition that handles any given event (be it explicit or automatic) will run. When a transition runs, the event is considered as handled and will no longer be processed by any other transition, except if that other transition happens to live in a parallel state (each parallel state can handle events even if that event was already handled by another parallel state). If the transition has a guard and it evaluates to `false` then the next transition that reacts to the event will be checked. If no transition reacts to the event, the event will bubble up to the parent state. This process will continue until the event is handled or the root state is reached. If the event is not handled by any state, it will be ignored. + +#### Example For this example we will use the following state chart: ![Example state chart for the order of events](order_of_events_chart.png) diff --git a/tests/framework/state_chart_test_base.gd b/tests/framework/state_chart_test_base.gd index f0b63f3..5968a92 100644 --- a/tests/framework/state_chart_test_base.gd +++ b/tests/framework/state_chart_test_base.gd @@ -73,6 +73,9 @@ func parallel_state(name: String, parent: StateChartState = null) -> ParallelSta @warning_ignore("shadowed_variable_base_class") func atomic_state( name: String, parent: StateChartState) -> AtomicState: + assert(not(parent is AtomicState)) + assert(not(parent is HistoryState)) + var state: AtomicState = AtomicState.new() state.name = name parent.add_child(state) @@ -82,7 +85,7 @@ func atomic_state( name: String, parent: StateChartState) -> AtomicState: @warning_ignore("shadowed_variable_base_class") -func transition(from: StateChartState, to: StateChartState, event: String, delay: String = "0", guard: Guard = null) -> Transition: +func transition(from: StateChartState, to: StateChartState, event: String = "", delay: String = "0", guard: Guard = null) -> Transition: @warning_ignore("shadowed_variable") var transition: Transition = Transition.new() from.add_child(transition) @@ -94,11 +97,12 @@ func transition(from: StateChartState, to: StateChartState, event: String, delay @warning_ignore("shadowed_variable_base_class") -func history_state(name: String, parent: CompoundState, deep:bool = false) -> HistoryState: +func history_state(name: String, parent: CompoundState, default_state:StateChartState, deep:bool = false) -> HistoryState: var state: HistoryState = HistoryState.new() state.name = name state.deep = deep parent.add_child(state) + state.default_state = state.get_path_to(default_state) # we don't set the initial state here, as it is not needed for history states return state diff --git a/tests/test_automatic_transition_leaving_compound_state.gd b/tests/test_automatic_transition_leaving_compound_state.gd new file mode 100644 index 0000000..0ac02d9 --- /dev/null +++ b/tests/test_automatic_transition_leaving_compound_state.gd @@ -0,0 +1,25 @@ +extends StateChartTestBase + +# Test that when automatic transition works when a compound state is left +func test_automatic_transition_leaving_compound_state(): + var root := compound_state("root") + var a := compound_state("a", root) + + var a1 := atomic_state("a1", a) + var a2 := atomic_state("a2", a) + + var b := atomic_state("b", root) + + transition(a, b, "") + + await finish_setup() + + # because we have the automatic transition to b, we should be in b + # right after the setup phase + assert_active(b) + + assert_inactive(a) + assert_inactive(a1) + assert_inactive(a2) + + diff --git a/tests/test_automatic_transition_on_state_enter.gd b/tests/test_automatic_transition_on_state_enter.gd index 80e1793..1511e0e 100644 --- a/tests/test_automatic_transition_on_state_enter.gd +++ b/tests/test_automatic_transition_on_state_enter.gd @@ -13,5 +13,5 @@ func test_automatic_transition_on_state_enter(): # because we have the automatic transition to b, we should be in b # right after the setup phase assert_active(b) - + assert_inactive(a) diff --git a/tests/test_explicit_event_consumption.gd b/tests/test_explicit_event_consumption.gd new file mode 100644 index 0000000..a43c9e9 --- /dev/null +++ b/tests/test_explicit_event_consumption.gd @@ -0,0 +1,36 @@ +extends StateChartTestBase + +func test_event_consumption(): + var root := compound_state("root") + var a := compound_state("a", root) + var b := compound_state("b", root) + # add a transition from a to b reacting on the "switch" event + var t1 := transition(a, b, "switch") + + var a1 := atomic_state("a1", a) + var a2 := atomic_state("a2", a) + # add a transition from a1 to a2 reacting on the "switch" event + var t2 := transition(a1, a2, "switch") + + await finish_setup() + assert_active(a) + assert_active(a1) + + watch_signals(t1) + watch_signals(t2) + + # when i send the "switch" event... + send_event("switch") + + # then + # the transition from a1 to a2 should be taken, consuming the event + assert_active(a) + assert_active(a2) + assert_inactive(a1) + assert_inactive(b) + + # the transition t2 should have emitted a "taken" signal + assert_signal_emitted(t2, "taken") + + # the transition t1 should not have emitted a "taken" signal + assert_signal_not_emitted(t1, "taken") diff --git a/tests/test_history_state_as_initial_state.gd b/tests/test_history_state_as_initial_state.gd new file mode 100644 index 0000000..9f8d3d0 --- /dev/null +++ b/tests/test_history_state_as_initial_state.gd @@ -0,0 +1,19 @@ +extends StateChartTestBase + +# Tests that a history state used as an initial state will work. +func test_history_state_as_initial_state(): + var root := compound_state("root") + + var a := compound_state("a", root) + atomic_state("a1", a) + var a2 := atomic_state("a2", a) + var h := history_state("h", a, a2) + + # overwrite the initial state of a to be h + a.initial_state = a.get_path_to(h) + + await finish_setup() + + # now a2 should be active, because h was initial state and the default + # state of h is a2 + assert_active(a2) diff --git a/tests/test_history_state_simple.gd b/tests/test_history_state_simple.gd index 44e28be..ac338e7 100644 --- a/tests/test_history_state_simple.gd +++ b/tests/test_history_state_simple.gd @@ -7,7 +7,7 @@ func test_history_state_simple(): var a := compound_state("a", root) var a1 := atomic_state("a1", a) var a2 := atomic_state("a2", a) - var h := history_state("h", a) + var h := history_state("h", a, a1) transition(a1, a2, "to_a2") diff --git a/tests/test_immediate_condition_state.gd b/tests/test_immediate_condition_state.gd new file mode 100644 index 0000000..3ff9904 --- /dev/null +++ b/tests/test_immediate_condition_state.gd @@ -0,0 +1,47 @@ +extends StateChartTestBase + +# This tests a condition state where the target states are immediate children of the source state +# but the source state holds all the transitions. +func test_immediate_condition_state(): + var root := compound_state("root") + var a := atomic_state("a", root) + var b := atomic_state("b", root) + var c := atomic_state("c", root) + + + transition(root, a, "", "0", expression_guard("state == 'a'")) + transition(root, b, "", "0", expression_guard("state == 'b'")) + transition(root, c, "", "0", expression_guard("state == 'c'")) + + set_initial_expression_properties({"state": "a"}) + + await finish_setup() + + # root state is active + assert_active(a) + assert_inactive(b) + assert_inactive(c) + + # WHEN: i change the property to enter state b + set_expression_property("state", "b") + + # THEN: state b is active + assert_active(b) + assert_inactive(a) + assert_inactive(c) + + # WHEN: i change the property to enter state c + set_expression_property("state", "c") + + # THEN: state c is active + assert_active(c) + assert_inactive(a) + assert_inactive(b) + + # WHEN: i change the property to enter state a + set_expression_property("state", "a") + + # THEN: state a is active + assert_active(a) + assert_inactive(b) + assert_inactive(c) diff --git a/tests/test_parallel_state_event_handling.gd b/tests/test_parallel_state_event_handling.gd new file mode 100644 index 0000000..3b8b66f --- /dev/null +++ b/tests/test_parallel_state_event_handling.gd @@ -0,0 +1,30 @@ +extends StateChartTestBase + +func test_parallel_state_event_handling(): + var root := parallel_state("root") + + var a := compound_state("a", root) + var a1 := atomic_state("a1", a) + var a2 := atomic_state("a2", a) + + var b := compound_state("b", root) + var b1 := atomic_state("b1", b) + var b2 := atomic_state("b2", b) + + transition(a1, a2, "to_a2") + transition( b1, b2, "", "0", state_is_active_guard(a2)) + + await finish_setup() + + assert_active(a1) + assert_active(b1) + + # when i send an event to switch to a2, b1 should automatically switch to b2 + send_event("to_a2") + + assert_active(a2) + # this only works because b2 is a child of b which is a parallel state, so b's transition + # will be triggered even if a1 consumes the event + assert_active(b2) + + diff --git a/tests/test_property_changes_immediately_visible.gd b/tests/test_property_changes_immediately_visible.gd new file mode 100644 index 0000000..52356df --- /dev/null +++ b/tests/test_property_changes_immediately_visible.gd @@ -0,0 +1,36 @@ +extends StateChartTestBase + +# This tests that property changes are immediately visible to all guards. +# see https://github.com/derkork/godot-statecharts/issues/82#issuecomment-1963417766 +func test_property_changes_immediately_visible(): + var root := compound_state("root") + var a := atomic_state("a", root) + var b := atomic_state("b", root) + var c := atomic_state("c", root) + + var d := atomic_state("d", root) + var e := atomic_state("e", root) + + transition(a, b, "to_b") + transition(b, c) + transition(c, d, "", "0", expression_guard("x > 0")) + transition(c, e, "", "0", expression_guard("x = 0")) + + + a.state_entered.connect(func(): set_expression_property("x", 0)) + b.state_entered.connect(func(): set_expression_property("x", 1)) + await finish_setup() + + # root state is active + assert_active(a) + + # when I transition to b + send_event("to_b") + + # then b is entered, which sets x to 1 + # then c is entered and because x is 1, d is entered + assert_active(d) + assert_inactive(e) + assert_inactive(a) + assert_inactive(b) + assert_inactive(c)