diff --git a/.changeset/guards-capability.md b/.changeset/guards-capability.md new file mode 100644 index 0000000..6fe72e3 --- /dev/null +++ b/.changeset/guards-capability.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/xstate": minor +--- + +`checkStateIn(stateValue)` returns a function of the snapshot, for use in `pipe`; `checkStateIn(snapshot, stateValue)` is unchanged. diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 323e157..edee06f 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -4,7 +4,7 @@ Root `AGENTS.md` governs; these are the facts no default gate reports. Forked from statelyai/xstate, branch `next` at `2146ae26ebfc7e6a624b3a1f237f9e6ddc30b9f5` (MIT). Members are owned (`REPO-O1`); upstream is a reference, never a merge source. Plan: systemfsoftware's `docs/plans/2026-10-05-2317-feat-xstate-v6-fork-plan.md`. Not forked (no consumer in this program): the Solid, Svelte, Vue, Angular and Preact bindings, `@xstate/codemod`, `@xstate/scxml`; core's `bin/xstate.js` only ran `@xstate/codemod` through `npx`, so it is not shipped. -| ID | Rule | Gate | -| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **XS1** | A member gains `stryker.config.ts`, `oxlint.config.ts` (extending the root `oxlint.shared.ts`) and its `mutation` and `lint` scripts with its first capability PR, and its debt-ledger entry goes in that PR: `scripts/stryker-plan-gate.ts` (`exemption`) reads any `XS1` entry for a member as leave to schedule no mutants. Until its last capability PR, a member whose `src` tsconfig lacks `@effect/language-service` points `lint:tsgo` at `tsconfig.tsgo.json`, which turns the plugin on for the enrolled `src` files only. Each capability PR adds the `src` files it covers to `mutate`, to the `lint` script and to `tsconfig.tsgo.json`, with its test files, and fixes their diagnostics without disable comments. The last capability PR turns the plugin on for the whole `src` tsconfig and deletes `tsconfig.tsgo.json`. Remaining debt: in `@systemfsoftware/xstate`, every `src` file outside `mutate` is unmutated, unlinted and unchecked by the Effect plugin until its capability PR (enrolled: `src/assert.ts`, `src/stateMatching.ts`, `src/mapState.ts`, `src/fsm.ts`, `src/graph/`; the `lint` script and `tsconfig.tsgo.json` name the whole folder, and `mutate` names its seven files with runtime logic, leaving out `types.ts` and `index.ts`). | `review` — wrong: a PR mutates `src/assert.ts` and leaves it out of the `lint` script or `tsconfig.tsgo.json`, or keeps a ledger entry for an enrolled member; right: `mutate`, the `lint` script, `tsconfig.tsgo.json` and this row's enrolled list all name `src/assert.ts`, and `lint:tsgo` prints a nonzero `Checked N files` | -| **XS2** | The persisted snapshot wire format is upstream v6's (actor refs `{ xstate$type: 'actorRef', id }`, children by logical address, timers with `startedAt`); it changes only through a versioned machine migration. | `review` — wrong: an encoder emits `{ _tag: 'ActorRef', id }`; right: the encoder writes `{ xstate$type: 'actorRef', id }` and the tagged class exists only after decode | +| ID | Rule | Gate | +| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **XS1** | A member gains `stryker.config.ts`, `oxlint.config.ts` (extending the root `oxlint.shared.ts`) and its `mutation` and `lint` scripts with its first capability PR, and its debt-ledger entry goes in that PR: `scripts/stryker-plan-gate.ts` (`exemption`) reads any `XS1` entry for a member as leave to schedule no mutants. Until its last capability PR, a member whose `src` tsconfig lacks `@effect/language-service` points `lint:tsgo` at `tsconfig.tsgo.json`, which turns the plugin on for the enrolled `src` files only. Each capability PR adds the `src` files it covers to `mutate`, to the `lint` script and to `tsconfig.tsgo.json`, with its test files, and fixes their diagnostics without disable comments. The last capability PR turns the plugin on for the whole `src` tsconfig and deletes `tsconfig.tsgo.json`. Remaining debt: in `@systemfsoftware/xstate`, every `src` file outside `mutate` is unmutated, unlinted and unchecked by the Effect plugin until its capability PR (enrolled: `src/assert.ts`, `src/stateMatching.ts`, `src/mapState.ts`, `src/fsm.ts`, `src/graph/`, `src/transitionGuards.ts`; the `lint` script and `tsconfig.tsgo.json` name the whole folder, and `mutate` names its seven files with runtime logic, leaving out `types.ts` and `index.ts`). | `review` — wrong: a PR mutates `src/assert.ts` and leaves it out of the `lint` script or `tsconfig.tsgo.json`, or keeps a ledger entry for an enrolled member; right: `mutate`, the `lint` script, `tsconfig.tsgo.json` and this row's enrolled list all name `src/assert.ts`, and `lint:tsgo` prints a nonzero `Checked N files` | +| **XS2** | The persisted snapshot wire format is upstream v6's (actor refs `{ xstate$type: 'actorRef', id }`, children by logical address, timers with `startedAt`); it changes only through a versioned machine migration. | `review` — wrong: an encoder emits `{ _tag: 'ActorRef', id }`; right: the encoder writes `{ xstate$type: 'actorRef', id }` and the tagged class exists only after decode | diff --git a/packages/unguarded-tests.json b/packages/unguarded-tests.json index 391b12b..4ba0fc4 100644 --- a/packages/unguarded-tests.json +++ b/packages/unguarded-tests.json @@ -93,7 +93,6 @@ "packages/xstate/test/examples.cd.test.ts", "packages/xstate/test/final.test.ts", "packages/xstate/test/firstTenMinutes.v6.test.ts", - "packages/xstate/test/guards.test.ts", "packages/xstate/test/history.test.ts", "packages/xstate/test/id.test.ts", "packages/xstate/test/initial.test.ts", @@ -149,7 +148,6 @@ "packages/xstate/test/spawn.types.test.ts", "packages/xstate/test/spawnChild.test.ts", "packages/xstate/test/state.test.ts", - "packages/xstate/test/stateIn.test.ts", "packages/xstate/test/stateInput.persistence.test.ts", "packages/xstate/test/stateInput.test.ts", "packages/xstate/test/strictTargets.types.test.ts", diff --git a/packages/xstate/etc/xstate-actors.api.md b/packages/xstate/etc/xstate-actors.api.md index c1237a6..5b838c8 100644 --- a/packages/xstate/etc/xstate-actors.api.md +++ b/packages/xstate/etc/xstate-actors.api.md @@ -491,11 +491,11 @@ export class TimeoutError extends Error { // Warnings were encountered during analysis: // -// dist/index-DpYRYoBT.d.ts:36:5 - (ae-forgotten-export) The symbol "ActorSystemRuntime" needs to be exported by the entry point actors.d.ts -// dist/index-DpYRYoBT.d.ts:71:3 - (ae-forgotten-export) The symbol "LogicTransition" needs to be exported by the entry point actors.d.ts -// dist/index-DpYRYoBT.d.ts:171:3 - (ae-forgotten-export) The symbol "Receiver" needs to be exported by the entry point actors.d.ts -// dist/index-DpYRYoBT.d.ts:298:3 - (ae-forgotten-export) The symbol "Subscription" needs to be exported by the entry point actors.d.ts -// dist/index-DpYRYoBT.d.ts:362:3 - (ae-forgotten-export) The symbol "ObservableActor" needs to be exported by the entry point actors.d.ts +// dist/index-BMYfZluc.d.ts:36:5 - (ae-forgotten-export) The symbol "ActorSystemRuntime" needs to be exported by the entry point actors.d.ts +// dist/index-BMYfZluc.d.ts:71:3 - (ae-forgotten-export) The symbol "LogicTransition" needs to be exported by the entry point actors.d.ts +// dist/index-BMYfZluc.d.ts:171:3 - (ae-forgotten-export) The symbol "Receiver" needs to be exported by the entry point actors.d.ts +// dist/index-BMYfZluc.d.ts:298:3 - (ae-forgotten-export) The symbol "Subscription" needs to be exported by the entry point actors.d.ts +// dist/index-BMYfZluc.d.ts:362:3 - (ae-forgotten-export) The symbol "ObservableActor" needs to be exported by the entry point actors.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/xstate/etc/xstate-validation.api.md b/packages/xstate/etc/xstate-validation.api.md index 456e36d..5373e58 100644 --- a/packages/xstate/etc/xstate-validation.api.md +++ b/packages/xstate/etc/xstate-validation.api.md @@ -101,10 +101,10 @@ export interface StandardSchemaValidatorOptions { // Warnings were encountered during analysis: // -// dist/types-Dlyzn8C_.d.ts:873:3 - (ae-forgotten-export) The symbol "AnyActorLogic" needs to be exported by the entry point validation.d.ts -// dist/types-Dlyzn8C_.d.ts:878:3 - (ae-forgotten-export) The symbol "AnyEventObject" needs to be exported by the entry point validation.d.ts -// dist/types-Dlyzn8C_.d.ts:883:3 - (ae-forgotten-export) The symbol "Snapshot" needs to be exported by the entry point validation.d.ts -// dist/types-Dlyzn8C_.d.ts:884:3 - (ae-forgotten-export) The symbol "ExecutableActionObject" needs to be exported by the entry point validation.d.ts +// dist/types-DNQON1bi.d.ts:873:3 - (ae-forgotten-export) The symbol "AnyActorLogic" needs to be exported by the entry point validation.d.ts +// dist/types-DNQON1bi.d.ts:878:3 - (ae-forgotten-export) The symbol "AnyEventObject" needs to be exported by the entry point validation.d.ts +// dist/types-DNQON1bi.d.ts:883:3 - (ae-forgotten-export) The symbol "Snapshot" needs to be exported by the entry point validation.d.ts +// dist/types-DNQON1bi.d.ts:884:3 - (ae-forgotten-export) The symbol "ExecutableActionObject" needs to be exported by the entry point validation.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/xstate/etc/xstate.api.md b/packages/xstate/etc/xstate.api.md index 0d46c31..03704bb 100644 --- a/packages/xstate/etc/xstate.api.md +++ b/packages/xstate/etc/xstate.api.md @@ -701,6 +701,9 @@ export interface CancelExecutableActionObject extends BaseExecutableActionObject // @public (undocumented) export type Cast = A extends B ? A : B; +// @public (undocumented) +export function checkStateIn(stateValue: StateValue): (snapshot: AnyMachineSnapshot) => boolean; + // @public (undocumented) export function checkStateIn(snapshot: AnyMachineSnapshot, stateValue: StateValue): boolean; @@ -3463,33 +3466,33 @@ export type WithRootSetupStateSchemas + admitTransitionCandidate( + candidate, + event, + snapshot, + this, + actorScope, + selectionResults, + ), + }) - return undefined + return admitted === undefined ? undefined : [admitted.candidate] } } diff --git a/packages/xstate/src/index.ts b/packages/xstate/src/index.ts index 807adb9..278dc9d 100644 --- a/packages/xstate/src/index.ts +++ b/packages/xstate/src/index.ts @@ -181,6 +181,7 @@ export { transition, } from './transition.js' export { executeEffects, isBuiltInExecutableAction } from './transitionActions.js' +export { checkStateIn } from './transitionGuards.js' export type * from './types.js' export { SpecialTargets } from './types.js' export type { @@ -194,5 +195,5 @@ export type { Sources, WidenLiterals, } from './types.v6.js' -export { checkStateIn, getAllOwnEventDescriptors as __unsafe_getAllOwnEventDescriptors, toObserver } from './utils.js' +export { getAllOwnEventDescriptors as __unsafe_getAllOwnEventDescriptors, toObserver } from './utils.js' export { waitFor } from './waitFor.js' diff --git a/packages/xstate/src/stateUtils.ts b/packages/xstate/src/stateUtils.ts index 6d0de57..08e10e8 100644 --- a/packages/xstate/src/stateUtils.ts +++ b/packages/xstate/src/stateUtils.ts @@ -1,7 +1,7 @@ import isDevelopment from '#is-development' import { builtInActions } from './actions.js' import { getActorScopeParent, isLazyActorScope, withActorScope, withActorSelfAndParent } from './actorScope.js' -import { STATE_DELIMITER, STATE_IDENTIFIER, XSTATE_INIT, XSTATE_STOP, XSTATE_TIMER } from './constants.js' +import { STATE_DELIMITER, XSTATE_INIT, XSTATE_STOP, XSTATE_TIMER } from './constants.js' import { parseDurationToMilliseconds } from './delay.js' import { createAfterEvent, @@ -30,6 +30,15 @@ import { mergeContextPatch, resolveActionsWithContext, } from './transitionActions.js' +import { + admitCandidate, + type CandidateDecision, + isAdmitted, + isStateId, + selectTransition, + type TransitionFunctionOutcome, + type TransitionSelection, +} from './transitionGuards.js' import type { AnyAction, AnyActor, @@ -49,7 +58,7 @@ import type { StateValue, TransitionDefinition, } from './types.js' -import { getEventOutput, isErrorEvent, matchesEvent, matchesEventDescriptor } from './utils.js' +import { getEventOutput, isErrorEvent, matchesEventDescriptor } from './utils.js' import { normalizeTarget, resolveOutput, resolveReferencedActor, toArray, toTransitionConfigArray } from './utils.js' type AnyStateNodeIterable = Iterable @@ -251,8 +260,6 @@ export function isInFinalState( return stateNode.type === 'final' } -export const isStateId = (str: string) => str[0] === STATE_IDENTIFIER - function getLegacyEventType(event: EventObject): string | undefined { switch (event.type) { case 'xstate.done.actor': @@ -954,6 +961,15 @@ export type TransitionSelectionResult = { reusable: boolean } +const selectionResultOf = ( + selection: TransitionSelection, +): TransitionSelectionResult => + selection.type === 'value' + ? { enabled: true, result: selection.result, reusable: true } + : selection.type === 'effect' + ? { enabled: true, result: undefined, reusable: false } + : { enabled: false, result: undefined, reusable: true } + export type TransitionSelectionResults = Map< AnyTransitionDefinition, TransitionSelectionResult @@ -2725,15 +2741,17 @@ export function hasEffect( actorScope: AnyActorScope, ): boolean { if (transition.to) { - return evaluateTransitionFunction( - transition.to, - context, - event, - snapshot, - actorScope, - snapshot.machine.sources, - transition.source.id, - ).enabled + return selectTransition( + evaluateTransitionOutcome( + transition.to, + context, + event, + snapshot, + actorScope, + snapshot.machine.sources, + transition.source.id, + ), + ).type !== 'absent' } return false @@ -2788,7 +2806,7 @@ function getTransitionEffectEnqueue() { )) } -function evaluateTransitionFunction( +function evaluateTransitionOutcome( transitionTo: NonNullable, context: MachineContext, event: EventObject, @@ -2796,7 +2814,7 @@ function evaluateTransitionFunction( actorScope: AnyActorScope, sources: AnyMachineSnapshot['machine']['sources'], sourceId: string, -): TransitionSelectionResult { +): TransitionFunctionOutcome { let res const parent = getActorScopeParent(actorScope) if (parent) { @@ -2825,7 +2843,7 @@ function evaluateTransitionFunction( ) } catch (err) { if (err === transitionEffectSignal) { - return { enabled: true, result: undefined, reusable: false } + return { type: 'effectEnqueued' } } throw err } finally { @@ -2836,7 +2854,7 @@ function evaluateTransitionFunction( } assertSyncTransitionResult(res, event, sourceId) - return { enabled: res !== undefined, result: res, reusable: true } + return { type: 'returned', value: res } } function stopChildren( @@ -2897,7 +2915,15 @@ function selectEventlessTransitions( } for (const transition of stateNode.always) { if ( - evaluateCandidate(transition, event, snapshot, stateNode, actorScope) + isAdmitted( + admitTransitionCandidate( + transition, + event, + snapshot, + stateNode, + actorScope, + ), + ) ) { enabledTransitionSet.add(transition) break loop @@ -2914,55 +2940,58 @@ function selectEventlessTransitions( ) } -export function evaluateCandidate( +export function admitTransitionCandidate( candidate: AnyTransitionDefinition, event: EventObject, snapshot: AnyMachineSnapshot, stateNode: AnyStateNode, actorScope: AnyActorScope, selectionResults?: TransitionSelectionResults, -): boolean { - if (candidate.matches && !matchesEvent(event, candidate.matches)) { - return false - } - - if (candidate._eventMatcher && !candidate._eventMatcher(event, snapshot)) { - return false - } - - if (candidate.guard) { - const guardArgs = withActorSelfAndParent( - { - context: snapshot.context, - event, - output: getEventOutput(event), - children: snapshot.children, - actions: stateNode.machine.sources.actions, - actors: stateNode.machine.sources.actors, - guards: stateNode.machine.sources.guards, - delays: stateNode.machine.sources.delays, - _snapshot: snapshot, - }, - actorScope, - ) - if (!(candidate.guard as (args: typeof guardArgs) => boolean)(guardArgs)) { - return false - } - } +): CandidateDecision { + const transitionTo = candidate.to + const decision = admitCandidate({ + event, + snapshot, + matches: candidate.matches, + eventMatcher: candidate._eventMatcher, + runGuard: candidate.guard + ? () => { + const guardArgs = withActorSelfAndParent( + { + context: snapshot.context, + event, + output: getEventOutput(event), + children: snapshot.children, + actions: stateNode.machine.sources.actions, + actors: stateNode.machine.sources.actors, + guards: stateNode.machine.sources.guards, + delays: stateNode.machine.sources.delays, + _snapshot: snapshot, + }, + actorScope, + ) + return !!(candidate.guard as (args: typeof guardArgs) => boolean)( + guardArgs, + ) + } + : undefined, + runTransitionFunction: transitionTo === undefined + ? undefined + : () => + evaluateTransitionOutcome( + transitionTo, + snapshot.context, + event, + snapshot, + actorScope, + stateNode.machine.sources, + candidate.source.id, + ), + }) - if (candidate.to) { - const evaluation = evaluateTransitionFunction( - candidate.to, - snapshot.context, - event, - snapshot, - actorScope, - stateNode.machine.sources, - candidate.source.id, - ) - selectionResults?.set(candidate, evaluation) - return evaluation.enabled + if (decision.type === 'enabled' || decision.type === 'disabled') { + selectionResults?.set(candidate, selectionResultOf(decision.selection)) } - return true + return decision } diff --git a/packages/xstate/src/transitionGuards.ts b/packages/xstate/src/transitionGuards.ts new file mode 100644 index 0000000..2011719 --- /dev/null +++ b/packages/xstate/src/transitionGuards.ts @@ -0,0 +1,164 @@ +import { STATE_IDENTIFIER } from './constants.js' +import type { AnyEventObject, AnyMachineSnapshot, EventObject, EventPayloadPattern, StateValue } from './types.js' + +export const isStateId = (str: string): boolean => str.startsWith(STATE_IDENTIFIER) + +const isStateIdValue = (stateValue: StateValue): stateValue is string => + typeof stateValue === 'string' && isStateId(stateValue) + +const stateInSnapshot = ( + snapshot: AnyMachineSnapshot, + stateValue: StateValue, +): boolean => { + if (isStateIdValue(stateValue)) { + const target = snapshot.machine.getStateNodeById(stateValue) + return snapshot.nodes.some((node) => node === target) + } + + return snapshot.matches(stateValue) +} + +/** @public */ +export function checkStateIn( + stateValue: StateValue, +): (snapshot: AnyMachineSnapshot) => boolean +export function checkStateIn( + snapshot: AnyMachineSnapshot, + stateValue: StateValue, +): boolean +export function checkStateIn( + ...args: + | readonly [stateValue: StateValue] + | readonly [snapshot: AnyMachineSnapshot, stateValue: StateValue] +): boolean | ((snapshot: AnyMachineSnapshot) => boolean) { + if (args.length === 1) { + const [stateValue] = args + return (snapshot: AnyMachineSnapshot) => stateInSnapshot(snapshot, stateValue) + } + return stateInSnapshot(args[0], args[1]) +} + +const matchesEvent = ( + event: EventObject, + pattern: EventPayloadPattern, +): boolean => Object.entries(pattern).every(([key, value]) => Object.is(Reflect.get(event, key), value)) + +export type TransitionFunctionOutcome = + | { readonly type: 'returned'; readonly value: Result | undefined } + | { readonly type: 'effectEnqueued' } + +export type TransitionSelection = + | { readonly type: 'value'; readonly result: Result } + | { readonly type: 'effect' } + | { readonly type: 'absent' } + +export const selectTransition = ( + outcome: TransitionFunctionOutcome, +): TransitionSelection => + outcome.type === 'returned' + ? selectionOfReturned(outcome.value) + : { type: 'effect' } + +const selectionOfReturned = ( + value: Result | undefined, +): TransitionSelection => value === undefined ? { type: 'absent' } : { type: 'value', result: value } + +export type SessionEventMatcher = ( + event: EventObject, + snapshot: AnyMachineSnapshot, +) => boolean + +export interface CandidateAdmissionInput { + readonly event: EventObject + readonly snapshot: AnyMachineSnapshot + readonly matches: EventPayloadPattern | undefined + readonly eventMatcher: SessionEventMatcher | undefined + readonly runGuard: (() => boolean) | undefined + readonly runTransitionFunction: + | (() => TransitionFunctionOutcome) + | undefined +} + +export type CandidateDecision = + | { readonly type: 'rejected' } + | { readonly type: 'admitted' } + | { readonly type: 'enabled'; readonly selection: TransitionSelection } + | { readonly type: 'disabled'; readonly selection: TransitionSelection } + +const admitsByDecisionType: Readonly< + Record['type'], boolean> +> = { + rejected: false, + admitted: true, + enabled: true, + disabled: false, +} + +export const isAdmitted = ( + decision: CandidateDecision, +): boolean => admitsByDecisionType[decision.type] + +const rejectedDecision: CandidateDecision = { type: 'rejected' } +const admittedDecision: CandidateDecision = { type: 'admitted' } + +const patternAccepts = ( + input: CandidateAdmissionInput, +): boolean => input.matches === undefined || matchesEvent(input.event, input.matches) + +const sessionAccepts = ( + input: CandidateAdmissionInput, +): boolean => + input.eventMatcher === undefined || + input.eventMatcher(input.event, input.snapshot) + +const guardAccepts = ( + input: CandidateAdmissionInput, +): boolean => input.runGuard === undefined || input.runGuard() + +const sessionAndGuardAccept = ( + input: CandidateAdmissionInput, +): boolean => sessionAccepts(input) && guardAccepts(input) + +const checksAccept = ( + input: CandidateAdmissionInput, +): boolean => patternAccepts(input) && sessionAndGuardAccept(input) + +const selectionDecision = ( + selection: TransitionSelection, +): CandidateDecision => + selection.type === 'absent' + ? { type: 'disabled', selection } + : { type: 'enabled', selection } + +const transitionDecision = ( + input: CandidateAdmissionInput, +): CandidateDecision => + input.runTransitionFunction === undefined + ? admittedDecision + : selectionDecision(selectTransition(input.runTransitionFunction())) + +export const admitCandidate = ( + input: CandidateAdmissionInput, +): CandidateDecision => checksAccept(input) ? transitionDecision(input) : rejectedDecision + +export interface AdmittedCandidate { + readonly candidate: TCandidate + readonly decision: CandidateDecision +} + +const admittedOf = ( + admit: (candidate: TCandidate) => CandidateDecision, + candidate: TCandidate, +): AdmittedCandidate | undefined => { + const decision = admit(candidate) + return isAdmitted(decision) ? { candidate, decision } : undefined +} + +export const firstAdmittedCandidate = (input: { + readonly candidates: readonly TCandidate[] + readonly admit: (candidate: TCandidate) => CandidateDecision +}): AdmittedCandidate | undefined => + input.candidates.reduce | undefined>( + (admitted, candidate) => admitted ?? admittedOf(input.admit, candidate), + undefined, + ) diff --git a/packages/xstate/src/utils.ts b/packages/xstate/src/utils.ts index f51f837..230dc80 100644 --- a/packages/xstate/src/utils.ts +++ b/packages/xstate/src/utils.ts @@ -1,7 +1,6 @@ import isDevelopment from '#is-development' import { TARGETLESS_KEY, WILDCARD } from './constants.js' import type { StateNode } from './StateNode.js' -import { isStateId } from './stateUtils.js' import type { AnyActor, AnyEventObject, @@ -17,24 +16,10 @@ import type { Observer, OutputArg, SingleOrArray, - StateValue, TransitionConfigTarget, } from './types.js' import { defaultWarn } from './warnSink.js' -/** @public */ -export function checkStateIn( - snapshot: AnyMachineSnapshot, - stateValue: StateValue, -) { - if (typeof stateValue === 'string' && isStateId(stateValue)) { - const target = snapshot.machine.getStateNodeById(stateValue) - return snapshot.nodes.some((sn) => sn === target) - } - - return snapshot.matches(stateValue) -} - export function mapValues>( collection: O, iteratee: (item: O[keyof O], key: keyof O, collection: O, i: number) => P, @@ -292,13 +277,6 @@ export function getAllOwnEvents(snapshot: AnyMachineSnapshot) { ) } -export function matchesEvent( - event: EventObject, - pattern: Record, -): boolean { - return Object.entries(pattern).every(([key, value]) => Object.is((event as AnyEventObject)[key], value)) -} - /** * Checks if an event type matches an event descriptor, supporting wildcards. * Event descriptors can be: diff --git a/packages/xstate/stryker.config.ts b/packages/xstate/stryker.config.ts index 80eb10a..213bf9e 100644 --- a/packages/xstate/stryker.config.ts +++ b/packages/xstate/stryker.config.ts @@ -12,4 +12,5 @@ export default packageStrykerConfig([ 'src/graph/pathFromEvents.ts', 'src/graph/shortestPaths.ts', 'src/graph/simplePaths.ts', + 'src/transitionGuards.ts', ]) diff --git a/packages/xstate/test/guards.test.ts b/packages/xstate/test/guards.test.ts deleted file mode 100644 index 0e7ddea..0000000 --- a/packages/xstate/test/guards.test.ts +++ /dev/null @@ -1,973 +0,0 @@ -import { describe, expect, it } from 'vitest' -import z from 'zod' -import { createActor, createMachine, matchesState } from '../src/index.js' -import { type InferEvents } from '../src/types.v6.js' -import { trackEntries } from './utils.js' - -describe('guard conditions', () => { - function minTimeElapsed(elapsed: number) { - return elapsed >= 100 && elapsed < 200 - } - - const lightMachine = createMachine({ - schemas: { - input: z.object({ - elapsed: z.number().optional(), - }), - context: z.object({ - elapsed: z.number(), - }), - - events: { - TIMER: z.object({}), - EMERGENCY: z.object({ isEmergency: z.boolean() }), - TIMER_COND_OBJ: z.object({}), - }, - }, - context: ({ input = {} }) => ({ - elapsed: input.elapsed ?? 0, - }), - initial: 'green', - states: { - green: { - on: { - // TIMER: [ - // { - // target: 'green', - // guard: ({ context: { elapsed } }) => elapsed < 100 - // }, - // { - // target: 'yellow', - // guard: ({ context: { elapsed } }) => - // elapsed >= 100 && elapsed < 200 - // } - // ], - TIMER: ({ context: { elapsed } }) => { - if (elapsed < 100) { - return { target: 'green' } - } - if (elapsed >= 100 && elapsed < 200) { - return { target: 'yellow' } - } - return undefined - }, - // EMERGENCY: { - // target: 'red', - // guard: ({ event }) => !!event.isEmergency - // } - EMERGENCY: ({ event }) => { - if (event.isEmergency) { - return { target: 'red' } - } - return undefined - }, - }, - }, - yellow: { - on: { - // TIMER: { - // target: 'red', - // guard: 'minTimeElapsed' - // }, - TIMER: ({ context: { elapsed } }) => { - if (minTimeElapsed(elapsed)) { - return { target: 'red' } - } - return undefined - }, - // TIMER_COND_OBJ: { - // target: 'red', - // guard: { - // type: 'minTimeElapsed' - // } - // } - TIMER_COND_OBJ: ({ context: { elapsed } }) => { - if (minTimeElapsed(elapsed)) { - return { target: 'red' } - } - return undefined - }, - }, - }, - red: { - on: { - // BAD_COND: { - // target: 'red', - // guard: 'doesNotExist' - // } - }, - }, - }, - }) - - it('should transition only if condition is met', () => { - const actorRef1 = createActor(lightMachine, { - input: { elapsed: 50 }, - }).start() - actorRef1.send({ type: 'TIMER' }) - expect(actorRef1.getSnapshot().value).toEqual('green') - - const actorRef2 = createActor(lightMachine, { - input: { elapsed: 120 }, - }).start() - actorRef2.send({ type: 'TIMER' }) - expect(actorRef2.getSnapshot().value).toEqual('yellow') - }) - - it('should transition if condition based on event is met', () => { - const actorRef = createActor(lightMachine, { input: {} }).start() - actorRef.send({ - type: 'EMERGENCY', - isEmergency: true, - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should not transition if condition based on event is not met', () => { - const actorRef = createActor(lightMachine, { input: {} }).start() - actorRef.send({ - type: 'EMERGENCY', - isEmergency: false, - }) - expect(actorRef.getSnapshot().value).toEqual('green') - }) - - it('should not transition if no condition is met', () => { - const machine = createMachine({ - schemas: { - events: { - TIMER: z.object({ elapsed: z.number() }), - }, - }, - initial: 'a', - states: { - a: { - on: { - TIMER: ({ event: { elapsed } }) => { - if (elapsed > 200) { - return { target: 'b' } - } - if (elapsed > 100) { - return { target: 'c' } - } - return undefined - }, - }, - }, - b: {}, - c: {}, - }, - }) - - const flushTracked = trackEntries(machine) - const actor = createActor(machine).start() - flushTracked() - - actor.send({ type: 'TIMER', elapsed: 10 }) - - expect(actor.getSnapshot().value).toBe('a') - expect(flushTracked()).toEqual([]) - }) - - it('should work with defined string transitions', () => { - const actorRef = createActor(lightMachine, { - input: { elapsed: 120 }, - }).start() - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('yellow') - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should work with guard objects', () => { - const actorRef = createActor(lightMachine, { - input: { elapsed: 150 }, - }).start() - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('yellow') - actorRef.send({ - type: 'TIMER_COND_OBJ', - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should work with defined string transitions (condition not met)', () => { - const minTimeElapsed = (elapsed: number) => elapsed >= 100 && elapsed < 200 - - const machine = createMachine({ - // types: {} as { context: LightMachineCtx; events: LightMachineEvents }, - schemas: { - context: z.object({ - elapsed: z.number(), - }), - events: { - TIMER: z.object({}), - EMERGENCY: z.object({ isEmergency: z.boolean() }), - }, - }, - context: { - elapsed: 10, - }, - initial: 'yellow', - states: { - green: { - on: { - // TIMER: [ - // { - // target: 'green', - // guard: ({ context: { elapsed } }) => elapsed < 100 - // }, - // { - // target: 'yellow', - // guard: ({ context: { elapsed } }) => - // elapsed >= 100 && elapsed < 200 - // } - // ], - TIMER: ({ context: { elapsed } }) => { - if (elapsed < 100) { - return { target: 'green' } - } - if (elapsed >= 100 && elapsed < 200) { - return { target: 'yellow' } - } - return undefined - }, - // EMERGENCY: { - // target: 'red', - // guard: ({ event }) => !!event.isEmergency - // } - EMERGENCY: ({ event }) => { - if (event.isEmergency) { - return { target: 'red' } - } - return undefined - }, - }, - }, - yellow: { - on: { - // TIMER: { - // target: 'red', - // guard: 'minTimeElapsed' - // } - TIMER: ({ context: { elapsed } }) => { - if (minTimeElapsed(elapsed)) { - return { target: 'red' } - } - return undefined - }, - }, - }, - red: {}, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ - type: 'TIMER', - }) - - expect(actorRef.getSnapshot().value).toEqual('yellow') - }) - - it('should guard against transition', () => { - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A2', - states: { - A0: {}, - A2: {}, - }, - }, - B: { - initial: 'B0', - states: { - B0: { - // always: [ - // { - // target: 'B4', - // guard: () => false - // } - // ], - always: () => { - if (1 + 1 !== 2) { - return { target: 'B4' } - } - return undefined - }, - on: { - // T1: [ - // { - // target: 'B1', - // guard: () => false - // } - // ] - T1: () => { - if (1 + 1 !== 2) { - return { target: 'B1' } - } - return undefined - }, - }, - }, - B1: {}, - B4: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'T1' }) - - expect(actorRef.getSnapshot().value).toEqual({ - A: 'A2', - B: 'B0', - }) - }) - - it('should allow a matching transition', () => { - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A2', - states: { - A0: {}, - A2: {}, - }, - }, - B: { - initial: 'B0', - states: { - B0: { - // always: [ - // { - // target: 'B4', - // guard: () => false - // } - // ], - always: () => { - if (1 + 1 !== 2) { - return { target: 'B4' } - } - return undefined - }, - on: { - // T2: [ - // { - // target: 'B2', - // guard: stateIn('A.A2') - // } - // ] - T2: ({ value }) => { - if (matchesState('A.A2', value)) { - return { target: 'B2' } - } - return undefined - }, - }, - }, - B1: {}, - B2: {}, - B4: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'T2' }) - - expect(actorRef.getSnapshot().value).toEqual({ - A: 'A2', - B: 'B2', - }) - }) - - it('should check guards with interim states', () => { - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A2', - states: { - A2: { - on: { - A: { target: 'A3' }, - }, - }, - A3: { - always: { target: 'A4' }, - }, - A4: { - always: { target: 'A5' }, - }, - A5: {}, - }, - }, - B: { - initial: 'B0', - states: { - B0: { - // always: [ - // { - // target: 'B4', - // guard: stateIn('A.A4') - // } - // ] - always: ({ value }) => { - if (matchesState('A.A4', value)) { - return { target: 'B4' } - } - return undefined - }, - }, - B4: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'A' }) - - expect(actorRef.getSnapshot().value).toEqual({ - A: 'A5', - B: 'B4', - }) - }) -}) - -describe('[function] guard conditions', () => { - const minTimeElapsed = (elapsed: number) => elapsed >= 100 && elapsed < 200 - - const lightMachine = createMachine({ - // types: {} as { - // input: { elapsed?: number }; - // context: LightMachineCtx; - // events: LightMachineEvents; - // }, - schemas: { - input: z.object({ - elapsed: z.number().optional(), - }), - context: z.object({ - elapsed: z.number(), - }), - events: { - TIMER: z.object({}), - TIMER_COND_OBJ: z.object({}), - EMERGENCY: z.object({ isEmergency: z.boolean() }), - }, - }, - context: ({ input = {} }) => ({ - elapsed: input.elapsed ?? 0, - }), - initial: 'green', - states: { - green: { - on: { - TIMER: ({ context }) => { - if (context.elapsed < 100) { - return { target: 'green' } - } - if (context.elapsed >= 100 && context.elapsed < 200) { - return { target: 'yellow' } - } - return undefined - }, - EMERGENCY: ({ event }) => event.isEmergency ? { target: 'red' } : undefined, - }, - }, - yellow: { - on: { - TIMER: ({ context }) => minTimeElapsed(context.elapsed) ? { target: 'red' } : undefined, - - TIMER_COND_OBJ: ({ context }) => minTimeElapsed(context.elapsed) ? { target: 'red' } : undefined, - }, - }, - red: {}, - }, - }) - - it('should transition only if condition is met', () => { - const actorRef1 = createActor(lightMachine, { - input: { elapsed: 50 }, - }).start() - actorRef1.send({ type: 'TIMER' }) - expect(actorRef1.getSnapshot().value).toEqual('green') - - const actorRef2 = createActor(lightMachine, { - input: { elapsed: 120 }, - }).start() - actorRef2.send({ type: 'TIMER' }) - expect(actorRef2.getSnapshot().value).toEqual('yellow') - }) - - it('should transition if condition based on event is met', () => { - const actorRef = createActor(lightMachine, { input: {} }).start() - actorRef.send({ - type: 'EMERGENCY', - isEmergency: true, - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should not transition if condition based on event is not met', () => { - const actorRef = createActor(lightMachine, { input: {} }).start() - actorRef.send({ - type: 'EMERGENCY', - isEmergency: false, - }) - expect(actorRef.getSnapshot().value).toEqual('green') - }) - - it('should not transition if no condition is met', () => { - const machine = createMachine({ - schemas: { - events: { - TIMER: z.object({ elapsed: z.number() }), - }, - }, - initial: 'a', - states: { - a: { - on: { - TIMER: ({ event }) => ({ - ...(event.elapsed > 200 - ? { target: 'b' } - : event.elapsed > 100 - ? { target: 'c' } - : {}), - }), - }, - }, - b: {}, - c: {}, - }, - }) - - const flushTracked = trackEntries(machine) - const actor = createActor(machine).start() - flushTracked() - - actor.send({ type: 'TIMER', elapsed: 10 }) - - expect(actor.getSnapshot().value).toBe('a') - expect(flushTracked()).toEqual([]) - }) - - it('should work with defined string transitions', () => { - const actorRef = createActor(lightMachine, { - input: { elapsed: 120 }, - }).start() - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('yellow') - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should work with guard objects', () => { - const actorRef = createActor(lightMachine, { - input: { elapsed: 150 }, - }).start() - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('yellow') - actorRef.send({ - type: 'TIMER_COND_OBJ', - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should work with defined string transitions (condition not met)', () => { - const machine = createMachine({ - // types: {} as { context: LightMachineCtx; events: LightMachineEvents }, - schemas: { - context: z.object({ - elapsed: z.number(), - }), - events: { - TIMER: z.object({}), - EMERGENCY: z.object({ isEmergency: z.boolean() }), - }, - }, - context: { - elapsed: 10, - }, - initial: 'yellow', - states: { - green: { - on: { - TIMER: ({ context }) => ({ - ...(context.elapsed < 100 - ? { target: 'green' } - : context.elapsed >= 100 && context.elapsed < 200 - ? { target: 'yellow' } - : {}), - }), - EMERGENCY: ({ event }) => ({ - ...(event.isEmergency ? { target: 'red' } : {}), - }), - }, - }, - yellow: { - on: { - TIMER: ({ context }) => ({ - ...(minTimeElapsed(context.elapsed) ? { target: 'red' } : {}), - }), - }, - }, - red: {}, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ - type: 'TIMER', - }) - - expect(actorRef.getSnapshot().value).toEqual('yellow') - }) - - it.skip('should allow a matching transition', () => { - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A2', - states: { - A0: {}, - A2: {}, - }, - }, - B: { - initial: 'B0', - states: { - B0: { - // always: [ - // { - // target: 'B4', - // guard: () => false - // } - // ], - always: () => { - if (1 + 1 !== 2) { - return { target: 'B4' } - } - return undefined - }, - on: { - // T2: [ - // { - // target: 'B2', - // guard: stateIn('A.A2') - // } - // ] - T2: ({ value }) => { - if (matchesState('A.A2', value)) { - return { target: 'B2' } - } - return undefined - }, - }, - }, - B1: {}, - B2: {}, - B4: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'T2' }) - - expect(actorRef.getSnapshot().value).toEqual({ - A: 'A2', - B: 'B2', - }) - }) - - it.skip('should check guards with interim states', () => { - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A2', - states: { - A2: { - on: { - A: { target: 'A3' }, - }, - }, - A3: { - always: { target: 'A4' }, - }, - A4: { - always: { target: 'A5' }, - }, - A5: {}, - }, - }, - B: { - initial: 'B0', - states: { - B0: { - // always: [ - // { - // target: 'B4', - // guard: stateIn('A.A4') - // } - // ] - always: ({ value }) => { - if (matchesState('A.A4', value)) { - return { target: 'B4' } - } - return undefined - }, - }, - B4: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'A' }) - - expect(actorRef.getSnapshot().value).toEqual({ - A: 'A5', - B: 'B4', - }) - }) -}) - -describe('custom guards', () => { - it('should evaluate custom guards', () => { - const contextSchema = z.object({ - count: z.number(), - }) - const eventSchema = { - EVENT: z.object({ value: z.number() }), - } - - function customGuard( - context: z.infer, - event: InferEvents, - params: { - prop: keyof z.infer - op: 'greaterThan' - compare: number - }, - ) { - const { prop, compare, op } = params - if (op === 'greaterThan') { - return context[prop] + event.value > compare - } - - return false - } - const machine = createMachine({ - // types: {} as { - // context: Ctx; - // events: Events; - // guards: { - // type: 'custom'; - // params: { - // prop: keyof Ctx; - // op: 'greaterThan'; - // compare: number; - // }; - // }; - // }, - schemas: { - context: contextSchema, - events: eventSchema, - }, - initial: 'inactive', - context: { - count: 0, - }, - states: { - inactive: { - on: { - // EVENT: { - // target: 'active', - // guard: { - // type: 'custom', - // params: { prop: 'count', op: 'greaterThan', compare: 3 } - // } - // } - EVENT: ({ context, event }) => { - if ( - customGuard(context, event, { - prop: 'count', - op: 'greaterThan', - compare: 3, - }) - ) { - return { target: 'active' } - } - return undefined - }, - }, - }, - active: {}, - }, - }) - - const actorRef1 = createActor(machine).start() - actorRef1.send({ type: 'EVENT', value: 4 }) - const passState = actorRef1.getSnapshot() - - expect(passState.value).toEqual('active') - - const actorRef2 = createActor(machine).start() - actorRef2.send({ type: 'EVENT', value: 3 }) - const failState = actorRef2.getSnapshot() - - expect(failState.value).toEqual('inactive') - }) -}) - -describe('guards - other', () => { - it('should allow for a fallback target to be a simple string', () => { - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { - // EVENT: [{ target: 'b', guard: () => false }, 'c'] - EVENT: () => { - if (1 + 1 !== 2) { - return { target: 'b' } - } - return { target: 'c' } - }, - }, - }, - b: {}, - c: {}, - }, - }) - - const actor = createActor(machine).start() - actor.send({ type: 'EVENT' }) - - expect(actor.getSnapshot().value).toBe('c') - }) -}) - -describe('guards - unknown references', () => { - it('should throw on a guard reference that is not implemented', () => { - const machine = createMachine({ - context: { ready: false }, - guards: { - isReady: (ready: boolean) => ready === true, - }, - initial: 'routing', - states: { - routing: { - always: ({ context, guards }) => { - if ( - guards - // @ts-expect-error - .isRedy(context.ready) - ) { - return { target: 'go' } - } - return { target: 'wait' } - }, - }, - go: {}, - wait: {}, - }, - }) - - const actor = createActor(machine) - actor.subscribe({ error: () => {} }) - actor.start() - - const snapshot = actor.getSnapshot() - expect(snapshot.status).toBe('error') - expect((snapshot as any).error.message).toMatch( - /guards.isRedy is not a function/, - ) - }) -}) - -describe('guards - plain function sources', () => { - it('passes only the caller-supplied params to the source', () => { - const received: unknown[][] = [] - const machine = createMachine({ - context: { count: 5 }, - guards: { - isAbove: (count: number, threshold: number) => { - received.push([count, threshold]) - return count > threshold - }, - }, - initial: 'a', - states: { - a: { - on: { - EV: ({ context, guards }) => { - if (guards.isAbove(context.count, 3)) { - return { target: 'b' } - } - return undefined - }, - }, - }, - b: {}, - }, - }) - - const actor = createActor(machine).start() - actor.send({ type: 'EV' }) - - expect(actor.getSnapshot().value).toBe('b') - expect(received).toEqual([[5, 3]]) - }) - - it('supports zero-param guards', () => { - const machine = createMachine({ - guards: { - isEnabled: () => true, - }, - initial: 'a', - states: { - a: { - on: { - EV: ({ guards }) => { - if (guards.isEnabled()) { - return { target: 'b' } - } - return undefined - }, - }, - }, - b: {}, - }, - }) - - const actor = createActor(machine).start() - actor.send({ type: 'EV' }) - expect(actor.getSnapshot().value).toBe('b') - }) -}) diff --git a/packages/xstate/test/stateIn.test.ts b/packages/xstate/test/stateIn.test.ts deleted file mode 100644 index 7956daa..0000000 --- a/packages/xstate/test/stateIn.test.ts +++ /dev/null @@ -1,552 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { checkStateIn, createActor, createMachine, matchesState, type StateValue } from '../src/index.js' - -describe('transition "in" check', () => { - it('should transition if string state path matches current state value', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: { - on: { - EVENT2: ({ value }) => { - if (matchesState({ b: 'b2' }, value)) { - return { target: 'a2' } - } - return undefined - }, - }, - }, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b2', - states: { - b1: { - on: { - // EVENT: { - // target: 'b2', - // guard: stateIn('#a_a2') - // } - }, - }, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo2', - states: { - foo1: {}, - foo2: {}, - }, - }, - bar: { - initial: 'bar1', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT2' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a2', - b: { - b2: { - foo: 'foo2', - bar: 'bar1', - }, - }, - }) - }) - - it('should transition if state node ID matches current state value', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: { - on: { - // EVENT3: { - // target: 'a2', - // guard: stateIn('#b_b2') - // } - EVENT3: ({ self }) => { - if (checkStateIn(self.getSnapshot(), '#b_b2')) { - return { target: 'a2' } - } - return undefined - // if (matchesState('#b_b2', value)) { - // return { target: 'a2' }; - // } - }, - }, - }, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b2', - states: { - b1: {}, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo2', - states: { - foo1: {}, - foo2: {}, - }, - }, - bar: { - initial: 'bar1', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT3' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a2', - b: { - b2: { - foo: 'foo2', - bar: 'bar1', - }, - }, - }) - }) - - it('should not transition if string state path does not match current state value', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: { - on: { - // EVENT1: { - // target: 'a2', - // guard: stateIn('b.b2') - // } - EVENT1: ({ value }) => { - if (matchesState('b.b2', value)) { - return { target: 'a2' } - } - return undefined - // if (checkStateIn(self.getSnapshot(), 'b.b2')) { - // return { target: 'a2' }; - // } - }, - }, - }, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b1', - states: { - b1: {}, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo1', - states: { - foo1: {}, - foo2: {}, - }, - }, - bar: { - initial: 'bar1', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT1' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a1', - b: 'b1', - }) - }) - - it('should not transition if state value matches current state value', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: { - on: { - // EVENT2: { - // target: 'a2', - // guard: stateIn({ b: 'b2' }) - // } - EVENT2: ({ value }) => { - if (matchesState({ b: 'b2' }, value)) { - return { target: 'a2' } - } - return undefined - }, - }, - }, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b2', - states: { - b1: {}, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo2', - states: { - foo1: {}, - foo2: {}, - }, - }, - bar: { - initial: 'bar1', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT2' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a2', - b: { - b2: { - foo: 'foo2', - bar: 'bar1', - }, - }, - }) - }) - - it('matching should be relative to grandparent (match)', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: {}, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b2', - states: { - b1: {}, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo1', - states: { - foo1: { - on: { - EVENT_DEEP: ({ self }) => { - if (checkStateIn(self.getSnapshot(), '#bar1')) { - return { target: 'foo2' } - } - return undefined - }, - }, - }, - foo2: {}, - }, - }, - bar: { - initial: 'bar1', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT_DEEP' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a1', - b: { - b2: { - foo: 'foo2', - bar: 'bar1', - }, - }, - }) - }) - - it('matching should be relative to grandparent (no match)', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: {}, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b2', - states: { - b1: {}, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo1', - states: { - foo1: { - on: { - EVENT_DEEP: ({ self }) => { - if (checkStateIn(self.getSnapshot(), '#bar1')) { - return { target: 'foo2' } - } - return undefined - }, - }, - }, - foo2: {}, - }, - }, - bar: { - initial: 'bar2', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT_DEEP' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a1', - b: { - b2: { - foo: 'foo1', - bar: 'bar2', - }, - }, - }) - }) - - it('should work to forbid events', () => { - const machine = createMachine({ - initial: 'green', - states: { - green: { on: { TIMER: { target: 'yellow' } } }, - yellow: { on: { TIMER: { target: 'red' } } }, - red: { - initial: 'walk', - states: { - walk: { - on: { TIMER: { target: 'wait' } }, - }, - wait: { - on: { TIMER: { target: 'stop' } }, - }, - stop: {}, - }, - on: { - // TIMER: [ - // { - // target: 'green', - // guard: stateIn({ red: 'stop' }) - // } - // ] - TIMER: ({ value }) => { - if (matchesState({ red: 'stop' }, value)) { - return { target: 'green' } - } - return undefined - }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'TIMER' }) - actorRef.send({ type: 'TIMER' }) - actorRef.send({ type: 'TIMER' }) - expect(actorRef.getSnapshot().value).toEqual({ red: 'wait' }) - - actorRef.send({ type: 'TIMER' }) - expect(actorRef.getSnapshot().value).toEqual({ red: 'stop' }) - - actorRef.send({ type: 'TIMER' }) - expect(actorRef.getSnapshot().value).toEqual('green') - }) - - it('should be possible to use a referenced `stateIn` guard', () => { - const machine = createMachine({ - type: 'parallel', - guards: { - // hasSelection: stateIn('selected') - hasSelection: (value: StateValue) => { - return matchesState('selected', value) - }, - }, - // machine definition, - states: { - selected: {}, - location: { - initial: 'home', - states: { - home: { - on: { - NEXT: ({ guards, value }) => { - if (guards.hasSelection(value)) { - return { - target: 'success', - } - } - return undefined - }, - }, - }, - success: {}, - }, - }, - }, - }) - - const actor = createActor(machine).start() - actor.send({ - type: 'NEXT', - }) - expect(actor.getSnapshot().value).toEqual({ - selected: {}, - location: 'success', - }) - }) - - it.skip('should be possible to check an ID with a path', () => { - const spy = vi.fn() - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A1', - states: { - A1: { - on: { - // MY_EVENT: { - // guard: stateIn('#b.B1'), - // actions: spy - // } - MY_EVENT: ({ value }, enq) => { - if (matchesState('#b.B1', value)) { - enq(spy) - } - }, - }, - }, - }, - }, - B: { - id: 'b', - initial: 'B1', - states: { - B1: {}, - }, - }, - }, - }) - - createActor(machine).start().send({ - type: 'MY_EVENT', - }) - - expect(spy).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/xstate/tests/__fixtures__/guards.model.ts b/packages/xstate/tests/__fixtures__/guards.model.ts new file mode 100644 index 0000000..c188ef8 --- /dev/null +++ b/packages/xstate/tests/__fixtures__/guards.model.ts @@ -0,0 +1,430 @@ +import { Match, Schema } from 'effect' + +export const NODE_KEYS = ['root', 'L', 'R', 'p', 'c1', 'c2', 'q', 'r1', 'r2'] as const +export type NodeKey = (typeof NODE_KEYS)[number] + +const IN_PATHS = ['L.p.c1', 'L.p.c2', 'L.q', 'R.r1', 'R.r2'] as const +type InPath = (typeof IN_PATHS)[number] + +export type SlotTag = 'Absent' | 'Target' | 'Pattern' | 'Fn' + +export const GuardsValueSchema = Schema.Struct({ + L: Schema.Union([Schema.Literal('q'), Schema.Struct({ p: Schema.Literals(['c1', 'c2']) })]), + R: Schema.Literals(['r1', 'r2']), +}) +export type GuardsValue = Schema.Schema.Type + +export const GuardsContextSchema = Schema.Struct({ n: Schema.Finite }) + +export interface GuardsContext { + readonly n: number +} + +export interface GuardsEffectLike { + readonly type: string +} + +export interface GuardsEffect extends GuardsEffectLike { + readonly type: 'effect' + readonly at: NodeKey +} + +interface Sim { + readonly value: GuardsValue + readonly n: number +} + +const INITIAL_SIM: Sim = { value: { L: { p: 'c1' }, R: 'r1' }, n: 0 } + +const Bump = Schema.Literals([0, 1]) +const Zero = Schema.Literals([0]) +const K = Schema.Literals([0, 1, 2, 3]) +const T = Schema.Literals([1, 2, 3]) +const AllTargets = Schema.Literals(['#c1', '#c2', '#p', '#q', '#r1', '#r2']) +const LTargets = Schema.Literals(['#c1', '#c2', '#p', '#q']) +const RTargets = Schema.Literals(['#r1', '#r2']) +const C2Targets = Schema.Literals(['#c1', '#q']) + +const Evaluator = Schema.Union([ + Schema.TaggedStruct('Always', {}), + Schema.TaggedStruct('Never', {}), + Schema.TaggedStruct('ContextAtLeast', { t: T }), + Schema.TaggedStruct('PayloadAtLeast', { t: T }), + Schema.TaggedStruct('Named', { t: T }), + Schema.TaggedStruct('In', { path: Schema.Literals(IN_PATHS) }), +]) +export type Evaluator = Schema.Schema.Type + +const StayBump = Schema.TaggedStruct('Stay', { bump: Bump }) +const StayZero = Schema.TaggedStruct('Stay', { bump: Zero }) +const EffectOutcome = Schema.TaggedStruct('Effect', {}) +const GoAll = Schema.TaggedStruct('Go', { target: AllTargets, bump: Bump }) +const GoL = Schema.TaggedStruct('Go', { target: LTargets, bump: Bump }) +const GoR = Schema.TaggedStruct('Go', { target: RTargets, bump: Zero }) +const GoC2 = Schema.TaggedStruct('Go', { target: C2Targets, bump: Zero }) + +export const Slot = Schema.Union([ + Schema.TaggedStruct('Absent', {}), + Schema.TaggedStruct('Target', { target: AllTargets }), + Schema.TaggedStruct('Pattern', { k: K, target: AllTargets }), + Schema.TaggedStruct('Fn', { when: Evaluator, outcome: Schema.Union([GoAll, StayBump, EffectOutcome]) }), +]) +export type Slot = Schema.Schema.Type + +const Absent = Schema.TaggedStruct('Absent', {}) +const TargetL = Schema.TaggedStruct('Target', { target: LTargets }) +const PatternL = Schema.TaggedStruct('Pattern', { k: K, target: LTargets }) +const FnL = Schema.TaggedStruct('Fn', { when: Evaluator, outcome: Schema.Union([GoL, StayBump, EffectOutcome]) }) +const LGoSlot = Schema.Union([Absent, TargetL, PatternL, FnL]) +const LTickSlot = Schema.Union([Absent, TargetL, FnL]) + +const TargetR = Schema.TaggedStruct('Target', { target: RTargets }) +const PatternR = Schema.TaggedStruct('Pattern', { k: K, target: RTargets }) +const FnR = Schema.TaggedStruct('Fn', { when: Evaluator, outcome: Schema.Union([GoR, StayZero, EffectOutcome]) }) +const RGoSlot = Schema.Union([Absent, TargetR, PatternR, FnR]) +const RTickSlot = Schema.Union([Absent, TargetR, FnR]) + +const FnRoot = Schema.TaggedStruct('Fn', { when: Evaluator, outcome: Schema.Union([StayBump, EffectOutcome]) }) +const RootSlot = Schema.Union([Absent, FnRoot]) + +const LSlots = Schema.Struct({ GO: LGoSlot, TICK: LTickSlot }) +const RSlots = Schema.Struct({ GO: RGoSlot, TICK: RTickSlot }) +const RootSlots = Schema.Struct({ GO: RootSlot, TICK: RootSlot }) +const C2Slots = Schema.Struct({ + GO: LGoSlot, + TICK: LTickSlot, + always: Schema.Struct({ when: Evaluator, outcome: GoC2 }), +}) +const R2Slots = Schema.Struct({ GO: RGoSlot, TICK: RTickSlot, always: Schema.Struct({ when: Evaluator }) }) + +export const MachineSpec = Schema.Struct({ + root: RootSlots, + L: LSlots, + R: RSlots, + p: LSlots, + c1: LSlots, + c2: C2Slots, + q: LSlots, + r1: RSlots, + r2: R2Slots, +}) +export type MachineSpec = Schema.Schema.Type + +const Driver = Schema.Literals(['actor', 'pure']) +export type Driver = Schema.Schema.Type + +const Factory = Schema.Literals(['createMachine', 'setup', 'provide']) +export type Factory = Schema.Schema.Type + +const ModelEvent = Schema.Union([ + Schema.Struct({ type: Schema.Literal('GO'), k: K }), + Schema.Struct({ type: Schema.Literal('TICK') }), +]) +export type ModelEvent = Schema.Schema.Type + +export const GuardsCommand = Schema.TaggedStruct('Run', { + machine: MachineSpec, + events: Schema.Array(ModelEvent).check(Schema.isMinLength(1), Schema.isMaxLength(6)), + driver: Driver, + factory: Factory, +}) +export type GuardsCommand = Schema.Schema.Type + +export interface GuardsStep { + readonly can: boolean + readonly effects: ReadonlyArray +} + +export interface GuardsObservation { + readonly value: GuardsValue + readonly n: number + readonly steps: ReadonlyArray + readonly activeIds: ReadonlyArray + readonly activeIdsDataLast: ReadonlyArray +} + +export interface Resolution { + readonly can: boolean + readonly effects: ReadonlyArray + readonly admitted: ReadonlyArray<{ readonly node: NodeKey; readonly kind: SlotTag }> + readonly rejected: ReadonlyArray<{ readonly node: NodeKey; readonly kind: SlotTag }> + readonly namedAsked: boolean + readonly inTrue: boolean + readonly inFalse: boolean + readonly rootAdmitted: boolean + readonly rootRejected: boolean + readonly eventlessL: boolean + readonly eventlessR: boolean + readonly crossRegionInterim: boolean + readonly after: Sim +} + +const tagOf = (slot: Slot): SlotTag => + Match.value(slot).pipe( + Match.tag('Absent', (): SlotTag => 'Absent'), + Match.tag('Target', (): SlotTag => 'Target'), + Match.tag('Pattern', (): SlotTag => 'Pattern'), + Match.tag('Fn', (): SlotTag => 'Fn'), + Match.exhaustive, + ) + +const evaluatorOf = (slot: Slot): Evaluator | undefined => + Match.value(slot).pipe( + Match.tag('Fn', (fn) => fn.when), + Match.orElse(() => undefined), + ) + +const answersTag = (evaluator: Evaluator, tag: 'Named' | 'In'): boolean => + Match.value(evaluator).pipe( + Match.tag('Named', () => tag === 'Named'), + Match.tag('In', () => tag === 'In'), + Match.orElse(() => false), + ) + +const slotOf = (spec: MachineSpec, node: NodeKey, event: ModelEvent): Slot => + event.type === 'GO' ? spec[node].GO : spec[node].TICK + +const matchesOf = (path: InPath, value: GuardsValue): boolean => + Match.value(path).pipe( + Match.when('L.p.c1', () => value.L !== 'q' && value.L.p === 'c1'), + Match.when('L.p.c2', () => value.L !== 'q' && value.L.p === 'c2'), + Match.when('L.q', () => value.L === 'q'), + Match.when('R.r1', () => value.R === 'r1'), + Match.when('R.r2', () => value.R === 'r2'), + Match.exhaustive, + ) + +const holds = (when: Evaluator, sim: Sim, event: ModelEvent | undefined): boolean => + Match.value(when).pipe( + Match.when({ _tag: 'Always' }, () => true), + Match.when({ _tag: 'Never' }, () => false), + Match.when({ _tag: 'ContextAtLeast' }, ({ t }) => sim.n >= t), + Match.when({ _tag: 'PayloadAtLeast' }, ({ t }) => event !== undefined && event.type === 'GO' && event.k >= t), + Match.when({ _tag: 'Named' }, ({ t }) => sim.n >= t), + Match.when({ _tag: 'In' }, ({ path }) => matchesOf(path, sim.value)), + Match.exhaustive, + ) + +interface Admission { + readonly target: string | undefined + readonly bump: number + readonly effect: boolean +} + +const admit = (slot: Slot, sim: Sim, event: ModelEvent): Admission | undefined => + Match.value(slot).pipe( + Match.when({ _tag: 'Absent' }, () => undefined), + Match.when({ _tag: 'Target' }, ({ target }) => ({ target, bump: 0, effect: false })), + Match.when( + { _tag: 'Pattern' }, + ({ k, target }) => event.type === 'GO' && event.k === k ? { target, bump: 0, effect: false } : undefined, + ), + Match.when( + { _tag: 'Fn' }, + ({ when, outcome }) => + holds(when, sim, event) + ? Match.value(outcome).pipe( + Match.when({ _tag: 'Go' }, ({ target, bump }) => ({ target, bump, effect: false })), + Match.when({ _tag: 'Stay' }, ({ bump }) => ({ target: undefined, bump, effect: false })), + Match.when({ _tag: 'Effect' }, () => ({ target: undefined, bump: 0, effect: true })), + Match.exhaustive, + ) + : undefined, + ), + Match.exhaustive, + ) + +const withTarget = (value: GuardsValue, target: string): GuardsValue => + Match.value(target).pipe( + Match.when('#c1', () => ({ ...value, L: { p: 'c1' as const } })), + Match.when('#c2', () => ({ ...value, L: { p: 'c2' as const } })), + Match.when('#p', () => ({ ...value, L: { p: 'c1' as const } })), + Match.when('#q', () => ({ ...value, L: 'q' as const })), + Match.when('#r1', () => ({ ...value, R: 'r1' as const })), + Match.when('#r2', () => ({ ...value, R: 'r2' as const })), + Match.orElse(() => value), + ) + +const lKey = (value: GuardsValue): string => value.L === 'q' ? 'q' : value.L.p + +const lOrder = (value: GuardsValue): ReadonlyArray => value.L === 'q' ? ['q', 'L'] : [value.L.p, 'p', 'L'] + +const rOrder = (value: GuardsValue): ReadonlyArray => [value.R, 'R'] + +interface Trace { + readonly admitted: Array<{ readonly node: NodeKey; readonly kind: SlotTag }> + readonly rejected: Array<{ readonly node: NodeKey; readonly kind: SlotTag }> + namedAsked: boolean + inTrue: boolean + inFalse: boolean +} + +const ask = ( + spec: MachineSpec, + nodes: ReadonlyArray, + sim: Sim, + event: ModelEvent, + trace: Trace, +): { readonly node: NodeKey; readonly admission: Admission } | undefined => { + for (const node of nodes) { + const slot = slotOf(spec, node, event) + const evaluator = evaluatorOf(slot) + if (evaluator !== undefined) { + if (answersTag(evaluator, 'Named')) { + trace.namedAsked = true + } + if (answersTag(evaluator, 'In')) { + if (holds(evaluator, sim, event)) { + trace.inTrue = true + } else { + trace.inFalse = true + } + } + } + const admission = admit(slot, sim, event) + if (admission === undefined) { + trace.rejected.push({ node, kind: tagOf(slot) }) + continue + } + trace.admitted.push({ node, kind: tagOf(slot) }) + return { node, admission } + } + return undefined +} + +const settle = ( + spec: MachineSpec, + sim: Sim, + event: ModelEvent, +): { readonly sim: Sim; readonly L: boolean; readonly R: boolean } => { + let value = sim.value + let l = false + let r = false + let rounds = 0 + for (;;) { + const lFires = value.L !== 'q' && value.L.p === 'c2' && + holds(spec.c2.always.when, { value, n: sim.n }, event) + const rFires = value.R === 'r2' && holds(spec.r2.always.when, { value, n: sim.n }, event) + if (!lFires && !rFires) { + return { sim: { value, n: sim.n }, L: l, R: r } + } + if (lFires) { + value = withTarget(value, spec.c2.always.outcome.target) + l = true + } + if (rFires) { + value = withTarget(value, '#r1') + r = true + } + rounds += 1 + if (rounds > 8) { + throw new Error('eventless loop did not settle') + } + } +} + +const resolveEvent = (spec: MachineSpec, sim: Sim, event: ModelEvent): Resolution => { + const trace: Trace = { admitted: [], rejected: [], namedAsked: false, inTrue: false, inFalse: false } + const L = ask(spec, lOrder(sim.value), sim, event, trace) + const R = ask(spec, rOrder(sim.value), sim, event, trace) + const regionAdmitted = L !== undefined || R !== undefined + const root = regionAdmitted ? undefined : ask(spec, ['root'], sim, event, trace) + + let value = sim.value + let n = sim.n + const effects: Array = [] + const apply = (asked: { readonly node: NodeKey; readonly admission: Admission }): void => { + if (asked.admission.target !== undefined) { + value = withTarget(value, asked.admission.target) + } + if (asked.admission.bump !== 0) { + n = (n + asked.admission.bump) % 4 + } + if (asked.admission.effect) { + effects.push(asked.node) + } + } + + if (L !== undefined) { + apply(L) + } + if (R !== undefined) { + apply(R) + } + if (root !== undefined) { + apply(root) + } + + const movedL = lKey(value) !== lKey(sim.value) + const settled = settle(spec, { value, n }, event) + + return { + can: regionAdmitted || root !== undefined, + effects, + admitted: trace.admitted, + rejected: trace.rejected, + namedAsked: trace.namedAsked, + inTrue: trace.inTrue, + inFalse: trace.inFalse, + rootAdmitted: root !== undefined, + rootRejected: !regionAdmitted && root === undefined, + eventlessL: settled.L, + eventlessR: settled.R, + crossRegionInterim: settled.R && movedL, + after: settled.sim, + } +} + +export const analyseRun = (command: GuardsCommand): ReadonlyArray => { + const resolutions: Array = [] + let sim = INITIAL_SIM + for (const event of command.events) { + const resolution = resolveEvent(command.machine, sim, event) + resolutions.push(resolution) + sim = resolution.after + } + return resolutions +} + +const activeIdsOf = (value: GuardsValue): ReadonlyArray => + NODE_KEYS.filter((key) => + Match.value(key).pipe( + Match.when('root', () => true), + Match.when('L', () => true), + Match.when('R', () => true), + Match.when('p', () => value.L !== 'q'), + Match.when('c1', () => value.L !== 'q' && value.L.p === 'c1'), + Match.when('c2', () => value.L !== 'q' && value.L.p === 'c2'), + Match.when('q', () => value.L === 'q'), + Match.when('r1', () => value.R === 'r1'), + Match.when('r2', () => value.R === 'r2'), + Match.exhaustive, + ) + ) + +const GuardsModelState = Schema.Struct({}) +type GuardsModelState = Schema.Schema.Type + +export const guardsModel = { + state: GuardsModelState, + initial: {}, + precondition: (): boolean => true, + step: (state: GuardsModelState, command: GuardsCommand): readonly [GuardsModelState, GuardsObservation] => { + const run = analyseRun(command) + const final = run.at(-1)?.after ?? INITIAL_SIM + const ids = activeIdsOf(final.value) + return [ + state, + { + value: final.value, + n: final.n, + steps: run.map((step) => ({ + can: step.can, + effects: step.effects.map((at): GuardsEffect => ({ type: 'effect', at })), + })), + activeIds: ids, + activeIdsDataLast: ids, + }, + ] + }, +} diff --git a/packages/xstate/tests/__fixtures__/guards.ts b/packages/xstate/tests/__fixtures__/guards.ts new file mode 100644 index 0000000..9bfbf70 --- /dev/null +++ b/packages/xstate/tests/__fixtures__/guards.ts @@ -0,0 +1,462 @@ +import { + type AnyMachineSnapshot, + type AnyStateMachine, + checkStateIn, + createActor, + createMachine, + initialTransition, + matchesState, + setup, + type StateValue, + transition, + types, +} from '@systemfsoftware/xstate' +import { Context, Effect, Layer, Match, Result, Schema } from 'effect' +import { + analyseRun, + type Driver, + type Evaluator, + type Factory, + type GuardsCommand, + type GuardsContext, + GuardsContextSchema, + type GuardsEffect, + type GuardsEffectLike, + type GuardsObservation, + type GuardsStep, + GuardsValueSchema, + type MachineSpec, + NODE_KEYS, + type NodeKey, + type Resolution, + type Slot, + type SlotTag, +} from './guards.model.js' + +type GuardSource = (...args: ReadonlyArray) => boolean + +const atLeast: GuardSource = (x, t) => x >= t + +const neverAtLeast: GuardSource = () => false + +type SubjectEvent = { readonly type: 'GO'; readonly k: number } | { readonly type: 'TICK' } + +interface SelectionArgs { + readonly context: GuardsContext + readonly event: SubjectEvent + readonly value: StateValue + readonly guards: Readonly> +} + +interface Enqueue { + emit(event: GuardsEffect): void +} + +interface TransitionOutcome { + readonly target?: string + readonly context?: { readonly n: number } +} + +type TransitionEntry = (args: SelectionArgs, enq: Enqueue) => TransitionOutcome | undefined + +type ObjectEntry = + | { readonly target: string; readonly matches?: { readonly k: number } } + | TransitionEntry + +type Fault = 'none' | 'rejects-as-empty' | 'drops-effect' + +const count = (flag: boolean): number => flag ? 1 : 0 + +const holdsInSubject = (when: Evaluator, args: SelectionArgs): boolean => + Match.value(when).pipe( + Match.when({ _tag: 'Always' }, () => true), + Match.when({ _tag: 'Never' }, () => false), + Match.when({ _tag: 'ContextAtLeast' }, ({ t }) => args.context.n >= t), + Match.when({ _tag: 'PayloadAtLeast' }, ({ t }) => args.event.type === 'GO' && args.event.k >= t), + Match.when({ _tag: 'Named' }, ({ t }) => { + const source = args.guards['atLeast'] + return source === undefined ? false : source(args.context.n, t) + }), + Match.when({ _tag: 'In' }, ({ path }) => matchesState(path, args.value)), + Match.exhaustive, + ) + +const outcomeOf = ( + outcome: Extract['outcome'], + node: NodeKey, + args: SelectionArgs, + enq: Enqueue, +): TransitionOutcome | undefined => + Match.value(outcome).pipe( + Match.when({ _tag: 'Go' }, ({ target, bump }) => ({ + target, + ...(bump === 0 ? {} : { context: { n: (args.context.n + bump) % 4 } }), + })), + Match.when({ _tag: 'Stay' }, ({ bump }) => bump === 0 ? {} : { context: { n: (args.context.n + bump) % 4 } }), + Match.when({ _tag: 'Effect' }, () => { + enq.emit({ type: 'effect', at: node }) + return undefined + }), + Match.exhaustive, + ) + +const dropsEffect = (outcome: Extract['outcome']): boolean => + Match.value(outcome).pipe( + Match.tag('Effect', () => true), + Match.orElse(() => false), + ) + +const transitionFn = + (slot: Extract, node: NodeKey, fault: Fault): TransitionEntry => + (args: SelectionArgs, enq: Enqueue): TransitionOutcome | undefined => { + if (!holdsInSubject(slot.when, args)) { + return fault === 'rejects-as-empty' ? {} : undefined + } + if (dropsEffect(slot.outcome) && fault === 'drops-effect') { + return undefined + } + return outcomeOf(slot.outcome, node, args, enq) + } + +const entryOf = (spec: MachineSpec, node: NodeKey, slot: Slot, fault: Fault): ObjectEntry | string | undefined => + Match.value(slot).pipe( + Match.when({ _tag: 'Absent' }, () => undefined), + Match.when({ _tag: 'Target' }, ({ target }) => target), + Match.when({ _tag: 'Pattern' }, ({ k, target }) => ({ target, matches: { k } })), + Match.when({ _tag: 'Fn' }, (fn) => transitionFn(fn, node, fault)), + Match.exhaustive, + ) + +const entryObjectOf = (spec: MachineSpec, node: NodeKey, slot: Slot, fault: Fault): ObjectEntry | undefined => + Match.value(slot).pipe( + Match.when({ _tag: 'Absent' }, () => undefined), + Match.when({ _tag: 'Target' }, ({ target }) => ({ target })), + Match.when({ _tag: 'Pattern' }, ({ k, target }) => ({ target, matches: { k } })), + Match.when({ _tag: 'Fn' }, (fn) => transitionFn(fn, node, fault)), + Match.exhaustive, + ) + +const onOf = (spec: MachineSpec, node: NodeKey, fault: Fault) => { + const go = entryOf(spec, node, spec[node].GO, fault) + const tick = entryOf(spec, node, spec[node].TICK, fault) + return { ...(go === undefined ? {} : { GO: go }), ...(tick === undefined ? {} : { TICK: tick }) } +} + +const onObjectOf = (spec: MachineSpec, node: NodeKey, fault: Fault) => { + const go = entryObjectOf(spec, node, spec[node].GO, fault) + const tick = entryObjectOf(spec, node, spec[node].TICK, fault) + return { ...(go === undefined ? {} : { GO: go }), ...(tick === undefined ? {} : { TICK: tick }) } +} + +const alwaysOf = (spec: MachineSpec): TransitionEntry => (args: SelectionArgs): TransitionOutcome | undefined => + holdsInSubject(spec.c2.always.when, args) ? { target: spec.c2.always.outcome.target } : undefined + +const alwaysOfR2 = (spec: MachineSpec): TransitionEntry => (args: SelectionArgs): TransitionOutcome | undefined => + holdsInSubject(spec.r2.always.when, args) ? { target: '#r1' } : undefined + +const configWith = ( + spec: MachineSpec, + onOfNode: (node: NodeKey) => Readonly>, +) => ({ + id: 'root', + type: 'parallel' as const, + context: { n: 0 }, + on: onOfNode('root'), + states: { + L: { + id: 'L', + type: 'compound' as const, + initial: 'p', + on: onOfNode('L'), + states: { + p: { + id: 'p', + type: 'compound' as const, + initial: 'c1', + on: onOfNode('p'), + states: { + c1: { id: 'c1', type: 'atomic' as const, on: onOfNode('c1') }, + c2: { id: 'c2', type: 'atomic' as const, on: onOfNode('c2'), always: alwaysOf(spec) }, + }, + }, + q: { id: 'q', type: 'atomic' as const, on: onOfNode('q') }, + }, + }, + R: { + id: 'R', + type: 'compound' as const, + initial: 'r1', + on: onOfNode('R'), + states: { + r1: { id: 'r1', type: 'atomic' as const, on: onOfNode('r1') }, + r2: { id: 'r2', type: 'atomic' as const, on: onOfNode('r2'), always: alwaysOfR2(spec) }, + }, + }, + }, +}) + +const configObjectWith = ( + spec: MachineSpec, + onOfNode: (node: NodeKey) => { GO?: ObjectEntry; TICK?: ObjectEntry }, +) => ({ + id: 'root', + type: 'parallel' as const, + context: { n: 0 }, + on: onOfNode('root'), + states: { + L: { + id: 'L', + type: 'compound' as const, + initial: 'p', + on: onOfNode('L'), + states: { + p: { + id: 'p', + type: 'compound' as const, + initial: 'c1', + on: onOfNode('p'), + states: { + c1: { id: 'c1', type: 'atomic' as const, on: onOfNode('c1') }, + c2: { id: 'c2', type: 'atomic' as const, on: onOfNode('c2'), always: alwaysOf(spec) }, + }, + }, + q: { id: 'q', type: 'atomic' as const, on: onOfNode('q') }, + }, + }, + R: { + id: 'R', + type: 'compound' as const, + initial: 'r1', + on: onOfNode('R'), + states: { + r1: { id: 'r1', type: 'atomic' as const, on: onOfNode('r1') }, + r2: { id: 'r2', type: 'atomic' as const, on: onOfNode('r2'), always: alwaysOfR2(spec) }, + }, + }, + }, +}) + +const configOf = (spec: MachineSpec, fault: Fault) => configWith(spec, (node) => onOf(spec, node, fault)) + +const configObjectOf = (spec: MachineSpec, fault: Fault) => + configObjectWith(spec, (node) => onObjectOf(spec, node, fault)) + +const configuredSetup = setup({ + guards: { atLeast }, + schemas: { + context: types<{ n: number }>(), + events: { GO: types<{ k: number }>(), TICK: types<{}>() }, + }, +}) + +const machineOf = (spec: MachineSpec, factory: Factory, fault: Fault): AnyStateMachine => + Match.value(factory).pipe( + Match.when('createMachine', () => createMachine({ guards: { atLeast }, ...configOf(spec, fault) })), + Match.when('setup', () => configuredSetup.createMachine(configObjectOf(spec, fault))), + Match.when('provide', () => + createMachine({ guards: { atLeast: neverAtLeast }, ...configOf(spec, fault) }).provide({ + guards: { atLeast }, + })), + Match.exhaustive, + ) + +export interface GuardsLedger { + drivers: Record + factories: Record + admitted: Record + rejected: Record + fallbackToP: number + fallbackToRegion: number + fallbackToRoot: number + rootAdmitted: number + rootRejected: number + effectsAdmitted: number + patternMatched: number + patternMismatched: number + namedAsked: Record + inTrue: number + inFalse: number + eventlessL: number + eventlessR: number + crossRegionInterim: number + canTrue: number + canFalse: number + checkStateIn: { + dataFirst: { true: number; false: number } + dataLast: { true: number; false: number } + } +} + +const emptyLedger = (): GuardsLedger => ({ + drivers: { actor: 0, pure: 0 }, + factories: { createMachine: 0, setup: 0, provide: 0 }, + admitted: { Absent: 0, Target: 0, Pattern: 0, Fn: 0 }, + rejected: { Absent: 0, Target: 0, Pattern: 0, Fn: 0 }, + fallbackToP: 0, + fallbackToRegion: 0, + fallbackToRoot: 0, + rootAdmitted: 0, + rootRejected: 0, + effectsAdmitted: 0, + patternMatched: 0, + patternMismatched: 0, + namedAsked: { createMachine: 0, setup: 0, provide: 0 }, + inTrue: 0, + inFalse: 0, + eventlessL: 0, + eventlessR: 0, + crossRegionInterim: 0, + canTrue: 0, + canFalse: 0, + checkStateIn: { dataFirst: { true: 0, false: 0 }, dataLast: { true: 0, false: 0 } }, +}) + +const noteResolution = (ledger: GuardsLedger, command: GuardsCommand, resolutions: ReadonlyArray): void => { + for (const resolution of resolutions) { + for (const entry of resolution.admitted) { + ledger.admitted[entry.kind] += 1 + ledger.patternMatched += count(entry.kind === 'Pattern') + } + for (const entry of resolution.rejected) { + ledger.rejected[entry.kind] += 1 + ledger.patternMismatched += count(entry.kind === 'Pattern') + } + ledger.fallbackToP += count(resolution.rejected.some((entry) => entry.node === 'c1' || entry.node === 'c2')) + ledger.fallbackToRegion += count(resolution.rejected.some((entry) => entry.node === 'L' || entry.node === 'R')) + ledger.fallbackToRoot += count(resolution.rootAdmitted || resolution.rootRejected) + ledger.rootAdmitted += count(resolution.rootAdmitted) + ledger.rootRejected += count(resolution.rootRejected) + ledger.namedAsked[command.factory] += count(resolution.namedAsked) + ledger.inTrue += count(resolution.inTrue) + ledger.inFalse += count(resolution.inFalse) + ledger.eventlessL += count(resolution.eventlessL) + ledger.eventlessR += count(resolution.eventlessR) + ledger.crossRegionInterim += count(resolution.crossRegionInterim) + } +} + +const activeIdsFirst = (snapshot: AnyMachineSnapshot, ledger: GuardsLedger): ReadonlyArray => + NODE_KEYS.filter((key) => { + const inside = checkStateIn(snapshot, `#${key}`) + ledger.checkStateIn.dataFirst[inside ? 'true' : 'false'] += 1 + return inside + }) + +const activeIdsDataLast = (snapshot: AnyMachineSnapshot, ledger: GuardsLedger): ReadonlyArray => + NODE_KEYS.filter((key) => { + const inside = checkStateIn(`#${key}`)(snapshot) + ledger.checkStateIn.dataLast[inside ? 'true' : 'false'] += 1 + return inside + }) + +const throwUncaptured = (): never => { + throw new Error('the published snapshot did not match the guards capture model') +} + +const observationOf = ( + snapshot: AnyMachineSnapshot, + steps: ReadonlyArray, + ledger: GuardsLedger, +): GuardsObservation => { + const value = Schema.decodeUnknownResult(GuardsValueSchema)(snapshot.value) + const context = Schema.decodeUnknownResult(GuardsContextSchema)(snapshot.context) + return { + value: Result.isSuccess(value) ? value.success : throwUncaptured(), + n: Result.isSuccess(context) ? context.success.n : throwUncaptured(), + steps, + activeIds: activeIdsFirst(snapshot, ledger), + activeIdsDataLast: activeIdsDataLast(snapshot, ledger), + } +} + +const noteStep = (ledger: GuardsLedger, can: boolean, effects: ReadonlyArray): void => { + ledger.canTrue += count(can) + ledger.canFalse += count(!can) + ledger.effectsAdmitted += count(effects.length > 0) +} + +const runPure = ( + machine: AnyStateMachine, + command: GuardsCommand, + ledger: GuardsLedger, +): { readonly steps: ReadonlyArray; readonly final: AnyMachineSnapshot } => { + const [initial] = initialTransition(machine) + let snapshot: AnyMachineSnapshot = initial + const steps: Array = [] + for (const event of command.events) { + const can = snapshot.can(event) + const [next, actions] = transition(machine, snapshot, event) + const effects: ReadonlyArray = actions + .filter((action) => action.kind === 'emit') + .map((action) => action.event) + noteStep(ledger, can, effects) + steps.push({ can, effects }) + snapshot = next + } + return { steps, final: snapshot } +} + +const runActor = ( + machine: AnyStateMachine, + command: GuardsCommand, + ledger: GuardsLedger, +): { readonly steps: ReadonlyArray; readonly final: AnyMachineSnapshot } => { + const actor = createActor(machine) + const emitted: Array = [] + actor.subscribe({ error: () => {} }) + actor.on('effect', (event) => { + emitted.push(event) + }) + actor.start() + const steps: Array = [] + for (const event of command.events) { + const can = actor.getSnapshot().can(event) + const before = emitted.length + actor.send(event) + const effects = emitted.slice(before) + noteStep(ledger, can, effects) + steps.push({ can, effects }) + } + const final = actor.getSnapshot() + actor.stop() + return { steps, final } +} + +export class GuardsSubject extends Context.Service GuardsObservation>()( + '@systemfsoftware/xstate/tests/guards/GuardsSubject', +) {} + +export interface GuardsHandle { + readonly layer: Layer.Layer + readonly observed: GuardsLedger +} + +const subjectOf = (fault: Fault): GuardsHandle => { + const observed = emptyLedger() + const run = (command: GuardsCommand): GuardsObservation => { + observed.drivers[command.driver] += 1 + observed.factories[command.factory] += 1 + noteResolution(observed, command, analyseRun(command)) + const machine = machineOf(command.machine, command.factory, fault) + const { steps, final } = command.driver === 'pure' ? runPure(machine, command, observed) : runActor( + machine, + command, + observed, + ) + const observation = observationOf(final, steps, observed) + return observation + } + return { observed, layer: Layer.succeed(GuardsSubject, run) } +} + +export const makeGuardsSubject = (): GuardsHandle => subjectOf('none') + +export const makeRejectsAsEmptySubject = (): GuardsHandle => subjectOf('rejects-as-empty') + +export const makeDropsEffectSubject = (): GuardsHandle => subjectOf('drops-effect') + +export const runGuardsCommand = (command: GuardsCommand): Effect.Effect => + Effect.gen(function*() { + const run = yield* GuardsSubject + return run(command) + }) diff --git a/packages/xstate/tests/guards.conformance.test.ts b/packages/xstate/tests/guards.conformance.test.ts new file mode 100644 index 0000000..d2caf50 --- /dev/null +++ b/packages/xstate/tests/guards.conformance.test.ts @@ -0,0 +1,472 @@ +import { Conformance } from '@systemfsoftware/conformance-spec' +import { And, Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { + checkStateIn, + createActor, + createMachine, + matchesState, + setup, + type StateValue, + types, +} from '@systemfsoftware/xstate' +import { Effect, Layer } from 'effect' +import { failReportOf, passReportOf } from './__fixtures__/checkReports.js' +import { + type GuardsHandle, + type GuardsLedger, + makeDropsEffectSubject, + makeGuardsSubject, + makeRejectsAsEmptySubject, + runGuardsCommand, +} from './__fixtures__/guards.js' +import { GuardsCommand, guardsModel } from './__fixtures__/guards.model.js' + +export const typeLevelContract = () => { + const machine = createMachine({ + type: 'parallel', + states: { a: { initial: 'a1', states: { a1: {}, a2: {} } }, b: { initial: 'b1', states: { b1: {}, b2: {} } } }, + }) + const snapshot = machine.getInitialSnapshot() + + return [ + checkStateIn(snapshot, 'a.a1') satisfies boolean, + checkStateIn('a.a1')(snapshot) satisfies boolean, + checkStateIn(snapshot, { a: 'a1' }) satisfies boolean, + checkStateIn({ a: 'a1' })(snapshot) satisfies boolean, + matchesState('a.a1', snapshot.value) satisfies boolean, + matchesState('a.a1')(snapshot.value) satisfies boolean, + ] as const +} + +const Feature = makeFeature({ it }) + +const sequences = 96 +const operations = 6 + +const liveness = (observed: GuardsLedger): boolean => + observed.drivers.actor > 0 && + observed.drivers.pure > 0 && + observed.factories.createMachine > 0 && + observed.factories.setup > 0 && + observed.factories.provide > 0 && + observed.admitted.Target > 0 && + observed.admitted.Pattern > 0 && + observed.admitted.Fn > 0 && + observed.rejected.Absent > 0 && + observed.rejected.Pattern > 0 && + observed.rejected.Fn > 0 && + observed.fallbackToP > 0 && + observed.fallbackToRegion > 0 && + observed.fallbackToRoot > 0 && + observed.rootAdmitted > 0 && + observed.rootRejected > 0 && + observed.effectsAdmitted > 0 && + observed.patternMatched > 0 && + observed.patternMismatched > 0 && + observed.namedAsked.createMachine > 0 && + observed.namedAsked.setup > 0 && + observed.namedAsked.provide > 0 && + observed.inTrue > 0 && + observed.inFalse > 0 && + observed.eventlessL > 0 && + observed.eventlessR > 0 && + observed.crossRegionInterim > 0 && + observed.canTrue > 0 && + observed.canFalse > 0 && + observed.checkStateIn.dataFirst.true > 0 && + observed.checkStateIn.dataFirst.false > 0 && + observed.checkStateIn.dataLast.true > 0 && + observed.checkStateIn.dataLast.false > 0 + +const checkOver = (handle: GuardsHandle, seed: number) => + Conformance.sequential(handle.layer, { + commands: GuardsCommand, + model: guardsModel, + run: runGuardsCommand, + sequences, + operations, + seed, + }) + +interface ActorStatus { + readonly status: string + readonly message: string +} + +Feature('Judging the published guard evaluation against a hand-written model of admission', { timeout: 0 }) + .withLayer(Layer.empty) + .live('each scenario drives the simulation kernel itself, and a conformance check cannot run inside a kernel run') + .body(({ scenario, scenarioOutline }) => { + scenarioOutline( + 'Every machine and event sequence the model draws from seed admits exactly what the published guards admit', + [{ seed: 1 }, { seed: 2 }, { seed: 3 }], + (row) => + Gherkin.Do.pipe( + Given('a subject bound to the published machine entrypoints, with a fresh ledger')( + 'subject', + () => Effect.succeed(makeGuardsSubject()), + ), + When('the sequential model check runs the generated machines and event sequences through it')( + 'report', + (s) => checkOver(s.subject, row.seed), + ), + Then('every sequence is explained by the hand-written admission model')((s, expect) => + expect(passReportOf(s.report), 'the published guards agree with the model').toMatchObject({ + _tag: 'Pass', + histories: sequences, + }) + ), + When('the steps the run produced are read off the fixture ledger')( + 'observed', + (s) => Effect.succeed(s.subject.observed), + ), + And( + 'every slot kind was admitted and rejected, every fallback depth and the root were reached, patterns matched and mismatched, an effect fired, a named source was asked under every factory, an in check answered both ways, each region stepped eventlessly with one cross-region interim step, can answered both ways, and checkStateIn answered both ways and both call forms', + )( + (s, expect) => + expect(s.observed, JSON.stringify(s.observed)).toSatisfy( + liveness, + 'the run exercised the whole admission surface across both drivers and all three factories', + ), + ), + ), + ) + + const divergesFromTheModel = (makeSubject: () => GuardsHandle) => + Gherkin.Do.pipe( + Given('a planted subject that admits differently from the published guards')( + 'subject', + () => Effect.succeed(makeSubject()), + ), + When('the same check runs the machines drawn from seed 1 through it')('report', (s) => checkOver(s.subject, 1)), + Then('the run is rejected with the model-diverged judgement at a numbered step')((s, expect) => { + const failure = failReportOf(s.report).failure + const rendered = Conformance.render(s.report) + return expect({ problem: failure.judgement.problem, step: failure.judgement.step, rendered }, rendered) + .toSatisfy( + (value) => + value.problem === 'model-diverged' && + value.step !== undefined && + value.rendered.includes(`the model diverged at step ${value.step}`), + 'the run diverged from the model at a numbered step, reported as the model-diverged judgement', + ) + }), + ) + + scenario( + 'A rejection that returns an empty object consumes the event and is caught as a model divergence', + divergesFromTheModel(makeRejectsAsEmptySubject), + ) + scenario( + 'An effect outcome that enqueues nothing falls through and is caught as a model divergence', + divergesFromTheModel(makeDropsEffectSubject), + ) + + scenario( + 'A guard source the machine does not implement leaves the actor in error status with a TypeError naming it', + Gherkin.Do.pipe( + Given('a machine whose routing step calls a guard source it does not implement')( + 'outcome', + () => + Effect.sync(() => { + const declared = { + isReady: (ready: boolean) => ready === true, + isRedy: (ready: boolean) => ready, + } + Reflect.deleteProperty(declared, 'isRedy') + const machine = createMachine({ + context: { ready: false }, + guards: declared, + initial: 'routing', + states: { + routing: { + always: ({ context, guards }) => + guards.isRedy(context.ready) ? { target: 'go' } : { target: 'wait' }, + }, + go: {}, + wait: {}, + }, + }) + const actor = createActor(machine) + const failures: Array = [] + actor.subscribe({ + error: (error) => { + failures.push( + error instanceof Error + ? error + : new Error('the missing source threw a non-Error', { cause: error }), + ) + }, + }) + actor.start() + const status = actor.getSnapshot().status + actor.stop() + const failure = failures.at(0) + return { status, message: failure === undefined ? '' : failure.message } + }), + ), + Then('the actor is in error status with a message naming the missing source')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: ActorStatus) => + outcome.status === 'error' && /guards[^\s]*isRedy[^\s]* is not a function/.test(outcome.message), + 'the actor reports a TypeError naming guards.isRedy', + ) + ), + ), + ) + + scenario( + 'A transition function that throws leaves the actor in error status with that error', + Gherkin.Do.pipe( + Given('a machine whose transition function throws')('outcome', () => + Effect.sync(() => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + GO: () => { + throw new Error('boom-guard') + }, + }, + }, + }, + }) + const actor = createActor(machine) + const failures: Array = [] + actor.subscribe({ + error: (error) => { + failures.push( + error instanceof Error ? error : new Error('the transition threw a non-Error', { cause: error }), + ) + }, + }) + actor.start() + actor.send({ type: 'GO' }) + const status = actor.getSnapshot().status + actor.stop() + const failure = failures.at(0) + return { status, message: failure === undefined ? '' : failure.message } + })), + Then('the actor carries the thrown error')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: ActorStatus) => outcome.status === 'error' && outcome.message === 'boom-guard', + 'the actor is in error status with the thrown error as its message', + ) + ), + ), + ) + + scenario( + 'A guard source receives exactly the arguments the transition function passes', + Gherkin.Do.pipe( + Given('a machine whose guard source records what it received')('outcome', () => + Effect.sync(() => { + const received: Array> = [] + const machine = createMachine({ + context: { count: 5 }, + guards: { + isAbove: (count: number, threshold: number) => { + received.push([count, threshold]) + return count > threshold + }, + }, + initial: 'a', + states: { + a: { + on: { + GO: ({ context, guards }) => (guards.isAbove(context.count, 3) ? { target: 'b' } : undefined), + }, + }, + b: {}, + }, + }) + const actor = createActor(machine).start() + actor.send({ type: 'GO' }) + const value = actor.getSnapshot().value + actor.stop() + return { value, received } + })), + Then('the source saw the caller-supplied arguments and the transition admitted')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { readonly value: StateValue; readonly received: ReadonlyArray> }) => + outcome.value === 'b' && JSON.stringify(outcome.received) === JSON.stringify([[5, 3]]), + 'the guard source received [5, 3] and the transition admitted', + ) + ), + ), + ) + + scenario( + 'A zero-parameter guard source is called with no arguments', + Gherkin.Do.pipe( + Given('a machine whose guard source declares no parameters')('outcome', () => + Effect.sync(() => { + const machine = createMachine({ + guards: { isEnabled: (): boolean => true }, + initial: 'a', + states: { + a: { on: { GO: ({ guards }) => (guards.isEnabled() ? { target: 'b' } : undefined) } }, + b: {}, + }, + }) + const actor = createActor(machine).start() + actor.send({ type: 'GO' }) + const value = actor.getSnapshot().value + actor.stop() + return { value } + })), + Then('the transition admits')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { readonly value: StateValue }) => outcome.value === 'b', + 'a zero-parameter source is callable and admits the transition', + ) + ), + ), + ) + + scenario( + 'checkStateIn answers path strings and state values like snapshot.matches, in both forms', + Gherkin.Do.pipe( + Given('a hand-written parallel machine resting in a1 and b2')('outcome', () => + Effect.sync(() => { + const machine = createMachine({ + type: 'parallel', + states: { + a: { initial: 'a1', states: { a1: {}, a2: {} } }, + b: { initial: 'b2', states: { b1: { id: 'b_b1' }, b2: { id: 'b_b2' } } }, + }, + }) + const actor = createActor(machine).start() + const snapshot = actor.getSnapshot() + actor.stop() + const values: ReadonlyArray = [{ a: 'a1', b: 'b2' }, 'a.a1', { a: 'a2' }, '#b_b2', '#b_b1'] + return { + dataFirst: values.map((value) => checkStateIn(snapshot, value)), + dataLast: values.map((value) => checkStateIn(value)(snapshot)), + matches: values.map((value) => snapshot.matches(value)), + } + })), + Then('both call forms answer the hand-written truth table')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { + readonly dataFirst: ReadonlyArray + readonly dataLast: ReadonlyArray + }) => + JSON.stringify(outcome.dataFirst) === JSON.stringify([true, true, false, true, false]) && + JSON.stringify(outcome.dataLast) === JSON.stringify([true, true, false, true, false]), + 'checkStateIn answers a state value, a path and an inactive path, and both active ids, in both forms', + ) + ), + ), + ) + + scenario( + 'A declared target admits the event and moves the actor to it', + Gherkin.Do.pipe( + Given('a machine whose event handler names a sibling state as its target')('outcome', () => + Effect.sync(() => { + const machine = createMachine({ + initial: 'a', + states: { a: { on: { EVENT: { target: 'c' } } }, b: {}, c: {} }, + }) + const actor = createActor(machine).start() + actor.send({ type: 'EVENT' }) + const value = actor.getSnapshot().value + actor.stop() + return { value } + })), + Then('the actor reaches the declared target')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { readonly value: StateValue }) => outcome.value === 'c', + 'the declared target admits the event and moves the actor to c', + ) + ), + ), + ) + + scenario( + 'A custom guard with parameters admits only when the context and the event together clear the threshold', + Gherkin.Do.pipe( + Given('a setup machine whose guard reads the context count and the event value')( + 'outcome', + () => + Effect.sync(() => { + const guardSetup = setup({ + schemas: { + context: types<{ count: number }>(), + events: { EVENT: types<{ value: number }>() }, + }, + }) + const machine = guardSetup.createMachine({ + context: { count: 0 }, + initial: 'inactive', + states: { + inactive: { + on: { + EVENT: ({ context, event }) => context.count + event.value > 3 ? { target: 'active' } : undefined, + }, + }, + active: {}, + }, + }) + const passing = createActor(machine).start() + passing.send({ type: 'EVENT', value: 4 }) + const reached = passing.getSnapshot().value + passing.stop() + const failing = createActor(machine).start() + failing.send({ type: 'EVENT', value: 3 }) + const held = failing.getSnapshot().value + failing.stop() + return { reached, held } + }), + ), + Then('the guard admits above the threshold and refuses at it')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { readonly reached: StateValue; readonly held: StateValue }) => + outcome.reached === 'active' && outcome.held === 'inactive', + 'the parameterised guard admits value 4 and refuses value 3', + ) + ), + ), + ) + + scenario( + 'A guard in one region reads the interim value another region reached in the same macrostep', + Gherkin.Do.pipe( + Given('a parallel machine whose right region reacts to the left region being in lq')( + 'outcome', + () => + Effect.sync(() => { + const machine = createMachine({ + type: 'parallel', + states: { + L: { initial: 'c1', states: { c1: { on: { GO: { target: 'lq' } } }, lq: {} } }, + R: { + initial: 'r1', + states: { + r1: { always: ({ value }) => (matchesState('L.lq', value) ? { target: 'r2' } : undefined) }, + r2: {}, + }, + }, + }, + }) + const actor = createActor(machine).start() + const initial = actor.getSnapshot().value + actor.send({ type: 'GO' }) + const interim = actor.getSnapshot().value + actor.stop() + return { initial, interim } + }), + ), + Then('the right region moves on the left region having moved in the same macrostep')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { readonly initial: StateValue; readonly interim: StateValue }) => + JSON.stringify(outcome.initial) === JSON.stringify({ L: 'c1', R: 'r1' }) && + JSON.stringify(outcome.interim) === JSON.stringify({ L: 'lq', R: 'r2' }), + 'the eventless guard in R sees L having reached lq', + ) + ), + ), + ) + }) diff --git a/packages/xstate/tsconfig.tsgo.json b/packages/xstate/tsconfig.tsgo.json index 7ddb911..b48f05c 100644 --- a/packages/xstate/tsconfig.tsgo.json +++ b/packages/xstate/tsconfig.tsgo.json @@ -8,5 +8,12 @@ "customConditions": ["@systemfsoftware/source"], "noEmit": true }, - "include": ["src/assert.ts", "src/stateMatching.ts", "src/mapState.ts", "src/fsm.ts", "src/graph"] + "include": [ + "src/assert.ts", + "src/stateMatching.ts", + "src/mapState.ts", + "src/fsm.ts", + "src/graph", + "src/transitionGuards.ts" + ] } diff --git a/packages/xstate/vitest.config.ts b/packages/xstate/vitest.config.ts index d752ef7..b4a8a74 100644 --- a/packages/xstate/vitest.config.ts +++ b/packages/xstate/vitest.config.ts @@ -49,7 +49,6 @@ const unguarded = [ 'test/examples.cd.test.ts', 'test/final.test.ts', 'test/firstTenMinutes.v6.test.ts', - 'test/guards.test.ts', 'test/history.test.ts', 'test/id.test.ts', 'test/initial.test.ts', @@ -105,7 +104,6 @@ const unguarded = [ 'test/spawn.types.test.ts', 'test/spawnChild.test.ts', 'test/state.test.ts', - 'test/stateIn.test.ts', 'test/stateInput.persistence.test.ts', 'test/stateInput.test.ts', 'test/strictTargets.types.test.ts',