Skip to content

Instantly share code, notes, and snippets.

@husa
Created May 2, 2022 10:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save husa/0dab27ee60ba34b5319b0bdf26cebfba to your computer and use it in GitHub Desktop.
Save husa/0dab27ee60ba34b5319b0bdf26cebfba to your computer and use it in GitHub Desktop.
"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;
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