Created
May 2, 2022 10:09
-
-
Save husa/0dab27ee60ba34b5319b0bdf26cebfba to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use strict"; | |
// Actually state-less machine | |
class TransitionForbiddenError extends Error { | |
constructor() { | |
super("Transition Forbidden"); | |
this.name = "TransitionForbiddenError"; | |
} | |
} | |
class StateMachine { | |
static get ForbiddenError() { | |
return TransitionForbiddenError; | |
} | |
constructor({ transitions, stateAccessor }) { | |
if (!transitions) { | |
throw new Error(`Parameter "transitions": ${transitions} is required`); | |
} | |
if (!stateAccessor) { | |
throw new Error( | |
`Parameter "stateAccessor": ${stateAccessor} is required` | |
); | |
} | |
if (typeof stateAccessor !== "function") { | |
throw new Error( | |
`Parameter "stateAccessor": ${stateAccessor} should be function` | |
); | |
} | |
if (!Array.isArray(transitions) || !transitions.length) { | |
throw new Error( | |
`Parameter "transitions": ${transitions} should be non-empty array` | |
); | |
} | |
// check if input valid | |
transitions.forEach((transition) => { | |
if (!transition.action || !transition.from || !transition.to) { | |
throw new Error( | |
`transition should contain "action", "from" and "to" props, received ${transition}` | |
); | |
} | |
}); | |
// check if no duplicate action-from pairs | |
transitions.reduce((set, transition) => { | |
const key = JSON.stringify([transition.action, transition.from]); | |
if (set.has(key)) | |
throw new Error(`Duplicate "action-from" pair: ${transition}`); | |
return set.add(key); | |
}, new Set()); | |
this.transitions = transitions; | |
this.stateAccessor = stateAccessor; | |
} | |
// check if "enity" can transition to "nextState" using "action"(optionally checking transition condition), return "nextState" | |
can(action, entity) { | |
const currentState = this.stateAccessor(entity); | |
const matchedTransition = this.transitions.find( | |
(transition) => | |
transition.action === action && transition.from === currentState | |
); | |
if (!matchedTransition) throw new TransitionForbiddenError(); | |
if ( | |
matchedTransition && | |
matchedTransition.when && | |
matchedTransition.when(entity) !== true | |
) | |
throw new TransitionForbiddenError(); | |
return matchedTransition.to; | |
} | |
// create simple GraphViz dot diagram description, which can exported | |
visualize() { | |
/* eslint-disable indent */ | |
return ` | |
digraph "state transitions" { | |
${Array.from( | |
this.transitions | |
.reduce((acc, { from, to }) => acc.add(from).add(to), new Set()) | |
.values() | |
) | |
.map((a) => ` "${a}";`) | |
.join("\n")} | |
${this.transitions | |
.map( | |
({ from, to, action, when }) => | |
` "${from}" -> "${to}" [ label="${action}${when ? "/conditional" : ""}" ]` | |
) | |
.join("\n")} | |
} | |
`; | |
/* eslint-enable indent */ | |
} | |
} | |
module.exports = StateMachine; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const StateMachine = require("../../../app/stateMachine"); | |
describe("[Utils:StateMachine]", () => { | |
const abTransitions = [ | |
{ | |
action: "a-to-b", | |
from: "A", | |
to: "B", | |
}, | |
{ | |
action: "b-to-a", | |
from: "B", | |
to: "A", | |
}, | |
{ | |
action: "b-to-c", | |
from: "B", | |
to: "C", | |
}, | |
{ | |
action: "c-to-a", | |
from: "C", | |
to: "A", | |
when: (entity) => entity.testProp === "test", | |
}, | |
]; | |
it("should throw if configuration not provided", () => { | |
expect(() => { | |
new StateMachine(); | |
}).toThrow(); | |
}); | |
it("should throw if stateAccessor not provided", () => { | |
expect(() => { | |
new StateMachine({ transitions: [{ from: "a", to: "b", action: "ab" }] }); | |
}).toThrow(); | |
}); | |
it("should throw if transitions not provided", () => { | |
expect(() => { | |
new StateMachine({ stateAccessor: () => true }); | |
}).toThrow(); | |
}); | |
it("should throw if transitions provided of invalid type or empty", () => { | |
expect(() => { | |
new StateMachine({ stateAccessor: () => true, transitions: [] }); | |
}).toThrow(); | |
expect(() => { | |
new StateMachine({ stateAccessor: () => true, transitions: {} }); | |
}).toThrow(); | |
expect(() => { | |
new StateMachine({ stateAccessor: () => true, transitions: "test" }); | |
}).toThrow(); | |
}); | |
it("should throw if transitions does not contain all required props", () => { | |
expect(() => { | |
new StateMachine({ stateAccessor: () => true, transitions: [{}] }); | |
}).toThrow(); | |
expect(() => { | |
new StateMachine({ | |
stateAccessor: () => true, | |
transitions: [{ from: "a", to: "b" }], | |
}); | |
}).toThrow(); | |
expect(() => { | |
new StateMachine({ | |
stateAccessor: () => true, | |
transitions: [{ from: "a", action: "b" }], | |
}); | |
}).toThrow(); | |
expect(() => { | |
new StateMachine({ | |
stateAccessor: () => true, | |
transitions: [{ action: "a", to: "b" }], | |
}); | |
}).toThrow(); | |
expect(() => { | |
new StateMachine({ | |
stateAccessor: () => true, | |
transitions: [{ from: "a", to: "b", action: "test" }], | |
}); | |
}).not.toThrow(); | |
}); | |
it("should throw if transitions contain duplicate pair from-action", () => { | |
expect(() => { | |
new StateMachine({ | |
stateAccessor: () => true, | |
transitions: [ | |
{ from: "a", action: "test", to: "b" }, | |
{ from: "a", action: "test", to: "c" }, | |
], | |
}); | |
}).toThrow(); | |
}); | |
it("should accept configuration object", () => { | |
const config = { | |
transitions: abTransitions, | |
stateAccessor: () => {}, | |
}; | |
expect(() => { | |
new StateMachine(config); | |
}).not.toThrow(); | |
}); | |
it("should accept configuration object", () => { | |
const config = { | |
transitions: abTransitions, | |
stateAccessor: () => {}, | |
}; | |
expect(() => { | |
new StateMachine(config); | |
}).not.toThrow(); | |
}); | |
describe("A -> B, B -> A, B -> C, C -> A/condition", () => { | |
let sm; | |
beforeEach(() => { | |
sm = new StateMachine({ | |
transitions: abTransitions, | |
stateAccessor: (entity) => entity.status, | |
}); | |
}); | |
afterEach(() => { | |
sm = null; | |
}); | |
it("should allow transition from A -> B using a-to-b action in A state", () => { | |
expect(() => sm.can("a-to-b", { status: "A" })).not.toThrow(); | |
}); | |
it("should return B state for a-to-b action in A state", () => { | |
expect(sm.can("a-to-b", { status: "A" })).toEqual("B"); | |
}); | |
it("should allow transition from B -> C using b-to-c action in A state", () => { | |
expect(() => sm.can("b-to-c", { status: "B" })).not.toThrow(); | |
}); | |
it("should return B state for a-to-b action in A state", () => { | |
expect(sm.can("b-to-c", { status: "B" })).toEqual("C"); | |
}); | |
it("should not allow transition from C -> B, A -> C, C -> A", () => { | |
expect(() => sm.can("c-to-b", { status: "C" })).toThrow(); | |
expect(() => sm.can("a-to-c", { status: "A" })).toThrow(); | |
expect(() => sm.can("c-to-a", { status: "C" })).toThrow(); | |
}); | |
it("should allow transition from C -> A if condition is true", () => { | |
expect(() => | |
sm.can("c-to-a", { status: "C", testProp: "test" }) | |
).not.toThrow(); | |
}); | |
it("should not allow transition from C -> A if condition is false", () => { | |
expect(() => | |
sm.can("c-to-a", { status: "C", testProp: "not test" }) | |
).toThrow(); | |
}); | |
}); | |
describe("visualize", () => { | |
it("should create GraphViz syntax visualization", () => { | |
const sm = new StateMachine({ | |
transitions: abTransitions, | |
stateAccessor: (entity) => entity.status, | |
}); | |
expect(sm.visualize()).toBe(` | |
digraph "state transitions" { | |
"A"; | |
"B"; | |
"C"; | |
"A" -> "B" [ label="a-to-b" ] | |
"B" -> "A" [ label="b-to-a" ] | |
"B" -> "C" [ label="b-to-c" ] | |
"C" -> "A" [ label="c-to-a/conditional" ] | |
} | |
`); | |
}); | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment