machina-test 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +69 -0
- package/README.md +110 -0
- package/dist/index.cjs +128 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +50 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +50 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +129 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +78 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Copyright (c) 2011-2023 Jim Cowart (MIT License)
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person
|
|
4
|
+
obtaining a copy of this software and associated documentation
|
|
5
|
+
files (the "Software"), to deal in the Software without
|
|
6
|
+
restriction, including without limitation the rights to use,
|
|
7
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the
|
|
9
|
+
Software is furnished to do so, subject to the following
|
|
10
|
+
conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be
|
|
13
|
+
included in all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
16
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
17
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
18
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
19
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
20
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
21
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
+
|
|
24
|
+
See http://opensource.org/licenses/MIT for more details.
|
|
25
|
+
|
|
26
|
+
--------------------------------------------------------------------
|
|
27
|
+
Also available under GPL 2.0
|
|
28
|
+
Copyright (c) 2011-2015 Jim Cowart (GPL-2.0 License)
|
|
29
|
+
|
|
30
|
+
This program is free software; you can redistribute it and/or modify
|
|
31
|
+
it under the terms of the GNU General Public License as published by
|
|
32
|
+
the Free Software Foundation; either version 2 of the License, or (at
|
|
33
|
+
your option) any later version.
|
|
34
|
+
|
|
35
|
+
This program is distributed in the hope that it will be useful, but
|
|
36
|
+
WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
37
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
38
|
+
General Public License for more details.
|
|
39
|
+
|
|
40
|
+
See http://opensource.org/licenses/GPL-2.0 for more details
|
|
41
|
+
|
|
42
|
+
--------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
The extend function included in machina was very heavily borrowed
|
|
45
|
+
from the backbone.js project, which can be found at http://backbonejs.org.
|
|
46
|
+
Due to its origin, the backbone license is included below as well.
|
|
47
|
+
|
|
48
|
+
Copyright (c) 2010-2012 Jeremy Ashkenas, DocumentCloud
|
|
49
|
+
|
|
50
|
+
Permission is hereby granted, free of charge, to any person
|
|
51
|
+
obtaining a copy of this software and associated documentation
|
|
52
|
+
files (the "Software"), to deal in the Software without
|
|
53
|
+
restriction, including without limitation the rights to use,
|
|
54
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
55
|
+
copies of the Software, and to permit persons to whom the
|
|
56
|
+
Software is furnished to do so, subject to the following
|
|
57
|
+
conditions:
|
|
58
|
+
|
|
59
|
+
The above copyright notice and this permission notice shall be
|
|
60
|
+
included in all copies or substantial portions of the Software.
|
|
61
|
+
|
|
62
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
63
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
64
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
65
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
66
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
67
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
68
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
69
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# machina-test
|
|
2
|
+
|
|
3
|
+
Jest/Vitest custom matchers for testing [machina](https://machina-js.org) FSM topology. Assert reachability, catch dead states, and verify structural invariants — all from your existing test suite.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import "machina-test";
|
|
7
|
+
|
|
8
|
+
expect(fsm).toHaveNoUnreachableStates();
|
|
9
|
+
expect(fsm).toAlwaysReach("delivered", { from: "placed" });
|
|
10
|
+
expect(fsm).toNeverReach("shipped", { from: "cancelled" });
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Built on [machina-inspect](https://www.npmjs.com/package/machina-inspect). Zero ceremony beyond the import.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install --save-dev machina-test
|
|
19
|
+
# or
|
|
20
|
+
pnpm add -D machina-test
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`machina` >= 6.1.0 is a peer dependency. Either `jest` or `vitest` must be available as the host test runner.
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
Import `machina-test` in your test files (or in a setup file that runs after the framework's globals are available). The import registers the matchers via `expect.extend()` as a side effect.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// in each test file
|
|
31
|
+
import "machina-test";
|
|
32
|
+
|
|
33
|
+
// or in jest.setup.ts / vitest.setup.ts
|
|
34
|
+
import "machina-test";
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That's it. TypeScript users get autocomplete for all three matchers automatically.
|
|
38
|
+
|
|
39
|
+
## Matchers
|
|
40
|
+
|
|
41
|
+
### `toHaveNoUnreachableStates()`
|
|
42
|
+
|
|
43
|
+
Asserts that every state in the FSM is reachable from `initialState`. Delegates to machina-inspect's `inspectGraph()`, which recurses into child FSM graphs.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
expect(fsm).toHaveNoUnreachableStates();
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `toAlwaysReach(targetState, { from })`
|
|
50
|
+
|
|
51
|
+
Asserts that a path exists from `from` to `targetState` in the FSM's top-level graph. BFS over all edges (both "definite" and "possible"). Top-level graph only — does not traverse into `_child` FSMs.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
expect(fsm).toAlwaysReach("delivered", { from: "placed" });
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
"Always" refers to graph topology — the plumbing exists — not runtime certainty. Whether the path executes at runtime depends on which handlers fire.
|
|
58
|
+
|
|
59
|
+
### `toNeverReach(targetState, { from })`
|
|
60
|
+
|
|
61
|
+
Asserts that no path exists from `from` to `targetState`. The logical inverse of `toAlwaysReach`.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
expect(fsm).toNeverReach("shipped", { from: "cancelled" });
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### `.not` variants
|
|
68
|
+
|
|
69
|
+
Standard Jest/Vitest negation works as expected:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
expect(fsm).not.toAlwaysReach("shipped", { from: "cancelled" });
|
|
73
|
+
expect(fsm).not.toNeverReach("delivered", { from: "placed" });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Invalid state names
|
|
77
|
+
|
|
78
|
+
Typos produce clean test failures (not thrown exceptions) with actionable messages:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
State 'shiped' does not exist in FSM 'order-workflow'. Available states: placed, validating, processing, shipped, delivered, cancelled, refunded.
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Testing hierarchical FSMs
|
|
85
|
+
|
|
86
|
+
`toAlwaysReach` and `toNeverReach` operate on the **top-level graph only**. They do not traverse into `_child` FSMs. This is intentional — it avoids ambiguity around composite state names and keeps assertions explicit about which level you're testing.
|
|
87
|
+
|
|
88
|
+
To test a child FSM, pass it directly to `expect()`:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// Test the parent — sees browsing, checkout, confirmation
|
|
92
|
+
expect(checkout).toAlwaysReach("confirmation", { from: "browsing" });
|
|
93
|
+
|
|
94
|
+
// Test the child — sees entering-details, processing, authorized, declined
|
|
95
|
+
const payment = createPaymentFsm();
|
|
96
|
+
expect(payment).toAlwaysReach("authorized", { from: "entering-details" });
|
|
97
|
+
expect(payment).toNeverReach("entering-details", { from: "authorized" });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**The one exception**: `toHaveNoUnreachableStates()` _does_ recurse into children via `inspectGraph()`. An orphaned state in a child surfaces as a failure when called on the parent.
|
|
101
|
+
|
|
102
|
+
## See also
|
|
103
|
+
|
|
104
|
+
- [machina-inspect](https://www.npmjs.com/package/machina-inspect) — the graph analysis engine these matchers are built on
|
|
105
|
+
- [eslint-plugin-machina](https://www.npmjs.com/package/eslint-plugin-machina) — catch structural issues at lint time in your editor
|
|
106
|
+
- [testing-with-machina-test example](../../examples/testing-with-machina-test/) — full working example with flat and hierarchical FSMs
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
let machina_inspect = require("machina-inspect");
|
|
2
|
+
|
|
3
|
+
//#region src/reachability.ts
|
|
4
|
+
/**
|
|
5
|
+
* Returns true if `to` is reachable from `from` via BFS over the
|
|
6
|
+
* top-level graph's edges. Returns false if either state is missing
|
|
7
|
+
* from the graph, or if no path exists.
|
|
8
|
+
*
|
|
9
|
+
* Treats `from === to` as trivially reachable (every state reaches itself).
|
|
10
|
+
*/
|
|
11
|
+
const canReach = (graph, from, to) => {
|
|
12
|
+
if (from === to) return true;
|
|
13
|
+
if (!(from in graph.nodes)) return false;
|
|
14
|
+
const visited = /* @__PURE__ */ new Set();
|
|
15
|
+
const queue = [from];
|
|
16
|
+
visited.add(from);
|
|
17
|
+
while (queue.length > 0) {
|
|
18
|
+
const current = queue.shift();
|
|
19
|
+
const node = graph.nodes[current];
|
|
20
|
+
if (!node) continue;
|
|
21
|
+
for (const edge of node.edges) {
|
|
22
|
+
if (edge.to === to) return true;
|
|
23
|
+
if (!visited.has(edge.to) && edge.to in graph.nodes) {
|
|
24
|
+
visited.add(edge.to);
|
|
25
|
+
queue.push(edge.to);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/matchers.ts
|
|
34
|
+
/**
|
|
35
|
+
* Build the StateGraph for the received FSM instance.
|
|
36
|
+
* `buildStateGraph` accepts both config objects and live instances;
|
|
37
|
+
* here we always receive live instances (users pass `expect(fsm)`).
|
|
38
|
+
*/
|
|
39
|
+
const getGraph = (received) => {
|
|
40
|
+
return (0, machina_inspect.buildStateGraph)(received);
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Produce the "invalid state" failure message. Used when a state name
|
|
44
|
+
* passed to a matcher does not exist in the graph.
|
|
45
|
+
*/
|
|
46
|
+
const invalidStateMessage = (stateName, fsmId, graph) => {
|
|
47
|
+
return `State '${stateName}' does not exist in FSM '${fsmId}'. Available states: ${Object.keys(graph.nodes).join(", ")}.`;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Passes when the FSM has no states that are unreachable from initialState.
|
|
51
|
+
* Uses inspectGraph() to run all checks and filters for unreachable-state
|
|
52
|
+
* findings, which includes recursive child graph analysis.
|
|
53
|
+
*/
|
|
54
|
+
const toHaveNoUnreachableStates = (received) => {
|
|
55
|
+
const graph = getGraph(received);
|
|
56
|
+
const findings = (0, machina_inspect.inspectGraph)(graph).filter((f) => f.type === "unreachable-state");
|
|
57
|
+
const pass = findings.length === 0;
|
|
58
|
+
const message = () => {
|
|
59
|
+
if (pass) return `Expected FSM '${graph.fsmId}' to have unreachable states, but all states are reachable.`;
|
|
60
|
+
const stateList = findings.map((f) => `'${f.states.join(", ")}'`).join(", ");
|
|
61
|
+
return `Expected FSM '${graph.fsmId}' to have no unreachable states, but found: ${stateList}.`;
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
pass,
|
|
65
|
+
message
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Passes when a path exists from `options.from` to `targetState` in the
|
|
70
|
+
* FSM's top-level graph (BFS over all edges, definite and possible).
|
|
71
|
+
*/
|
|
72
|
+
const toAlwaysReach = (received, targetState, options) => {
|
|
73
|
+
const graph = getGraph(received);
|
|
74
|
+
const { from } = options;
|
|
75
|
+
if (!(from in graph.nodes)) return {
|
|
76
|
+
pass: false,
|
|
77
|
+
message: () => invalidStateMessage(from, graph.fsmId, graph)
|
|
78
|
+
};
|
|
79
|
+
if (!(targetState in graph.nodes)) return {
|
|
80
|
+
pass: false,
|
|
81
|
+
message: () => invalidStateMessage(targetState, graph.fsmId, graph)
|
|
82
|
+
};
|
|
83
|
+
const pass = canReach(graph, from, targetState);
|
|
84
|
+
const message = () => {
|
|
85
|
+
if (pass) return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;
|
|
86
|
+
return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
pass,
|
|
90
|
+
message
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Passes when NO path exists from `options.from` to `targetState`.
|
|
95
|
+
* The logical inverse of toAlwaysReach.
|
|
96
|
+
*/
|
|
97
|
+
const toNeverReach = (received, targetState, options) => {
|
|
98
|
+
const graph = getGraph(received);
|
|
99
|
+
const { from } = options;
|
|
100
|
+
if (!(from in graph.nodes)) return {
|
|
101
|
+
pass: false,
|
|
102
|
+
message: () => invalidStateMessage(from, graph.fsmId, graph)
|
|
103
|
+
};
|
|
104
|
+
if (!(targetState in graph.nodes)) return {
|
|
105
|
+
pass: false,
|
|
106
|
+
message: () => invalidStateMessage(targetState, graph.fsmId, graph)
|
|
107
|
+
};
|
|
108
|
+
const pass = !canReach(graph, from, targetState);
|
|
109
|
+
const message = () => {
|
|
110
|
+
if (pass) return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;
|
|
111
|
+
return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
pass,
|
|
115
|
+
message
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/index.ts
|
|
121
|
+
expect.extend({
|
|
122
|
+
toHaveNoUnreachableStates,
|
|
123
|
+
toAlwaysReach,
|
|
124
|
+
toNeverReach
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
//#endregion
|
|
128
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../src/reachability.ts","../src/matchers.ts","../src/index.ts"],"sourcesContent":["// =============================================================================\n// reachability.ts — BFS reachability check for StateGraph\n//\n// Top-level graph only. Child graphs are not traversed here — users who want\n// to assert reachability inside a child FSM pass the child instance directly\n// to expect().\n//\n// Both \"definite\" and \"possible\" edges are followed. The question is whether\n// the plumbing exists, not whether runtime conditions will always cause it to\n// execute. See design decisions in the build plan for rationale.\n// =============================================================================\n\nimport type { StateGraph } from \"machina-inspect\";\n\n/**\n * Returns true if `to` is reachable from `from` via BFS over the\n * top-level graph's edges. Returns false if either state is missing\n * from the graph, or if no path exists.\n *\n * Treats `from === to` as trivially reachable (every state reaches itself).\n */\nconst canReach = (graph: StateGraph, from: string, to: string): boolean => {\n // Trivially reachable — a state always reaches itself\n if (from === to) {\n return true;\n }\n\n // If the source state isn't in the graph, there's nothing to walk\n if (!(from in graph.nodes)) {\n return false;\n }\n\n const visited = new Set<string>();\n const queue: string[] = [from];\n visited.add(from);\n\n while (queue.length > 0) {\n const current = queue.shift()!;\n const node = graph.nodes[current];\n\n // Missing node is a safety check — shouldn't happen in a well-formed graph\n if (!node) {\n continue;\n }\n\n for (const edge of node.edges) {\n if (edge.to === to) {\n return true;\n }\n if (!visited.has(edge.to) && edge.to in graph.nodes) {\n visited.add(edge.to);\n queue.push(edge.to);\n }\n }\n }\n\n return false;\n};\n\nexport { canReach };\n","// =============================================================================\n// matchers.ts — Custom matcher implementations for machina FSMs\n//\n// Each matcher follows the expect.extend() contract:\n// - Receives the subject as `received`\n// - Returns { pass: boolean, message: () => string }\n// - `message` is always the FAILURE message — jest/vitest call it only\n// when the assertion fails (accounting for .not inversion)\n//\n// Invalid state names produce matcher failures, not throws, so they appear\n// as clean test failures in the runner rather than unhandled exceptions.\n// =============================================================================\n\nimport { buildStateGraph, inspectGraph, type StateGraph } from \"machina-inspect\";\nimport type { MachinaInstance } from \"machina\";\nimport { canReach } from \"./reachability\";\nimport type { ReachabilityOptions } from \"./types\";\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build the StateGraph for the received FSM instance.\n * `buildStateGraph` accepts both config objects and live instances;\n * here we always receive live instances (users pass `expect(fsm)`).\n */\nconst getGraph = (received: MachinaInstance): StateGraph => {\n return buildStateGraph(received);\n};\n\n/**\n * Produce the \"invalid state\" failure message. Used when a state name\n * passed to a matcher does not exist in the graph.\n */\nconst invalidStateMessage = (stateName: string, fsmId: string, graph: StateGraph): string => {\n const available = Object.keys(graph.nodes).join(\", \");\n return `State '${stateName}' does not exist in FSM '${fsmId}'. Available states: ${available}.`;\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toHaveNoUnreachableStates\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when the FSM has no states that are unreachable from initialState.\n * Uses inspectGraph() to run all checks and filters for unreachable-state\n * findings, which includes recursive child graph analysis.\n */\nconst toHaveNoUnreachableStates = (received: MachinaInstance): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const findings = inspectGraph(graph).filter(f => f.type === \"unreachable-state\");\n\n const pass = findings.length === 0;\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toHaveNoUnreachableStates() but all states reachable\n return `Expected FSM '${graph.fsmId}' to have unreachable states, but all states are reachable.`;\n }\n const stateList = findings.map(f => `'${f.states.join(\", \")}'`).join(\", \");\n return `Expected FSM '${graph.fsmId}' to have no unreachable states, but found: ${stateList}.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toAlwaysReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when a path exists from `options.from` to `targetState` in the\n * FSM's top-level graph (BFS over all edges, definite and possible).\n */\nconst toAlwaysReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n const pass = canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toAlwaysReach but path exists\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toNeverReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when NO path exists from `options.from` to `targetState`.\n * The logical inverse of toAlwaysReach.\n */\nconst toNeverReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n // pass: true means \"no path exists\" (the assertion that toNeverReach checks)\n const pass = !canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toNeverReach but no path exists\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n };\n\n return { pass, message };\n};\n\nexport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach };\n","// =============================================================================\n// index.ts — Side-effect entry point for machina-test\n//\n// Importing this module registers all three custom matchers with the global\n// `expect` provided by Jest or Vitest. This is the same pattern used by\n// @testing-library/jest-dom — the import IS the side effect.\n//\n// `expect` must be available as a global at the time this module is evaluated.\n// Put the import in a test file or in a setup file that runs after the test\n// framework's globals are injected.\n// =============================================================================\n\nimport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach } from \"./matchers\";\n\n// Register with the host test runner's global expect\nexpect.extend({ toHaveNoUnreachableStates, toAlwaysReach, toNeverReach });\n\n// Re-export the options type so consumers can import it alongside the package\nexport type { ReachabilityOptions } from \"./types\";\n"],"mappings":";;;;;;;;;;AAqBA,MAAM,YAAY,OAAmB,MAAc,OAAwB;AAEvE,KAAI,SAAS,GACT,QAAO;AAIX,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;CAGX,MAAM,0BAAU,IAAI,KAAa;CACjC,MAAM,QAAkB,CAAC,KAAK;AAC9B,SAAQ,IAAI,KAAK;AAEjB,QAAO,MAAM,SAAS,GAAG;EACrB,MAAM,UAAU,MAAM,OAAO;EAC7B,MAAM,OAAO,MAAM,MAAM;AAGzB,MAAI,CAAC,KACD;AAGJ,OAAK,MAAM,QAAQ,KAAK,OAAO;AAC3B,OAAI,KAAK,OAAO,GACZ,QAAO;AAEX,OAAI,CAAC,QAAQ,IAAI,KAAK,GAAG,IAAI,KAAK,MAAM,MAAM,OAAO;AACjD,YAAQ,IAAI,KAAK,GAAG;AACpB,UAAM,KAAK,KAAK,GAAG;;;;AAK/B,QAAO;;;;;;;;;;AC7BX,MAAM,YAAY,aAA0C;AACxD,6CAAuB,SAAS;;;;;;AAOpC,MAAM,uBAAuB,WAAmB,OAAe,UAA8B;AAEzF,QAAO,UAAU,UAAU,2BAA2B,MAAM,uBAD1C,OAAO,KAAK,MAAM,MAAM,CAAC,KAAK,KAAK,CACwC;;;;;;;AAYjG,MAAM,6BAA6B,aAAwD;CACvF,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,6CAAwB,MAAM,CAAC,QAAO,MAAK,EAAE,SAAS,oBAAoB;CAEhF,MAAM,OAAO,SAAS,WAAW;CAEjC,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM;EAExC,MAAM,YAAY,SAAS,KAAI,MAAK,IAAI,EAAE,OAAO,KAAK,KAAK,CAAC,GAAG,CAAC,KAAK,KAAK;AAC1E,SAAO,iBAAiB,MAAM,MAAM,8CAA8C,UAAU;;AAGhG,QAAO;EAAE;EAAM;EAAS;;;;;;AAW5B,MAAM,iBACF,UACA,aACA,YAC2B;CAC3B,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,EAAE,SAAS;AAGjB,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,MAAM,MAAM,OAAO,MAAM;EAC/D;AAGL,KAAI,EAAE,eAAe,MAAM,OACvB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,aAAa,MAAM,OAAO,MAAM;EACtE;CAGL,MAAM,OAAO,SAAS,OAAO,MAAM,YAAY;CAE/C,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM,0BAA0B,KAAK,QAAQ,YAAY;AAE3F,SAAO,iBAAiB,MAAM,MAAM,yBAAyB,KAAK,QAAQ,YAAY;;AAG1F,QAAO;EAAE;EAAM;EAAS;;;;;;AAW5B,MAAM,gBACF,UACA,aACA,YAC2B;CAC3B,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,EAAE,SAAS;AAGjB,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,MAAM,MAAM,OAAO,MAAM;EAC/D;AAGL,KAAI,EAAE,eAAe,MAAM,OACvB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,aAAa,MAAM,OAAO,MAAM;EACtE;CAIL,MAAM,OAAO,CAAC,SAAS,OAAO,MAAM,YAAY;CAEhD,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM,yBAAyB,KAAK,QAAQ,YAAY;AAE1F,SAAO,iBAAiB,MAAM,MAAM,0BAA0B,KAAK,QAAQ,YAAY;;AAG3F,QAAO;EAAE;EAAM;EAAS;;;;;AC1I5B,OAAO,OAAO;CAAE;CAA2B;CAAe;CAAc,CAAC"}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
interface ReachabilityOptions {
|
|
3
|
+
/** The state to start the BFS from */
|
|
4
|
+
from: string;
|
|
5
|
+
}
|
|
6
|
+
declare global {
|
|
7
|
+
namespace jest {
|
|
8
|
+
interface Matchers<R> {
|
|
9
|
+
/**
|
|
10
|
+
* Asserts that every state in the FSM is reachable from its
|
|
11
|
+
* initialState. Delegates to `inspectGraph` and filters for
|
|
12
|
+
* unreachable-state findings.
|
|
13
|
+
*/
|
|
14
|
+
toHaveNoUnreachableStates(): R;
|
|
15
|
+
/**
|
|
16
|
+
* Asserts that a path exists (via BFS over graph edges) from
|
|
17
|
+
* `options.from` to `targetState`.
|
|
18
|
+
*/
|
|
19
|
+
toAlwaysReach(targetState: string, options: ReachabilityOptions): R;
|
|
20
|
+
/**
|
|
21
|
+
* Asserts that NO path exists (via BFS over graph edges) from
|
|
22
|
+
* `options.from` to `targetState`.
|
|
23
|
+
*/
|
|
24
|
+
toNeverReach(targetState: string, options: ReachabilityOptions): R;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
declare module "vitest" {
|
|
29
|
+
interface Assertion<T = any> {
|
|
30
|
+
/**
|
|
31
|
+
* Asserts that every state in the FSM is reachable from its
|
|
32
|
+
* initialState. Delegates to `inspectGraph` and filters for
|
|
33
|
+
* unreachable-state findings.
|
|
34
|
+
*/
|
|
35
|
+
toHaveNoUnreachableStates(): T;
|
|
36
|
+
/**
|
|
37
|
+
* Asserts that a path exists (via BFS over graph edges) from
|
|
38
|
+
* `options.from` to `targetState`.
|
|
39
|
+
*/
|
|
40
|
+
toAlwaysReach(targetState: string, options: ReachabilityOptions): T;
|
|
41
|
+
/**
|
|
42
|
+
* Asserts that NO path exists (via BFS over graph edges) from
|
|
43
|
+
* `options.from` to `targetState`.
|
|
44
|
+
*/
|
|
45
|
+
toNeverReach(targetState: string, options: ReachabilityOptions): T;
|
|
46
|
+
}
|
|
47
|
+
} //# sourceMappingURL=types.d.ts.map
|
|
48
|
+
//#endregion
|
|
49
|
+
export { type ReachabilityOptions };
|
|
50
|
+
//# sourceMappingURL=index.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts"],"mappings":";UAWiB,mBAAA;EAAA;EAEb,IAAA;AAAA;AAAA,QAII,MAAA;EAAA,UAEM,IAAA;IAAA,UACI,QAAA;MANjB;;;;;MAYW,yBAAA,IAA6B,CAAA;MAYoC;;;;MANjE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;MAZ5D;;;;MAkBN,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAA;EAAA;AAAA;AAAA;EAAA,UAQ/D,SAAA;IARW;;;;;IAcjB,yBAAA,IAA6B,CAAA;IAdyC;AAAA;;;IAoBtE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAtB;;;;IAM5C,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;EAAA;AAAA"}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
interface ReachabilityOptions {
|
|
3
|
+
/** The state to start the BFS from */
|
|
4
|
+
from: string;
|
|
5
|
+
}
|
|
6
|
+
declare global {
|
|
7
|
+
namespace jest {
|
|
8
|
+
interface Matchers<R> {
|
|
9
|
+
/**
|
|
10
|
+
* Asserts that every state in the FSM is reachable from its
|
|
11
|
+
* initialState. Delegates to `inspectGraph` and filters for
|
|
12
|
+
* unreachable-state findings.
|
|
13
|
+
*/
|
|
14
|
+
toHaveNoUnreachableStates(): R;
|
|
15
|
+
/**
|
|
16
|
+
* Asserts that a path exists (via BFS over graph edges) from
|
|
17
|
+
* `options.from` to `targetState`.
|
|
18
|
+
*/
|
|
19
|
+
toAlwaysReach(targetState: string, options: ReachabilityOptions): R;
|
|
20
|
+
/**
|
|
21
|
+
* Asserts that NO path exists (via BFS over graph edges) from
|
|
22
|
+
* `options.from` to `targetState`.
|
|
23
|
+
*/
|
|
24
|
+
toNeverReach(targetState: string, options: ReachabilityOptions): R;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
declare module "vitest" {
|
|
29
|
+
interface Assertion<T = any> {
|
|
30
|
+
/**
|
|
31
|
+
* Asserts that every state in the FSM is reachable from its
|
|
32
|
+
* initialState. Delegates to `inspectGraph` and filters for
|
|
33
|
+
* unreachable-state findings.
|
|
34
|
+
*/
|
|
35
|
+
toHaveNoUnreachableStates(): T;
|
|
36
|
+
/**
|
|
37
|
+
* Asserts that a path exists (via BFS over graph edges) from
|
|
38
|
+
* `options.from` to `targetState`.
|
|
39
|
+
*/
|
|
40
|
+
toAlwaysReach(targetState: string, options: ReachabilityOptions): T;
|
|
41
|
+
/**
|
|
42
|
+
* Asserts that NO path exists (via BFS over graph edges) from
|
|
43
|
+
* `options.from` to `targetState`.
|
|
44
|
+
*/
|
|
45
|
+
toNeverReach(targetState: string, options: ReachabilityOptions): T;
|
|
46
|
+
}
|
|
47
|
+
} //# sourceMappingURL=types.d.ts.map
|
|
48
|
+
//#endregion
|
|
49
|
+
export { type ReachabilityOptions };
|
|
50
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";UAWiB,mBAAA;EAAA;EAEb,IAAA;AAAA;AAAA,QAII,MAAA;EAAA,UAEM,IAAA;IAAA,UACI,QAAA;MANjB;;;;;MAYW,yBAAA,IAA6B,CAAA;MAYoC;;;;MANjE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;MAZ5D;;;;MAkBN,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAA;EAAA;AAAA;AAAA;EAAA,UAQ/D,SAAA;IARW;;;;;IAcjB,yBAAA,IAA6B,CAAA;IAdyC;AAAA;;;IAoBtE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAtB;;;;IAM5C,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;EAAA;AAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { buildStateGraph, inspectGraph } from "machina-inspect";
|
|
2
|
+
|
|
3
|
+
//#region src/reachability.ts
|
|
4
|
+
/**
|
|
5
|
+
* Returns true if `to` is reachable from `from` via BFS over the
|
|
6
|
+
* top-level graph's edges. Returns false if either state is missing
|
|
7
|
+
* from the graph, or if no path exists.
|
|
8
|
+
*
|
|
9
|
+
* Treats `from === to` as trivially reachable (every state reaches itself).
|
|
10
|
+
*/
|
|
11
|
+
const canReach = (graph, from, to) => {
|
|
12
|
+
if (from === to) return true;
|
|
13
|
+
if (!(from in graph.nodes)) return false;
|
|
14
|
+
const visited = /* @__PURE__ */ new Set();
|
|
15
|
+
const queue = [from];
|
|
16
|
+
visited.add(from);
|
|
17
|
+
while (queue.length > 0) {
|
|
18
|
+
const current = queue.shift();
|
|
19
|
+
const node = graph.nodes[current];
|
|
20
|
+
if (!node) continue;
|
|
21
|
+
for (const edge of node.edges) {
|
|
22
|
+
if (edge.to === to) return true;
|
|
23
|
+
if (!visited.has(edge.to) && edge.to in graph.nodes) {
|
|
24
|
+
visited.add(edge.to);
|
|
25
|
+
queue.push(edge.to);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/matchers.ts
|
|
34
|
+
/**
|
|
35
|
+
* Build the StateGraph for the received FSM instance.
|
|
36
|
+
* `buildStateGraph` accepts both config objects and live instances;
|
|
37
|
+
* here we always receive live instances (users pass `expect(fsm)`).
|
|
38
|
+
*/
|
|
39
|
+
const getGraph = (received) => {
|
|
40
|
+
return buildStateGraph(received);
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Produce the "invalid state" failure message. Used when a state name
|
|
44
|
+
* passed to a matcher does not exist in the graph.
|
|
45
|
+
*/
|
|
46
|
+
const invalidStateMessage = (stateName, fsmId, graph) => {
|
|
47
|
+
return `State '${stateName}' does not exist in FSM '${fsmId}'. Available states: ${Object.keys(graph.nodes).join(", ")}.`;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Passes when the FSM has no states that are unreachable from initialState.
|
|
51
|
+
* Uses inspectGraph() to run all checks and filters for unreachable-state
|
|
52
|
+
* findings, which includes recursive child graph analysis.
|
|
53
|
+
*/
|
|
54
|
+
const toHaveNoUnreachableStates = (received) => {
|
|
55
|
+
const graph = getGraph(received);
|
|
56
|
+
const findings = inspectGraph(graph).filter((f) => f.type === "unreachable-state");
|
|
57
|
+
const pass = findings.length === 0;
|
|
58
|
+
const message = () => {
|
|
59
|
+
if (pass) return `Expected FSM '${graph.fsmId}' to have unreachable states, but all states are reachable.`;
|
|
60
|
+
const stateList = findings.map((f) => `'${f.states.join(", ")}'`).join(", ");
|
|
61
|
+
return `Expected FSM '${graph.fsmId}' to have no unreachable states, but found: ${stateList}.`;
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
pass,
|
|
65
|
+
message
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Passes when a path exists from `options.from` to `targetState` in the
|
|
70
|
+
* FSM's top-level graph (BFS over all edges, definite and possible).
|
|
71
|
+
*/
|
|
72
|
+
const toAlwaysReach = (received, targetState, options) => {
|
|
73
|
+
const graph = getGraph(received);
|
|
74
|
+
const { from } = options;
|
|
75
|
+
if (!(from in graph.nodes)) return {
|
|
76
|
+
pass: false,
|
|
77
|
+
message: () => invalidStateMessage(from, graph.fsmId, graph)
|
|
78
|
+
};
|
|
79
|
+
if (!(targetState in graph.nodes)) return {
|
|
80
|
+
pass: false,
|
|
81
|
+
message: () => invalidStateMessage(targetState, graph.fsmId, graph)
|
|
82
|
+
};
|
|
83
|
+
const pass = canReach(graph, from, targetState);
|
|
84
|
+
const message = () => {
|
|
85
|
+
if (pass) return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;
|
|
86
|
+
return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
pass,
|
|
90
|
+
message
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Passes when NO path exists from `options.from` to `targetState`.
|
|
95
|
+
* The logical inverse of toAlwaysReach.
|
|
96
|
+
*/
|
|
97
|
+
const toNeverReach = (received, targetState, options) => {
|
|
98
|
+
const graph = getGraph(received);
|
|
99
|
+
const { from } = options;
|
|
100
|
+
if (!(from in graph.nodes)) return {
|
|
101
|
+
pass: false,
|
|
102
|
+
message: () => invalidStateMessage(from, graph.fsmId, graph)
|
|
103
|
+
};
|
|
104
|
+
if (!(targetState in graph.nodes)) return {
|
|
105
|
+
pass: false,
|
|
106
|
+
message: () => invalidStateMessage(targetState, graph.fsmId, graph)
|
|
107
|
+
};
|
|
108
|
+
const pass = !canReach(graph, from, targetState);
|
|
109
|
+
const message = () => {
|
|
110
|
+
if (pass) return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;
|
|
111
|
+
return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
pass,
|
|
115
|
+
message
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/index.ts
|
|
121
|
+
expect.extend({
|
|
122
|
+
toHaveNoUnreachableStates,
|
|
123
|
+
toAlwaysReach,
|
|
124
|
+
toNeverReach
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
//#endregion
|
|
128
|
+
export { };
|
|
129
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/reachability.ts","../src/matchers.ts","../src/index.ts"],"sourcesContent":["// =============================================================================\n// reachability.ts — BFS reachability check for StateGraph\n//\n// Top-level graph only. Child graphs are not traversed here — users who want\n// to assert reachability inside a child FSM pass the child instance directly\n// to expect().\n//\n// Both \"definite\" and \"possible\" edges are followed. The question is whether\n// the plumbing exists, not whether runtime conditions will always cause it to\n// execute. See design decisions in the build plan for rationale.\n// =============================================================================\n\nimport type { StateGraph } from \"machina-inspect\";\n\n/**\n * Returns true if `to` is reachable from `from` via BFS over the\n * top-level graph's edges. Returns false if either state is missing\n * from the graph, or if no path exists.\n *\n * Treats `from === to` as trivially reachable (every state reaches itself).\n */\nconst canReach = (graph: StateGraph, from: string, to: string): boolean => {\n // Trivially reachable — a state always reaches itself\n if (from === to) {\n return true;\n }\n\n // If the source state isn't in the graph, there's nothing to walk\n if (!(from in graph.nodes)) {\n return false;\n }\n\n const visited = new Set<string>();\n const queue: string[] = [from];\n visited.add(from);\n\n while (queue.length > 0) {\n const current = queue.shift()!;\n const node = graph.nodes[current];\n\n // Missing node is a safety check — shouldn't happen in a well-formed graph\n if (!node) {\n continue;\n }\n\n for (const edge of node.edges) {\n if (edge.to === to) {\n return true;\n }\n if (!visited.has(edge.to) && edge.to in graph.nodes) {\n visited.add(edge.to);\n queue.push(edge.to);\n }\n }\n }\n\n return false;\n};\n\nexport { canReach };\n","// =============================================================================\n// matchers.ts — Custom matcher implementations for machina FSMs\n//\n// Each matcher follows the expect.extend() contract:\n// - Receives the subject as `received`\n// - Returns { pass: boolean, message: () => string }\n// - `message` is always the FAILURE message — jest/vitest call it only\n// when the assertion fails (accounting for .not inversion)\n//\n// Invalid state names produce matcher failures, not throws, so they appear\n// as clean test failures in the runner rather than unhandled exceptions.\n// =============================================================================\n\nimport { buildStateGraph, inspectGraph, type StateGraph } from \"machina-inspect\";\nimport type { MachinaInstance } from \"machina\";\nimport { canReach } from \"./reachability\";\nimport type { ReachabilityOptions } from \"./types\";\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build the StateGraph for the received FSM instance.\n * `buildStateGraph` accepts both config objects and live instances;\n * here we always receive live instances (users pass `expect(fsm)`).\n */\nconst getGraph = (received: MachinaInstance): StateGraph => {\n return buildStateGraph(received);\n};\n\n/**\n * Produce the \"invalid state\" failure message. Used when a state name\n * passed to a matcher does not exist in the graph.\n */\nconst invalidStateMessage = (stateName: string, fsmId: string, graph: StateGraph): string => {\n const available = Object.keys(graph.nodes).join(\", \");\n return `State '${stateName}' does not exist in FSM '${fsmId}'. Available states: ${available}.`;\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toHaveNoUnreachableStates\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when the FSM has no states that are unreachable from initialState.\n * Uses inspectGraph() to run all checks and filters for unreachable-state\n * findings, which includes recursive child graph analysis.\n */\nconst toHaveNoUnreachableStates = (received: MachinaInstance): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const findings = inspectGraph(graph).filter(f => f.type === \"unreachable-state\");\n\n const pass = findings.length === 0;\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toHaveNoUnreachableStates() but all states reachable\n return `Expected FSM '${graph.fsmId}' to have unreachable states, but all states are reachable.`;\n }\n const stateList = findings.map(f => `'${f.states.join(\", \")}'`).join(\", \");\n return `Expected FSM '${graph.fsmId}' to have no unreachable states, but found: ${stateList}.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toAlwaysReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when a path exists from `options.from` to `targetState` in the\n * FSM's top-level graph (BFS over all edges, definite and possible).\n */\nconst toAlwaysReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n const pass = canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toAlwaysReach but path exists\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toNeverReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when NO path exists from `options.from` to `targetState`.\n * The logical inverse of toAlwaysReach.\n */\nconst toNeverReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n // pass: true means \"no path exists\" (the assertion that toNeverReach checks)\n const pass = !canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toNeverReach but no path exists\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n };\n\n return { pass, message };\n};\n\nexport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach };\n","// =============================================================================\n// index.ts — Side-effect entry point for machina-test\n//\n// Importing this module registers all three custom matchers with the global\n// `expect` provided by Jest or Vitest. This is the same pattern used by\n// @testing-library/jest-dom — the import IS the side effect.\n//\n// `expect` must be available as a global at the time this module is evaluated.\n// Put the import in a test file or in a setup file that runs after the test\n// framework's globals are injected.\n// =============================================================================\n\nimport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach } from \"./matchers\";\n\n// Register with the host test runner's global expect\nexpect.extend({ toHaveNoUnreachableStates, toAlwaysReach, toNeverReach });\n\n// Re-export the options type so consumers can import it alongside the package\nexport type { ReachabilityOptions } from \"./types\";\n"],"mappings":";;;;;;;;;;AAqBA,MAAM,YAAY,OAAmB,MAAc,OAAwB;AAEvE,KAAI,SAAS,GACT,QAAO;AAIX,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;CAGX,MAAM,0BAAU,IAAI,KAAa;CACjC,MAAM,QAAkB,CAAC,KAAK;AAC9B,SAAQ,IAAI,KAAK;AAEjB,QAAO,MAAM,SAAS,GAAG;EACrB,MAAM,UAAU,MAAM,OAAO;EAC7B,MAAM,OAAO,MAAM,MAAM;AAGzB,MAAI,CAAC,KACD;AAGJ,OAAK,MAAM,QAAQ,KAAK,OAAO;AAC3B,OAAI,KAAK,OAAO,GACZ,QAAO;AAEX,OAAI,CAAC,QAAQ,IAAI,KAAK,GAAG,IAAI,KAAK,MAAM,MAAM,OAAO;AACjD,YAAQ,IAAI,KAAK,GAAG;AACpB,UAAM,KAAK,KAAK,GAAG;;;;AAK/B,QAAO;;;;;;;;;;AC7BX,MAAM,YAAY,aAA0C;AACxD,QAAO,gBAAgB,SAAS;;;;;;AAOpC,MAAM,uBAAuB,WAAmB,OAAe,UAA8B;AAEzF,QAAO,UAAU,UAAU,2BAA2B,MAAM,uBAD1C,OAAO,KAAK,MAAM,MAAM,CAAC,KAAK,KAAK,CACwC;;;;;;;AAYjG,MAAM,6BAA6B,aAAwD;CACvF,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,WAAW,aAAa,MAAM,CAAC,QAAO,MAAK,EAAE,SAAS,oBAAoB;CAEhF,MAAM,OAAO,SAAS,WAAW;CAEjC,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM;EAExC,MAAM,YAAY,SAAS,KAAI,MAAK,IAAI,EAAE,OAAO,KAAK,KAAK,CAAC,GAAG,CAAC,KAAK,KAAK;AAC1E,SAAO,iBAAiB,MAAM,MAAM,8CAA8C,UAAU;;AAGhG,QAAO;EAAE;EAAM;EAAS;;;;;;AAW5B,MAAM,iBACF,UACA,aACA,YAC2B;CAC3B,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,EAAE,SAAS;AAGjB,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,MAAM,MAAM,OAAO,MAAM;EAC/D;AAGL,KAAI,EAAE,eAAe,MAAM,OACvB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,aAAa,MAAM,OAAO,MAAM;EACtE;CAGL,MAAM,OAAO,SAAS,OAAO,MAAM,YAAY;CAE/C,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM,0BAA0B,KAAK,QAAQ,YAAY;AAE3F,SAAO,iBAAiB,MAAM,MAAM,yBAAyB,KAAK,QAAQ,YAAY;;AAG1F,QAAO;EAAE;EAAM;EAAS;;;;;;AAW5B,MAAM,gBACF,UACA,aACA,YAC2B;CAC3B,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,EAAE,SAAS;AAGjB,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,MAAM,MAAM,OAAO,MAAM;EAC/D;AAGL,KAAI,EAAE,eAAe,MAAM,OACvB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,aAAa,MAAM,OAAO,MAAM;EACtE;CAIL,MAAM,OAAO,CAAC,SAAS,OAAO,MAAM,YAAY;CAEhD,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM,yBAAyB,KAAK,QAAQ,YAAY;AAE1F,SAAO,iBAAiB,MAAM,MAAM,0BAA0B,KAAK,QAAQ,YAAY;;AAG3F,QAAO;EAAE;EAAM;EAAS;;;;;AC1I5B,OAAO,OAAO;CAAE;CAA2B;CAAe;CAAc,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "machina-test",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Jest/Vitest custom matchers for testing machina FSMs.",
|
|
5
|
+
"author": "Jim Cowart (http://ifandelse.com)",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://machina-js.org/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git://github.com/ifandelse/machina.js.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "http://github.com/ifandelse/machina.js/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"licenses": [
|
|
19
|
+
{
|
|
20
|
+
"type": "MIT",
|
|
21
|
+
"url": "http://opensource.org/licenses/MIT"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"type": "GPL",
|
|
25
|
+
"url": "http://opensource.org/licenses/GPL-2.0"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"main": "dist/index.cjs",
|
|
29
|
+
"module": "dist/index.mjs",
|
|
30
|
+
"types": "dist/index.d.mts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.mts",
|
|
34
|
+
"import": "./dist/index.mjs",
|
|
35
|
+
"require": "./dist/index.cjs"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22.22"
|
|
44
|
+
},
|
|
45
|
+
"sideEffects": true,
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"machina-inspect": "1.0.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"machina": ">=6.1.0",
|
|
51
|
+
"jest": ">=29",
|
|
52
|
+
"vitest": ">=1"
|
|
53
|
+
},
|
|
54
|
+
"peerDependenciesMeta": {
|
|
55
|
+
"jest": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"vitest": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/jest": "^30.0.0",
|
|
64
|
+
"eslint": "^9.39.2",
|
|
65
|
+
"jest": "^30.2.0",
|
|
66
|
+
"ts-jest": "^29.4.6",
|
|
67
|
+
"tsdown": "^0.20.3",
|
|
68
|
+
"typescript": "^5.9.3",
|
|
69
|
+
"machina": "6.1.0"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"dev": "tsdown --watch",
|
|
73
|
+
"build": "tsdown",
|
|
74
|
+
"test": "jest --passWithNoTests",
|
|
75
|
+
"test:watch": "jest --watch",
|
|
76
|
+
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint --config ../../.eslintrc.js 'src/**/*.{ts,tsx}'"
|
|
77
|
+
}
|
|
78
|
+
}
|