-
Notifications
You must be signed in to change notification settings - Fork 0
FSM Integration
ZSpec provides comprehensive integration with zigfsm to make testing Finite State Machines easier and more expressive using helper functions and builder patterns.
Add both ZSpec and zigfsm to your build.zig.zon:
.dependencies = .{
.zspec = .{
.url = "https://github.com/apotema/zspec/archive/refs/heads/main.tar.gz",
.hash = "...",
},
.zigfsm = .{
.url = "https://github.com/cryptocode/zigfsm/archive/refs/heads/main.tar.gz",
.hash = "...",
},
},In your test file:
const zspec = @import("zspec");
const zigfsm = @import("zigfsm");
const FSM = @import("zspec-fsm");
const expect = zspec.expect;
const Factory = zspec.Factory;const State = enum {
idle,
running,
stopped,
};
const Event = enum {
start,
stop,
reset,
};
const MyFSM = zigfsm.StateMachine(State, Event, .idle);pub const FSMTests = struct {
var fsm: MyFSM = undefined;
test "tests:before" {
Factory.resetSequences();
fsm = MyFSM.init();
// Bulk transition setup
try FSM.addTransitions(MyFSM, &fsm, &.{
.{ .event = .start, .from = .idle, .to = .running },
.{ .event = .stop, .from = .running, .to = .stopped },
.{ .event = .reset, .from = .stopped, .to = .idle },
});
}
test "tests:after" {
fsm.deinit();
}
test "state transitions work" {
try fsm.do(.start);
try expect.toBeTrue(fsm.isCurrently(.running));
try fsm.do(.stop);
try expect.toBeTrue(fsm.isCurrently(.stopped));
}
};ZSpec provides helpers to configure FSM transitions:
-
FSM.addTransitions(FSMType, fsm, transitions)- Bulk transition setup -
FSM.Transition(State, Event)- Transition type for bulk operations - Uses zigfsm's
addEventAndTransition()under the hood
Test complex state flows with event sequences:
// Apply multiple events and verify final state
try FSM.applyEventsAndVerify(MyFSM, &fsm, &.{
.start,
.stop,
.reset,
}, .idle);Validate state transition possibilities:
// Verify these states are reachable
try FSM.expectValidNextStates(MyFSM, &fsm, &.{.running});
// Verify these states are NOT reachable
try FSM.expectInvalidNextStates(MyFSM, &fsm, &.{.stopped});Fluently configure FSMs:
const Builder = FSM.FSMBuilder(MyFSM);
var builder = Builder.init();
_ = try builder.withEvent(.start, .idle, .running);
_ = try builder.withEvent(.stop, .running, .stopped);
var fsm = builder.build();
defer fsm.deinit();Creates a transition descriptor type for bulk operations.
Fields:
-
event: Event- The event that triggers the transition -
from: State- The source state -
to: State- The destination state
Example:
const MyTransition = FSM.Transition(State, Event);
const t = MyTransition{
.event = .start,
.from = .idle,
.to = .running,
};Adds multiple transitions to an FSM in bulk.
Parameters:
-
FSMType- The state machine type -
fsm- Pointer to the FSM instance -
transitions- Slice of transition descriptors
Example:
try FSM.addTransitions(MyFSM, &fsm, &.{
.{ .event = .start, .from = .idle, .to = .running },
.{ .event = .stop, .from = .running, .to = .stopped },
});applyEventsAndVerify(comptime FSMType: type, fsm: *FSMType, events: []const Event, expected_final_state: State)
Applies a sequence of events and verifies the final state.
Parameters:
-
FSMType- The state machine type -
fsm- Pointer to the FSM instance -
events- Slice of events to apply in order -
expected_final_state- Expected state after all events
Example:
try FSM.applyEventsAndVerify(MyFSM, &fsm, &.{
.start,
.stop,
}, .stopped);Verifies that specified states are reachable from the current state.
Parameters:
-
FSMType- The state machine type -
fsm- Const pointer to the FSM instance -
expected_states- Slice of states that should be valid next states
Verifies that specified states are NOT reachable from the current state.
Parameters:
-
FSMType- The state machine type -
fsm- Const pointer to the FSM instance -
invalid_states- Slice of states that should NOT be valid next states
Creates a builder for fluent FSM configuration.
Methods:
-
init()- Creates a new builder instance -
withEvent(event: Event, from: State, to: State) !*Self- Adds a transition (chainable) -
build() FSMType- Builds and returns the configured FSM
Example:
const Builder = FSM.FSMBuilder(MyFSM);
var builder = Builder.init();
_ = try builder.withEvent(.start, .idle, .running);
_ = try builder.withEvent(.stop, .running, .stopped);
_ = try builder.withEvent(.reset, .stopped, .idle);
var fsm = builder.build();
defer fsm.deinit();pub const MyTests = struct {
var fsm: MyFSM = undefined;
test "tests:before" {
Factory.resetSequences();
fsm = MyFSM.init();
try FSM.addTransitions(MyFSM, &fsm, &.{
.{ .event = .start, .from = .idle, .to = .running },
.{ .event = .stop, .from = .running, .to = .stopped },
});
}
test "tests:after" {
fsm.deinit();
}
test "my test" {
try fsm.do(.start);
try expect.toBeTrue(fsm.isCurrently(.running));
}
};fn createMenuFSM() !MenuFSM {
var fsm = MenuFSM.init();
errdefer fsm.deinit();
try fsm.addEventAndTransition(.start_game, .main_menu, .loading);
try fsm.addEventAndTransition(.open_settings, .main_menu, .settings);
try fsm.addEventAndTransition(.back, .settings, .main_menu);
return fsm;
}
pub const MenuTests = struct {
var fsm: MenuFSM = undefined;
test "tests:before" {
fsm = createMenuFSM() catch unreachable;
}
test "tests:after" {
fsm.deinit();
}
test "navigation" {
try fsm.do(.open_settings);
try expect.toBeTrue(fsm.isCurrently(.settings));
}
};test "complete navigation flow" {
var fsm = createMenuFSM() catch unreachable;
defer fsm.deinit();
// Test complete flow with sequence
try FSM.applyEventsAndVerify(MenuFSM, &fsm, &.{
.open_settings,
.open_graphics,
.back,
.back,
}, .main_menu);
}test "build complex FSM" {
const Builder = FSM.FSMBuilder(GameFSM);
var builder = Builder.init();
// Menu states
_ = try builder.withEvent(.start, .main_menu, .loading);
_ = try builder.withEvent(.settings, .main_menu, .settings);
// Gameplay states
_ = try builder.withEvent(.pause, .gameplay, .paused);
_ = try builder.withEvent(.resume, .paused, .gameplay);
_ = try builder.withEvent(.quit, .paused, .main_menu);
// Game over
_ = try builder.withEvent(.game_over, .gameplay, .game_over);
_ = try builder.withEvent(.restart, .game_over, .loading);
var fsm = builder.build();
defer fsm.deinit();
// Test full game flow
try FSM.applyEventsAndVerify(GameFSM, &fsm, &.{
.start,
.pause,
.resume,
}, .gameplay);
}pub const ValidationTests = struct {
var fsm: MenuFSM = undefined;
test "tests:before" {
fsm = createMenuFSM() catch unreachable;
}
test "tests:after" {
fsm.deinit();
}
test "validate transitions from main menu" {
// From main menu, can go to loading or settings
try FSM.expectValidNextStates(MenuFSM, &fsm, &.{
.loading,
.settings,
});
// Cannot go directly to gameplay or paused
try FSM.expectInvalidNextStates(MenuFSM, &fsm, &.{
.gameplay,
.paused,
});
}
};const std = @import("std");
const zspec = @import("zspec");
const zigfsm = @import("zigfsm");
const FSM = @import("zspec-fsm");
const expect = zspec.expect;
const Factory = zspec.Factory;
test {
zspec.runAll(@This());
}
// Menu State Machine
const MenuState = enum {
main_menu,
settings,
graphics_settings,
audio_settings,
gameplay,
paused,
game_over,
loading,
};
const MenuEvent = enum {
start_game,
open_settings,
open_graphics,
open_audio,
back,
pause,
resume,
quit_to_menu,
game_over,
restart,
};
const MenuFSM = zigfsm.StateMachine(MenuState, MenuEvent, .main_menu);
fn createMenuFSM() !MenuFSM {
var fsm = MenuFSM.init();
errdefer fsm.deinit();
try FSM.addTransitions(MenuFSM, &fsm, &.{
// Main menu transitions
.{ .event = .start_game, .from = .main_menu, .to = .loading },
.{ .event = .open_settings, .from = .main_menu, .to = .settings },
// Settings menu
.{ .event = .open_graphics, .from = .settings, .to = .graphics_settings },
.{ .event = .open_audio, .from = .settings, .to = .audio_settings },
.{ .event = .back, .from = .settings, .to = .main_menu },
// Sub-settings
.{ .event = .back, .from = .graphics_settings, .to = .settings },
.{ .event = .back, .from = .audio_settings, .to = .settings },
// Gameplay
.{ .event = .pause, .from = .gameplay, .to = .paused },
.{ .event = .game_over, .from = .gameplay, .to = .game_over },
// Paused
.{ .event = .resume, .from = .paused, .to = .gameplay },
.{ .event = .quit_to_menu, .from = .paused, .to = .main_menu },
// Game over
.{ .event = .restart, .from = .game_over, .to = .loading },
.{ .event = .quit_to_menu, .from = .game_over, .to = .main_menu },
});
// Loading transition (no event needed)
try fsm.addTransition(.loading, .gameplay);
return fsm;
}
pub const MenuNavigationTests = struct {
var fsm: MenuFSM = undefined;
test "tests:before" {
Factory.resetSequences();
fsm = createMenuFSM() catch unreachable;
}
test "tests:after" {
fsm.deinit();
}
test "starts at main menu" {
try expect.toBeTrue(fsm.isCurrently(.main_menu));
}
test "can navigate to settings" {
try fsm.do(.open_settings);
try expect.toBeTrue(fsm.isCurrently(.settings));
}
test "can navigate back from settings" {
try fsm.do(.open_settings);
try fsm.do(.back);
try expect.toBeTrue(fsm.isCurrently(.main_menu));
}
test "complete settings navigation flow" {
try FSM.applyEventsAndVerify(MenuFSM, &fsm, &.{
.open_settings,
.open_graphics,
.back,
.open_audio,
.back,
.back,
}, .main_menu);
}
};
pub const GameplayTests = struct {
var fsm: MenuFSM = undefined;
test "tests:before" {
fsm = createMenuFSM() catch unreachable;
}
test "tests:after" {
fsm.deinit();
}
test "can start game and pause" {
try fsm.do(.start_game);
try fsm.transitionTo(.gameplay);
try fsm.do(.pause);
try expect.toBeTrue(fsm.isCurrently(.paused));
}
test "complete game flow" {
try fsm.do(.start_game);
try fsm.transitionTo(.gameplay);
try fsm.do(.game_over);
try fsm.do(.restart);
try expect.toBeTrue(fsm.isCurrently(.loading));
}
test "pause and resume" {
try FSM.applyEventsAndVerify(MenuFSM, &fsm, &.{
.start_game,
}, .loading);
try fsm.transitionTo(.gameplay);
try FSM.applyEventsAndVerify(MenuFSM, &fsm, &.{
.pause,
.resume,
}, .gameplay);
}
};
pub const StateValidationTests = struct {
var fsm: MenuFSM = undefined;
test "tests:before" {
fsm = createMenuFSM() catch unreachable;
}
test "tests:after" {
fsm.deinit();
}
test "validate main menu transitions" {
try FSM.expectValidNextStates(MenuFSM, &fsm, &.{
.loading,
.settings,
});
}
test "validate settings transitions" {
try fsm.do(.open_settings);
try FSM.expectValidNextStates(MenuFSM, &fsm, &.{
.graphics_settings,
.audio_settings,
.main_menu,
});
}
};
// Player State Machine
const PlayerState = enum { alive, invincible, dead };
const PlayerEvent = enum { take_damage, collect_powerup, die, respawn, powerup_expired };
const PlayerFSM = zigfsm.StateMachine(PlayerState, PlayerEvent, .alive);
pub const PlayerTests = struct {
var fsm: PlayerFSM = undefined;
test "tests:before" {
fsm = PlayerFSM.init();
try FSM.addTransitions(PlayerFSM, &fsm, &.{
.{ .event = .take_damage, .from = .alive, .to = .alive },
.{ .event = .die, .from = .alive, .to = .dead },
.{ .event = .collect_powerup, .from = .alive, .to = .invincible },
.{ .event = .powerup_expired, .from = .invincible, .to = .alive },
.{ .event = .die, .from = .invincible, .to = .dead },
.{ .event = .respawn, .from = .dead, .to = .alive },
});
}
test "tests:after" {
fsm.deinit();
}
test "death and respawn cycle" {
try FSM.applyEventsAndVerify(PlayerFSM, &fsm, &.{
.take_damage,
.take_damage,
.die,
.respawn,
}, .alive);
}
test "powerup flow" {
try FSM.applyEventsAndVerify(PlayerFSM, &fsm, &.{
.collect_powerup,
.powerup_expired,
}, .alive);
}
};- Use factory functions to create FSMs with common transition configurations
- Reset sequences in beforeAll/before hooks for test isolation
-
Use bulk transitions with
addTransitions()for cleaner setup -
Test event sequences with
applyEventsAndVerify()to validate complete flows -
Validate state transitions with
expectValidNextStates()andexpectInvalidNextStates() - Use builder pattern for complex FSMs with many transitions
- Always deinit FSMs in after hooks to prevent memory leaks
- Separate FSMs by concern - menu navigation, player state, connection state, etc.
- Factory Guide - Complete guide to ZSpec's Factory system
- examples/fsm_integration_test.zig - Comprehensive integration examples
- usage/fsm/ - Complete example project with menu system
- zigfsm Documentation