Skip to content

Commit

Permalink
Add strong type for transitions. Simplify createMachina needed typings.
Browse files Browse the repository at this point in the history
  • Loading branch information
brianzinn committed Feb 21, 2021
1 parent a418fe0 commit fe7ce55
Show file tree
Hide file tree
Showing 6 changed files with 203 additions and 60 deletions.
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
Simple Typesafe State Machine.

name inspired from the movie ex-machina.
[![NPM version](http://img.shields.io/npm/v/xmachina.svg?style=flat-square)](https://www.npmjs.com/package/xmachina)
[![Coverage Status](https://coveralls.io/repos/github/brianzinn/xmachina/badge.svg?branch=main)](https://coveralls.io/github/brianzinn/xmachina?branch=main)

Just a simple state machine that allows working with a typesafe state machine. Although you can use strings to represent states, also allows numbers/enums.

Has a fluent API for building state machines or you can create you own with a Map.
100% code coverage. Fluent API for building state machines or you can create you own with a Map (and extra edge properties).

To include in your project:
```bash
Expand All @@ -19,24 +21,31 @@ enum LightState {
Off
};

const machina = createMachina<LightState, Transition<LightState>>(LightState.On)
enum LightTransition {
TurnOff,
TurnOn
}

const machina = createMachina<LightState, LightTransition>(LightState.On)
.addState(LightState.On, {
name: 'off',
edge: LightTransition.TurnOff,
nextState: LightState.Off,
description: 'turn off light switch'
})
.addState(LightState.Off, {
name: 'on',
edge: LightTransition.TurnOn,
nextState: LightState.On,
description: 'turn on light switch'
})
.build();

assert.strictEqual(LightState.On, machina.state.current);
assert.deepStrictEqual(['off'], machina.state.possibleTransitions.map(t => t.name));
assert.deepStrictEqual([LightTransition.TurnOff], machina.state.possibleTransitions.map(t => t.edge));

const newState = machina.transitionTo('off');
const newState = machina.trigger(LightTransition.TurnOff);
assert.notStrictEqual(null, newState);
assert.strictEqual(newState!.current, LightState.Off);
```

## TODO:
* add observables
* add observables/events
17 changes: 10 additions & 7 deletions src/Builder.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { Machina, Transition } from "./Machina";

export interface IMachinaBuilder<S, T extends Transition<S>> {
export interface IMachinaBuilder<S, E, T extends Transition<S, E>> {
/**
* Add state and list of possible transitions (can be called with same state, but name must be unique)
* @param state State to include
* @param transitions transition(s) from state to include
*/
addState(state: S, transitions: T | T[]): IMachinaBuilder<S, T>
addState(state: S, transitions: T | T[]): IMachinaBuilder<S, E, T>
/**
* Return the state machine based on all states and transitions added.
*/
build(): Machina<S, T>
build(): Machina<S, E, T>
}

export class MachinaBuilder<S, T extends Transition<S>> implements IMachinaBuilder<S, T> {
/**
* Machine builder.
*/
export class MachinaBuilder<S, E, T extends Transition<S, E>> implements IMachinaBuilder<S, E, T> {
private stateMap: Map<S, T[]> = new Map<S, T[]>();

constructor(private initialState: S) {
Expand All @@ -22,7 +25,7 @@ export class MachinaBuilder<S, T extends Transition<S>> implements IMachinaBuild
addState = (state: S, transitions: T | T[]) => {
const newTransitions: T[] = Array.isArray(transitions) ? transitions : [transitions];
if(this.stateMap.has(state)) {
this.stateMap.get(state)?.push(...newTransitions);
this.stateMap.get(state)!.push(...newTransitions);
} else {
this.stateMap.set(state, newTransitions);
}
Expand All @@ -38,6 +41,6 @@ export class MachinaBuilder<S, T extends Transition<S>> implements IMachinaBuild
* Factory method to create a fluent/builder for a state type.
* @param initialState Start state
*/
export const createMachina = <S, T extends Transition<S>>(initialState: S): IMachinaBuilder<S, T> => {
return new MachinaBuilder<S, T>(initialState);
export const createMachina = <S, E>(initialState: S): IMachinaBuilder<S, E, Transition<S, E>> => {
return new MachinaBuilder<S, E, Transition<S, E>>(initialState);
}
46 changes: 30 additions & 16 deletions src/Machina.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { Nullable } from ".";
import { Nullable } from "./index";

export type MachinaState<S, T extends Transition<S>> = {
/**
* Current state and transitions to other states.
*/
export type MachinaState<S, E, T extends Transition<S, E>> = {
/**
* Current machina state
*/
current: S,
/**
* All transitions (edges) to other states, if any.
*/
possibleTransitions: T[]
}

export type Transition<S> = {
export type Transition<S, E> = {
/**
* Name to uniquely (from this state) identify the transition (not needed to be unique across other states)
*/
name: string
edge: E
/**
* Description of the transition (optional)
*/
Expand All @@ -20,36 +29,41 @@ export type Transition<S> = {
nextState: S
}

export interface IMachina<S, T extends Transition<S>> {
transitionTo: (transition: T) => Nullable<MachinaState<S, T>>
readonly state: MachinaState<S, T>
export interface IMachina<S, E, T extends Transition<S, E>> {
/**
* Edge to follow from current state to another state (edge is the input that triggers a transition).
*/
trigger: (edge: E) => Nullable<MachinaState<S, E, T>>

/**
* Current machine state (includes transitions out of current state, if any)
*/
readonly state: MachinaState<S, E, T>
}

export class Machina<S, T extends Transition<S>> implements IMachina<S, T> {
export class Machina<S, E, T extends Transition<S, E>> implements IMachina<S, E, T> {
private currentState: S;
constructor(initialState: S, private stateMap: Map<S, T[]>) {
this.currentState = initialState;
}

get state(): MachinaState<S, T> {
get state(): MachinaState<S, E, T> {
return {
current: this.currentState,
possibleTransitions: this.stateMap.get(this.currentState) ?? []
possibleTransitions: this.stateMap.get(this.currentState)!
}
}

transitionTo = (transition: T | string): Nullable<MachinaState<S, T>> => {
trigger = (edge: E) => {
const transitions: T[] = (this.stateMap.get(this.currentState))!;
const transitionName: string = typeof(transition) === 'string' ? transition : transition.name;
const match: T | undefined = transitions.find(t => t.name === transitionName);
const match: T | undefined = transitions.find(t => t.edge === edge);
if (match === undefined) {
return null;
} else {
this.currentState = match.nextState;
const posssibleTransitions = this.stateMap.get(match.nextState);
const result: MachinaState<S, T> = {
const result: MachinaState<S, E, T> = {
current: match.nextState,
possibleTransitions: posssibleTransitions ?? []
possibleTransitions: this.stateMap.get(match.nextState)!
}
return result;
}
Expand Down
86 changes: 65 additions & 21 deletions test/createMachina.lightSwitch.spec.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,101 @@
import assert from 'assert';
import { createMachina, Transition } from '../src';
import { createMachina } from '../src';

describe(' > createMachina builder tests', () => {
it('Toggle basic test by transition object', async () => {
enum LightState {
On,
Off
};
describe(' > createMachina light switch builder tests', () => {
enum LightState {
On,
Off
};

enum LightTransition {
TurnOff,
TurnOn
}

const machina = createMachina<LightState, Transition<LightState>>(LightState.On)
it('Toggle basic test by transition object', async () => {
const machina = createMachina<LightState, LightTransition>(LightState.On)
.addState(LightState.On, {
name: 'off',
edge: LightTransition.TurnOff,
nextState: LightState.Off,
description: 'turn off light switch'
})
.addState(LightState.Off, {
name: 'on',
edge: LightTransition.TurnOn,
nextState: LightState.On,
description: 'turn on light switch'
})
.build();

assert.strictEqual(LightState.On, machina.state.current);
assert.deepStrictEqual(['off'], machina.state.possibleTransitions.map(t => t.name));
assert.deepStrictEqual([LightTransition.TurnOff], machina.state.possibleTransitions.map(t => t.edge));

const newState = machina.transitionTo(machina.state.possibleTransitions[0]);
const newState = machina.trigger(machina.state.possibleTransitions[0].edge);
assert.notStrictEqual(null, newState);
assert.strictEqual(newState!.current, LightState.Off);
});

it('Toggle basic test by transition name (string)', async () => {
enum LightState {
On,
Off
};
const machina = createMachina<LightState, LightTransition>(LightState.On)
.addState(LightState.On, {
edge: LightTransition.TurnOff,
nextState: LightState.Off,
description: 'turn off light switch'
})
.addState(LightState.Off, {
edge: LightTransition.TurnOn,
nextState: LightState.On,
description: 'turn on light switch'
})
.build();

const machina = createMachina<LightState, Transition<LightState>>(LightState.On)
assert.strictEqual(LightState.On, machina.state.current);
assert.deepStrictEqual([LightTransition.TurnOff], machina.state.possibleTransitions.map(t => t.edge));

const newState = machina.trigger(LightTransition.TurnOff);
assert.notStrictEqual(null, newState);
assert.strictEqual(newState!.current, LightState.Off);
});

it('Toggle basic test by transition name (string)', async () => {
const machina = createMachina<LightState, LightTransition>(LightState.On)
.addState(LightState.On, {
name: 'off',
edge: LightTransition.TurnOff,
nextState: LightState.Off,
description: 'turn off light switch'
})
.addState(LightState.Off, {
name: 'on',
edge: LightTransition.TurnOn,
nextState: LightState.On,
description: 'turn on light switch'
})
.build();

assert.strictEqual(LightState.On, machina.state.current);
assert.deepStrictEqual(['off'], machina.state.possibleTransitions.map(t => t.name));
assert.deepStrictEqual([LightTransition.TurnOff], machina.state.possibleTransitions.map(t => t.edge));

// cannot traverse to "on" it's already "on".
const newState = machina.trigger(LightTransition.TurnOn);
assert.strictEqual(null, newState);
});

it('Toggle basic test by transition object (add states as arrays)', async () => {
const machina = createMachina<LightState, LightTransition>(LightState.On)
.addState(LightState.On, [{
edge: LightTransition.TurnOff,
nextState: LightState.Off,
description: 'turn off light switch'
}])
.addState(LightState.Off, [{
edge: LightTransition.TurnOn,
nextState: LightState.On,
description: 'turn on light switch'
}])
.build();

assert.strictEqual(LightState.On, machina.state.current);
assert.deepStrictEqual([LightTransition.TurnOff], machina.state.possibleTransitions.map(t => t.edge));

const newState = machina.transitionTo('off');
const newState = machina.trigger(LightTransition.TurnOff);
assert.notStrictEqual(null, newState);
assert.strictEqual(newState!.current, LightState.Off);
});
Expand Down
67 changes: 67 additions & 0 deletions test/createMachine.answerPhone.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from 'assert';
import { createMachina } from '../src';

describe(' > createMachina light switch builder tests', () => {
enum PhoneState {
Idle,
Ringing,
InCall,
OnHold,
};

enum PhoneEdge {
IncomingCall,
AnswerPhone,
HangUp,
PutOnHold,
TakeOffHold
}

it('Test a basic phone call is answered (check options) and hang up.', async () => {
const machina = createMachina<PhoneState, PhoneEdge>(PhoneState.Idle)
.addState(PhoneState.Idle, {
edge: PhoneEdge.IncomingCall,
nextState: PhoneState.Ringing,
description: 'Incoming call'
})
.addState(PhoneState.Ringing, {
edge: PhoneEdge.AnswerPhone,
nextState: PhoneState.InCall,
description: 'answer the incoming phone call'
})
.addState(PhoneState.InCall, {
edge: PhoneEdge.HangUp,
nextState: PhoneState.Idle,
description: "End current call"
})
.addState(PhoneState.InCall, {
edge: PhoneEdge.PutOnHold,
nextState: PhoneState.OnHold,
description: "Put current call on hold"
})
.addState(PhoneState.OnHold, [{
edge: PhoneEdge.TakeOffHold,
nextState: PhoneState.InCall,
description: "Stop holding call"
}, {
edge: PhoneEdge.HangUp,
nextState: PhoneState.Idle,
description: "Hang up call on hold (not nice!)"
}])
.build();

assert.strictEqual(PhoneState.Idle, machina.state.current);
assert.deepStrictEqual([PhoneEdge.IncomingCall], machina.state.possibleTransitions.map(t => t.edge));

const newState = machina.trigger(PhoneEdge.IncomingCall);
assert.strictEqual(newState!.current, PhoneState.Ringing);

machina.trigger(PhoneEdge.AnswerPhone);
assert.strictEqual(machina.state.current, PhoneState.InCall);

assert.deepStrictEqual([PhoneEdge.HangUp, PhoneEdge.PutOnHold], machina.state.possibleTransitions.map(t => t.edge));

machina.trigger(PhoneEdge.HangUp);
assert.strictEqual(machina.state.current, PhoneState.Idle);
});
});
Loading

0 comments on commit fe7ce55

Please sign in to comment.