diff --git a/gpt/funky-count.benchmark.md b/gpt/funky-count.benchmark.md new file mode 100644 index 0000000000..5ac9533749 --- /dev/null +++ b/gpt/funky-count.benchmark.md @@ -0,0 +1,15 @@ +I would like to build a counting state machine that allows me to +increment and decrement the count with special behaviors at +specific thresholds. + +When the count reaches three, the machine enforces a 400 ms wait before +allowing further increments; if the user attempts to increment during +this wait, the machine transitions to inverted count mode, incrementing when +decrement event is received and vice versa. + +In reverse state, the user can hold the increment button for 2-seconds to reset +the behavior to normal + +celebrates milestones when the count reaches 7 + +tests for all functionality diff --git a/gpt/issues.test.ts b/gpt/issues.test.ts new file mode 100644 index 0000000000..597197f1d4 --- /dev/null +++ b/gpt/issues.test.ts @@ -0,0 +1,184 @@ +import { assign, createActor, setup, SimulatedClock } from 'xstate'; + +function celebrate(_: any) { + console.log('Celebration! Count reached 7!'); +} + +interface CountingContext { + count: number; +} + +const initialContext: CountingContext = { + count: 0 +}; + +type CountingEvents = + | { type: 'increment' } + | { type: 'decrement' } + | { type: 'holdIncrement' }; + +const countingMachine = setup({ + types: { + context: {} as CountingContext, + events: {} as CountingEvents + }, + actions: { + increment: assign({ + count: ({ context }) => context.count + 1 + }), + decrement: assign({ + count: ({ context }) => context.count - 1 + }), + invertIncrement: assign({ + count: ({ context }) => context.count - 1 + }), + invertDecrement: assign({ + count: ({ context }) => context.count + 1 + }), + celebrate + }, + guards: { + reachedThreshold: ({ context }) => context.count === 3, + reachedMilestone: ({ context }) => context.count === 7 + }, + delays: { + waitDelay: 400 + } +}).createMachine({ + context: initialContext, + initial: 'normal', + states: { + normal: { + on: { + increment: [ + { + target: 'delayed', + guard: 'reachedThreshold' + }, + { + actions: 'increment', + guard: 'reachedMilestone', + internal: false, + after: { + type: 'celebrate' + } + }, + { + actions: 'increment' + } + ], + decrement: { + actions: 'decrement' + } + } + }, + delayed: { + after: { + waitDelay: 'normal' + }, + on: { + increment: { + target: 'inverted' + } + } + }, + inverted: { + on: { + increment: { + actions: 'invertIncrement' + }, + decrement: { + actions: 'invertDecrement' + }, + holdIncrement: { + target: 'normal', + internal: false, + delay: 2000 + } + } + } + } +}); + +// Type tests to ensure type satisfaction +import { + type ActorRefFrom, + type SnapshotFrom, + type EventFromLogic +} from 'xstate'; + +// Strongly-typed actor reference +type CountingActorRef = ActorRefFrom; +// @ts-expect-error +const invalidActorRef: CountingActorRef = { invalid: true }; // Should produce a type error +const validActorRef: CountingActorRef = createActor(countingMachine); // Should be valid + +// Strongly-typed snapshot +type CountingSnapshot = SnapshotFrom; +// @ts-expect-error +const invalidSnapshot: CountingSnapshot = { invalid: true }; // Should produce a type error +const validSnapshot: CountingSnapshot = validActorRef.getSnapshot(); // Should be valid + +// Union of all event types +type CountingEvent = EventFromLogic; +// @ts-expect-error +const invalidEvent: CountingEvent = { invalid: true }; // Should produce a type error +const validEvent: CountingEvent = { type: 'increment' }; // Should be valid + +describe('countingMachine', () => { + it('should increment normally', () => { + const actor = createActor(countingMachine).start(); + actor.send({ type: 'increment' }); + expect(actor.getSnapshot().context.count).toEqual(1); + }); + + it('should transition to "delayed" state when count reaches 3', () => { + const actor = createActor(countingMachine).start(); + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + expect(actor.getSnapshot().matches('delayed')).toBeTruthy(); + }); + + it('should transition to "inverted" state if incremented during delay', () => { + const clock = new SimulatedClock(); + const actor = createActor(countingMachine, { clock }).start(); + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + expect(actor.getSnapshot().matches('inverted')).toBeTruthy(); + }); + + it('should increment when decrement event is received in inverted state', () => { + const actor = createActor(countingMachine).start(); + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + actor.send({ type: 'decrement' }); + expect(actor.getSnapshot().context.count).toEqual(3); + }); + + it('should transition back to normal state when holdIncrement is held for 2 seconds', () => { + const clock = new SimulatedClock(); + const actor = createActor(countingMachine, { clock }).start(); + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + actor.send({ type: 'increment' }); + actor.send({ type: 'holdIncrement' }); + clock.increment(2000); + expect(actor.getSnapshot().matches('normal')).toBeTruthy(); + }); + + it('should celebrate when count reaches 7', () => { + const actor = createActor(countingMachine).start(); + const celebrateSpy = jest.spyOn(console, 'log').mockImplementation(); + for (let i = 0; i < 7; i++) { + actor.send({ type: 'increment' }); + } + expect(celebrateSpy).toHaveBeenCalledWith('Celebration! Count reached 7!'); + celebrateSpy.mockRestore(); + }); +}); diff --git a/gpt/prompt.test.ts b/gpt/prompt.test.ts new file mode 100644 index 0000000000..71cdb887f7 --- /dev/null +++ b/gpt/prompt.test.ts @@ -0,0 +1,258 @@ +/** + * ## SITUATION + * You have access to extensive training data on XState version 4. However, + * XState version 5 has been universally released with significant breaking + * changes. Follow these guidelines for XState version 5 unless explicitly + * stated otherwise by the user: + * + * ## INSTRUCTIONS + * + * 1. **Gather Requirements and Clarify** + * - Obtain a description of the state machine from the user, assuming + * it will be incomplete. Lay out a happy path of events causing + * transitions, side effects, actions, and triggers. Fill in gaps by + * asking clarifying questions to establish a full picture before + * starting to code. WAIT for the user to respond before writing any code. + * + * 1.5! DO NOT WRITE CODE UNTIL USER HAS RESPONDED + * + * 2. **Test-Driven Development** + * - Write tests first based on the clarified requirements + * - use the SimulatedClock instead of Jest's simulated clock. + * + * 3. **Define Action Functions** + * - Define action functions with an empty first parameter of type `any` + * and strongly typed parameters. + * + * 4. **Declare Context and Initial Context** + * - Declare a machine context as an interface and define an initial + * context outside of the machine using your best judgment for naming. + * + * 5. **Define Union Type of Machine Events** + * - Create a union type of machine events prefixed by a short domain, + * using lowercase camelCase for event types. Attach event values + * directly to the type, avoiding nested params. + * + * 6. **Use the New v5 APIs** + * - guard has replaced cond. cond is deprecated + * - Use the new setup API as demonstrated in the example machine, + * passing in types for context, events, guards, actors, etc. + * + * 7. **Pass in Actions and Define Inline Assign Actions** + * - Pass in actions, defining inline assign actions in the setup block. + * + * 8. **Define Guards** + * - Define guards in the setup block with an underscore ignored argument + * as the first argument and strongly typed parameters for the second. + * + * 9. **Casing Conventions** + * - Events: lowercase camelCase, prefixed by domain type. + * - States and Delays: PascalCase. + * - Actions: camelCase. + * - Avoid using screaming snakecase, snakecase, or kebabcase. + * + * 10. Write all code in one code fence so user can easily copy and paste to test + * + * 11. if you are unsure, use browsing to check stately.ai/docs with your query + */ + +import { assign, createActor, setup, SimulatedClock } from 'xstate'; + +function logInitialRating(_: any, params: { initialRating: number }) { + console.log(`Initial rating: ${params.initialRating}`); +} + +interface ExampleMachineContext { + feedback: string; + initialRating: number; + user: { name: string }; + count: number; +} + +const InitialContext: ExampleMachineContext = { + feedback: '', + initialRating: 3, + user: { name: 'David' }, + count: 0 +} as const; + +// Define event types +type ExampleMachineEvents = + | { type: 'feedback.good' } + | { type: 'feedback.bad' } + | { type: 'count.increment' } + | { type: 'count.incrementBy'; increment: number }; + +// Machine setup with strongly typed context and events +const exampleMachine = setup({ + types: { + context: {} as ExampleMachineContext, + events: {} as ExampleMachineEvents + }, + actions: { + logInitialRating, + increment: assign({ + count: ({ context }) => { + return context.count + 1; + } + }), + decrement: assign({ count: ({ context }) => context.count - 1 }), + incrementBy: assign({ + count: ({ context }, params: { increment: number }) => { + const result = context.count + params.increment; + return result; + } + }) + }, + guards: { + isGreaterThan: (_, params: { count: number; min: number }) => { + return params.count > params.min; + } + }, + delays: { + testDelay: 10_000 + } +}).createMachine({ + context: InitialContext, + entry: [ + { + type: 'logInitialRating', + params: ({ context }) => ({ initialRating: context.initialRating }) + } + ], + initial: 'Question', + states: { + Question: { + on: { + 'feedback.good': { + actions: [ + { + type: 'logInitialRating', + params: ({ context }) => ({ + initialRating: context.initialRating + }) + } + ] + }, + 'count.increment': [ + { + actions: 'increment', + guard: { + type: 'isGreaterThan', + params: ({ context }) => ({ count: context.count, min: 5 }) + }, + target: 'Greater' + }, + { + actions: 'increment' + } + ], + 'count.incrementBy': [ + { + actions: { + type: 'incrementBy', + params: ({ event }) => ({ + increment: event.increment + }) + }, + guard: { + type: 'isGreaterThan', + params: ({ context }) => ({ count: context.count, min: 5 }) + }, + target: 'Greater' + }, + { + actions: { + type: 'incrementBy', + params: ({ event }) => ({ + increment: event.increment + }) + } + } + ] + }, + after: { + testDelay: 'TimedOut' + } + }, + Greater: { + type: 'final' + }, + Less: { + type: 'final' + }, + Negative: { + type: 'final' + }, + TimedOut: { + type: 'final' + } + } +}); + +// Type tests to ensure type satisfaction +import { + type ActorRefFrom, + type SnapshotFrom, + type EventFromLogic +} from 'xstate'; + +// Strongly-typed actor reference +type SomeActorRef = ActorRefFrom; +// @ts-expect-error +const invalidActorRef: SomeActorRef = { invalid: true }; // Should produce a type error +const validActorRef: SomeActorRef = createActor(exampleMachine); // Should be valid + +// Strongly-typed snapshot +type SomeSnapshot = SnapshotFrom; +// @ts-expect-error +const invalidSnapshot: SomeSnapshot = { invalid: true }; // Should produce a type error +const validSnapshot: SomeSnapshot = validActorRef.getSnapshot(); // Should be valid + +// Union of all event types +type SomeEvent = EventFromLogic; +// @ts-expect-error +const invalidEvent: SomeEvent = { invalid: true }; // Should produce a type error +const validEvent: SomeEvent = { type: 'feedback.good' }; // Should be valid + +describe('exampleMachine', () => { + it('should log initial rating on entry', () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(); + + createActor(exampleMachine).start(); + + expect(logSpy).toHaveBeenCalledWith('Initial rating: 3'); + + logSpy.mockRestore(); + }); + + it('should transition to "Greater" state if count is greater than 5', () => { + const actor = createActor(exampleMachine).start(); + + actor.send({ type: 'count.incrementBy', increment: 6 }); + actor.send({ type: 'count.incrementBy', increment: 6 }); + expect(actor.getSnapshot().context.count).toEqual(12); + expect(actor.getSnapshot().matches('Greater')).toBeTruthy(); + }); + + it('should stay in "Question" state if no guards are satisfied', () => { + const actor = createActor(exampleMachine).start(); + + actor.send({ type: 'count.incrementBy', increment: -5 }); + expect(actor.getSnapshot().context.count).toEqual(-5); + expect(actor.getSnapshot().matches('Question')).toBeTruthy(); + }); + + it('should transition to "TimedOut" state after delay', () => { + const clock = new SimulatedClock(); + const actor = createActor(exampleMachine, { + clock + }).start(); + + expect(actor.getSnapshot().value).toEqual('Question'); + + clock.increment(10_000); + + expect(actor.getSnapshot().value).toEqual('TimedOut'); + }); +}); diff --git a/gpt/todo.md b/gpt/todo.md new file mode 100644 index 0000000000..514c5fc955 --- /dev/null +++ b/gpt/todo.md @@ -0,0 +1 @@ +- [ ] Introduce parallel states diff --git a/packages/core/test/kitchen-sink.actor.test.ts b/packages/core/test/kitchen-sink.actor.test.ts new file mode 100644 index 0000000000..c07941ffc3 --- /dev/null +++ b/packages/core/test/kitchen-sink.actor.test.ts @@ -0,0 +1,212 @@ +import { interval, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { sendParent } from '../src/actions.ts'; +import { assign } from '../src/actions/assign'; +import { raise } from '../src/actions/raise'; +import { sendTo } from '../src/actions/send'; +import { CallbackActorRef, fromCallback } from '../src/actors/callback.ts'; +import { + fromEventObservable, + fromObservable +} from '../src/actors/observable.ts'; +import { + PromiseActorLogic, + PromiseActorRef, + fromPromise +} from '../src/actors/promise.ts'; +import { + ActorLogic, + ActorRef, + ActorRefFrom, + AnyActorRef, + createActor, + createMachine +} from '../src/index.ts'; + +describe('composite state machine', () => { + type CompositeEvent = + | { type: 'PING' } + | { type: 'PONG' } + | { type: 'START_CB' } + | { type: 'SEND_BACK' } + | { type: 'GREET' } + | { type: 'ACTIVATE' } + | { type: 'SUCCESS' } + | { type: 'SET_COMPLETE'; id: number } + | { type: 'COUNT'; val: number }; + + interface CompositeContext { + server?: ActorRef; + promiseRef?: PromiseActorRef; + callbackRef?: CallbackActorRef<{ type: 'START' }>; + observableRef?: AnyActorRef; + eventObservableRef?: AnyActorRef; + childRef?: ActorRef; + parent: AnyActorRef; + } + + const serverMachine = createMachine({ + types: {} as { + events: CompositeEvent; + }, + id: 'server', + initial: 'waitPing', + states: { + waitPing: { + on: { + PING: 'sendPong' + } + }, + sendPong: { + entry: [sendParent({ type: 'PONG' }), raise({ type: 'SUCCESS' })], + on: { + SUCCESS: 'waitPing' + } + } + } + }); + + const childMachine = createMachine({ + types: {} as { + context: { parent: AnyActorRef }; + input: { parent: AnyActorRef }; + }, + context: ({ input }) => ({ + parent: input.parent + }), + entry: sendTo(({ context }) => context.parent, { type: 'GREET' }) + }); + + const promiseLogic: PromiseActorLogic = { + transition: (state, event, { self }) => { + if (event.type === 'PING') { + self._parent?.send({ type: 'PONG' }); + } + return state; + }, + getInitialSnapshot: (input) => ({ + status: 'active', + output: undefined, + error: undefined, + input + }), + getPersistedSnapshot: (s) => s + }; + + const compositeMachine = createMachine({ + types: {} as { context: CompositeContext; events: CompositeEvent }, + id: 'composite', + initial: 'init', + context: ({ self }) => ({ + server: undefined, + promiseRef: undefined, + callbackRef: undefined, + observableRef: undefined, + eventObservableRef: undefined, + childRef: undefined, + parent: self + }), + states: { + init: { + entry: [ + assign({ + server: ({ spawn }) => spawn(serverMachine), + promiseRef: ({ spawn }) => + spawn( + fromPromise(() => Promise.resolve('response')), + { id: 'my-promise' } + ), + callbackRef: ({ spawn }) => + spawn( + fromCallback(({ sendBack, receive }) => { + receive((event) => { + if (event.type === 'START') { + setTimeout(() => { + sendBack({ type: 'SEND_BACK' }); + }, 10); + } + }); + }) + ), + observableRef: ({ spawn }) => + spawn(fromObservable(() => interval(10))), + eventObservableRef: ({ spawn }) => + spawn( + fromEventObservable(() => + interval(10).pipe(map((val) => ({ type: 'COUNT', val }))) + ) + ), + childRef: ({ spawn, context }) => + spawn(childMachine, { input: { parent: context.parent } }) + }), + raise({ type: 'SUCCESS' }) + ], + on: { + SUCCESS: 'active' + } + }, + active: { + on: { + PING: { + actions: sendTo(({ context }) => context.server!, { type: 'PING' }) + }, + START_CB: { + actions: sendTo(({ context }) => context.callbackRef!, { + type: 'START' + }) + }, + SET_COMPLETE: { + actions: sendTo(({ context }) => context.server!, { + type: 'SET_COMPLETE', + id: 5 + }) + }, + COUNT: { + target: 'success', + guard: ({ event }) => event.val === 5 + }, + GREET: 'success', + SEND_BACK: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + it('should work as a composite state machine', (done) => { + const actor = createActor(compositeMachine); + let eventsSent = 0; + + actor.subscribe({ + complete: () => { + done(); + }, + next: (state) => { + // Log state transitions + console.log('State:', state.value); + + if (state.matches('active')) { + // Send events sequentially + if (eventsSent === 0) { + console.log('Sending PING event'); + actor.send({ type: 'PING' }); + } else if (eventsSent === 1) { + console.log('Sending START_CB event'); + actor.send({ type: 'START_CB' }); + } else if (eventsSent === 2) { + console.log('Sending SET_COMPLETE event'); + actor.send({ type: 'SET_COMPLETE', id: 42 }); + } else if (eventsSent === 3) { + console.log('Sending COUNT event'); + actor.send({ type: 'COUNT', val: 5 }); + } + eventsSent++; + } + } + }); + + actor.start(); + }); +}); diff --git a/packages/core/test/kitchen-sink.guard.test.ts b/packages/core/test/kitchen-sink.guard.test.ts new file mode 100644 index 0000000000..e740f2d837 --- /dev/null +++ b/packages/core/test/kitchen-sink.guard.test.ts @@ -0,0 +1,280 @@ +/* + Documentation for Guards: + + Guards + A guard is a condition function that the machine checks when it goes through an event. If the condition is true, the machine follows the transition to the next state. If the condition is false, the machine follows the rest of the conditions to the next state. + + A guarded transition is a transition that is enabled only if its guard evaluates to true. The guard determines whether or not the transition can be enabled. Any transition can be a guarded transition. + + Guards and TypeScript + XState v5 requires TypeScript version 5.0 or greater. + + For best results, use the latest TypeScript version. Read more about XState and TypeScript + + You MUST strongly type the guards of your machine by setting up their implementations in setup({ guards: { … } }). You can provide the params type in the 2nd argument of the guard function: + + import { setup } from 'xstate'; + + const machine = setup({ + guards: { + isGreaterThan: (_, params: { count: number; min: number; }) => { + return params.count > params.min; + } + } + }).createMachine({ + // ... + on: { + someEvent: { + guard: { + type: 'isGreaterThan', + // Strongly-typed params + params: ({ event }) => ({ + count: event.count, + min: 10 + }) + }, + // ... + }, + }, + }); + + Multiple guarded transitions + If you want to have a single event transition to different states in certain situations, you can supply an array of guarded transitions. Each transition will be tested in order, and the first transition whose guard evaluates to true will be taken. + + Guard object + A guard can be defined as an object with a type, which is the type of guard that references the provided guard implementation, and optional params, which can be read by the implemented guard. + + Guards can later be provided or overridden by providing custom guard implementations in the .provide() method. + + Higher-level guards + XState provides higher-level guards, which are guards that compose other guards. There are three higher-level guards – and, or, and not: + + and([...]) - evaluates to true if all guards in and([...guards]) evaluate to true + or([...]) - evaluates to true if any guards in or([...guards]) evaluate to true + not(...) - evaluates to true if the guard in not(guard) evaluates to false + + In-state guards + You can use the stateIn(stateValue) guard to check if the current state matches the provided stateValue. This is most useful for parallel states. + */ + +import { createActor, setup } from '../src/index.ts'; +import { and, not, or, stateIn } from '../src/guards'; + +describe('comprehensive guard conditions', () => { + interface MachineContext { + elapsed: number; + count: number; + } + type MachineEvents = + | { type: 'TIMER'; elapsed: number } + | { type: 'EMERGENCY'; isEmergency?: boolean } + | { type: 'EVENT'; value: number } + | { type: 'TIMER_COND_OBJ' } + | { type: 'TEST' } + | { type: 'GO_TO_ACTIVE' } + | { type: 'BAD_COND' }; + + const machine = setup({ + types: {} as { + input: { elapsed?: number; count?: number }; + context: MachineContext; + events: MachineEvents; + }, + guards: { + minTimeElapsed: (_, params: { elapsed: number }) => + params.elapsed >= 100 && params.elapsed < 200, + custom: ( + _, + params: { compare: number; op: string; value: number; prop: number } + ) => { + const { prop, compare, op, value } = params; + if (op === 'greaterThan') { + return prop + value > compare; + } + return false; + }, + isCountHigh: (_, params: { count: number }) => params.count > 5, + isElapsedHigh: (_, params: { elapsed: number }) => params.elapsed > 150, + isEmergency: (_, params: { isEmergency: boolean }) => !!params.isEmergency + } + }).createMachine({ + context: ({ input = {} }) => ({ + elapsed: input.elapsed ?? 0, + count: input.count ?? 0 + }), + initial: 'green', + states: { + green: { + on: { + GO_TO_ACTIVE: { + target: 'active' + }, + TIMER: [ + { + target: 'green', + guard: ({ context: { elapsed } }) => ({ + type: 'minTimeElapsed', + params: { elapsed } + }) + }, + { + target: 'yellow', + guard: { type: 'minTimeElapsed', params: { elapsed: 150 } } + } + ], + EMERGENCY: { + target: 'red', + guard: { type: 'isEmergency', params: { isEmergency: true } } + } + } + }, + yellow: { + on: { + TIMER: { + target: 'red', + guard: { type: 'minTimeElapsed', params: { elapsed: 150 } } + }, + TIMER_COND_OBJ: { + target: 'red', + guard: { + type: 'minTimeElapsed', + params: { elapsed: 150 } + } + } + } + }, + red: { + type: 'final' + }, + active: { + on: { + EVENT: { + target: 'inactive', + guard: { + type: 'custom', + params: ({ context, event }) => ({ + prop: 2, + op: 'greaterThan', + compare: 3, + value: event.value + }) + } + } + } + }, + inactive: {} + } + }); + + it('should transition if custom guard condition is met', () => { + const actorRef = createActor(machine, { input: { count: 2 } }).start(); + actorRef.send({ type: 'GO_TO_ACTIVE' }); + actorRef.send({ type: 'EVENT', value: 2 }); + expect(actorRef.getSnapshot().value).toEqual('inactive'); + }); + + it('should not transition if custom guard condition is not met', () => { + const actorRef = createActor(machine, { input: { count: 1 } }).start(); + actorRef.send({ type: 'GO_TO_ACTIVE' }); + expect(actorRef.getSnapshot().value).toEqual('active'); + actorRef.send({ type: 'EVENT', value: 1 }); + expect(actorRef.getSnapshot().value).toEqual('active'); + }); + + it('should guard with higher-level guards', () => { + const highLevelMachine = setup({ + types: {} as { + input: { elapsed?: number; count?: number }; + context: MachineContext; + events: MachineEvents; + }, + guards: { + isCountHigh: (_, params: { count: number }) => params.count > 5, + isElapsedHigh: (_, params: { elapsed: number }) => params.elapsed > 150 + } + }).createMachine({ + context: ({ input = {} }) => ({ + elapsed: input.elapsed ?? 0, + count: input.count ?? 0 + }), + initial: 'start', + states: { + start: { + on: { + TEST: { + target: 'success', + guard: and([ + { type: 'isCountHigh', params: { count: 6 } }, + { type: 'isElapsedHigh', params: { elapsed: 160 } } + ]) + } + } + }, + success: {} + } + }); + + const actorRef = createActor(highLevelMachine, { + input: { count: 6, elapsed: 160 } + }).start(); + actorRef.send({ type: 'TEST' }); + expect(actorRef.getSnapshot().value).toEqual('success'); + }); + + it('should guard with in-state guards', () => { + const inStateMachine = setup({ + types: {} as { + input: { elapsed?: number; count?: number }; + context: MachineContext; + events: MachineEvents; + } + }).createMachine({ + context: ({ input = {} }) => ({ + elapsed: input.elapsed ?? 0, + count: input.count ?? 0 + }), + type: 'parallel', + states: { + A: { + initial: 'A2', + states: { + A0: {}, + A2: { + on: { + A: 'A3' + } + }, + A3: { + always: 'A4' + }, + A4: { + always: 'A5' + }, + A5: {} + } + }, + B: { + initial: 'B0', + states: { + B0: { + always: [ + { + target: 'B4', + guard: stateIn('A.A4') + } + ] + }, + B4: {} + } + } + } + }); + + const actorRef = createActor(inStateMachine).start(); + actorRef.send({ type: 'A' }); + expect(actorRef.getSnapshot().value).toEqual({ + A: 'A5', + B: 'B4' + }); + }); +}); diff --git a/packages/core/test/kitchen-sync.actions.test.ts b/packages/core/test/kitchen-sync.actions.test.ts new file mode 100644 index 0000000000..755defaffa --- /dev/null +++ b/packages/core/test/kitchen-sync.actions.test.ts @@ -0,0 +1,197 @@ +import { + ActorRefFrom, + and, + assign, + cancel, + createActor, + createMachine, + enqueueActions, + fromPromise, + fromTransition, + log, + not, + raise, + sendParent, + sendTo, + setup, + stopChild, + SimulatedClock +} from '../src'; + +describe('Consolidated Actions Setup', () => { + const childMachine = createMachine({ + id: 'child', + initial: 'active', + states: { + active: { + on: { + FINISH: 'done' + } + }, + done: { + type: 'final' + } + }, + exit: sendParent({ type: 'CHILD_DONE' }) + }); + + const machine = setup({ + types: {} as { + context: { + count: number; + enabled: boolean; + data?: any; + child?: ActorRefFrom; + }; + events: + | { type: 'FOO' } + | { type: 'BAR' } + | { type: 'NEXT' } + | { type: 'SOMETHING' } + | { type: 'FINISH_CHILD' } + | { type: 'CHILD_DONE' } + | { type: 'e1' } + | { type: 'SOMETHING_ELSE' }; + children: { + myChild: 'child'; + fetchUserChild: 'fetchUser'; + }; + meta: { layout: string }; + }, + guards: { + check: () => true, + checkStuff: () => true, + checkWithParams: (_: any, params: number) => true, + opposite: not('check'), + combinedCheck: and([ + { type: 'check' }, + { type: 'checkWithParams', params: 42 } // Ensure the correct parameter type is provided + ]) + }, + actions: { + resetTo: assign((_, params: number) => ({ + count: params + })), + spawnFetcher: assign(({ spawn }) => ({ + data: { + child: spawn(childMachine) + } + })), + raiseFoo: raise({ type: 'FOO' }), + sendFoo: sendTo( + ({ self }) => self, + { type: 'FOO' }, + // Why can't we use 'hundred' here? + { delay: 100 } + ), + sendParentFoo: sendParent({ type: 'FOO' }), + enqueueSomething: enqueueActions(({ enqueue }) => { + enqueue.raise({ type: 'SOMETHING_ELSE' }); + }), + writeDown: log('foo'), + revert: cancel('foo'), + releaseFromDuty: stopChild('foo') + }, + actors: { + fetchUser: fromPromise( + async ({ input }: { input: { userId: string } }) => ({ + id: input.userId, + name: 'Andarist' + }) + ), + greet: fromPromise(async () => 'hello'), + throwDice: fromPromise(async () => Math.random()), + reducer: fromTransition((s) => s, { count: 42 }), + child: childMachine + }, + delays: { + hundred: 100 + } + }).createMachine({ + context: ({ spawn }) => ({ + count: 0, + enabled: true, + child: spawn(childMachine) + }), + initial: 'a', + on: { + SOMETHING_ELSE: { + actions: 'spawnFetcher' + } + }, + states: { + a: { + meta: { layout: 'a-layout' }, + after: { + hundred: 'b' + }, + on: { + NEXT: { + guard: 'checkStuff', + target: 'b' + }, + FINISH_CHILD: { + actions: [ + ({ context }) => { + if (context.child) { + return sendTo(() => context.child!, { type: 'FINISH' }); + } + } + ] + }, + CHILD_DONE: { + actions: () => { + // Handle child done + } + } + } + }, + b: { + meta: { layout: 'b-layout' }, + + on: { + SOMETHING: { + actions: 'enqueueSomething' + } + } + }, + c: {}, + d: {} + } + }); + + it('should setup a comprehensive machine with all functionalities', () => { + const clock = new SimulatedClock(); + const actor = createActor(machine, { clock }); + + actor.start(); + let snapshot = actor.getSnapshot(); + + expect(snapshot.context.count).toBe(0); + expect(snapshot.context.enabled).toBe(true); + + // Verify the initial state and context + expect(snapshot.value).toEqual('a'); + expect(snapshot.context.count).toBe(0); + expect(snapshot.context.enabled).toBe(true); + + // Send NEXT event and verify transition to state 'b' + actor.send({ type: 'NEXT' }); + snapshot = actor.getSnapshot(); + expect(snapshot.value).toEqual('b'); + + // Send SOMETHING event and verify action is called + actor.send({ type: 'SOMETHING' }); + snapshot = actor.getSnapshot(); + expect(snapshot.context.data).toBeDefined(); + + actor.send({ type: 'FINISH_CHILD' }); + snapshot = actor.getSnapshot(); + expect(snapshot.context.child).toBeDefined(); + + // Increment the clock to simulate delay and verify state transition after delay + clock.increment(100); + snapshot = actor.getSnapshot(); + expect(snapshot.value).toEqual('b'); + }); +});