From ffcff5f43fa92a26455263dcdc21d6617bfd41f1 Mon Sep 17 00:00:00 2001 From: Joshua Claunch Date: Fri, 27 Feb 2026 09:57:47 -0500 Subject: [PATCH] feat(machines)!: switch machines to signals --- packages/atoms/src/utils/general.ts | 3 +- packages/machines/README.md | 2 + packages/machines/package.json | 6 +- packages/machines/src/MachineSignal.ts | 120 ++++++ packages/machines/src/MachineStore.ts | 79 ---- packages/machines/src/index.ts | 4 +- ...MachineStore.ts => injectMachineSignal.ts} | 163 +++---- packages/machines/src/types.ts | 36 +- packages/machines/src/utils.ts | 19 + .../test/integrations/state-machines.test.tsx | 408 +++++++++++++++--- packages/machines/test/snippets/api.tsx | 62 +-- packages/machines/test/types.test.ts | 193 +++++++++ .../machines/test/units/MachineStore.test.ts | 296 +++++++++++-- packages/machines/test/utils.ts | 22 +- pnpm-lock.yaml | 3 - 15 files changed, 1083 insertions(+), 333 deletions(-) create mode 100644 packages/machines/src/MachineSignal.ts delete mode 100644 packages/machines/src/MachineStore.ts rename packages/machines/src/{injectMachineStore.ts => injectMachineSignal.ts} (64%) create mode 100644 packages/machines/src/utils.ts create mode 100644 packages/machines/test/types.test.ts diff --git a/packages/atoms/src/utils/general.ts b/packages/atoms/src/utils/general.ts index a0cba447..26eb2c81 100644 --- a/packages/atoms/src/utils/general.ts +++ b/packages/atoms/src/utils/general.ts @@ -24,7 +24,8 @@ import type { * that they don't prevent node destruction. * * IMPORTANT: Keep these in-sync with the copies in the react package - - * packages/react/src/utils.ts + * packages/react/src/utils.ts - and the machines package = + * packages/machines/src/utils.ts */ export const TopPrio = 0 export const Eventless = 1 diff --git a/packages/machines/README.md b/packages/machines/README.md index 509b6b82..6da1216a 100644 --- a/packages/machines/README.md +++ b/packages/machines/README.md @@ -1,5 +1,7 @@ # `@zedux/machines` +> IMPORTANT: This readme has not yet been updated for the Zedux v2 signal-based implementation of the `@zedux/machines` package. That will happen after the v2 versions of all linked docs pages have been merged to the docs site. + A simple, TypeScript-first state machine implementation for Zedux. This is an addon package, meaning it doesn't have any own dependencies or re-export any APIs from other packages. It uses peer dependencies instead, expecting you to download the needed packages yourself. See [the documentation](https://omnistac.github.io/zedux/docs/packages/machines/overview) for this package. diff --git a/packages/machines/package.json b/packages/machines/package.json index ad0cb592..1366807e 100644 --- a/packages/machines/package.json +++ b/packages/machines/package.json @@ -17,8 +17,7 @@ "react-dom": "catalog:", "@types/react-dom": "catalog:", "@types/react": "catalog:", - "@zedux/atoms": "2.0.0-rc.12", - "@zedux/core": "2.0.0-rc.12" + "@zedux/atoms": "2.0.0-rc.12" }, "exports": { ".": { @@ -43,8 +42,7 @@ ], "license": "MIT", "peerDependencies": { - "@zedux/atoms": "2.0.0-rc.12", - "@zedux/core": "2.0.0-rc.12" + "@zedux/atoms": "2.0.0-rc.12" }, "repository": { "directory": "packages/machines", diff --git a/packages/machines/src/MachineSignal.ts b/packages/machines/src/MachineSignal.ts new file mode 100644 index 00000000..51730f90 --- /dev/null +++ b/packages/machines/src/MachineSignal.ts @@ -0,0 +1,120 @@ +import { + Ecosystem, + RecursivePartial, + Signal, + Settable, +} from '@zedux/atoms' +import { MachineStateShape } from './types' + +// eslint-disable-next-line @typescript-eslint/ban-types +const signalSend = Signal.prototype.send as (...args: any[]) => void + +/** + * A low-level Signal subclass that represents a state machine. Don't create + * this class yourself, use a helper such as @zedux/machines' + * `injectMachineSignal()` + */ +export class MachineSignal< + StateNames extends string = string, + EventNames extends string = string, + Context extends Record | undefined = undefined +> extends Signal<{ + Events: Record + Params: undefined + State: MachineStateShape + Template: undefined +}> { + #guard?: (currentState: any, nextValue: any) => boolean + + #states: Record< + string, + Record boolean }> + > + + constructor( + ecosystem: Ecosystem, + id: string, + initialState: StateNames, + states: Record< + StateNames, + Record< + EventNames, + { name: StateNames; guard?: (context: Context) => boolean } + > + >, + initialContext?: Context, + guard?: ( + currentState: MachineStateShape, + nextValue: StateNames + ) => boolean + ) { + super(ecosystem, id, { + context: initialContext as Context, + value: initialState, + }) + + this.#states = states + this.#guard = guard + + this.on(eventMap => { + for (const key of Object.keys(eventMap)) { + const currentState = this.v + const transition = this.#states[currentState.value]?.[key] + + if ( + !transition || + (transition.guard && !transition.guard(currentState.context)) || + (this.#guard && !this.#guard(currentState, transition.name)) + ) { + continue + } + + this.set({ + context: currentState.context, + value: transition.name as StateNames, + }) + } + }) + } + + public getContext = () => this.v.context + + public getValue = () => this.v.value + + public is = (stateName: StateNames) => this.v.value === stateName + + public send = ( + eventNameOrEvents: EventNames | Partial> + ) => { + signalSend.call(this, eventNameOrEvents) + } + + public setContext = (context: Settable) => + this.set(state => ({ + context: + typeof context === 'function' + ? (context as (prev: Context) => Context)(state.context) + : context, + value: state.value, + })) + + public mutateContext = ( + mutatable: + | RecursivePartial + | ((context: Context) => void | RecursivePartial) + ) => { + if (typeof mutatable === 'function') { + this.mutate(state => { + const result = ( + mutatable as (ctx: Context) => void | RecursivePartial + )(state.context) + + if (result && typeof result === 'object') { + return { context: result } as any + } + }) + } else { + this.mutate({ context: mutatable } as any) + } + } +} diff --git a/packages/machines/src/MachineStore.ts b/packages/machines/src/MachineStore.ts deleted file mode 100644 index d6056d6e..00000000 --- a/packages/machines/src/MachineStore.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { RecursivePartial, StoreSettable, Store } from '@zedux/core' -import { MachineStateShape } from './types' - -/** - * An extremely low-level Store class that represents a state machine. Don't - * create this class yourself, use a helper such as @zedux/machine's - * `injectMachineStore()` - */ -export class MachineStore< - StateNames extends string = string, - EventNames extends string = string, - Context extends Record | undefined = undefined -> extends Store> { - constructor( - initialState: StateNames, - public readonly states: Record< - StateNames, - Record< - EventNames, - { name: StateNames; guard?: (context: Context) => boolean } - > - >, - initialContext?: Context, - private readonly guard?: ( - currentState: MachineStateShape, - nextValue: StateNames - ) => boolean - ) { - super(null, { - context: initialContext as Context, - value: initialState, - }) - } - - public getContext = () => this.getState().context - - public getValue = () => this.getState().value - - public is = (stateName: StateNames) => this.getState().value === stateName - - public send = (eventName: EventNames, meta?: any) => - this.setState(currentState => { - const nextValue = this.states[currentState.value][eventName] - - if ( - !nextValue || - (nextValue.guard && !nextValue.guard(currentState.context)) || - (this.guard && !this.guard(currentState, nextValue.name)) - ) { - return currentState - } - - return { context: currentState.context, value: nextValue.name } - }, meta) - - public setContext = (context: StoreSettable, meta?: any) => - this.setState( - state => ({ - context: - typeof context === 'function' ? context(state.context) : context, - value: state.value, - }), - meta - ) - - public setContextDeep = ( - partialContext: StoreSettable, Context>, - meta?: any - ) => - this.setStateDeep( - state => ({ - context: - typeof partialContext === 'function' - ? partialContext(state.context) - : partialContext, - }), - meta - ) -} diff --git a/packages/machines/src/index.ts b/packages/machines/src/index.ts index 9649aad0..71cb0a37 100644 --- a/packages/machines/src/index.ts +++ b/packages/machines/src/index.ts @@ -1,3 +1,3 @@ -export * from './injectMachineStore' -export * from './MachineStore' +export * from './injectMachineSignal' +export * from './MachineSignal' export * from './types' diff --git a/packages/machines/src/injectMachineStore.ts b/packages/machines/src/injectMachineSignal.ts similarity index 64% rename from packages/machines/src/injectMachineStore.ts rename to packages/machines/src/injectMachineSignal.ts index f2451717..13326daa 100644 --- a/packages/machines/src/injectMachineStore.ts +++ b/packages/machines/src/injectMachineSignal.ts @@ -1,14 +1,7 @@ -import { - injectEffect, - injectMemo, - injectRef, - injectSelf, - InjectStoreConfig, -} from '@zedux/atoms' -import { zeduxTypes } from '@zedux/core' -import { MachineStore } from './MachineStore' +import { injectMemo, injectRef, injectSelf } from '@zedux/atoms' +import { MachineSignal } from './MachineSignal' import { MachineHook, MachineStateShape } from './types' -import { PartialStoreAtomInstance } from '@zedux/stores' +import { Eventless, EventlessStatic } from './utils' type ArrToUnion = S extends [infer K, ...infer Rest] ? Rest extends string[] @@ -16,7 +9,7 @@ type ArrToUnion = S extends [infer K, ...infer Rest] : never : never -export type InjectMachineStoreParams< +export type InjectMachineSignalParams< States extends MachineState[], Context extends Record | undefined = undefined > = [ @@ -34,7 +27,9 @@ export type InjectMachineStoreParams< MapStatesToEvents, Context > - } & InjectStoreConfig + hydrate?: boolean + reactive?: boolean + } ] export interface MachineState< @@ -107,16 +102,13 @@ type StateNameType = S extends MachineState< : never /** - * Create a MachineStore. Pass a statesFactory + * Create a MachineSignal. Pass a statesFactory. * * The first state in the state list returned from your statesFactory will - * become the initial state (`.value`) of the store. - * - * Registers an effect that listens to all store changes and calls the - * configured listeners appropriately. + * become the initial state (`.value`) of the signal. * * ```ts - * const store = injectMachineStore(state => [ + * const signal = injectMachineSignal(state => [ * state('a') * .on('next', 'b', localGuard) * .onEnter(enterListener) @@ -127,33 +119,32 @@ type StateNameType = S extends MachineState< * * Set a universal transition guard via the 3rd `config` object param. This * guard will be called every time a valid transition is about to occur. It will - * be called with the current `.context` value and should return a boolean. - * Return true to allow the transition, or any falsy value to deny it. + * be called with the current state and the next state name and should return a + * boolean. Return true to allow the transition, or any falsy value to deny it. * * Set a universal `onTransition` listener via the 3rd `config` object param. * This listener will be called every time the machine transitions to a new * state (after the state is updated). It will be called with 2 params: The - * current MachineStore and the storeEffect of the action that transitioned the - * store. For example, use `storeEffect.oldState.value` to see what state the - * machine just transitioned from. + * current MachineSignal and the change event containing `newState` and + * `oldState`. * * @param statesFactory Required. A function. Use the received state factory to * create a list of states for the machine and specify their transitions, * guards, and listeners. * @param initialContext Optional. An object or undefined. Will be set as the - * initial `.context` value of the machine store's state. - * @param config Optional. An object with 2 additional properties: `guard` and - * `onTransition`. + * initial `.context` value of the machine signal's state. + * @param config Optional. An object with additional properties: `guard`, + * `onTransition`, and `hydrate`. */ -export const injectMachineStore: < +export const injectMachineSignal: < States extends MachineState[], Context extends Record | undefined = undefined >( - ...[statesFactory, initialContext, config]: InjectMachineStoreParams< + ...[statesFactory, initialContext, config]: InjectMachineSignalParams< States, Context > -) => MachineStore< +) => MachineSignal< MapStatesToStateNames, MapStatesToEvents, Context @@ -161,7 +152,7 @@ export const injectMachineStore: < States extends MachineState[], Context extends Record | undefined = undefined >( - ...[statesFactory, initialContext, config]: InjectMachineStoreParams< + ...[statesFactory, initialContext, config]: InjectMachineSignalParams< States, Context > @@ -169,9 +160,9 @@ export const injectMachineStore: < type EventNames = MapStatesToEvents type StateNames = MapStatesToStateNames - const instance = injectSelf() as PartialStoreAtomInstance + const instance = injectSelf() - const { enterHooks, leaveHooks, store } = injectMemo(() => { + const { enterHooks, signal } = injectMemo(() => { const enterHooks: Record< string, MachineHook[] @@ -238,79 +229,67 @@ export const injectMachineStore: < const [initialState] = statesFactory(createState) const hydration = config?.hydrate && instance.e.hydration?.[instance.id] + const id = instance.e.makeId('signal', instance) - const store = new MachineStore( + const signal = new MachineSignal( + instance.e, + id, hydration?.value ?? (initialState.stateName as StateNames), states, hydration?.context ?? initialContext, config?.guard ) - return { enterHooks, leaveHooks, store } - }, []) + instance.e.n.set(id, signal) - const subscribeRef = injectRef() - subscribeRef.current = config?.subscribe + type State = MachineStateShape - injectEffect( - () => { - const subscription = store.subscribe({ - effects: storeEffect => { - const { action, newState, oldState } = storeEffect + signal.on('change', (changeEvent: { newState: State; oldState: State }) => { + const { newState, oldState } = changeEvent - if (newState.value === oldState?.value) return + if (newState.value === oldState.value) return - if (oldState && leaveHooks[oldState.value]) { - leaveHooks[oldState.value].forEach(callback => - callback(store, storeEffect) - ) - } - if (enterHooks[newState.value]) { - enterHooks[newState.value].forEach(callback => - callback(store, storeEffect) - ) - } - if (config?.onTransition) { - config.onTransition(store, storeEffect) - } - - // Nothing to do if the state hasn't changed. Also ignore state updates - // during evaluation or that are caused by `zeduxTypes.ignore` actions - if ( - !subscribeRef.current || - newState === oldState || - instance._isEvaluating || - action?.meta === zeduxTypes.ignore - ) { - return - } + if (leaveHooks[oldState.value]) { + leaveHooks[oldState.value].forEach(callback => + callback(signal, { newState, oldState }) + ) + } + if (enterHooks[newState.value]) { + enterHooks[newState.value].forEach(callback => + callback(signal, { newState, oldState }) + ) + } + if (config?.onTransition) { + config.onTransition(signal, { newState, oldState }) + } + }) - instance.r({ o: oldState }) + return { enterHooks, leaveHooks, signal } + }, []) - // run the scheduler synchronously after any store update - if (action?.meta !== zeduxTypes.batch) { - instance.e.syncScheduler.flush() - } - }, - }) - - return () => subscription.unsubscribe() - }, - [], - { synchronous: true } - ) - - const currentState = store.getState() - - if (enterHooks[currentState.value]) { - enterHooks[currentState.value].forEach(callback => - callback(store, { - action: { type: zeduxTypes.prime }, - newState: currentState, - store, - }) - ) + // Only fire initial onEnter hooks on the first evaluation. onEnter callbacks + // may call setContext(), which defers a signal.set(). That triggers + // re-evaluation, which would fire onEnter again → infinite loop. + const initialEnterFired = injectRef(false) + + if (!initialEnterFired.current) { + initialEnterFired.current = true + const currentState = signal.v + + if (enterHooks[currentState.value]) { + enterHooks[currentState.value].forEach(callback => + callback(signal, { + newState: currentState, + }) + ) + } } - return store + // Create a graph edge so the atom reacts to signal state changes + signal.get({ + f: config?.reactive === false ? EventlessStatic : Eventless, + op: 'injectMachineSignal', + }) + + return signal } diff --git a/packages/machines/src/types.ts b/packages/machines/src/types.ts index 3a87be6a..9e54ab9b 100644 --- a/packages/machines/src/types.ts +++ b/packages/machines/src/types.ts @@ -1,16 +1,15 @@ -import { StoreEffect } from '@zedux/core' -import { MachineStore } from './MachineStore' +import { MachineSignal } from './MachineSignal' export type MachineHook< - StateNames extends string, - EventNames extends string, - Context extends Record | undefined + StateNames extends string = string, + EventNames extends string = string, + Context extends Record | undefined = undefined > = ( - store: MachineStore, - storeEffect: StoreEffect< - MachineStateShape, - MachineStore - > + signal: MachineSignal, + event: { + newState: MachineStateShape + oldState?: MachineStateShape + } ) => void export interface MachineStateShape< @@ -21,16 +20,11 @@ export interface MachineStateShape< context: Context } -export type MachineStoreContextType = - M extends MachineStore ? C : never - -export type MachineStoreEventNamesType = - M extends MachineStore ? E : never +export type ContextOf> = + M extends MachineSignal ? C : never -export type MachineStoreStateType = - M extends MachineStore - ? MachineStateShape - : never +export type EventNamesOf> = + M extends MachineSignal ? E : never -export type MachineStoreStateNamesType = - M extends MachineStore ? S : never +export type StateNamesOf> = + M extends MachineSignal ? S : never diff --git a/packages/machines/src/utils.ts b/packages/machines/src/utils.ts new file mode 100644 index 00000000..b3cfd4a2 --- /dev/null +++ b/packages/machines/src/utils.ts @@ -0,0 +1,19 @@ +/** + * The EdgeFlags. These are used as bitwise flags. + * + * The flag score determines job priority in the scheduler. Scores range from + * 0-8. Lower score = higher prio. Examples: + * + * - 0 = eventAware-implicit-internal-dynamic (aka TopPrio) + * - 3 = eventless-explicit-internal-dynamic + * - 15 = eventless-explicit-external-static + * + * Event edges are (currently) never paired with other flags and are unique in + * that they don't prevent node destruction. + * + * IMPORTANT: Keep these in-sync with the main package - + * packages/atoms/src/utils/general.ts + */ +export const Eventless = 1 +export const Static = 8 +export const EventlessStatic = Eventless | Static diff --git a/packages/machines/test/integrations/state-machines.test.tsx b/packages/machines/test/integrations/state-machines.test.tsx index 9111b6bd..26d249df 100644 --- a/packages/machines/test/integrations/state-machines.test.tsx +++ b/packages/machines/test/integrations/state-machines.test.tsx @@ -1,34 +1,21 @@ -import { - injectMachineStore, - InjectMachineStoreParams, - MachineState, -} from '@zedux/machines' -import { storeApi, storeAtom } from '@zedux/stores' +import { injectMachineSignal, MachineSignal } from '@zedux/machines' +import { atom } from '@zedux/atoms' import { ecosystem } from '../../../react/test/utils/ecosystem' -const injectMachine = < - States extends MachineState[], - Context extends Record | undefined = undefined ->( - ...args: InjectMachineStoreParams -) => { - const store = injectMachineStore(...args) - return storeApi(store) -} - describe('state machines', () => { test('toggler machine be togglin', () => { - const toggleAtom = storeAtom('toggle', () => - injectMachine(state => [ + const toggleAtom = atom('toggle', () => + injectMachineSignal(state => [ state('a').on('toggle', 'b'), state('b').on('toggle', 'a'), ]) ) const instance = ecosystem.getInstance(toggleAtom) - const { getValue, is, send } = instance.store + const signal = instance.S! as unknown as MachineSignal<'a' | 'b', 'toggle'> + const { getValue, is, send } = signal - expect(instance.getState()).toEqual({ context: undefined, value: 'a' }) + expect(instance.v).toEqual({ context: undefined, value: 'a' }) expect(getValue()).toBe('a') expect(is('a')).toBe(true) expect(is('b')).toBe(false) @@ -52,15 +39,17 @@ describe('state machines', () => { }) test('only takes valid transitions', () => { - const machineAtom = storeAtom('machine', () => - injectMachine(state => [ + const machineAtom = atom('machine', () => + injectMachineSignal(state => [ state('a').on('up', 'b'), state('b').on('up', 'c').on('down', 'a'), state('c').on('down', 'b'), ]) ) - const { is, send } = ecosystem.getInstance(machineAtom).store + const signal = ecosystem.getInstance(machineAtom) + .S! as unknown as MachineSignal<'a' | 'b' | 'c', 'up' | 'down'> + const { is, send } = signal expect(is('a')).toBe(true) send('down') @@ -88,18 +77,23 @@ describe('state machines', () => { test('context helpers update context correctly', () => { const initialContext = { a: 1, b: { c: 2, d: 3 } } - const machineAtom = storeAtom('machine', () => - injectMachine(state => [state('a')], initialContext) + const machineAtom = atom('machine', () => + injectMachineSignal(state => [state('a')], initialContext) ) const instance = ecosystem.getInstance(machineAtom) - const { getContext, setContext, setContextDeep } = instance.store + const signal = instance.S! as unknown as MachineSignal< + 'a', + never, + typeof initialContext + > + const { getContext, setContext, mutateContext } = signal expect(getContext()).toBe(initialContext) - expect(instance.getState()).toEqual({ context: initialContext, value: 'a' }) - setContextDeep({ b: { d: 4 } }) + expect(instance.v).toEqual({ context: initialContext, value: 'a' }) + mutateContext({ b: { d: 4 } }) expect(getContext()).toEqual({ a: 1, b: { c: 2, d: 4 } }) - setContextDeep(state => ({ b: { c: state.b.c + 1 } })) + mutateContext(state => ({ b: { c: state.b.c + 1 } })) expect(getContext()).toEqual({ a: 1, b: { c: 3, d: 4 } }) setContext(state => ({ ...state, a: 5 })) expect(getContext()).toEqual({ a: 5, b: { c: 3, d: 4 } }) @@ -108,8 +102,8 @@ describe('state machines', () => { }) test('state transition guard be guardin', () => { - const toggleAtom = storeAtom('toggle', () => - injectMachine( + const toggleAtom = atom('toggle', () => + injectMachineSignal( state => [ state('a').on('toggle', 'b', context => !context.isPaused), state('b').on('toggle', 'a'), @@ -118,7 +112,9 @@ describe('state machines', () => { ) ) - const { is, send, setContext } = ecosystem.getInstance(toggleAtom).store + const signal = ecosystem.getInstance(toggleAtom) + .S! as unknown as MachineSignal<'a' | 'b', 'toggle', { isPaused: boolean }> + const { is, send, setContext } = signal send('toggle') expect(is('b')).toBe(true) @@ -138,15 +134,17 @@ describe('state machines', () => { }) test('universal transition guard be guardin', () => { - const toggleAtom = storeAtom('toggle', () => - injectMachine( + const toggleAtom = atom('toggle', () => + injectMachineSignal( state => [state('a').on('toggle', 'b'), state('b').on('toggle', 'a')], { isPaused: false }, { guard: state => !state.context.isPaused } ) ) - const { is, send, setContext } = ecosystem.getInstance(toggleAtom).store + const signal = ecosystem.getInstance(toggleAtom) + .S! as unknown as MachineSignal<'a' | 'b', 'toggle', { isPaused: boolean }> + const { is, send, setContext } = signal send('toggle') expect(is('b')).toBe(true) @@ -166,8 +164,8 @@ describe('state machines', () => { }) test('universal guard is called when state guard returns true', () => { - const toggleAtom = storeAtom('toggle', () => - injectMachine( + const toggleAtom = atom('toggle', () => + injectMachineSignal( state => [ state('a').on('toggle', 'b', () => true), state('b').on('toggle', 'a'), @@ -177,7 +175,9 @@ describe('state machines', () => { ) ) - const { is, send, setContext } = ecosystem.getInstance(toggleAtom).store + const signal = ecosystem.getInstance(toggleAtom) + .S! as unknown as MachineSignal<'a' | 'b', 'toggle', { isPaused: boolean }> + const { is, send, setContext } = signal send('toggle') expect(is('b')).toBe(true) @@ -202,36 +202,42 @@ describe('state machines', () => { const enter = jest.fn() const leave = jest.fn() const leave2 = jest.fn() - const trafficLightAtom = storeAtom('trafficLight', () => - injectMachine( + const trafficLightAtom = atom('trafficLight', () => + injectMachineSignal( state => [ state('green') .on('timer', 'yellow') - .onEnter(store => { - const handle = setTimeout(() => store.send('timer')) - store.setContext({ handle }) + .onEnter(signal => { + const handle = setTimeout(() => signal.send('timer')) + signal.setContext({ handle }) }), state('yellow') .on('timer', 'red') - .onEnter(store => { - const handle = setTimeout(() => store.send('timer')) - store.setContext({ handle }) + .onEnter(signal => { + const handle = setTimeout(() => signal.send('timer')) + signal.setContext({ handle }) }) .onLeave(leave) .onLeave(leave2) .onEnter(enter), state('red') .on('timer', 'green') - .onEnter(store => { - const handle = setTimeout(() => store.send('timer')) - store.setContext({ handle }) + .onEnter(signal => { + const handle = setTimeout(() => signal.send('timer')) + signal.setContext({ handle }) }), ], { handle: undefined as undefined | ReturnType } ) ) - const { getContext, is } = ecosystem.getInstance(trafficLightAtom).store + const signal = ecosystem.getInstance(trafficLightAtom) + .S! as unknown as MachineSignal< + 'green' | 'yellow' | 'red', + 'timer', + { handle: undefined | ReturnType } + > + const { getContext, is } = signal expect(is('green')).toBe(true) expect(enter).not.toHaveBeenCalled() @@ -252,8 +258,8 @@ describe('state machines', () => { test('universal transition listener be listenin', () => { const onTransition = jest.fn() - const toggleAtom = storeAtom('toggle', () => - injectMachine( + const toggleAtom = atom('toggle', () => + injectMachineSignal( state => [state('a').on('toggle', 'b'), state('b').on('toggle', 'a')], undefined, { onTransition } @@ -261,7 +267,8 @@ describe('state machines', () => { ) const instance = ecosystem.getInstance(toggleAtom) - const { send } = instance.store + const signal = instance.S! as unknown as MachineSignal<'a' | 'b', 'toggle'> + const { send } = signal send('toggle') send('toggle') @@ -272,12 +279,303 @@ describe('state machines', () => { expect(onTransition).toHaveBeenCalledTimes(4) expect(onTransition).toHaveBeenLastCalledWith( - instance.store, + signal, expect.objectContaining({ newState: { context: undefined, value: 'a' }, oldState: { context: undefined, value: 'b' }, - store: instance.store, }) ) }) + + test('onEnter calling setContext during initial evaluation does not loop', () => { + const enterCalls = jest.fn() + const machineAtom = atom('onEnterSetCtx', () => + injectMachineSignal( + state => [ + state('idle') + .on('start', 'running') + .onEnter(signal => { + enterCalls() + signal.setContext({ initialized: true }) + }), + state('running').on('stop', 'idle'), + ], + { initialized: false } + ) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal< + 'idle' | 'running', + 'start' | 'stop', + { initialized: boolean } + > + + expect(enterCalls).toHaveBeenCalledTimes(1) + expect(signal.getContext()).toEqual({ initialized: true }) + expect(signal.getValue()).toBe('idle') + }) + + test('onEnter and onLeave receive correct event arguments', () => { + const onEnterA = jest.fn() + const onLeaveA = jest.fn() + const onEnterB = jest.fn() + + const machineAtom = atom('hookArgs', () => + injectMachineSignal(state => [ + state('a').on('next', 'b').onEnter(onEnterA).onLeave(onLeaveA), + state('b').on('next', 'a').onEnter(onEnterB), + ]) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal<'a' | 'b', 'next'> + + // Initial onEnter for 'a' has newState but no oldState + expect(onEnterA).toHaveBeenCalledTimes(1) + expect(onEnterA).toHaveBeenCalledWith(signal, { + newState: { context: undefined, value: 'a' }, + }) + + signal.send('next') // a -> b + + expect(onLeaveA).toHaveBeenCalledTimes(1) + expect(onLeaveA).toHaveBeenCalledWith(signal, { + newState: { context: undefined, value: 'b' }, + oldState: { context: undefined, value: 'a' }, + }) + + expect(onEnterB).toHaveBeenCalledTimes(1) + expect(onEnterB).toHaveBeenCalledWith(signal, { + newState: { context: undefined, value: 'b' }, + oldState: { context: undefined, value: 'a' }, + }) + }) + + test('state guard receives context as its argument', () => { + const guardSpy = jest.fn(() => true) + + const machineAtom = atom('stateGuardArg', () => + injectMachineSignal( + state => [state('a').on('next', 'b', guardSpy), state('b')], + { count: 42 } + ) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal< + 'a' | 'b', + 'next', + { count: number } + > + + signal.send('next') + expect(guardSpy).toHaveBeenCalledWith({ count: 42 }) + expect(signal.getValue()).toBe('b') + }) + + test('universal guard receives currentState and nextValue', () => { + const guardSpy = jest.fn(() => true) + + const machineAtom = atom('universalGuardArg', () => + injectMachineSignal( + state => [state('a').on('next', 'b'), state('b')], + { count: 7 }, + { guard: guardSpy } + ) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal< + 'a' | 'b', + 'next', + { count: number } + > + + signal.send('next') + expect(guardSpy).toHaveBeenCalledWith( + { context: { count: 7 }, value: 'a' }, + 'b' + ) + expect(signal.getValue()).toBe('b') + }) + + test('state guard returning false prevents universal guard from running', () => { + const universalGuard = jest.fn(() => true) + + const machineAtom = atom('guardOrder', () => + injectMachineSignal( + state => [state('a').on('next', 'b', () => false), state('b')], + undefined, + { guard: universalGuard } + ) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal<'a' | 'b', 'next'> + + signal.send('next') + expect(signal.getValue()).toBe('a') + expect(universalGuard).not.toHaveBeenCalled() + }) + + test('onTransition is not called for invalid transitions', () => { + const onTransition = jest.fn() + + const machineAtom = atom('invalidTransition', () => + injectMachineSignal( + state => [ + state('a').on('next', 'b'), + state('b').on('back', 'a'), + ], + undefined, + { onTransition } + ) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal< + 'a' | 'b', + 'next' | 'back' + > + + signal.send('back') // invalid from 'a' + expect(onTransition).not.toHaveBeenCalled() + + signal.send('next') // valid: a -> b + expect(onTransition).toHaveBeenCalledTimes(1) + + signal.send('next') // invalid from 'b' + expect(onTransition).toHaveBeenCalledTimes(1) + }) + + test('machine with terminal state stays in that state', () => { + const machineAtom = atom('terminal', () => + injectMachineSignal(state => [ + state('alive').on('die', 'dead'), + state('dead'), + ]) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal< + 'alive' | 'dead', + 'die' + > + + expect(signal.getValue()).toBe('alive') + signal.send('die') + expect(signal.getValue()).toBe('dead') + signal.send('die') + expect(signal.getValue()).toBe('dead') + }) + + test('atom value updates when signal transitions', () => { + const machineAtom = atom('atomReactivity', () => + injectMachineSignal(state => [ + state('a').on('next', 'b'), + state('b').on('next', 'a'), + ]) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal<'a' | 'b', 'next'> + + expect(instance.v).toEqual({ context: undefined, value: 'a' }) + signal.send('next') + expect(instance.v).toEqual({ context: undefined, value: 'b' }) + signal.send('next') + expect(instance.v).toEqual({ context: undefined, value: 'a' }) + }) + + test('setContext updates context without changing state value', () => { + const machineAtom = atom('setCtxValue', () => + injectMachineSignal( + state => [state('a').on('next', 'b'), state('b')], + { count: 0 } + ) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal< + 'a' | 'b', + 'next', + { count: number } + > + + signal.setContext({ count: 1 }) + expect(signal.getValue()).toBe('a') + expect(signal.getContext()).toEqual({ count: 1 }) + + signal.setContext(ctx => ({ count: ctx.count + 1 })) + expect(signal.getValue()).toBe('a') + expect(signal.getContext()).toEqual({ count: 2 }) + }) + + test('mutateContext deep-merges partial context', () => { + const machineAtom = atom('deepMerge', () => + injectMachineSignal( + state => [state('a')], + { nested: { x: 1, y: 2 }, other: 'keep' } + ) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal< + 'a', + never, + { nested: { x: number; y: number }; other: string } + > + + signal.mutateContext({ nested: { x: 99 } }) + expect(signal.getContext()).toEqual({ nested: { x: 99, y: 2 }, other: 'keep' }) + }) + + test('mutateContext supports in-place mutation', () => { + const machineAtom = atom('inPlaceMutate', () => + injectMachineSignal( + state => [state('a')], + { items: [1, 2, 3], label: 'test' } + ) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal< + 'a', + never, + { items: number[]; label: string } + > + + signal.mutateContext(ctx => { + ctx.items.push(4) + ctx.label = 'updated' + }) + expect(signal.getContext()).toEqual({ items: [1, 2, 3, 4], label: 'updated' }) + }) + + test('send with object form processes events sequentially', () => { + const machineAtom = atom('sendObject', () => + injectMachineSignal(state => [ + state('a').on('next', 'b'), + state('b').on('next', 'c'), + state('c').on('back', 'a'), + ]) + ) + + const instance = ecosystem.getInstance(machineAtom) + const signal = instance.S! as unknown as MachineSignal< + 'a' | 'b' | 'c', + 'next' | 'back' + > + + expect(signal.getValue()).toBe('a') + + // next: a -> b, then next again: b -> c + signal.send({ next: undefined } as any) + expect(signal.getValue()).toBe('b') + + signal.send({ next: undefined, back: undefined } as any) + // next: b -> c, back: c -> a + expect(signal.getValue()).toBe('a') + }) }) diff --git a/packages/machines/test/snippets/api.tsx b/packages/machines/test/snippets/api.tsx index 6f562b1a..3d50f1e8 100644 --- a/packages/machines/test/snippets/api.tsx +++ b/packages/machines/test/snippets/api.tsx @@ -1,69 +1,51 @@ import { useAtomSelector, useAtomValue } from '../../../react/src' -import { injectMachineStore } from '@zedux/machines' +import { injectMachineSignal } from '@zedux/machines' +import { atom, api } from '@zedux/atoms' import { storeApi, storeAtom, injectStore, storeIon } from '@zedux/stores' import React, { Suspense, useState } from 'react' const a = storeAtom('a', () => injectStore('a')) const b = storeAtom('b', () => storeApi(injectStore('a'))) -const c = storeAtom('c', () => - injectMachineStore(state => [state('a').on('next', 'b')]) +const c = atom('c', () => + injectMachineSignal(state => [state('a').on('next', 'b')]) ) -const d = storeAtom('d', () => - storeApi(injectMachineStore(state => [state('a').on('next', 'b')])) +const d = atom('d', () => + api(injectMachineSignal(state => [state('a').on('next', 'b')])) ) const e = storeAtom('e', () => storeApi(new Promise(resolve => resolve('a'))) ) const f = storeIon('f', () => injectStore('a')) const g = storeIon('g', () => storeApi(injectStore('a'))) -const h = storeIon('h', () => - injectMachineStore(state => [state('a').on('next', 'b')]) -) -const i = storeIon('i', () => - storeApi(injectMachineStore(state => [state('a').on('next', 'b')])) -) function Child() { - const [ - storeA, - storeB, - storeC, - storeD, - storeE, - storeF, - storeG, - storeH, - storeI, - ] = useAtomSelector( - ({ getInstance }) => - [ - getInstance(a).store, - getInstance(b).store, - getInstance(c).store, - getInstance(d).store, - getInstance(e).store, - getInstance(f).store, - getInstance(g).store, - getInstance(h).store, - getInstance(i).store, - ] as const - ) + const [storeA, storeB, instanceC, instanceD, storeE, storeF, storeG] = + useAtomSelector( + ({ getInstance }) => + [ + getInstance(a).store, + getInstance(b).store, + getInstance(c), + getInstance(d), + getInstance(e).store, + getInstance(f).store, + getInstance(g).store, + ] as const + ) storeA.getState().slice() storeB.getState().slice() - storeC.is('a') - storeD.is('a') + instanceC.v.value + instanceD.v.value storeE.getState().data storeF.getState().slice() storeG.getState().slice() - storeH.is('a') - storeI.is('a') return (
{useAtomValue(a)} {useAtomValue(b)} {useAtomValue(c).value}{' '} {useAtomValue(d).value} {useAtomValue(e).data} {useAtomValue(f)}{' '} - {useAtomValue(g)} {useAtomValue(h).value} {useAtomValue(i).value} + {useAtomValue(g)}
) } diff --git a/packages/machines/test/types.test.ts b/packages/machines/test/types.test.ts new file mode 100644 index 00000000..3464e6a1 --- /dev/null +++ b/packages/machines/test/types.test.ts @@ -0,0 +1,193 @@ +import { atom, StateOf } from '@zedux/atoms' +import { + ContextOf, + EventNamesOf, + injectMachineSignal, + MachineSignal, + MachineStateShape, + StateNamesOf, +} from '@zedux/machines' +import { expectTypeOf } from 'expect-type' +import { ecosystem } from '../../react/test/utils/ecosystem' + +// -- Test fixtures -- + +const toggleAtom = atom('toggle', () => + injectMachineSignal( + state => [state('on').on('toggle', 'off'), state('off').on('toggle', 'on')], + { count: 0 } + ) +) + +type ToggleContext = { count: number } + +describe('machine type helpers', () => { + afterEach(() => { + ecosystem.reset() + }) + + test('ContextOf extracts context type from MachineSignal', () => { + type WithContext = MachineSignal<'a' | 'b', 'next', { count: number }> + type NoContext = MachineSignal<'a', 'next'> + type UndefinedContext = MachineSignal<'a', 'next', undefined> + + expectTypeOf>().toEqualTypeOf<{ count: number }>() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + }) + + test('ContextOf works with inferred signal from injectMachineSignal', () => { + const instance = ecosystem.getInstance(toggleAtom) + const signal = instance.S! as unknown as MachineSignal< + 'on' | 'off', + 'toggle', + ToggleContext + > + + expectTypeOf>().toEqualTypeOf() + }) + + test('EventNamesOf extracts event name union from MachineSignal', () => { + type MultiEvent = MachineSignal<'a' | 'b', 'up' | 'down' | 'reset'> + type SingleEvent = MachineSignal<'a' | 'b', 'toggle'> + + expectTypeOf>().toEqualTypeOf< + 'up' | 'down' | 'reset' + >() + expectTypeOf>().toEqualTypeOf<'toggle'>() + }) + + test('EventNamesOf works with inferred signal from injectMachineSignal', () => { + const instance = ecosystem.getInstance(toggleAtom) + const signal = instance.S! as unknown as MachineSignal< + 'on' | 'off', + 'toggle', + ToggleContext + > + + expectTypeOf>().toEqualTypeOf<'toggle'>() + }) + + test('StateNamesOf extracts state name union from MachineSignal', () => { + type MultiState = MachineSignal<'idle' | 'loading' | 'error' | 'success'> + type SingleState = MachineSignal<'only'> + + expectTypeOf>().toEqualTypeOf< + 'idle' | 'loading' | 'error' | 'success' + >() + expectTypeOf>().toEqualTypeOf<'only'>() + }) + + test('StateNamesOf works with inferred signal from injectMachineSignal', () => { + const instance = ecosystem.getInstance(toggleAtom) + const signal = instance.S! as unknown as MachineSignal< + 'on' | 'off', + 'toggle', + ToggleContext + > + + expectTypeOf>().toEqualTypeOf<'on' | 'off'>() + }) + + test('StateOf from @zedux/atoms extracts full state shape from MachineSignal', () => { + type WithContext = MachineSignal<'a' | 'b', 'next', { count: number }> + type NoContext = MachineSignal<'a' | 'b', 'next'> + + expectTypeOf>().toEqualTypeOf< + MachineStateShape<'a' | 'b', { count: number }> + >() + + expectTypeOf>().toEqualTypeOf< + MachineStateShape<'a' | 'b', undefined> + >() + }) + + test('StateOf resolves to an object with value and context', () => { + type Sig = MachineSignal<'idle' | 'running', 'start' | 'stop', { progress: number }> + type State = StateOf + + expectTypeOf().toHaveProperty('value') + expectTypeOf().toEqualTypeOf<'idle' | 'running'>() + + expectTypeOf().toHaveProperty('context') + expectTypeOf().toEqualTypeOf<{ progress: number }>() + }) + + test('StateOf works with inferred signal from injectMachineSignal', () => { + const instance = ecosystem.getInstance(toggleAtom) + const signal = instance.S! as unknown as MachineSignal< + 'on' | 'off', + 'toggle', + ToggleContext + > + + expectTypeOf>().toEqualTypeOf< + MachineStateShape<'on' | 'off', ToggleContext> + >() + }) + + test('all helpers work together on the same signal type', () => { + type Sig = MachineSignal< + 'idle' | 'loading' | 'done', + 'start' | 'finish' | 'reset', + { data: string[]; error: string | null } + > + + type Ctx = ContextOf + type Events = EventNamesOf + type Names = StateNamesOf + type State = StateOf + + expectTypeOf().toEqualTypeOf<{ data: string[]; error: string | null }>() + expectTypeOf().toEqualTypeOf<'start' | 'finish' | 'reset'>() + expectTypeOf().toEqualTypeOf<'idle' | 'loading' | 'done'>() + expectTypeOf().toEqualTypeOf< + MachineStateShape<'idle' | 'loading' | 'done', { data: string[]; error: string | null }> + >() + + // State's value matches StateNamesOf + expectTypeOf().toEqualTypeOf() + + // State's context matches ContextOf + expectTypeOf().toEqualTypeOf() + }) + + test('helpers work with generic MachineSignal (no type params)', () => { + type Generic = MachineSignal + + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toBeString() + expectTypeOf>().toBeString() + expectTypeOf>().toEqualTypeOf< + MachineStateShape + >() + }) + + test('MachineSignal method return types are properly typed', () => { + type Sig = MachineSignal<'a' | 'b', 'toggle', { count: number }> + + expectTypeOf().returns.toEqualTypeOf<{ count: number }>() + expectTypeOf().returns.toEqualTypeOf<'a' | 'b'>() + expectTypeOf().parameter(0).toEqualTypeOf<'a' | 'b'>() + expectTypeOf().returns.toBeBoolean() + expectTypeOf().parameter(0).toEqualTypeOf< + 'toggle' | Partial> + >() + }) + + test('MachineStateShape has correct structure', () => { + type Shape = MachineStateShape<'x' | 'y', { label: string }> + + expectTypeOf().toHaveProperty('value') + expectTypeOf().toHaveProperty('context') + expectTypeOf().toEqualTypeOf<'x' | 'y'>() + expectTypeOf().toEqualTypeOf<{ label: string }>() + }) + + test('MachineStateShape defaults', () => { + type DefaultShape = MachineStateShape + + expectTypeOf().toBeString() + expectTypeOf().toEqualTypeOf() + }) +}) diff --git a/packages/machines/test/units/MachineStore.test.ts b/packages/machines/test/units/MachineStore.test.ts index 53eac047..b6b21084 100644 --- a/packages/machines/test/units/MachineStore.test.ts +++ b/packages/machines/test/units/MachineStore.test.ts @@ -1,69 +1,305 @@ -import { Store } from '@zedux/core' +import { createEcosystem, Signal } from '@zedux/atoms' +import { MachineSignal } from '@zedux/machines' import { getDoorMachine, getToggleMachine } from '../utils' -describe('MachineStore', () => { - test('returns a Store', () => { +describe('MachineSignal', () => { + test('returns a Signal', () => { const machine = getToggleMachine() - expect(machine instanceof Store).toBe(true) + expect(machine instanceof Signal).toBe(true) }) }) -describe('MachineStore.send', () => { +describe('MachineSignal.send', () => { test('updates the state on a valid transition', () => { const machine = getDoorMachine() - expect(machine.getState().value).toBe('open') + expect(machine.v.value).toBe('open') machine.send('buttonPress') - expect(machine.getState().value).toBe('closing') + expect(machine.v.value).toBe('closing') machine.send('buttonPress') - expect(machine.getState().value).toBe('opening') + expect(machine.v.value).toBe('opening') machine.send('buttonPress') - expect(machine.getState().value).toBe('closing') + expect(machine.v.value).toBe('closing') machine.send('timeout') - expect(machine.getState().value).toBe('closed') + expect(machine.v.value).toBe('closed') machine.send('buttonPress') - expect(machine.getState().value).toBe('opening') + expect(machine.v.value).toBe('opening') machine.send('buttonPress') - expect(machine.getState().value).toBe('closing') + expect(machine.v.value).toBe('closing') machine.send('buttonPress') - expect(machine.getState().value).toBe('opening') + expect(machine.v.value).toBe('opening') machine.send('timeout') - expect(machine.getState().value).toBe('open') + expect(machine.v.value).toBe('open') }) test("doesn't update the state on an invalid transition", () => { const machine = getDoorMachine() - expect(machine.getState().value).toBe('open') + expect(machine.v.value).toBe('open') machine.send('timeout') - expect(machine.getState().value).toBe('open') + expect(machine.v.value).toBe('open') machine.send('buttonPress') - expect(machine.getState().value).toBe('closing') + expect(machine.v.value).toBe('closing') machine.send('timeout') - expect(machine.getState().value).toBe('closed') + expect(machine.v.value).toBe('closed') machine.send('timeout') - expect(machine.getState().value).toBe('closed') + expect(machine.v.value).toBe('closed') machine.send('buttonPress') - expect(machine.getState().value).toBe('opening') + expect(machine.v.value).toBe('opening') machine.send('timeout') - expect(machine.getState().value).toBe('open') + expect(machine.v.value).toBe('open') + }) + + test('sends a single event via object form', () => { + const machine = getDoorMachine() + + expect(machine.v.value).toBe('open') + machine.send({ buttonPress: undefined } as any) + expect(machine.v.value).toBe('closing') + }) + + test('sends multiple events in order via object form', () => { + const machine = getDoorMachine() + + machine.send('buttonPress') // open -> closing + expect(machine.v.value).toBe('closing') + + // From closing: buttonPress -> opening, timeout -> closed + // Object keys iterate in insertion order + // buttonPress: closing -> opening + // timeout: opening -> open + machine.send({ buttonPress: undefined, timeout: undefined } as any) + expect(machine.v.value).toBe('open') }) }) -describe('MachineStore.setContext', () => { - test('sets the context recursively', () => { +describe('MachineSignal.setContext', () => { + test('updates context with a function', () => { + const machine = getDoorMachine() + + machine.setContext(ctx => ({ ...ctx, timeoutId: { nestedId: 42 } })) + expect(machine.v.context).toEqual({ timeoutId: { nestedId: 42 } }) + }) + + test('preserves current state value', () => { const machine = getDoorMachine() - expect(machine.getState().context).toEqual({ timeoutId: null }) - machine.setContextDeep({ timeoutId: {} }) - expect(machine.getState().context).toEqual({ timeoutId: {} }) - machine.setContextDeep({ timeoutId: { nestedId: 1 } }) - expect(machine.getState().context).toEqual({ timeoutId: { nestedId: 1 } }) - machine.setContextDeep({ other: 'b' }) - expect(machine.getState().context).toEqual({ + machine.send('buttonPress') // open -> closing + expect(machine.v.value).toBe('closing') + + machine.setContext(ctx => ({ ...ctx, timeoutId: { nestedId: 1 } })) + expect(machine.v.value).toBe('closing') + expect(machine.v.context).toEqual({ timeoutId: { nestedId: 1 } }) + }) +}) + +describe('MachineSignal.mutateContext', () => { + test('deep-merges partial context via object form', () => { + const machine = getDoorMachine() + + expect(machine.v.context).toEqual({ timeoutId: null }) + machine.mutateContext({ timeoutId: {} }) + expect(machine.v.context).toEqual({ timeoutId: {} }) + machine.mutateContext({ timeoutId: { nestedId: 1 } }) + expect(machine.v.context).toEqual({ timeoutId: { nestedId: 1 } }) + machine.mutateContext({ other: 'b' }) + expect(machine.v.context).toEqual({ timeoutId: { nestedId: 1 }, other: 'b', }) }) + + test('deep-merges partial context via function returning partial', () => { + const machine = getDoorMachine() + + machine.mutateContext(ctx => ({ timeoutId: { nestedId: ctx.timeoutId ? 1 : 2 } })) + expect(machine.v.context).toEqual({ timeoutId: { nestedId: 2 } }) + }) + + test('supports in-place mutation via function', () => { + const ecosystem = createEcosystem({ id: 'mutate-inplace-test' }) + const machine = new MachineSignal( + ecosystem, + ecosystem.makeId('signal'), + 'a', + { a: {} } as any, + { items: [1, 2, 3], label: 'test' } + ) + + machine.mutateContext(ctx => { + ctx.items.push(4) + ctx.label = 'updated' + }) + expect(machine.v.context).toEqual({ items: [1, 2, 3, 4], label: 'updated' }) + ecosystem.reset() + }) + + test('object form merges arrays by index', () => { + const ecosystem = createEcosystem({ id: 'array-test' }) + const machine = new MachineSignal( + ecosystem, + ecosystem.makeId('signal'), + 'a', + { a: {} } as any, + { items: [1, 2, 3], label: 'test' } + ) + + // object form deep-merges by index, not replaces + machine.mutateContext({ items: [4, 5] } as any) + expect(machine.v.context).toEqual({ items: [4, 5, 3], label: 'test' }) + ecosystem.reset() + }) + + test('function form can replace arrays entirely', () => { + const ecosystem = createEcosystem({ id: 'array-replace-test' }) + const machine = new MachineSignal( + ecosystem, + ecosystem.makeId('signal'), + 'a', + { a: {} } as any, + { items: [1, 2, 3], label: 'test' } + ) + + machine.mutateContext(ctx => { + ctx.items = [4, 5] + }) + expect(machine.v.context).toEqual({ items: [4, 5], label: 'test' }) + ecosystem.reset() + }) +}) + +describe('MachineSignal with guard', () => { + test('constructor guard blocks transitions', () => { + const ecosystem = createEcosystem({ id: 'guard-unit-test' }) + const machine = new MachineSignal( + ecosystem, + ecosystem.makeId('signal'), + 'a', + { + a: { next: { name: 'b' } }, + b: { next: { name: 'a' } }, + } as any, + undefined, + () => false + ) + + machine.send('next') + expect(machine.v.value).toBe('a') + ecosystem.reset() + }) + + test('constructor guard allows transitions when returning true', () => { + const ecosystem = createEcosystem({ id: 'guard-allow-test' }) + const machine = new MachineSignal( + ecosystem, + ecosystem.makeId('signal'), + 'a', + { + a: { next: { name: 'b' } }, + b: { next: { name: 'a' } }, + } as any, + undefined, + () => true + ) + + machine.send('next') + expect(machine.v.value).toBe('b') + ecosystem.reset() + }) +}) + +describe('MachineSignal event system', () => { + test('.set() with machine events triggers transitions', () => { + const ecosystem = createEcosystem({ id: 'set-events-test' }) + const machine = new MachineSignal( + ecosystem, + ecosystem.makeId('signal'), + 'a', + { + a: { next: { name: 'b' } }, + b: { next: { name: 'a' } }, + } as any + ) + + expect(machine.v.value).toBe('a') + + // .set() only dispatches events when state changes (new reference required) + machine.set({ ...machine.v }, { next: undefined } as any) + expect(machine.v.value).toBe('b') + + machine.set({ ...machine.v }, { next: undefined } as any) + expect(machine.v.value).toBe('a') + ecosystem.reset() + }) + + test('.mutate() with machine events triggers transitions', () => { + const ecosystem = createEcosystem({ id: 'mutate-events-test' }) + const machine = new MachineSignal( + ecosystem, + ecosystem.makeId('signal'), + 'a', + { + a: { next: { name: 'b' } }, + b: { next: { name: 'a' } }, + } as any, + { count: 0 } + ) + + expect(machine.v.value).toBe('a') + + // Dispatching event via .mutate() should trigger the catch-all listener + machine.mutate(state => { + state.context.count = 1 + }, { next: undefined } as any) + + expect(machine.v.value).toBe('b') + expect(machine.v.context.count).toBe(1) + ecosystem.reset() + }) + + test('signal.on receives specific machine events', () => { + const ecosystem = createEcosystem({ id: 'on-event-test' }) + const machine = new MachineSignal( + ecosystem, + ecosystem.makeId('signal'), + 'a', + { + a: { next: { name: 'b' } }, + b: { next: { name: 'a' } }, + } as any + ) + + const callback = jest.fn() + machine.on('next' as any, callback) + + machine.send('next') + expect(callback).toHaveBeenCalledTimes(1) + + machine.send('next') + expect(callback).toHaveBeenCalledTimes(2) + ecosystem.reset() + }) + + test('catch-all external listener receives machine events', () => { + const ecosystem = createEcosystem({ id: 'catch-all-test' }) + const machine = new MachineSignal( + ecosystem, + ecosystem.makeId('signal'), + 'a', + { + a: { next: { name: 'b' } }, + b: { next: { name: 'a' } }, + } as any + ) + + const callback = jest.fn() + machine.on(callback) + + machine.send('next') + // catch-all listener fires for the send event and the resulting state change + expect(callback).toHaveBeenCalled() + expect(callback.mock.calls[0][0]).toHaveProperty('next') + ecosystem.reset() + }) }) diff --git a/packages/machines/test/utils.ts b/packages/machines/test/utils.ts index 36b8d60c..0ef45bc7 100644 --- a/packages/machines/test/utils.ts +++ b/packages/machines/test/utils.ts @@ -1,11 +1,16 @@ -import { MachineStore } from '@zedux/machines' +import { createEcosystem } from '@zedux/atoms' +import { MachineSignal } from '@zedux/machines' + +const ecosystem = createEcosystem({ id: 'machines-test' }) export const getDoorMachine = () => - new MachineStore< + new MachineSignal< 'open' | 'opening' | 'closing' | 'closed', 'buttonPress' | 'timeout', { timeoutId: null | { nestedId: null | number }; other?: string } >( + ecosystem, + ecosystem.makeId('signal'), 'open', { open: { buttonPress: { name: 'closing' } }, @@ -20,7 +25,12 @@ export const getDoorMachine = () => ) export const getToggleMachine = () => - new MachineStore('a', { - a: { toggle: { name: 'b' } }, - b: { toggle: { name: 'a' } }, - }) + new MachineSignal( + ecosystem, + ecosystem.makeId('signal'), + 'a', + { + a: { toggle: { name: 'b' } }, + b: { toggle: { name: 'a' } }, + } + ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dc419bf..f615a7c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,9 +252,6 @@ importers: '@zedux/atoms': specifier: 2.0.0-rc.12 version: link:../atoms - '@zedux/core': - specifier: 2.0.0-rc.12 - version: link:../core react: specifier: 'catalog:' version: 19.0.1