Compare commits
2 Commits
953fb3aff1
...
c80e595585
| Author | SHA1 | Date | |
|---|---|---|---|
| c80e595585 | |||
| 27aa62f950 |
33
bun.lock
33
bun.lock
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tmp",
|
||||
"dependencies": {
|
||||
"robot3": "./packages/robot3",
|
||||
"hono": "^4.10.4",
|
||||
"openai": "^6.9.0",
|
||||
"robot3": "1.1.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
|
|
@ -18,14 +19,32 @@
|
|||
},
|
||||
},
|
||||
"packages": {
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
|
||||
|
||||
"acorn": ["acorn@7.4.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="],
|
||||
|
|
@ -34,10 +53,20 @@
|
|||
|
||||
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||
|
||||
"robot3": ["robot3@1.1.1", "", {}, "sha512-kuD0oQg2KUE74FCQ1a5uoRsEJ/bUKrU1D3vnluop9X7LSiGLndejQgjUEcMqJMVzUA836HSXhtY7XNtQiPTCLQ=="],
|
||||
"robot3": ["robot3@file:packages/robot3", { "devDependencies": { "rollup": "^1.21.4", "terser": "^5.16.1" } }],
|
||||
|
||||
"rollup": ["rollup@1.32.1", "", { "dependencies": { "@types/estree": "*", "@types/node": "*", "acorn": "^7.1.0" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"terser/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"dependencies": {
|
||||
"hono": "^4.10.4",
|
||||
"openai": "^6.9.0",
|
||||
"robot3": "1.1.1"
|
||||
"robot3": "./packages/robot3"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
|
|
|
|||
70
packages/robot3/CHANGELOG.md
Normal file
70
packages/robot3/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# robot3
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- bcc2995: Improve the type definition for state and invoke functions
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0cf6366: '/logging' is now exported, so you can import it in your dev environment to log state changes.
|
||||
|
||||
```ts
|
||||
import 'robot3/logging';
|
||||
|
||||
import {...} from 'robot3';
|
||||
```
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 950b6fa: Fix syntax error in state function type definition that caused TypeScript compilation failures. The previous change had a missing space in a conditional type expression, breaking type inference for state transitions.
|
||||
|
||||
## 1.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1d6179a: Fixes types for the state() function.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4f6fb69: Autocomplete for service.send()
|
||||
|
||||
This makes it so that the event name in `service.send(event)` is inferred from the transitions used to create the machine.
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9fbdbcb: Set the most deeply nested current service to current
|
||||
- 0409089: Documentation for advanced use of 'invoke()'
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cc17481: Add debug to package exports
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- 52742ab: Call onChange callbacks for immediate states too
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fc4806e: Adding an export property to the core package.json for 'import' so that destructured imports work, in addition to the default imports handled by the 'default' property
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- cce2ae6: Drop support for Node 14
|
||||
|
||||
This drops support for Node 14, with it no longer being supported by the LTS in February. Robot might still work in Node 14 but is not tested in our CI.
|
||||
8
packages/robot3/bundlesize.json
Normal file
8
packages/robot3/bundlesize.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "./machine.min.js",
|
||||
"maxSize": "1.4 kB"
|
||||
}
|
||||
]
|
||||
}
|
||||
40
packages/robot3/debug.js
Normal file
40
packages/robot3/debug.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { d, invoke } from './machine.js';
|
||||
|
||||
const invokePromiseType = Object.getPrototypeOf(invoke(Promise.resolve()));
|
||||
|
||||
function unknownState(from, state) {
|
||||
throw new Error(`Cannot transition from ${from} to unknown state: ${state}`);
|
||||
}
|
||||
|
||||
d._create = function(current, states) {
|
||||
if(!(current in states)) {
|
||||
throw new Error(`Initial state [${current}] is not a known state.`);
|
||||
}
|
||||
for(let p in states) {
|
||||
let state = states[p];
|
||||
for(let [, candidates] of state.transitions) {
|
||||
for(let {to} of candidates) {
|
||||
if(!(to in states)) {
|
||||
unknownState(p, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (invokePromiseType.isPrototypeOf(state)) {
|
||||
let hasErrorFrom = false;
|
||||
for(let [, candidates] of state.transitions) {
|
||||
for(let {from} of candidates) {
|
||||
if (from === 'error') hasErrorFrom = true;
|
||||
}
|
||||
}
|
||||
if(!hasErrorFrom) {
|
||||
console.warn(
|
||||
`When using invoke [current state: ${p}] with Promise-returning function, you need to add 'error' state. Otherwise, robot will hide errors in Promise-returning function`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
d._send = function(eventName, currentStateName) {
|
||||
throw new Error(`No transitions for event ${eventName} from the current state [${currentStateName}]`);
|
||||
};
|
||||
253
packages/robot3/index.d.ts
vendored
Normal file
253
packages/robot3/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
declare module 'robot3' {
|
||||
|
||||
/**
|
||||
* TS Helpers
|
||||
*/
|
||||
type NestedKeys<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]-?: P extends string ? keyof T[P] : never
|
||||
}[keyof T]
|
||||
: never
|
||||
|
||||
type AllStateKeys<T> = NestedKeys<T> | keyof T;
|
||||
|
||||
type MachineStates<S = {}, F extends string = string> = {
|
||||
[K in keyof S]: {
|
||||
final: boolean
|
||||
transitions: Map<string, Transition<F>[]>
|
||||
immediates?: Map<string, Immediate<F>[]>
|
||||
enter?: any
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The debugging object contains an _onEnter method, wich can be set to invoke
|
||||
* this function on every transition.
|
||||
*/
|
||||
export const d: {
|
||||
_onEnter?: OnEnterFunction<Machine<any>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The `createMachine` function creates a state machine. It takes an object of *states* with the key being the state name.
|
||||
* The value is usually *state* but might also be *invoke*.
|
||||
*
|
||||
* @param initial - Creates a machine that has *initial* as it's initial state.
|
||||
* @param states - An object of states, where each key is a state name, and the values are one of *state* or *invoke*.
|
||||
* @param context - A function that returns an object of extended state values. The function can receive an `event` argument.
|
||||
*/
|
||||
export function createMachine<S extends MachineStates<S, F>, C = {}, F extends string = string>(
|
||||
initial: keyof S,
|
||||
states: S,
|
||||
context?: ContextFunction<C>
|
||||
): Machine<S, C, AllStateKeys<S>>
|
||||
/**
|
||||
* The `createMachine` function creates a state machine. It takes an object of *states* with the key being the state name.
|
||||
* The value is usually *state* but might also be *invoke*.
|
||||
*
|
||||
* @param states - An object of states, where each key is a state name, and the values are one of *state* or *invoke*.
|
||||
* @param context - A function that returns an object of extended state values. The function can receive an `event` argument.
|
||||
*/
|
||||
export function createMachine<S extends MachineStates<S, F>, C = {}, F extends string = string>(
|
||||
states: S,
|
||||
context?: ContextFunction<C>
|
||||
): Machine<S, C, AllStateKeys<S>>;
|
||||
|
||||
/**
|
||||
* The `state` function returns a state object. A state can take transitions and immediates as arguments.
|
||||
*
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function state<T extends ReadonlyArray<Transition<any> | Immediate<any>>>(
|
||||
...args: T
|
||||
): MachineState<ExtractTransitionOrImmediateTypes<T>[number]>;
|
||||
|
||||
/**
|
||||
* A `transition` function is used to move from one state to another.
|
||||
*
|
||||
* @param event - This will give the name of the event that triggers this transition.
|
||||
* @param state - The name of the destination state.
|
||||
* @param args - Any extra argument will be evaluated to check if they are one of Reducer, Guard or Action.
|
||||
*/
|
||||
export function transition<F extends string, C, E>(
|
||||
event: F,
|
||||
state: string,
|
||||
...args: (Reducer<C, E> | Guard<C, E> | Action<C, E>)[]
|
||||
): Transition<F>;
|
||||
|
||||
/**
|
||||
* An `immediate` function is a type of transition that occurs immediately; it doesn't wait for an event to proceed.
|
||||
* This is a state that immediately proceeds to the next.
|
||||
*
|
||||
* @param state - The name of the destination state.
|
||||
* @param args - Any extra argument will be evaluated to check if they are a Reducer or a Guard.
|
||||
*/
|
||||
export function immediate<F extends string, C, E>(
|
||||
state: string,
|
||||
...args: (Reducer<C, E> | Guard<C, E> | Action<C, E>)[]
|
||||
): Transition<F>
|
||||
|
||||
/**
|
||||
* A `guard` is a method that determines if a transition can proceed.
|
||||
* Returning true allows the transition to occur, returning false prevents it from doing so and leaves the state in its current place.
|
||||
*
|
||||
* @param guardFunction A Function that can receive *context* and *event* and will return true or false.
|
||||
*/
|
||||
export function guard<C, E>(guardFunction?: GuardFunction<C, E>): Guard<C, E>
|
||||
|
||||
/**
|
||||
* A `reduce` takes a reducer function for changing the context of the machine. A common use case is to set values coming from form fields.
|
||||
*
|
||||
* @param reduceFunction A Function that can receive *context* and *event* and will return the context.
|
||||
*/
|
||||
export function reduce<C, E>(reduceFunction?: ReduceFunction<C, E>): Reducer<C, E>
|
||||
|
||||
/**
|
||||
* An `action` function takes a function that will be run during a transition. The primary purpose of using action is to perform side-effects.
|
||||
*
|
||||
* @param actionFunction A Function that can receive *context* and *event*. Returned values are discarded.
|
||||
*/
|
||||
export function action<C, E>(actionFunction?: ActionFunction<C, E>): Action<C, E>
|
||||
|
||||
/**
|
||||
* The `interpret` function takes a machine and creates a service that can send events into the machine, changing its states.
|
||||
* A service does not mutate a machine, but rather creates derived machines with the current state set.
|
||||
*
|
||||
* @param machine The state `machine`, created with *createMachine* to create a new service for.
|
||||
* @param onChange A callback that is called when the machine completes a transition. Even if the transition results in returning to the same state, the `onChange` callback is still called.
|
||||
* @param event The `event` can be any object. It is passed to the context function
|
||||
*/
|
||||
export function interpret<M extends Machine, E>(
|
||||
machine: M,
|
||||
onChange?: InterpretOnChangeFunction<typeof machine>,
|
||||
initialContext?: M['context'],
|
||||
event?: { [K in keyof E]: any }
|
||||
): Service<typeof machine>
|
||||
|
||||
/**
|
||||
* The `invoke` is a special type of state that immediately invokes a Promise-returning or Machine-returning function, or another machine.
|
||||
*
|
||||
* @param fn - Promise-returning function
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function invoke<C, T, E extends {} = any>(fn: (ctx: C, e?: E) => Promise<T>, ...args: (Transition<any> | Immediate<any>)[]): MachineState<any>
|
||||
|
||||
/**
|
||||
* The `invoke` is a special type of state that immediately invokes a Promise-returning or Machine-returning function, or another machine.
|
||||
*
|
||||
* @param fn - Machine-returning function
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function invoke<C, E extends {} = any, M extends Machine = any>(fn: (ctx: C, e?: E) => M, ...args: (Transition<any> | Immediate<any>)[]): MachineState<any>
|
||||
|
||||
/**
|
||||
* The `invoke` is a special type of state that immediately invokes a Promise-returning or Machine-returning function, or another machine.
|
||||
*
|
||||
* @param machine - Machine
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function invoke<M extends Machine>(machine: M, ...args: (Transition<any> | Immediate<any>)[]):
|
||||
// fixme: this type isn't 100% correct, as it adds the child's transitions to the parent.
|
||||
MachineState<GetMachineTransitions<M>>
|
||||
|
||||
/* General Types */
|
||||
|
||||
export type ContextFunction<T> = (initialContext: T) => T
|
||||
|
||||
export type GuardFunction<C, E> = (context: C, event: E) => boolean
|
||||
|
||||
export type ActionFunction<C, E> = (context: C, event: E) => unknown
|
||||
|
||||
export type ReduceFunction<C, E> = (context: C, event: E) => C
|
||||
|
||||
export type InterpretOnChangeFunction<T extends Machine> = (
|
||||
service: Service<T>
|
||||
) => void
|
||||
|
||||
export type SendEvent<T extends string = string> = T | { type: T; [key: string]: any }
|
||||
export type SendFunction<T extends string> = (event: SendEvent<T> & {}) => void
|
||||
|
||||
/**
|
||||
* This function is invoked before entering a new state and is bound to the debug
|
||||
* object. It is usable to inspect or log changes.
|
||||
*
|
||||
* @param machine - Machine
|
||||
* @param to - name of the target state
|
||||
* @param state - current state
|
||||
* @param prevState - previous state
|
||||
* @param event - event provoking the state change
|
||||
*/
|
||||
export type OnEnterFunction<M extends Machine<any>> =
|
||||
<C = M['state']>(machine: M, to: string, state: C, prevState: C, event?: SendEvent) => void
|
||||
|
||||
export type Machine<S extends MachineStates<S, F> = {}, C = {}, K = string, F extends string = string> = {
|
||||
context: C
|
||||
current: K
|
||||
states: S
|
||||
state: {
|
||||
name: K
|
||||
value: MachineState<F>
|
||||
}
|
||||
}
|
||||
|
||||
export type Action<C, E> = {
|
||||
fn: ActionFunction<C, E>
|
||||
}
|
||||
|
||||
export type Reducer<C, E> = {
|
||||
fn: ReduceFunction<C, E>
|
||||
}
|
||||
|
||||
export type Guard<C, E> = {
|
||||
fn: GuardFunction<C, E>
|
||||
}
|
||||
|
||||
export interface MachineState<F extends string> {
|
||||
final: boolean
|
||||
transitions: Map<F, Transition<F>[]>
|
||||
immediates?: Map<F, Immediate<F>[]>
|
||||
enter?: any
|
||||
}
|
||||
|
||||
export interface Transition<F extends string> {
|
||||
from: F | null
|
||||
to: string
|
||||
guards: any[]
|
||||
reducers: any[]
|
||||
}
|
||||
|
||||
export interface Service<M extends Machine> {
|
||||
child?: Service<M>
|
||||
machine: M
|
||||
context: M['context']
|
||||
onChange: InterpretOnChangeFunction<M>
|
||||
send: SendFunction<GetMachineTransitions<M>>
|
||||
}
|
||||
|
||||
export type Immediate<F extends string> = Transition<F>;
|
||||
|
||||
// Utilities
|
||||
type IsAny<T> = 0 extends (1 & T) ? true : false;
|
||||
|
||||
// Get state objects from a Machine
|
||||
type GetMachineStateObject<M extends Machine> = M['states'];
|
||||
|
||||
// Create mapped type without the final indexing
|
||||
type GetTransitionsFromStates<S> = {
|
||||
[K in keyof S]: S[K] extends { transitions: Map<string, Array<Transition<infer F>>> }
|
||||
? IsAny<F> extends true
|
||||
? never
|
||||
: F
|
||||
: never
|
||||
}
|
||||
|
||||
type ExtractNonAnyValues<T> = {
|
||||
[K in keyof T]: IsAny<T[K]> extends true ? never : T[K]
|
||||
}[keyof T] & {};
|
||||
|
||||
export type GetMachineTransitions<M extends Machine> =
|
||||
ExtractNonAnyValues<GetTransitionsFromStates<GetMachineStateObject<M>>>;
|
||||
|
||||
type ExtractTransitionOrImmediateTypes<T extends ReadonlyArray<Transition<any> | Immediate<any>>> =
|
||||
{ [K in keyof T]: T[K] extends Transition<infer V> ? V : T[K] extends Immediate<infer V> ? V : never };
|
||||
}
|
||||
17
packages/robot3/logging.js
Normal file
17
packages/robot3/logging.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { d } from './machine.js';
|
||||
|
||||
d._onEnter = function(machine, to, state, prevState, event) {
|
||||
console.log(`Enter state ${to}`);
|
||||
console.groupCollapsed(`Details:`);
|
||||
console.log(`Machine`, machine);
|
||||
console.log(`Current state`, state);
|
||||
console.log(`Previous state`, prevState);
|
||||
|
||||
if (typeof event === "string") {
|
||||
console.log(`Event ${event}`);
|
||||
} else if (typeof event === "object") {
|
||||
console.log(`Event`, event);
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
207
packages/robot3/machine.js
Normal file
207
packages/robot3/machine.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
function valueEnumerable(value) {
|
||||
return { enumerable: true, value };
|
||||
}
|
||||
|
||||
function valueEnumerableWritable(value) {
|
||||
return { enumerable: true, writable: true, value };
|
||||
}
|
||||
|
||||
export let d = {};
|
||||
let truthy = () => true;
|
||||
let empty = () => ({});
|
||||
let identity = a => a;
|
||||
let callBoth = (par, fn, self, args) => par.apply(self, args) && fn.apply(self, args);
|
||||
let callForward = (par, fn, self, [a, b]) => fn.call(self, par.call(self, a, b), b);
|
||||
let create = (a, b) => Object.freeze(Object.create(a, b));
|
||||
|
||||
function stack(fns, def, caller) {
|
||||
return fns.reduce((par, fn) => {
|
||||
return function(...args) {
|
||||
return caller(par, fn, this, args);
|
||||
};
|
||||
}, def);
|
||||
}
|
||||
|
||||
function fnType(fn) {
|
||||
return create(this, { fn: valueEnumerable(fn) });
|
||||
}
|
||||
|
||||
let reduceType = {};
|
||||
export let reduce = fnType.bind(reduceType);
|
||||
export let action = fn => reduce((ctx, ev) => !!~fn(ctx, ev) && ctx);
|
||||
|
||||
let guardType = {};
|
||||
export let guard = fnType.bind(guardType);
|
||||
|
||||
function filter(Type, arr) {
|
||||
return arr.filter(value => Type.isPrototypeOf(value));
|
||||
}
|
||||
|
||||
function makeTransition(from, to, ...args) {
|
||||
let guards = stack(filter(guardType, args).map(t => t.fn), truthy, callBoth);
|
||||
let reducers = stack(filter(reduceType, args).map(t => t.fn), identity, callForward);
|
||||
return create(this, {
|
||||
from: valueEnumerable(from),
|
||||
to: valueEnumerable(to),
|
||||
guards: valueEnumerable(guards),
|
||||
reducers: valueEnumerable(reducers)
|
||||
});
|
||||
}
|
||||
|
||||
let transitionType = {};
|
||||
let immediateType = {};
|
||||
export let transition = makeTransition.bind(transitionType);
|
||||
export let immediate = makeTransition.bind(immediateType, null);
|
||||
|
||||
function enterImmediate(machine, service, event) {
|
||||
return transitionTo(service, machine, event, this.immediates) || machine;
|
||||
}
|
||||
|
||||
function transitionsToMap(transitions) {
|
||||
let m = new Map();
|
||||
for(let t of transitions) {
|
||||
if(!m.has(t.from)) m.set(t.from, []);
|
||||
m.get(t.from).push(t);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
let stateType = { enter: identity };
|
||||
export function state(...args) {
|
||||
let transitions = filter(transitionType, args);
|
||||
let immediates = filter(immediateType, args);
|
||||
let desc = {
|
||||
final: valueEnumerable(args.length === 0),
|
||||
transitions: valueEnumerable(transitionsToMap(transitions))
|
||||
};
|
||||
if(immediates.length) {
|
||||
desc.immediates = valueEnumerable(immediates);
|
||||
desc.enter = valueEnumerable(enterImmediate);
|
||||
}
|
||||
return create(stateType, desc);
|
||||
}
|
||||
|
||||
let invokeFnType = {
|
||||
enter(machine2, service, event) {
|
||||
let rn = this.fn.call(service, service.context, event)
|
||||
if(machine.isPrototypeOf(rn))
|
||||
return create(invokeMachineType, {
|
||||
machine: valueEnumerable(rn),
|
||||
transitions: valueEnumerable(this.transitions)
|
||||
}).enter(machine2, service, event)
|
||||
rn
|
||||
.then(data => {
|
||||
if (machine2 === service.machine)
|
||||
return service.send({ type: 'done', data });
|
||||
})
|
||||
.catch(error => {
|
||||
if (machine2 === service.machine)
|
||||
return service.send({ type: 'error', error });
|
||||
});
|
||||
return machine2;
|
||||
}
|
||||
};
|
||||
let invokeMachineType = {
|
||||
enter(machine, service, event) {
|
||||
service.child = interpret(this.machine, s => {
|
||||
service.onChange(s);
|
||||
if(service.child == s && s.machine.state.value.final) {
|
||||
delete service.child;
|
||||
service.send({ type: 'done', data: s.context });
|
||||
}
|
||||
}, service.context, event);
|
||||
if(service.child.machine.state.value.final) {
|
||||
let data = service.child.context;
|
||||
delete service.child;
|
||||
return transitionTo(service, machine, { type: 'done', data }, this.transitions.get('done'));
|
||||
}
|
||||
return machine;
|
||||
}
|
||||
};
|
||||
export function invoke(fn, ...transitions) {
|
||||
let t = valueEnumerable(transitionsToMap(transitions));
|
||||
return machine.isPrototypeOf(fn) ?
|
||||
create(invokeMachineType, {
|
||||
machine: valueEnumerable(fn),
|
||||
transitions: t
|
||||
}) :
|
||||
create(invokeFnType, {
|
||||
fn: valueEnumerable(fn),
|
||||
transitions: t
|
||||
});
|
||||
}
|
||||
|
||||
let machine = {
|
||||
get state() {
|
||||
return {
|
||||
name: this.current,
|
||||
value: this.states[this.current]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function createMachine(current, states, contextFn = empty) {
|
||||
if(typeof current !== 'string') {
|
||||
contextFn = states || empty;
|
||||
states = current;
|
||||
current = Object.keys(states)[0];
|
||||
}
|
||||
if(d._create) d._create(current, states);
|
||||
return create(machine, {
|
||||
context: valueEnumerable(contextFn),
|
||||
current: valueEnumerable(current),
|
||||
states: valueEnumerable(states)
|
||||
});
|
||||
}
|
||||
|
||||
function transitionTo(service, machine, fromEvent, candidates) {
|
||||
let { context } = service;
|
||||
for(let { to, guards, reducers } of candidates) {
|
||||
if(guards(context, fromEvent)) {
|
||||
service.context = reducers.call(service, context, fromEvent);
|
||||
|
||||
let original = machine.original || machine;
|
||||
let newMachine = create(original, {
|
||||
current: valueEnumerable(to),
|
||||
original: { value: original }
|
||||
});
|
||||
|
||||
if (d._onEnter) d._onEnter(machine, to, service.context, context, fromEvent);
|
||||
let state = newMachine.state.value;
|
||||
service.machine = newMachine;
|
||||
let ret = state.enter(newMachine, service, fromEvent);
|
||||
service.onChange(service);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function send(service, event) {
|
||||
let eventName = event.type || event;
|
||||
let { machine } = service;
|
||||
let { value: state, name: currentStateName } = machine.state;
|
||||
|
||||
if(state.transitions.has(eventName)) {
|
||||
return transitionTo(service, machine, event, state.transitions.get(eventName)) || machine;
|
||||
} else {
|
||||
if(d._send) d._send(eventName, currentStateName);
|
||||
}
|
||||
return machine;
|
||||
}
|
||||
|
||||
let service = {
|
||||
send(event) {
|
||||
send(this, event);
|
||||
}
|
||||
};
|
||||
|
||||
export function interpret(machine, onChange, initialContext, event) {
|
||||
let s = Object.create(service, {
|
||||
machine: valueEnumerableWritable(machine),
|
||||
context: valueEnumerableWritable(machine.context(initialContext, event)),
|
||||
onChange: valueEnumerable(onChange)
|
||||
});
|
||||
s.send = s.send.bind(s);
|
||||
s.machine = s.machine.state.value.enter(s.machine, s, event);
|
||||
return s;
|
||||
}
|
||||
121
packages/robot3/package.json
Normal file
121
packages/robot3/package.json
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
{
|
||||
"name": "robot3",
|
||||
"version": "1.3.0",
|
||||
"description": "A functional, immutable Finite State Machine library",
|
||||
"main": "dist/machine.js",
|
||||
"types": "./index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/machine.js",
|
||||
"import": "./machine.js",
|
||||
"default": "./machine.js",
|
||||
"types": "./index.d.ts"
|
||||
},
|
||||
"./debug": {
|
||||
"require": "./dist/debug.js",
|
||||
"import": "./debug.js",
|
||||
"default": "./debug.js"
|
||||
},
|
||||
"./logging": {
|
||||
"require": "./dist/logging.js",
|
||||
"import": "./logging.js",
|
||||
"default": "./logging.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"debug.js",
|
||||
"logging.js",
|
||||
"machine.js",
|
||||
"index.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"minify": "wireit",
|
||||
"bundlesize": "wireit",
|
||||
"server": "wireit",
|
||||
"test": "wireit",
|
||||
"test:types": "wireit",
|
||||
"test:browser": "wireit",
|
||||
"build:cjs": "wireit",
|
||||
"build": "wireit"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/matthewp/robot.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Finite State Machine"
|
||||
],
|
||||
"author": "Matthew Phillips <matthew@matthewphillips.info>",
|
||||
"license": "BSD-2-Clause",
|
||||
"bugs": {
|
||||
"url": "https://github.com/matthewp/robot/issues"
|
||||
},
|
||||
"homepage": "https://github.com/matthewp/robot#readme",
|
||||
"devDependencies": {
|
||||
"rollup": "^1.21.4",
|
||||
"terser": "^5.16.1"
|
||||
},
|
||||
"wireit": {
|
||||
"minify": {
|
||||
"command": "terser machine.js -m --module -o machine.min.js",
|
||||
"files": [
|
||||
"machine.js"
|
||||
],
|
||||
"output": [
|
||||
"machine.min.js"
|
||||
]
|
||||
},
|
||||
"bundlesize": {
|
||||
"command": "bundlesize --config bundlesize.json",
|
||||
"dependencies": [
|
||||
"minify"
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"command": "ws -p 1965",
|
||||
"service": {
|
||||
"readyWhen": {
|
||||
"lineMatches": "Listening on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"dependencies": [
|
||||
"test:types",
|
||||
"test:browser"
|
||||
]
|
||||
},
|
||||
"test:types": {
|
||||
"command": "tsc -p test/types/tsconfig.json",
|
||||
"files": []
|
||||
},
|
||||
"test:browser": {
|
||||
"command": "node-qunit-puppeteer http://localhost:1965/test/test.html 10000",
|
||||
"dependencies": [
|
||||
"server"
|
||||
],
|
||||
"files": [
|
||||
"machine.js"
|
||||
],
|
||||
"output": []
|
||||
},
|
||||
"build:cjs": {
|
||||
"command": "rollup -d dist -f cjs machine.js debug.js logging.js",
|
||||
"files": [
|
||||
"machine.js",
|
||||
"debug.js",
|
||||
"logging.js"
|
||||
],
|
||||
"output": [
|
||||
"dist"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"dependencies": [
|
||||
"build:cjs",
|
||||
"minify"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/robot3/test/test-action.js
Normal file
21
packages/robot3/test/test-action.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createMachine, action, interpret, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Action', () => {
|
||||
QUnit.test('Can be used to do side-effects', assert => {
|
||||
let count = 0;
|
||||
let orig = {};
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
action(() => count++)
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
}, () => orig);
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
|
||||
assert.equal(service.context, orig, 'context stays the same');
|
||||
assert.equal(count, 1, 'side-effect performed');
|
||||
});
|
||||
});
|
||||
53
packages/robot3/test/test-debug.js
Normal file
53
packages/robot3/test/test-debug.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { createMachine, interpret, state, transition, reduce, d} from '../machine.js';
|
||||
|
||||
QUnit.module('robot/debug');
|
||||
|
||||
QUnit.test('Errors for transitions to states that don\'t exist', assert => {
|
||||
try {
|
||||
createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
)
|
||||
});
|
||||
} catch(e) {
|
||||
assert.ok(/unknown state/.test(e.message), 'Gets an error about unknown states');
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('Does not error for transitions to states when state does exist', assert => {
|
||||
try {
|
||||
createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
assert.ok(true, 'Created a valid machine!');
|
||||
} catch(e) {
|
||||
assert.ok(false, 'Should not have errored');
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('Errors if an invalid initial state is provided', assert => {
|
||||
try {
|
||||
createMachine('oops', {
|
||||
one: state()
|
||||
});
|
||||
assert.ok(false, 'should have failed');
|
||||
} catch(e) {
|
||||
assert.ok(true, 'it is errored');
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('Errors when no transitions for event from the current state', assert => {
|
||||
try {
|
||||
const machine = createMachine('one', {
|
||||
one: state(),
|
||||
});
|
||||
const { send } = interpret(machine, () => {});
|
||||
send('go');
|
||||
assert.ok(false, 'should have failed');
|
||||
} catch(e) {
|
||||
assert.ok(true, 'it is errored');
|
||||
}
|
||||
});
|
||||
50
packages/robot3/test/test-guard.js
Normal file
50
packages/robot3/test/test-guard.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { createMachine, interpret, guard, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Guards', hooks => {
|
||||
QUnit.test('Can prevent changing states', assert => {
|
||||
let canProceed = false;
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two', guard(() => canProceed))
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, service => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'one');
|
||||
canProceed = true;
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'two');
|
||||
});
|
||||
|
||||
QUnit.test('If there are multiple guards, any returning false prevents a transition', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
guard(() => false),
|
||||
guard(() => true)
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, service => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'one');
|
||||
});
|
||||
|
||||
QUnit.test('Guards are passed the event', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
guard((ctx, ev) => ev.canProceed)
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send({ type: 'ping' });
|
||||
assert.equal(service.machine.current, 'one', 'still in the initial state');
|
||||
service.send({ type: 'ping', canProceed: true });
|
||||
assert.equal(service.machine.current, 'two', 'now moved');
|
||||
});
|
||||
});
|
||||
52
packages/robot3/test/test-immediate.js
Normal file
52
packages/robot3/test/test-immediate.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { createMachine, guard, interpret, immediate, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Immediate', hooks => {
|
||||
QUnit.test('Will immediately transition', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two')
|
||||
),
|
||||
two: state(
|
||||
immediate('three')
|
||||
),
|
||||
three: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'three');
|
||||
});
|
||||
|
||||
QUnit.test('Will not reject state when a guard fails', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two')
|
||||
),
|
||||
two: state(
|
||||
immediate('three', guard(() => false)),
|
||||
transition('next', 'three')
|
||||
),
|
||||
three: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'two');
|
||||
service.send('next');
|
||||
assert.equal(service.machine.current, 'three');
|
||||
});
|
||||
|
||||
QUnit.test('Can immediately transitions past 2 states', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
immediate('two')
|
||||
),
|
||||
two: state(
|
||||
immediate('three')
|
||||
),
|
||||
three: state()
|
||||
});
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
assert.equal(service.machine.current, 'three', 'transitioned to 3');
|
||||
assert.ok(service.machine.state.value.final, 'in the final state');
|
||||
});
|
||||
});
|
||||
387
packages/robot3/test/test-invoke.js
Normal file
387
packages/robot3/test/test-invoke.js
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import { createMachine, immediate, interpret, invoke, reduce, state, state as final, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Invoke', hooks => {
|
||||
QUnit.module('Promise');
|
||||
|
||||
QUnit.test('Goes to the "done" event when complete', async assert => {
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(() => Promise.resolve(13),
|
||||
transition('done', 'three',
|
||||
reduce((ctx, ev) => ({ ...ctx, age: ev.data }))
|
||||
)
|
||||
),
|
||||
three: state()
|
||||
}, () => ({age: 0}));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('click');
|
||||
await Promise.resolve();
|
||||
assert.equal(service.context.age, 13, 'Invoked');
|
||||
assert.equal(service.machine.current, 'three', 'now in the next state');
|
||||
});
|
||||
|
||||
QUnit.test('Goes to the "error" event when there is an error', async assert => {
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(() => Promise.reject(new Error('oh no')),
|
||||
transition('error', 'three',
|
||||
reduce((ctx, ev) => ({ ...ctx, error: ev.error }))
|
||||
)
|
||||
),
|
||||
three: state()
|
||||
}, () => ({age: 0}));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('click');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(service.context.error.message, 'oh no', 'Got the right error');
|
||||
});
|
||||
|
||||
QUnit.test('The initial state can be an invoke', async assert => {
|
||||
let machine = createMachine({
|
||||
one: invoke(() => Promise.resolve(2),
|
||||
transition('done', 'two', reduce((ctx, ev) => ({...ctx, age: ev.data})))
|
||||
),
|
||||
two: state()
|
||||
}, () => ({ age: 0 }));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
await Promise.resolve();
|
||||
assert.equal(service.context.age, 2, 'Invoked immediately');
|
||||
assert.equal(service.machine.current, 'two', 'in the new state');
|
||||
});
|
||||
|
||||
QUnit.test('Should not fire "done" event when state changes', async assert => {
|
||||
const wait = ms => () => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(wait(10),
|
||||
transition('done', 'one'),
|
||||
transition('click', 'three')
|
||||
),
|
||||
three: state(
|
||||
transition('done', 'error'),
|
||||
),
|
||||
error: state(),
|
||||
});
|
||||
|
||||
let service = interpret(machine, () => { });
|
||||
service.send('click');
|
||||
service.send('click');
|
||||
await wait(15)()
|
||||
assert.equal(service.machine.current, 'three', 'now in the next state');
|
||||
});
|
||||
|
||||
QUnit.test('Should fire "done" when context changes', async assert => {
|
||||
const wait = ms => () => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(wait(10),
|
||||
transition('done', 'three'),
|
||||
transition('click', 'two', reduce((ctx) => ({ value: ctx.value + 1 })))
|
||||
),
|
||||
three: state(),
|
||||
error: state(),
|
||||
}, () => ({ value: 0 }));
|
||||
|
||||
let service = interpret(machine, () => { });
|
||||
service.send('click');
|
||||
service.send('click');
|
||||
service.send('click');
|
||||
await wait(15)()
|
||||
assert.equal(service.context.value, 2, 'value should be 2');
|
||||
assert.equal(service.machine.current, 'three', 'now in the correct state');
|
||||
});
|
||||
|
||||
QUnit.module('Machine');
|
||||
|
||||
QUnit.test('Can invoke a child machine', async assert => {
|
||||
assert.expect(4);
|
||||
let one = createMachine({
|
||||
nestedOne: state(
|
||||
transition('go', 'nestedTwo')
|
||||
),
|
||||
nestedTwo: final()
|
||||
});
|
||||
let two = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: invoke(one,
|
||||
transition('done', 'three')
|
||||
),
|
||||
three: final()
|
||||
});
|
||||
let c = 0;
|
||||
let service = interpret(two, thisService => {
|
||||
switch(c) {
|
||||
case 0:
|
||||
assert.equal(service.machine.current, 'two');
|
||||
break;
|
||||
case 1:
|
||||
assert.notEqual(thisService, service, 'second time a different service');
|
||||
break;
|
||||
case 2:
|
||||
assert.equal(service.machine.current, 'three', 'now in three state');
|
||||
break;
|
||||
}
|
||||
c++;
|
||||
});
|
||||
service.send('go');
|
||||
service.child.send('go');
|
||||
assert.equal(c, 3, 'there were 3 transitions');
|
||||
});
|
||||
|
||||
QUnit.test('Can invoke a dynamic child machine', async assert => {
|
||||
assert.expect(10);
|
||||
let dynamicMachines = [
|
||||
createMachine({
|
||||
nestedOne: state(
|
||||
transition('go', 'nestedTwo')
|
||||
),
|
||||
nestedTwo: final()
|
||||
}),
|
||||
createMachine({
|
||||
nestedThree: state(
|
||||
transition('go', 'nestedFour')
|
||||
),
|
||||
nestedFour: final()
|
||||
})
|
||||
]
|
||||
|
||||
let root = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: invoke(() => dynamicMachines[0],
|
||||
transition('done', 'three')
|
||||
),
|
||||
three: state(
|
||||
transition('go', 'four')
|
||||
),
|
||||
four: invoke(() => dynamicMachines[1],
|
||||
transition('done', 'five')
|
||||
),
|
||||
five: final()
|
||||
});
|
||||
let c = 0;
|
||||
let service = interpret(root, thisService => {
|
||||
switch(c) {
|
||||
case 0:
|
||||
assert.equal(service.machine.current, 'two');
|
||||
break;
|
||||
case 1:
|
||||
assert.notEqual(thisService, service, 'second time a different service');
|
||||
assert.equal(thisService.machine.current, 'nestedTwo');
|
||||
break;
|
||||
case 2:
|
||||
assert.equal(thisService, service, 'equal service');
|
||||
assert.equal(service.machine.current, 'three', 'now in three state');
|
||||
break;
|
||||
case 3:
|
||||
assert.equal(service.machine.current, 'four');
|
||||
break;
|
||||
case 4:
|
||||
assert.notEqual(thisService, service, 'third time a different service');
|
||||
assert.equal(thisService.machine.current, 'nestedFour');
|
||||
break;
|
||||
case 5:
|
||||
assert.equal(service.machine.current, 'five', 'now in five state');
|
||||
break;
|
||||
}
|
||||
c++;
|
||||
});
|
||||
service.send('go');
|
||||
service.child.send('go');
|
||||
service.send('go');
|
||||
service.child.send('go');
|
||||
assert.equal(c, 6, 'there were 6 transitions');
|
||||
});
|
||||
|
||||
QUnit.test('Child machines receive events from their parents', async assert => {
|
||||
const action = fn =>
|
||||
reduce((ctx, ev) => {
|
||||
fn(ctx, ev);
|
||||
return ctx;
|
||||
});
|
||||
|
||||
const wait = ms => () => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const child = createMachine({
|
||||
init: state(
|
||||
immediate('waiting',
|
||||
action(ctx => {
|
||||
ctx.stuff.push(1);
|
||||
})
|
||||
)
|
||||
),
|
||||
waiting: invoke(
|
||||
wait(50),
|
||||
transition('done', 'fin',
|
||||
action(ctx => {
|
||||
ctx.stuff.push(2);
|
||||
})
|
||||
)
|
||||
),
|
||||
fin: state()
|
||||
}, ctx => ctx);
|
||||
|
||||
const machine = createMachine(
|
||||
{
|
||||
idle: state(transition("next", "child")),
|
||||
child: invoke(child, transition("done", "end")),
|
||||
end: state()
|
||||
},
|
||||
() => ({ stuff: [] })
|
||||
);
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('next');
|
||||
|
||||
await wait(50)();
|
||||
|
||||
assert.deepEqual(service.context.stuff, [1, 2]);
|
||||
});
|
||||
|
||||
QUnit.test('Service does not have a child when not in an invoked state', assert => {
|
||||
const child = createMachine({
|
||||
nestedOne: state(
|
||||
transition('next', 'nestedTwo')
|
||||
),
|
||||
nestedTwo: state()
|
||||
});
|
||||
const parent = createMachine({
|
||||
one: invoke(child,
|
||||
transition('done', 'two')
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
|
||||
let service = interpret(parent, () => {});
|
||||
assert.ok(service.child, 'there is a child service');
|
||||
|
||||
service.child.send('next');
|
||||
assert.notOk(service.child, 'No longer a child');
|
||||
});
|
||||
|
||||
QUnit.test('Multi level nested machines resolve in correct order', async assert => {
|
||||
assert.expect(18);
|
||||
|
||||
const four = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: state(
|
||||
transition('DONE', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
const three = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: invoke(four,
|
||||
transition('done', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
const two = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: invoke(three,
|
||||
transition('done', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
const one = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: invoke(two,
|
||||
transition('done', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
let c = 0;
|
||||
let service = interpret(one, thisService => {
|
||||
switch (c) {
|
||||
case 0:
|
||||
assert.equal(service.machine.current, 'start', 'initial state');
|
||||
break;
|
||||
case 1:
|
||||
assert.notEqual(thisService.machine.states, service.machine.states, 'second time a different service');
|
||||
assert.ok(service.child, 'has child');
|
||||
assert.equal(service.child.machine.current, 'start');
|
||||
break;
|
||||
case 2:
|
||||
assert.ok(service.child.child, 'has grand child');
|
||||
assert.equal(service.child.machine.current, 'start');
|
||||
assert.equal(service.child.child.machine.current, 'start');
|
||||
break;
|
||||
case 3:
|
||||
assert.ok(service.child.child.child, 'has grand grand child');
|
||||
assert.equal(service.child.child.machine.current, 'start');
|
||||
assert.equal(service.child.child.child.machine.current, 'start');
|
||||
break;
|
||||
case 4:
|
||||
assert.equal(service.child.child.child.machine.current, 'done');
|
||||
break;
|
||||
case 5:
|
||||
assert.equal(service.child.child.machine.current, 'done');
|
||||
assert.equal(service.child.child.child, undefined, 'child is removed when resolved');
|
||||
break;
|
||||
case 6:
|
||||
assert.equal(service.child.machine.current, 'done');
|
||||
assert.equal(service.child.child, undefined, 'child is removed when resolved');
|
||||
break;
|
||||
case 7:
|
||||
assert.equal(service.machine.current, 'done');
|
||||
assert.equal(service.child, undefined, 'child is removed when resolved');
|
||||
break;
|
||||
}
|
||||
c++;
|
||||
});
|
||||
service.send('START') // machine one
|
||||
service.child.send('START') // machine two
|
||||
service.child.child.send('START') // machine tree
|
||||
service.child.child.child.send('START') // machine four
|
||||
service.child.child.child.send('DONE') // machine four
|
||||
assert.equal(c, 8, 'there were 6 transitions');
|
||||
});
|
||||
|
||||
QUnit.test('Invoking a machine that immediately finishes', async assert => {
|
||||
assert.expect(3);
|
||||
const expectations = [ 'nestedTwo', 'three', 'three' ];
|
||||
|
||||
const child = createMachine({
|
||||
nestedOne: state(
|
||||
immediate('nestedTwo')
|
||||
),
|
||||
nestedTwo: final()
|
||||
});
|
||||
|
||||
const parent = createMachine({
|
||||
one: state(
|
||||
transition('next', 'two')
|
||||
),
|
||||
two: invoke(child,
|
||||
transition('done', 'three')
|
||||
),
|
||||
three: final()
|
||||
});
|
||||
|
||||
let service = interpret(parent, s => {
|
||||
assert.equal(s.machine.current, expectations.shift());
|
||||
});
|
||||
|
||||
service.send('next');
|
||||
});
|
||||
});
|
||||
27
packages/robot3/test/test-logging.js
Normal file
27
packages/robot3/test/test-logging.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createMachine, interpret, state, transition, reduce, d} from '../machine.js';
|
||||
|
||||
QUnit.module('robot/logging');
|
||||
|
||||
QUnit.test('Calls the onEnter function if the state is changed', assert => {
|
||||
const machine = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two', reduce((ctx) => (
|
||||
{ ...ctx, x: 1 }
|
||||
)))
|
||||
),
|
||||
two: state()
|
||||
}, () => ({x: 0, y: 0}));
|
||||
|
||||
const service = interpret(machine, () => {});
|
||||
const enterFN = (m, to, state, prevState, event) => {
|
||||
assert.deepEqual(m, machine, 'Machines equal');
|
||||
assert.deepEqual(state, {x:1, y:0}, 'Changed state passed.')
|
||||
assert.deepEqual(prevState, {x:0, y:0}, 'Previous state passed.')
|
||||
assert.equal(to, 'two', 'To state passed.')
|
||||
assert.equal(event, 'go', 'Send event passed.')
|
||||
}
|
||||
|
||||
d._onEnter = enterFN;
|
||||
|
||||
service.send('go');
|
||||
});
|
||||
53
packages/robot3/test/test-reduce.js
Normal file
53
packages/robot3/test/test-reduce.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { createMachine, interpret, reduce, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Reduce', () => {
|
||||
QUnit.test('Basic state change', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
reduce((ctx) => ({ ...ctx, one: 1 })),
|
||||
reduce((ctx) => ({ ...ctx, two: 2 }))
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
|
||||
let { one, two } = service.context;
|
||||
assert.equal(one, 1, 'first reducer ran');
|
||||
assert.equal(two, 2, 'second reducer ran');
|
||||
});
|
||||
|
||||
QUnit.test('If no reducers, the context remains', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: state()
|
||||
}, () => ({ one: 1, two: 2 }));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('go');
|
||||
assert.deepEqual(service.context, { one: 1, two: 2 }, 'context remains');
|
||||
});
|
||||
|
||||
QUnit.test('Event is the second argument', assert => {
|
||||
assert.expect(2);
|
||||
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two',
|
||||
reduce(function(ctx, ev) {
|
||||
assert.equal(ev, 'go');
|
||||
return { ...ctx, worked: true };
|
||||
})
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('go');
|
||||
assert.equal(service.context.worked, true, 'changed the context');
|
||||
});
|
||||
});
|
||||
77
packages/robot3/test/test-state.js
Normal file
77
packages/robot3/test/test-state.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { createMachine, interpret, invoke, reduce, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('States', hooks => {
|
||||
QUnit.test('Basic state change', assert => {
|
||||
assert.expect(5);
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two')
|
||||
),
|
||||
two: state(
|
||||
transition('pong', 'one')
|
||||
)
|
||||
});
|
||||
let service = interpret(machine, service => {
|
||||
assert.ok(true, 'Callback called');
|
||||
});
|
||||
assert.equal(service.machine.current, 'one');
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'two');
|
||||
service.send('pong');
|
||||
assert.equal(service.machine.current, 'one');
|
||||
});
|
||||
|
||||
QUnit.test('Data can be passed into the initial context', assert => {
|
||||
let machine = createMachine({
|
||||
one: state()
|
||||
}, ev => ({ foo: ev.foo }));
|
||||
|
||||
let service = interpret(machine, () => {}, {
|
||||
foo: 'bar'
|
||||
});
|
||||
|
||||
assert.equal(service.context.foo, 'bar', 'works!');
|
||||
});
|
||||
|
||||
QUnit.test('First argument sets the initial state', assert => {
|
||||
let machine = createMachine('two', {
|
||||
one: state(transition('next', 'two')),
|
||||
two: state(transition('next', 'three')),
|
||||
three: state()
|
||||
});
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
assert.equal(service.machine.current, 'two', 'in the initial state');
|
||||
|
||||
machine = createMachine('two', {
|
||||
one: state(transition('next', 'two')),
|
||||
two: state(),
|
||||
});
|
||||
service = interpret(machine, () => {});
|
||||
assert.equal(service.machine.current, 'two', 'in the initial state');
|
||||
assert.equal(service.machine.state.value.final, true, 'in the final state');
|
||||
});
|
||||
|
||||
QUnit.test('Child machines receive the event used to invoke them', assert => {
|
||||
let child = createMachine({
|
||||
final: state()
|
||||
}, (ctx, ev) => ({ count: ev.count }));
|
||||
let parent = createMachine({
|
||||
start: state(
|
||||
transition('next', 'next')
|
||||
),
|
||||
next: invoke(child,
|
||||
transition('done', 'end',
|
||||
reduce((ctx, ev) => ({
|
||||
...ctx,
|
||||
...ev.data
|
||||
}))
|
||||
)
|
||||
),
|
||||
end: state()
|
||||
});
|
||||
let service = interpret(parent, () => {});
|
||||
service.send({ type: 'next', count: 14 });
|
||||
assert.equal(service.context.count, 14, 'event sent through');
|
||||
});
|
||||
});
|
||||
12
packages/robot3/test/test.html
Normal file
12
packages/robot3/test/test.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Robot tests</title>
|
||||
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.9.2.css">
|
||||
|
||||
<div id="qunit"></div>
|
||||
<div id="qunit-fixture"></div>
|
||||
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
|
||||
<script type="module" src="../debug.js"></script>
|
||||
<script type="module" src="./test.js"></script>
|
||||
7
packages/robot3/test/test.js
Normal file
7
packages/robot3/test/test.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import './test-state.js';
|
||||
import './test-invoke.js';
|
||||
import './test-guard.js';
|
||||
import './test-immediate.js';
|
||||
import './test-reduce.js';
|
||||
import './test-action.js';
|
||||
import './test-debug.js';
|
||||
90
packages/robot3/test/types/send.ts
Normal file
90
packages/robot3/test/types/send.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import {expectTypeOf} from 'expect-type';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import {
|
||||
type Service,
|
||||
createMachine,
|
||||
transition,
|
||||
state,
|
||||
invoke, interpret
|
||||
} from 'robot3';
|
||||
|
||||
test('send(event) is typed', () => {
|
||||
const machine = createMachine({
|
||||
one: state(transition('go-two', 'two')),
|
||||
two: state(transition('go-one', 'one')),
|
||||
three: state()
|
||||
});
|
||||
|
||||
type Params = Parameters<Service<typeof machine>['send']>;
|
||||
type EventParam = Params[0];
|
||||
type StringParams = Extract<EventParam, string>;
|
||||
expectTypeOf<StringParams>().toEqualTypeOf<'go-one' | 'go-two'>();
|
||||
|
||||
type ObjectParams = Extract<EventParam, { type: string; }>;
|
||||
expectTypeOf<ObjectParams['type']>().toEqualTypeOf<'go-one' | 'go-two'>();
|
||||
});
|
||||
|
||||
test('types machine with multiple transitions from one state', () => {
|
||||
const machine = createMachine({
|
||||
one: state(transition('go-two', 'two'), transition('go-three', 'three')),
|
||||
two: state(transition('go-one', 'one')),
|
||||
three: state()
|
||||
});
|
||||
|
||||
type Params = Parameters<Service<typeof machine>['send']>;
|
||||
type EventParam = Params[0];
|
||||
type StringParams = Extract<EventParam, string>;
|
||||
expectTypeOf<StringParams>().toEqualTypeOf<'go-one' | 'go-two' | 'go-three'>();
|
||||
|
||||
type ObjectParams = Extract<EventParam, { type: string; }>;
|
||||
expectTypeOf<ObjectParams['type']>().toEqualTypeOf<'go-one' | 'go-two' | 'go-three'>();
|
||||
});
|
||||
|
||||
|
||||
test('types nested machine', () => {
|
||||
const stopwalk = createMachine({
|
||||
walk: state(
|
||||
transition('startBlinking', 'blink'),
|
||||
),
|
||||
blink: state(
|
||||
transition('finishBlinking', 'dontWalk'),
|
||||
),
|
||||
dontWalk: state()
|
||||
});
|
||||
|
||||
const stoplight = createMachine({
|
||||
green: state(
|
||||
transition('next', 'yellow')
|
||||
),
|
||||
yellow: state(
|
||||
transition('next', 'red')
|
||||
),
|
||||
red: invoke(stopwalk,
|
||||
transition('done', 'green')
|
||||
)
|
||||
});
|
||||
|
||||
const s = interpret(stoplight, console.log);
|
||||
|
||||
assert.equal(s.machine.current, 'green')
|
||||
s.send("next")
|
||||
assert.equal(s.machine.current, 'yellow')
|
||||
s.send("next")
|
||||
assert.equal(s.machine.current, 'red')
|
||||
assert.equal(s.child?.machine.current, 'walk')
|
||||
s.child?.send("startBlinking")
|
||||
assert.equal(s.child?.machine.current, 'blink')
|
||||
s.child?.send("finishBlinking")
|
||||
assert.equal(s.child, undefined)
|
||||
assert.equal(s.machine.current, "green")
|
||||
|
||||
type Params = Parameters<Service<typeof stoplight>['send']>;
|
||||
type EventParam = Params[0];
|
||||
type StringParams = Extract<EventParam, string>;
|
||||
expectTypeOf<StringParams>().toEqualTypeOf<'next' | 'startBlinking' | 'finishBlinking'>();
|
||||
|
||||
type ObjectParams = Extract<EventParam, { type: string; }>;
|
||||
expectTypeOf<ObjectParams['type']>().toEqualTypeOf<'next' | 'startBlinking' | 'finishBlinking'>();
|
||||
})
|
||||
|
||||
33
packages/robot3/test/types/tsconfig.json
Normal file
33
packages/robot3/test/types/tsconfig.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"include": ["../..", "."],
|
||||
"exclude": ["../../node_modules"],
|
||||
"compilerOptions": {
|
||||
/* Base Options: */
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "es2022",
|
||||
"allowJs": false,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "nodenext",
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
/* AND if you're building for a library: */
|
||||
"declaration": true,
|
||||
|
||||
/* AND if you're building for a library in a monorepo: */
|
||||
"composite": true,
|
||||
"declarationMap": true,
|
||||
|
||||
/* If NOT transpiling with TypeScript: */
|
||||
"module": "nodenext",
|
||||
"noEmit": true,
|
||||
|
||||
/* If your code runs in the DOM: */
|
||||
"lib": ["es2022", "dom", "dom.iterable"]
|
||||
}
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ const listenForPhoneEvents = (
|
|||
|
||||
rotaryInUse.onChange((event) => {
|
||||
if (event.value === 0) {
|
||||
phoneService.send({ type: "dial-start" })
|
||||
phoneService.send({ type: "dial-start" } as any)
|
||||
} else {
|
||||
phoneService.send({ type: "dial-stop" })
|
||||
}
|
||||
|
|
@ -110,7 +110,7 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer
|
|||
|
||||
baresip.callEstablished.on(({ contact }) => {
|
||||
log(`🐻 call established with ${contact}`)
|
||||
phoneService.send({ type: "answered" })
|
||||
phoneService.send({ type: "answered" } as any)
|
||||
})
|
||||
|
||||
baresip.hungUp.on(() => {
|
||||
|
|
@ -223,7 +223,7 @@ const startListening = (service: Service<typeof phoneMachine>, agent: Agent) =>
|
|||
const rms = Buzz.calculateRMS(chunk)
|
||||
if (rms > 5000) {
|
||||
dialTonePlayback?.stop()
|
||||
service.send({ type: "start-agent" })
|
||||
service.send({ type: "start-agent" } as any)
|
||||
waitingForVoice = false
|
||||
|
||||
backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true })
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user