diff --git a/.changeset/soft-hounds-decline.md b/.changeset/soft-hounds-decline.md new file mode 100644 index 0000000..29b9f0e --- /dev/null +++ b/.changeset/soft-hounds-decline.md @@ -0,0 +1,11 @@ +--- +"@typeonce/effect-machine": minor +--- + +Add opt-in declinable transitions for conditional statechart dispatch. + +Set `declinable: true` on `Machine.transition` to expose a typed `decline()` resolver capability. Declining selects no transition, discards operations enqueued by that resolver, and lets hierarchical event or eventless dispatch continue with the next eligible ancestor. `target.none()` remains handled and continues to consume the trigger. + +Declining a completion or invocation outcome ignores that lifecycle occurrence because those triggers do not dispatch to ancestor handlers. + +Static transition definitions now expose `acceptance: "required" | "declinable"` alongside their exact target branches. Choices and initial routing remain total and reject declinable transitions. diff --git a/README.md b/README.md index 48412de..7b813f5 100644 --- a/README.md +++ b/README.md @@ -383,7 +383,7 @@ paths. `parent` always means the owning machine target. | `target.full` | Replacing or selecting a complete root | Nothing implicit for a newly selected root | | `target.history` | Restoring a declared history node | The remembered configuration or its typed default | -Every installed transition handler returns either a concrete target or +Every required transition handler returns either a concrete target or `target.none()`. An absent handler ignores the trigger; `target.none()` handles it and retains queued commands, raised events, and emitted events without selecting a destination. Declared `targets` constrain only concrete @@ -392,6 +392,28 @@ next logical configuration. Shared states exit and enter only when paths change; use `{ reenter: true, transition }` when the source must restart. With `target.none()`, reentry restarts the source while retaining its configuration. +Use `declinable: true` when a resolver may decide that its transition is not +enabled. Only that resolver receives `decline()`, and its return type expands to +accept the opaque declined result: + +```ts +Submit: Machine.transition({ + declinable: true, + target: (to) => to.local.Saving(), + resolve: ({ event, target, decline }) => accepts(event) ? target.from({ draft: event.draft }) : decline() +}) +``` + +Declining discards work enqueued by that resolver. Event and eventless dispatch +continues with the next eligible ancestor; if no candidate accepts, no +transition is selected. This differs from `target.none()`, which consumes the +trigger and prevents an ancestor from handling it. `transitionDefinitions` +reports each handler's `acceptance` as `"required"` or `"declinable"` while +preserving the exact declared target branches. Choices and initial routing must +remain total and cannot use declinable transitions. Completion and invocation +outcomes have no ancestor candidate: declining one ignores that lifecycle +occurrence and leaves the current configuration active. + ## Statechart capabilities `Machine.states` supports: diff --git a/docs/agent-guide.md b/docs/agent-guide.md index ad8a12d..d808d7e 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -661,6 +661,34 @@ an optional `title` controls presentation and otherwise defaults to the key. Selecting a branch whose target is `to.none()` handles the transition without a destination while retaining queued commands, raised events, and emitted events. +Set `declinable: true` only when the resolver may decide that its transition is +not enabled. The flag adds a typed `decline()` capability to that resolver and +permits its opaque result: + +```ts +Submit: Machine.transition({ + declinable: true, + branches: (to) => ({ + accepted: { target: to.local.Saving() }, + consumed: { target: to.none() } + }), + resolve: ({ event, select, decline }) => { + if (!belongsToThisState(event)) return decline() + return event.consume ? select.consumed() : select.accepted.from() + } +}) +``` + +Declining discards that resolver's enqueue buffer and resumes hierarchical +event or eventless selection at the next eligible ancestor. If no candidate +accepts, the trigger is unhandled. This is deliberately different from +`to.none()`, which consumes the trigger. `decline()` is absent and its result is +rejected unless the literal flag is present. Choice and initial routing remain +total and cannot decline. Static inspection exposes the distinction through +`TransitionDefinition.acceptance` without executing resolver code. Completion +and invocation outcomes have no ancestor candidate; declining one ignores that +lifecycle occurrence and leaves the current configuration active. + The `branches` callback runs once when handlers are installed. Its record uses the deterministic ECMAScript property order for presentation and `branchIndex`; array-index and symbol keys are rejected. Treat the string key as semantic: @@ -1313,9 +1341,10 @@ const step = yield * probe.sendAndAwait(event) ``` Inspect `step.before`, `step.after`, `step.plan`, `step.handled`, and -`step.configurationChanged`. An ignored event has `handled: false` and an -empty microstep list, but still completes its acknowledgement. A targetless -handler has `handled: true` even if its before and after snapshots are equal. +`step.configurationChanged`. An ignored event, including one for which every +eligible candidate declines, has `handled: false` and an empty microstep list, +but still completes its acknowledgement. A targetless handler has +`handled: true` even if its before and after snapshots are equal. Do not use a probe as a substitute for a domain completion event. The acknowledgement covers the submitted event's synchronous macrostep, state diff --git a/examples/platformer/src/machine.test.ts b/examples/platformer/src/machine.test.ts index fba39aa..502ceeb 100644 --- a/examples/platformer/src/machine.test.ts +++ b/examples/platformer/src/machine.test.ts @@ -165,6 +165,7 @@ describe("platformer history integration", () => { source: "Character.locomotion.Playing.Airborne.airJump", trigger: { type: "event", event: "WallJump" }, reenter: true, + acceptance: "required", branches: [{ type: "direct", target: "Character.locomotion.Playing.Airborne.airJump.AirJumpWallLock", diff --git a/src/Machine.ts b/src/Machine.ts index a348f26..282e1ab 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -3306,6 +3306,7 @@ export declare namespace Machine { readonly source: SourcePath readonly trigger: TransitionTrigger readonly reenter: boolean + readonly acceptance: TransitionAcceptance readonly branches: ReadonlyArray> } @@ -4184,6 +4185,24 @@ export declare namespace Machine { readonly [Topology.NoTargetTypeId]: typeof Topology.NoTargetTypeId } + /** + * Opaque result returned when a declinable transition does not accept the + * current event or lifecycle outcome. + * + * Declining selects no transition and discards operations enqueued by that + * resolver. Hierarchical event and eventless dispatch continues with the + * next eligible ancestor candidate. + * + * @category models + * @since 0.17.0 + */ + export interface Declined { + readonly [Topology.DeclinedTypeId]: typeof Topology.DeclinedTypeId + } + + /** Static acceptance contract of one transition definition. */ + export type TransitionAcceptance = "required" | "declinable" + /** * Transition instruction that restores a history pseudo-state's parent. * @@ -5172,17 +5191,20 @@ export declare namespace Machine { Emits extends ReadonlyArray, StateId extends StateNodeIdentifier, Context, - Reenter extends boolean + Reenter extends boolean, + Acceptance extends TransitionAcceptance = "required" > { readonly [TransitionTypeId]: { readonly owner: Types.Covariant + readonly acceptance: Types.Covariant } } /** Type evidence for a targetless transition reusable in every owning context. */ - export interface TargetlessTransitionTyped { + export interface TargetlessTransitionTyped { readonly [TransitionTypeId]: { readonly targetless: true + readonly acceptance: Types.Covariant } } @@ -5193,11 +5215,12 @@ export declare namespace Machine { Emits extends ReadonlyArray, StateId extends StateNodeIdentifier, Context, - Reenter extends boolean = false + Reenter extends boolean = false, + Acceptance extends TransitionAcceptance = "required" > = & ( - | TransitionTyped - | TargetlessTransitionTyped + | TransitionTyped + | TargetlessTransitionTyped | TargetlessTransitionInput ) & { @@ -5227,6 +5250,7 @@ export declare namespace Machine { readonly resolve?: TargetlessTransitionResolver readonly branches?: never readonly reenter?: never + readonly declinable?: false } export type SelectionBuilder = Selection extends TargetSelection ? Builder : never @@ -5246,6 +5270,11 @@ export declare namespace Machine { & Omit & (SelectionKind extends "none" ? {} : { readonly target: SelectionBuilder }) + /** Context capability available only to explicitly declinable resolvers. */ + export interface DeclineCapability { + readonly decline: () => Declined + } + export type TransitionResolver< Events extends ReadonlyArray, Emits extends ReadonlyArray, @@ -5269,6 +5298,35 @@ export declare namespace Machine { readonly resolve?: TransitionResolver readonly branches?: never readonly reenter?: Reenter + readonly declinable?: false + } + + export type DeclinableTransitionResolver< + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + Context, + Selection + > = ( + context: TransitionResolveContext & DeclineCapability, + enqueue: Enqueue, EmitOf> + ) => + | (SelectionKind extends "none" ? undefined : SelectedTargetResult | undefined) + | Declined + + export type DeclinableTransitionDirectInput< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateNodeIdentifier, + Context, + Reenter extends boolean, + Selection extends TargetSelection + > = { + readonly target: (to: TargetSelector) => Selection + readonly resolve: DeclinableTransitionResolver + readonly branches?: never + readonly reenter?: Reenter + readonly declinable: true } /** One named destination declared by a branching transition. */ @@ -5357,6 +5415,33 @@ export declare namespace Machine { readonly branches: (to: TargetSelector) => Branches readonly resolve: TransitionBranchesResolver readonly reenter?: Reenter + readonly declinable?: false + } + + export type DeclinableTransitionBranchesResolver< + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + Context, + Branches extends Readonly> + > = ( + context: TransitionBranchesResolveContext & DeclineCapability, + enqueue: Enqueue, EmitOf> + ) => BranchSelectionResult | Declined + + export type DeclinableTransitionBranchesInput< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateNodeIdentifier, + Context, + Reenter extends boolean, + Branches extends Readonly> + > = { + readonly target?: never + readonly branches: (to: TargetSelector) => Branches + readonly resolve: DeclinableTransitionBranchesResolver + readonly reenter?: Reenter + readonly declinable: true } export type InvokeTransition< @@ -5365,7 +5450,7 @@ export declare namespace Machine { Emits extends ReadonlyArray, StateId extends StateNodeIdentifier, Context - > = TransitionConfig + > = TransitionConfig export type InvokeSource = Value | ((context: Context) => Value) @@ -6011,14 +6096,18 @@ export declare namespace Machine { Events, Emits, StateId, - AlwaysContext + AlwaysContext, + false, + TransitionAcceptance > readonly onDone?: TransitionConfig< States, Events, Emits, StateId, - DoneContext + DoneContext, + false, + TransitionAcceptance > readonly on?: { readonly [EventTag in TagOf]?: TransitionConfig< @@ -6027,7 +6116,8 @@ export declare namespace Machine { Emits, StateId, HandlerContext, - true + true, + TransitionAcceptance > } readonly initialize?: StateInitializeHandler @@ -7022,7 +7112,8 @@ export declare namespace Machine { Emits, StateId, HandlerContext, - true + true, + TransitionAcceptance > > > @@ -7056,14 +7147,18 @@ export declare namespace Machine { Events, Emits, StateId, - AlwaysContext + AlwaysContext, + false, + TransitionAcceptance > readonly onDone?: TransitionConfig< States, Events, Emits, StateId, - DoneContext + DoneContext, + false, + TransitionAcceptance > readonly output?: | ((context: FinalOutputContext) => unknown) @@ -7964,10 +8059,32 @@ type BranchingTransitionResult< StateId extends Machine.StateNodeIdentifier, Context, Reenter extends boolean, - Branches extends Readonly> + Branches extends Readonly>, + Acceptance extends Machine.TransitionAcceptance = "required" > = - & Machine.TransitionBranchesInput - & Machine.TransitionTyped + & (Acceptance extends "declinable" ? Machine.DeclinableTransitionBranchesInput< + States, + Events, + Emits, + StateId, + Context, + Reenter, + Branches + > + : Machine.TransitionBranchesInput) + & Machine.TransitionTyped + +type DirectTransitionEvidence< + States extends Machine.StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends Machine.StateNodeIdentifier, + Context, + Reenter extends boolean, + Selection, + Acceptance extends Machine.TransitionAcceptance +> = Machine.SelectionKind extends "none" ? Machine.TargetlessTransitionTyped + : Machine.TransitionTyped type TransitionBranchRecordError = { readonly "~effect/Machine/TransitionBranchRecordError": Message @@ -7991,6 +8108,19 @@ type ValidateTransitionBranchRecord = [keyof Branches] extends [never] * @since 0.14.0 */ export interface TransitionConstructor { + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + StateId extends Machine.StateNodeIdentifier, + Context, + const Selection extends Machine.TargetSelection, + Reenter extends boolean = never + >( + config: Machine.DeclinableTransitionDirectInput + ): + & Machine.DeclinableTransitionDirectInput + & DirectTransitionEvidence < const States extends Machine.StateSchemas, const Events extends ReadonlyArray, @@ -8003,8 +8133,37 @@ export interface TransitionConstructor { config: Machine.TransitionDirectInput ): & Machine.TransitionDirectInput - & (Machine.SelectionKind extends "none" ? Machine.TargetlessTransitionTyped - : Machine.TransitionTyped) + & DirectTransitionEvidence + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + StateId extends Machine.StateNodeIdentifier, + Context, + const Branches extends Readonly>, + Reenter extends boolean = never + >( + config: + & Machine.DeclinableTransitionBranchesInput< + States, + Events, + Emits, + StateId, + Context, + Reenter, + Branches + > + & ValidateTransitionBranchRecord> + ): BranchingTransitionResult< + States, + Events, + Emits, + StateId, + Context, + Reenter, + Branches, + "declinable" + > < const States extends Machine.StateSchemas, const Events extends ReadonlyArray, @@ -8060,6 +8219,14 @@ export interface TransitionConstructor { * ? select.cached.from({ data: event.cached }) * : select.loading.from() * }) + * + * Machine.transition({ + * declinable: true, + * target: (to) => to.full.Ready(), + * resolve: ({ event, target, decline }) => event.accepted + * ? target.from() + * : decline() + * }) * ``` * * @category constructors @@ -8899,7 +9066,8 @@ export const initialDefinition: (machine: M) => Machine.I * Event handlers retain their handler-key order within each source state and * are followed by eventless and completion handlers. This function does not * execute resolvers. Every direct, named, and targetless branch exposes the - * destination selected by its required static `target` declaration. + * destination selected by its required static `target` declaration, while + * `acceptance` reports whether the resolver may decline the transition. * * @category getters * @since 0.4.0 @@ -8952,7 +9120,12 @@ export const configuration: ( > = internal.configuration /** - * Returns the event tags handled by the current state snapshot. + * Returns event tags with at least one structurally eligible handler in the + * current state snapshot. + * + * A `declinable` handler may still reject a concrete event at planning time, + * so this is a static candidate query rather than a guarantee that every value + * with the returned tag will be handled. * * @category getters * @since 0.4.0 diff --git a/src/internal/machine/executionPlan.ts b/src/internal/machine/executionPlan.ts index c9f6b88..bf43109 100644 --- a/src/internal/machine/executionPlan.ts +++ b/src/internal/machine/executionPlan.ts @@ -152,6 +152,7 @@ const compileIndexedExecutionDescriptor = ( for (const tag of Reflect.ownKeys(config.on)) { const transition = normalizeTransition(config.on[tag] as Parameters[0]) if (transition !== undefined) { + if (transition.declinable) return undefined byEvent.set(tag, transition) } } diff --git a/src/internal/machine/machine.ts b/src/internal/machine/machine.ts index 495d912..7c8cd31 100644 --- a/src/internal/machine/machine.ts +++ b/src/internal/machine/machine.ts @@ -139,6 +139,7 @@ const makeWithHandlers = ( type DefinitionBranch = { readonly target: (selector: unknown) => unknown readonly resolve?: (context: any, enqueue: unknown) => unknown + readonly declinable?: boolean } type CapturedBranch = DefinitionBranch & { @@ -352,7 +353,14 @@ const runCapturedBranch = ( const resolverContext = { ...context } if (branch.selection.kind === "none") delete resolverContext.target else resolverContext.target = selectedTarget + if (branch.declinable === true) resolverContext.decline = Topology.makeDeclined const resolved = branch.resolve(resolverContext, enqueue) + if (Topology.isDeclined(resolved)) { + if (branch.declinable !== true) { + throw new Error(`Machine transition for state "${source}" returned decline without declaring declinable: true`) + } + return resolved + } validateResolvedSelection(resolved, branch.selection, stateNodes) return resolved === undefined ? constructSelectedTarget(selectedTarget) : resolved } @@ -485,6 +493,7 @@ const captureTransition = ( const definition = transition as Record const selector = makeTargetSelector(stateNodes, path) const reenter = definition.reenter === true + const declinable = definition.declinable === true if (hasProperty(definition, "branches")) { const branching = definition as { readonly branches: unknown; readonly resolve?: unknown } if (typeof branching.branches !== "function" || typeof branching.resolve !== "function") { @@ -499,7 +508,18 @@ const captureTransition = ( const resolverContext = { ...context } delete resolverContext.target resolverContext.select = makeBranchSelectors(context, branches, owner, stateNodes, path) + if (declinable) resolverContext.decline = Topology.makeDeclined const selected = resolve(resolverContext, enqueue) + if (Topology.isDeclined(selected)) { + if (!declinable) { + throw new Error( + `Machine branching transition for state "${path}" on "${ + String(trigger) + }" returned decline without declaring declinable: true` + ) + } + return { result: selected, branchIndex: -1, branchKey: undefined } + } if (!Topology.isSelectedBranch(selected) || selected.owner !== owner) { throw new Error( `Machine branching transition for state "${path}" on "${String(trigger)}" must select one declared branch` @@ -518,6 +538,7 @@ const captureTransition = ( } return { reenter, + declinable, targets: [ ...new Set( branches.flatMap((branch) => branch.selection.path === undefined ? [] : [branch.selection.path]) @@ -544,6 +565,7 @@ const captureTransition = ( }) return { reenter, + declinable, targets: branch.selection.path === undefined ? [] : [branch.selection.path], branches: [{ type: "direct" as const, diff --git a/src/internal/machine/planner.ts b/src/internal/machine/planner.ts index c1d40f0..09be77d 100644 --- a/src/internal/machine/planner.ts +++ b/src/internal/machine/planner.ts @@ -53,6 +53,7 @@ import { getNode, type InitialTarget as InitialTargetInstruction, isChoiceTarget, + isDeclined, isHistoryTarget, isInitialTarget, isNoTarget, @@ -104,10 +105,10 @@ export type MacrostepPlan = export type TransitionHandler = ( context: Context, enqueue: Enqueue -) => Machine.HandlerResult +) => Machine.HandlerResult | Machine.Declined type TransitionEvaluation = { - readonly result: Machine.HandlerResult + readonly result: Machine.HandlerResult | Machine.Declined readonly branchIndex: number readonly branchKey: string | undefined } @@ -153,6 +154,7 @@ type EventTransition = | TransitionHandler | { readonly reenter?: boolean + readonly declinable?: boolean readonly targets?: ReadonlyArray readonly transition: TransitionHandler readonly evaluate?: TransitionEvaluator @@ -160,6 +162,7 @@ type EventTransition = export type MicrostepTransition = { readonly reenter: boolean + readonly declinable: boolean readonly targets: ReadonlyArray | undefined readonly transition: TransitionHandler readonly evaluate: TransitionEvaluator | undefined @@ -172,9 +175,10 @@ export const normalizeTransition = readonly context: Context + readonly collected?: { + readonly declined: false + readonly state: unknown + readonly branchIndex: number + readonly branchKey: string | undefined + readonly commands: ReadonlyArray + readonly raisedEvents: ReadonlyArray + readonly emittedEvents: ReadonlyArray + } +} + +const resolveDeclinableCandidate = ( + machine: Machine.Any, + selection: SelectedTransition +): SelectedTransition | undefined => { + if (!selection.transition.declinable) return selection + const collected = collectTransition( + machine, + selection.transition.transition, + selection.context, + selection.transition.evaluate + ) + return collected.declined ? undefined : { ...selection, collected } } export type EvaluatedTransition = { @@ -759,15 +790,16 @@ const selectAlwaysTransitions = < > > = [] const selectedSources = new Set() + const evaluatedSources = new Map | undefined>() let snapshot: Machine.Snapshot | undefined const capturedSnapshot = () => snapshot ??= snapshotFromConfiguration(machine, configuration) for (const leaf of getActiveLeafPaths(machine, configuration)) { for (const path of getLeafCandidatePaths(machine, leaf)) { const always = normalizeTransition(machine.handlers[path]?.always) if (always !== undefined) { - if (!selectedSources.has(path)) { - selectedSources.add(path) - selected.push({ + let candidate = evaluatedSources.get(path) + if (!evaluatedSources.has(path)) { + candidate = resolveDeclinableCandidate(machine, { sourcePath: path, leafPath: leaf, trigger: { type: "always" }, @@ -796,6 +828,12 @@ const selectAlwaysTransitions = < target: getTargetBuilder(machine, path) } }) + evaluatedSources.set(path, candidate) + } + if (candidate === undefined) continue + if (!selectedSources.has(path)) { + selectedSources.add(path) + selected.push(candidate as (typeof selected)[number]) } break } @@ -838,7 +876,7 @@ const selectDoneTransitions = < const onDone = normalizeTransition(machine.handlers[completion.path]?.onDone) if (onDone !== undefined && !selectedSources.has(completion.path)) { selectedSources.add(completion.path) - selected.push({ + const candidate = resolveDeclinableCandidate(machine, { sourcePath: completion.path, leafPath: getActiveLeafPathFrom(machine, configuration, completion.path), trigger: { type: "done" }, @@ -857,6 +895,7 @@ const selectDoneTransitions = < capturedSnapshot() ) }) + if (candidate !== undefined) selected.push(candidate) } } return selected @@ -897,15 +936,16 @@ const selectEventTransitions = < > > = [] const selectedSources = new Set() + const evaluatedSources = new Map | undefined>() let snapshot: Machine.Snapshot | undefined const capturedSnapshot = () => snapshot ??= snapshotFromConfiguration(machine, configuration) for (const leaf of getActiveLeafPaths(machine, configuration)) { for (const path of getLeafCandidatePaths(machine, leaf)) { const transition = normalizeTransition(machine.handlers[path]?.on?.[event._tag]) if (transition !== undefined) { - if (!selectedSources.has(path)) { - selectedSources.add(path) - selected.push({ + let candidate = evaluatedSources.get(path) + if (!evaluatedSources.has(path)) { + candidate = resolveDeclinableCandidate(machine, { sourcePath: path, leafPath: leaf, trigger: { type: "event", event: event._tag }, @@ -931,6 +971,12 @@ const selectEventTransitions = < Machine.TagOf >(machine as any, configuration, path, event, capturedSnapshot()) }) + evaluatedSources.set(path, candidate) + } + if (candidate === undefined) continue + if (!selectedSources.has(path)) { + selectedSources.add(path) + selected.push(candidate as (typeof selected)[number]) } break } @@ -983,13 +1029,14 @@ const selectInvocationTransition = < ? { error: event.error } : { snapshot: event.snapshot }) } - return [{ + const candidate = resolveDeclinableCandidate(machine, { sourcePath: event.path, leafPath: getActiveLeafPathFrom(machine, configuration, event.path), trigger: { type: "invoke", id: event.id, outcome: event.type }, transition: transition as unknown as MicrostepTransition, context - }] + }) + return candidate === undefined ? [] : [candidate] } export const getTargetNodePath = ( @@ -1275,12 +1322,15 @@ const collectEvaluatedTransition = < selection: SelectedTransition ) => { const stateIdentifier = selection.leafPath - const transitionResult = collectTransition( + const transitionResult = selection.collected ?? collectTransition( machine, selection.transition.transition, selection.context, selection.transition.evaluate ) + if (transitionResult.declined) { + throw new Error("Machine transition returned decline without declaring declinable: true") + } const unresolvedTarget = transitionResult.state === undefined ? undefined : transitionResult.state as diff --git a/src/internal/machine/topology.ts b/src/internal/machine/topology.ts index 7da4239..88da383 100644 --- a/src/internal/machine/topology.ts +++ b/src/internal/machine/topology.ts @@ -25,6 +25,8 @@ export const ChoiceTargetTypeId: unique symbol = Symbol("effect/Machine/ChoiceTa export const NoTargetTypeId: unique symbol = Symbol("effect/Machine/NoTarget") +export const DeclinedTypeId: unique symbol = Symbol("effect/Machine/Declined") + export const TargetSelectionTypeId: unique symbol = Symbol("effect/Machine/TargetSelection") export const SelectedBranchTypeId: unique symbol = Symbol("effect/Machine/SelectedBranch") @@ -66,6 +68,11 @@ export interface NoTarget { readonly [NoTargetTypeId]: typeof NoTargetTypeId } +/** Internal marker returned when a declinable transition is not enabled. */ +export interface Declined { + readonly [DeclinedTypeId]: typeof DeclinedTypeId +} + export type TargetSelectionKind = "state" | "initial" | "history" | "choice" | "none" export type TargetSelectionScope = "local" | "branch" | "full" | "initial" @@ -125,6 +132,14 @@ export const makeNoTarget = (): Machine.NoTarget => noTarget export const isNoTarget = (u: unknown): u is Machine.NoTarget => hasProperty(u, NoTargetTypeId) +const declined = Object.freeze({ + [DeclinedTypeId]: DeclinedTypeId +}) as Declined + +export const makeDeclined = (): Machine.Declined => declined + +export const isDeclined = (u: unknown): u is Machine.Declined => hasProperty(u, DeclinedTypeId) + export const makeHistoryTarget = (path: string, parent: string): HistoryTarget => ({ [HistoryTargetTypeId]: HistoryTargetTypeId, path, @@ -441,6 +456,11 @@ const transitionBranches = (handler: unknown): ReadonlyArray) : [] +const transitionAcceptance = (handler: unknown): Machine.TransitionAcceptance => + typeof handler === "object" && handler !== null && "declinable" in handler && handler.declinable === true + ? "declinable" + : "required" + export const transitionDefinitions = ( machine: Machine.Any ): ReadonlyArray => { @@ -457,6 +477,7 @@ export const transitionDefinitions = ( source: node.path, trigger: { type: "choice" }, reenter: false, + acceptance: "required", branches: transitionBranches(choice) }) } @@ -468,6 +489,7 @@ export const transitionDefinitions = ( source: node.path, trigger: { type: "event", event }, reenter: hasProperty(handler, "reenter") && handler.reenter === true, + acceptance: transitionAcceptance(handler), branches: transitionBranches(handler) }) } @@ -476,6 +498,7 @@ export const transitionDefinitions = ( source: node.path, trigger: { type: "always" }, reenter: false, + acceptance: transitionAcceptance(config.always), branches: transitionBranches(config.always) }) } @@ -484,6 +507,7 @@ export const transitionDefinitions = ( source: node.path, trigger: { type: "done" }, reenter: false, + acceptance: transitionAcceptance(config.onDone), branches: transitionBranches(config.onDone) }) } @@ -507,6 +531,7 @@ export const transitionDefinitions = ( source: node.path, trigger: { type: "invoke", id, outcome }, reenter: typeof handler === "object" && handler !== null && handler.reenter === true, + acceptance: transitionAcceptance(handler), branches: transitionBranches(handler) }) } diff --git a/src/internal/testing/machine/transitionCoverage.ts b/src/internal/testing/machine/transitionCoverage.ts index 987cc36..46264e2 100644 --- a/src/internal/testing/machine/transitionCoverage.ts +++ b/src/internal/testing/machine/transitionCoverage.ts @@ -66,6 +66,7 @@ export const makeTransitionCoverageCollector = ( source: definition.source, trigger: definition.trigger, reenter: definition.reenter, + acceptance: definition.acceptance, branches: definition.branches }) ) @@ -85,6 +86,7 @@ export const makeTransitionCoverageCollector = ( source: definition.source, trigger: definition.trigger, reenter: definition.reenter, + acceptance: definition.acceptance, branch }) }) diff --git a/src/testing/MachineTest.ts b/src/testing/MachineTest.ts index 82045ef..55da490 100644 --- a/src/testing/MachineTest.ts +++ b/src/testing/MachineTest.ts @@ -1703,6 +1703,7 @@ export interface TransitionDefinitionCoverageItem< readonly source: SourcePath readonly trigger: Machine.Machine.TransitionTrigger readonly reenter: boolean + readonly acceptance: Machine.Machine.TransitionAcceptance readonly branches: ReadonlyArray> } @@ -1725,6 +1726,7 @@ export interface TransitionBranchCoverageItem< readonly source: SourcePath readonly trigger: Machine.Machine.TransitionTrigger readonly reenter: boolean + readonly acceptance: Machine.Machine.TransitionAcceptance readonly branch: Machine.Machine.TransitionBranch } diff --git a/test/internal/machine/strategyDifferential.test.ts b/test/internal/machine/strategyDifferential.test.ts index 2cf6377..3dd5644 100644 --- a/test/internal/machine/strategyDifferential.test.ts +++ b/test/internal/machine/strategyDifferential.test.ts @@ -113,6 +113,38 @@ describe("machine planner and runtime strategies", () => { }) }) + it.effect("fails closed to generic planning for declinable transitions", () => { + const states = Machine.states({ Count }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(Select), + initial: { + target: (to) => to.Count(), + resolve: ({ target }) => target(new Count({ value: 0 })) + } + }).handle({ + Count: { + on: { + Select: Machine.transition({ + declinable: true, + target: (to) => to.full.Count(), + resolve: ({ event, state, target, decline }) => + event.value < 0 + ? decline() + : target(new Count({ value: state.value + event.value })) + }) + } + } + }) + + return verifyPlannerStrategies({ + machine, + events: [new Select({ value: -1 }), new Select({ value: 2 })], + expected: "generic", + label: "declinable transition" + }) + }) + it.effect("reenters the source when an explicit targetless transition requests reentry", () => Effect.gen(function*() { const machine = makeFlatMachine() diff --git a/test/machine/Choice.test.ts b/test/machine/Choice.test.ts index dd12cf4..57f6138 100644 --- a/test/machine/Choice.test.ts +++ b/test/machine/Choice.test.ts @@ -626,6 +626,7 @@ describe("Machine choice pseudo-states", () => { source: "Flow.Routing", trigger: { type: "choice" }, reenter: false, + acceptance: "required", branches: [ { type: "branch", diff --git a/test/machine/Invoke.test.ts b/test/machine/Invoke.test.ts index a6b4c26..60a87aa 100644 --- a/test/machine/Invoke.test.ts +++ b/test/machine/Invoke.test.ts @@ -24,6 +24,38 @@ class FinishStream extends Schema.TaggedClass("InvokeFinishStream" const States = Machine.states({ Idle, Loading, Complete, Failed }) describe("inline invoke", () => { + it.effect("ignores an invocation outcome when its transition declines", () => + Effect.gen(function*() { + const machine = Machine.make({ + states: States.states, + events: Machine.events(), + initial: { + target: (to) => to.Loading(), + resolve: ({ target }) => target.from() + } + }).handle({ + Loading: { + invoke: Machine.invoke({ + id: "load", + effect: () => Effect.succeed("ignored"), + onDone: Machine.transition({ + declinable: true, + target: (to) => to.full.Complete(), + resolve: ({ decline }) => decline() + }) + }) + }, + Complete: {}, + Failed: {}, + Idle: {} + }) + + assert.strictEqual(Machine.transitionDefinitions(machine)[0]?.acceptance, "declinable") + const ref = yield* Machine.start(machine) + for (let index = 0; index < 5; index += 1) yield* Effect.yieldNow + assert.deepStrictEqual(yield* ref.state, { path: "Loading" as const, value: new Loading({}) }) + })) + it.effect("handles Stream elements sequentially before completion", () => Effect.gen(function*() { const states = Machine.states({ Collecting, Complete }) @@ -66,6 +98,7 @@ describe("inline invoke", () => { source: "Collecting", trigger: { type: "event", event: "Add" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: "Collecting", @@ -76,6 +109,7 @@ describe("inline invoke", () => { source: "Collecting", trigger: { type: "invoke", id: "numbers", outcome: "element" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: undefined, @@ -86,6 +120,7 @@ describe("inline invoke", () => { source: "Collecting", trigger: { type: "invoke", id: "numbers", outcome: "done" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: "Complete", @@ -257,6 +292,7 @@ describe("inline invoke", () => { source: "Loading", trigger: { type: "invoke", id: "load", outcome: "done" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: "Complete", diff --git a/test/machine/LocalTargetWith.test.ts b/test/machine/LocalTargetWith.test.ts index cc15b47..1af263e 100644 --- a/test/machine/LocalTargetWith.test.ts +++ b/test/machine/LocalTargetWith.test.ts @@ -58,6 +58,7 @@ describe("local compound target selection", () => { source: "search", trigger: { type: "event", event: "UpdateQuery" }, reenter: true, + acceptance: "required", branches: [{ type: "direct", target: "search", @@ -67,6 +68,7 @@ describe("local compound target selection", () => { source: "search.Updated", trigger: { type: "event", event: "Reset" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: "search.Idle", @@ -143,6 +145,7 @@ describe("local compound target selection", () => { source: "search.Searching", trigger: { type: "invoke", id: "search", outcome: "done" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: "search", diff --git a/test/machine/Machine.test.ts b/test/machine/Machine.test.ts index 316cbba..b1400e3 100644 --- a/test/machine/Machine.test.ts +++ b/test/machine/Machine.test.ts @@ -160,6 +160,7 @@ describe("Machine", () => { source: "Stable", trigger: { type: "event", event: "Ping" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: undefined, @@ -221,6 +222,7 @@ describe("Machine", () => { source: "Stable", trigger: { type: "event", event: "Ping" }, reenter: true, + acceptance: "required", branches: [{ type: "branch", key: "unchanged", @@ -2395,6 +2397,278 @@ describe("Machine", () => { }) })) + it.effect("lets declinable child handlers yield to ancestors without retaining queued work", () => + Effect.gen(function*() { + class Notice extends Schema.TaggedClass("DeclineNotice")("Notice", {}) {} + const payment = new Payment({ id: "payment-1" }) + const entering = new EnteringPayment({ amount: 100 }) + const machine = Machine.make({ + states: { + payment: { + schema: Payment, + initial: "entering", + states: { + entering: EnteringPayment, + authorized: AuthorizedPayment + } + }, + failed: Failed + }, + events: Machine.events(Authorize), + emittedEvents: Machine.emittedEvents(Notice), + initial: { + target: (to) => to.payment.initial(), + resolve: () => ({ + path: "payment" as const, + value: payment, + state: { + path: "payment.entering" as const, + value: entering + } + }) + } + }).handle({ + payment: { + on: { + Authorize: Machine.transition({ + target: (to) => to.full.failed(), + resolve: ({ target }) => target(new Failed({ message: "parent" })) + }) + }, + states: { + entering: { + on: { + Authorize: Machine.transition({ + declinable: true, + branches: (to) => ({ + authorize: { target: to.local.authorized() }, + consume: { target: to.none() } + }), + resolve: ({ event, select, decline }, enqueue) => { + if (event.code === "child") { + return select.authorize(new AuthorizedPayment({ code: event.code })) + } + if (event.code === "consume") return select.consume() + enqueue.emit(new Notice({})) + return decline() + } + }) + } + } + } + } + }) + + assert.strictEqual( + Machine.transitionDefinitions(machine).find(({ source }) => source === "payment.entering")?.acceptance, + "declinable" + ) + const initial = yield* Machine.planInitial(machine) + const child = yield* Machine.plan(machine, initial.state, new Authorize({ code: "child" })) + assert.strictEqual(child.next.path, "payment") + if (child.next.path === "payment") assert.strictEqual(child.next.state.path, "payment.authorized") + assert.strictEqual(child.microsteps[0]?.transitions[0]?.source, "payment.entering") + assert.strictEqual(child.microsteps[0]?.transitions[0]?.branchKey, "authorize") + + const consumed = yield* Machine.plan(machine, initial.state, new Authorize({ code: "consume" })) + assert.deepStrictEqual(consumed.next, initial.state) + assert.strictEqual(consumed.microsteps[0]?.transitions[0]?.source, "payment.entering") + assert.strictEqual(consumed.microsteps[0]?.transitions[0]?.branchKey, "consume") + assert.strictEqual(consumed.microsteps[0]?.transitions[0]?.target, undefined) + + const declined = yield* Machine.plan(machine, initial.state, new Authorize({ code: "parent" })) + assert.strictEqual(declined.next.path, "failed") + assert.strictEqual(declined.microsteps[0]?.transitions[0]?.source, "payment") + assert.deepStrictEqual(declined.emittedEvents, []) + })) + + it.effect("continues eventless selection at an ancestor when a child declines", () => + Effect.gen(function*() { + class Workflow extends Schema.TaggedClass("DeclineWorkflow")("Workflow", {}) {} + class Waiting extends Schema.TaggedClass("DeclineWaiting")("Waiting", { ready: Schema.Boolean }) {} + class Finished extends Schema.TaggedClass("DeclineFinished")("Finished", {}) {} + const states = Machine.states({ + workflow: { + schema: Workflow, + initial: "waiting", + states: { waiting: Waiting } + }, + finished: Finished + }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(), + initial: { + target: (to) => to.workflow.initial(), + resolve: ({ target }) => + target(new Workflow({}), (workflow) => workflow.waiting(new Waiting({ ready: false }))) + } + }).handle({ + workflow: { + always: Machine.transition({ + target: (to) => to.full.finished(), + resolve: ({ target }) => target(new Finished({})) + }), + states: { + waiting: { + always: Machine.transition({ + declinable: true, + target: (to) => to.none(), + resolve: ({ state, decline }) => state.ready ? undefined : decline() + }) + } + } + } + }) + + const planned = yield* Machine.planInitial(machine) + assert.strictEqual(planned.state.path, "finished") + assert.strictEqual(planned.microsteps[0]?.transitions[0]?.source, "workflow") + })) + + it.effect("treats an event as unhandled when every candidate declines", () => + Effect.gen(function*() { + class Stable extends Schema.TaggedClass("DeclineStable")("Stable", {}) {} + class Ping extends Schema.TaggedClass("DeclinePing")("Ping", {}) {} + const states = Machine.states({ Stable }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(Ping), + initial: { + target: (to) => to.Stable(), + resolve: ({ target }) => target(new Stable({})) + } + }).handle({ + Stable: { + on: { + Ping: Machine.transition({ + declinable: true, + target: (to) => to.none(), + resolve: ({ decline }) => decline() + }) + } + } + }) + + const initial = yield* Machine.planInitial(machine) + assert.deepStrictEqual(Machine.enabled(machine, initial.state), ["Ping"]) + const planned = yield* Machine.plan(machine, initial.state, new Ping({})) + assert.deepStrictEqual(planned.next, initial.state) + assert.deepStrictEqual(planned.microsteps, []) + })) + + it.effect("leaves a completed compound state active when onDone declines", () => + Effect.gen(function*() { + class Workflow extends Schema.TaggedClass("DeclineDoneWorkflow")("Workflow", {}) {} + class Complete extends Schema.TaggedClass("DeclineDoneComplete")("Complete", {}) {} + class Finished extends Schema.TaggedClass("DeclineDoneFinished")("Finished", {}) {} + const states = Machine.states({ + workflow: { + schema: Workflow, + initial: "complete", + states: { + complete: { schema: Complete, type: "final", output: Schema.String } + } + }, + finished: Finished + }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(), + initial: { + target: (to) => to.workflow.initial(), + resolve: ({ target }) => target(new Workflow({}), (workflow) => workflow.complete(new Complete({}))) + } + }).handle({ + workflow: { + onDone: Machine.transition({ + declinable: true, + target: (to) => to.full.finished(), + resolve: ({ decline }) => decline() + }), + states: { + complete: { output: () => "complete" } + } + } + }) + + const planned = yield* Machine.planInitial(machine) + assert.isFalse(planned.done) + assert.strictEqual(planned.state.path, "workflow") + if (planned.state.path === "workflow") assert.strictEqual(planned.state.state.path, "workflow.complete") + })) + + it.effect("preserves descendant preemption when parallel candidates decline", () => + Effect.gen(function*() { + class Root extends Schema.TaggedClass("DeclineParallelRoot")("Root", {}) {} + class Left extends Schema.TaggedClass("DeclineParallelLeft")("Left", {}) {} + class Right extends Schema.TaggedClass("DeclineParallelRight")("Right", {}) {} + class Finished extends Schema.TaggedClass("DeclineParallelFinished")("Finished", {}) {} + class Ping extends Schema.TaggedClass("DeclineParallelPing")("Ping", { + handleRight: Schema.Boolean + }) {} + const states = Machine.states({ + root: { + schema: Root, + type: "parallel", + states: { left: Left, right: Right } + }, + finished: Finished + }) + let parentCalls = 0 + const machine = Machine.make({ + states: states.states, + events: Machine.events(Ping), + initial: { + target: (to) => to.root.initial(), + resolve: ({ target }) => target(new Root({}), (root) => root.left(new Left({})).right(new Right({}))) + } + }).handle({ + root: { + on: { + Ping: Machine.transition({ + target: (to) => to.full.finished(), + resolve: ({ target }) => { + parentCalls++ + return target(new Finished({})) + } + }) + }, + states: { + left: { + on: { + Ping: Machine.transition({ + declinable: true, + target: (to) => to.none(), + resolve: ({ decline }) => decline() + }) + } + }, + right: { + on: { + Ping: Machine.transition({ + declinable: true, + target: (to) => to.none(), + resolve: ({ event, decline }) => event.handleRight ? undefined : decline() + }) + } + } + } + } + }) + + const initial = yield* Machine.planInitial(machine) + const descendant = yield* Machine.plan(machine, initial.state, new Ping({ handleRight: true })) + assert.strictEqual(descendant.next.path, "root") + assert.deepStrictEqual(descendant.microsteps[0]?.transitions.map(({ source }) => source), ["root.right"]) + assert.strictEqual(parentCalls, 0) + + const ancestor = yield* Machine.plan(machine, initial.state, new Ping({ handleRight: false })) + assert.strictEqual(ancestor.next.path, "finished") + assert.deepStrictEqual(ancestor.microsteps[0]?.transitions.map(({ source }) => source), ["root"]) + assert.strictEqual(parentCalls, 1) + })) + it.effect("handles parent config and nested states in the same object", () => Effect.gen(function*() { const payment = new Payment({ id: "payment-1" }) diff --git a/test/machine/MermaidVisualization.test.ts b/test/machine/MermaidVisualization.test.ts index 043581e..c0d543a 100644 --- a/test/machine/MermaidVisualization.test.ts +++ b/test/machine/MermaidVisualization.test.ts @@ -42,15 +42,25 @@ const states: ReadonlyArray = [ const inspection: InspectionApi = { stateNodes: () => states, initialDefinition: () => ({ target: "Root" }), - transitionDefinitions: () => [{ - source: "Root.Route", - trigger: { type: "choice" }, - reenter: false, - branches: [ - { type: "branch", key: "approved", title: "approved %%\nnow", target: "Root.Done" }, - { type: "branch", key: "unchanged", title: "unchanged", target: undefined } - ] - }], + transitionDefinitions: () => [ + { + source: "Root.Route", + trigger: { type: "choice" }, + reenter: false, + acceptance: "required", + branches: [ + { type: "branch", key: "approved", title: "approved %%\nnow", target: "Root.Done" }, + { type: "branch", key: "unchanged", title: "unchanged", target: undefined } + ] + }, + { + source: "Root.Done", + trigger: { type: "event", event: "Retry" }, + reenter: false, + acceptance: "declinable", + branches: [{ type: "direct", target: "Root.Route" }] + } + ], activityDefinitions: () => [{ source: "Root.Route", id: "worker %%\nend note", type: "process" }], configuration: () => states.slice(0, 2), enabled: () => ["Continue %%\nnow"] @@ -68,6 +78,7 @@ describe("Mermaid visualization", () => { assert.include(rendered, "state \"● Choose route (Route)\" as state_1") assert.include(rendered, "state state_1 <>") assert.include(rendered, "state_1 --> state_2: choice [approved #37;#37; now]") + assert.include(rendered, "state_2 --> state_1: Retry [declinable]") assert.notMatch(rendered, /state_1 --> .*otherwise/) assert.include(rendered, "state_1: process / worker #37;#37; end note") assert.notInclude(rendered, "Candidate events") diff --git a/test/machine/Visualization.test.ts b/test/machine/Visualization.test.ts index e288255..d6e6d13 100644 --- a/test/machine/Visualization.test.ts +++ b/test/machine/Visualization.test.ts @@ -243,6 +243,7 @@ describe("Machine structural visualization", () => { source: "application.workflow.idle", trigger: { type: "event", event: "Start" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: "application.workflow.running", @@ -253,6 +254,7 @@ describe("Machine structural visualization", () => { source: "application.workflow.idle", trigger: { type: "event", event: "Refresh" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: undefined, @@ -263,6 +265,7 @@ describe("Machine structural visualization", () => { source: "application.connection.online", trigger: { type: "event", event: "Disconnect" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: "application.connection.offline", @@ -295,6 +298,7 @@ describe("Machine structural visualization", () => { source: "idle", trigger: { type: "event", event: "Refresh" }, reenter: true, + acceptance: "required", branches: [{ type: "direct", target: undefined, @@ -305,6 +309,7 @@ describe("Machine structural visualization", () => { source: "idle", trigger: { type: "always" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: undefined, @@ -315,6 +320,7 @@ describe("Machine structural visualization", () => { source: "idle", trigger: { type: "done" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: undefined, @@ -336,6 +342,7 @@ describe("Machine structural visualization", () => { source: "idle", trigger: { type: "always" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: "workflow", @@ -346,6 +353,7 @@ describe("Machine structural visualization", () => { source: "workflow", trigger: { type: "done" }, reenter: false, + acceptance: "required", branches: [{ type: "direct", target: "disabled", diff --git a/test/machine/visualization/mermaid.ts b/test/machine/visualization/mermaid.ts index 07ae197..4104aea 100644 --- a/test/machine/visualization/mermaid.ts +++ b/test/machine/visualization/mermaid.ts @@ -45,7 +45,9 @@ const triggerLabel = (definition: TransitionDefinition): string => { definition.trigger.type === "invoke" ? `invoke ${definition.trigger.id} ${definition.trigger.outcome}` : definition.trigger.type - return `${trigger}${definition.reenter ? " [reenter]" : ""}` + return `${trigger}${definition.reenter ? " [reenter]" : ""}${ + definition.acceptance === "declinable" ? " [declinable]" : "" + }` } const branchLabel = ( diff --git a/test/machine/visualization/model.ts b/test/machine/visualization/model.ts index 0bc1b37..dbd65ea 100644 --- a/test/machine/visualization/model.ts +++ b/test/machine/visualization/model.ts @@ -41,6 +41,7 @@ export interface TransitionDefinition { readonly outcome: "element" | "done" | "failure" | "snapshot" } readonly reenter: boolean + readonly acceptance: "required" | "declinable" readonly branches: ReadonlyArray< | { readonly type: "direct" diff --git a/test/machine/visualization/text.ts b/test/machine/visualization/text.ts index 7808424..f6426be 100644 --- a/test/machine/visualization/text.ts +++ b/test/machine/visualization/text.ts @@ -33,15 +33,16 @@ const triggerLabels = (definitions: ReadonlyArray): Readon if (branches.length === 0) return [] const reenter = definition.reenter ? " [reenter]" : "" + const acceptance = definition.acceptance === "declinable" ? " [declinable]" : "" const trigger = definition.trigger.type === "event" ? - `◇ on: ${String(definition.trigger.event)}${reenter}` + `◇ on: ${String(definition.trigger.event)}${reenter}${acceptance}` : definition.trigger.type === "always" ? - `◇ always${reenter}` + `◇ always${reenter}${acceptance}` : definition.trigger.type === "done" ? - `◇ done${reenter}` + `◇ done${reenter}${acceptance}` : definition.trigger.type === "choice" ? "◇ choice" - : `◇ invoke ${definition.trigger.id} ${definition.trigger.outcome}${reenter}` + : `◇ invoke ${definition.trigger.id} ${definition.trigger.outcome}${reenter}${acceptance}` return [{ trigger, branches }] }) diff --git a/test/testing/Coverage.test.ts b/test/testing/Coverage.test.ts index 8e9a310..72702c7 100644 --- a/test/testing/Coverage.test.ts +++ b/test/testing/Coverage.test.ts @@ -28,6 +28,7 @@ const counterMachine = Machine.make({ count: { on: { Add: Machine.transition({ + declinable: true, target: (to) => to.full.count(), resolve: ({ event, state, target }) => target(new Count({ value: state.value + event.amount })), reenter: true @@ -241,6 +242,8 @@ describe("MachineTest trace coverage", () => { const partial = MachineTest.coverage(counterMachine, addTrace) assert.strictEqual(partial.events.available, true) if (!partial.events.available) return + assert.strictEqual(partial.transitions.definitions.hits[0]?.acceptance, "declinable") + assert.strictEqual(partial.transitions.branches.hits[0]?.acceptance, "declinable") assert.deepStrictEqual(partial.states.activation.misses.map(({ path }) => path), ["done"]) assert.deepStrictEqual( partial.transitions.definitions.misses.map(({ source, trigger }) => ({ source, trigger })), diff --git a/test/testing/Probe.test.ts b/test/testing/Probe.test.ts index fe8f828..79da6ba 100644 --- a/test/testing/Probe.test.ts +++ b/test/testing/Probe.test.ts @@ -13,6 +13,7 @@ class Increment extends Schema.TaggedClass("ProbeIncrement")("Increme class Noop extends Schema.TaggedClass("ProbeNoop")("Noop", {}) {} class Ignored extends Schema.TaggedClass("ProbeIgnored")("Ignored", {}) {} +class Decline extends Schema.TaggedClass("ProbeDecline")("Decline", {}) {} class Burst extends Schema.TaggedClass("ProbeBurst")("Burst", {}) {} class Reenter extends Schema.TaggedClass("ProbeReenter")("Reenter", {}) {} class RaisedIncrement extends Schema.TaggedClass("ProbeRaisedIncrement")("RaisedIncrement", {}) {} @@ -21,7 +22,7 @@ const states = Machine.states({ Counter }) const machine = Machine.make({ states: states.states, - events: Machine.events(Increment, Noop, Ignored, Burst, Reenter), + events: Machine.events(Increment, Noop, Ignored, Decline, Burst, Reenter), internalEvents: Machine.internalEvents(RaisedIncrement), initial: { target: (to) => to.Counter(), @@ -38,6 +39,11 @@ const machine = Machine.make({ target: (to) => to.none(), resolve: () => undefined }), + Decline: Machine.transition({ + declinable: true, + target: (to) => to.none(), + resolve: ({ decline }) => decline() + }), Reenter: Machine.transition({ target: (to) => to.full.Counter(), resolve: ({ state, target }) => target(new Counter({ count: state.count })), @@ -93,6 +99,11 @@ describe("MachineTest probe", () => { assert.strictEqual(ignored.before.value.count, 0) assert.strictEqual(ignored.after.value.count, 0) + const declined = yield* probe.sendAndAwait(new Decline({})) + assert.strictEqual(declined.handled, false) + assert.strictEqual(declined.configurationChanged, false) + assert.strictEqual(declined.plan.microsteps.length, 0) + const targetless = yield* probe.sendAndAwait(new Noop({})) assert.strictEqual(targetless.handled, true) assert.strictEqual(targetless.configurationChanged, false) diff --git a/typetest/machine/Choice.tst.ts b/typetest/machine/Choice.tst.ts index 5885021..2745f55 100644 --- a/typetest/machine/Choice.tst.ts +++ b/typetest/machine/Choice.tst.ts @@ -157,5 +157,22 @@ describe("Machine choice pseudo-states", () => { } } }) + + const declinableChoice = null as unknown as Machine.Machine.TransitionConfig< + typeof States.states, + readonly [], + readonly [], + "Flow.Routing", + Machine.Machine.ChoiceContext, + false, + "declinable" + > + expect(base.handle).type.not.toBeCallableWith({ + Flow: { + states: { + Routing: { choice: declinableChoice } + } + } + }) }) }) diff --git a/typetest/machine/Inspection.tst.ts b/typetest/machine/Inspection.tst.ts index 2e5d098..9e7f463 100644 --- a/typetest/machine/Inspection.tst.ts +++ b/typetest/machine/Inspection.tst.ts @@ -193,6 +193,7 @@ describe("Machine inspection", () => { const definition = Machine.transitionDefinitions(machine)[0]! expect(definition.source).type.toBe<"root" | "root.idle" | "root.recent">() + expect(definition.acceptance).type.toBe() if (definition.trigger.type === "event") { expect(definition.trigger.event).type.toBe<"Reset">() } diff --git a/typetest/machine/Machine.tst.ts b/typetest/machine/Machine.tst.ts index 9c373a8..53c2233 100644 --- a/typetest/machine/Machine.tst.ts +++ b/typetest/machine/Machine.tst.ts @@ -1337,6 +1337,51 @@ describe("Machine", () => { } }) + machine.handle({ + down: { + on: { + SignIn: Machine.transition({ + declinable: true, + branches: (to) => ({ + accepted: { target: to.full.down() }, + consumed: { target: to.none() } + }), + resolve: (context) => { + expect(context.decline()).type.toBe() + if (context.event.userId === "decline") return context.decline() + if (context.event.userId === "consume") return context.select.consumed() + return context.select.accepted(new Down({})) + } + }) + } + } + }) + + machine.handle({ + down: { + on: { + SignIn: Machine.transition({ + target: (to) => to.none(), + resolve: (context) => { + expect(context).type.not.toHaveProperty("decline") + return undefined + } + }) + } + } + }) + + const declined = null as unknown as Machine.Machine.Declined + expect(Machine.transition).type.not.toBeCallableWith({ + target: (to: Machine.Machine.TargetSelector) => to.none(), + resolve: () => declined + }) + expect(Machine.transition).type.not.toBeCallableWith({ + declinable: true as boolean, + target: (to: Machine.Machine.TargetSelector) => to.none(), + resolve: () => declined + }) + const target = (to: Machine.Machine.TargetSelector) => to.none() type BranchesInput = Machine.Machine.TransitionBranchesInput< typeof UpStates.states, diff --git a/typetest/testing/Coverage.tst.ts b/typetest/testing/Coverage.tst.ts index e4d5143..dce1168 100644 --- a/typetest/testing/Coverage.tst.ts +++ b/typetest/testing/Coverage.tst.ts @@ -39,8 +39,10 @@ describe("MachineTest coverage and observed graph", () => { expect(result.states.activation.hits[0]!.path).type.toBe<"idle" | "done">() expect(result.transitions.definitions.hits[0]!.source).type.toBe<"idle" | "done">() expect(result.transitions.definitions.hits[0]!.trigger).type.toBe>() + expect(result.transitions.definitions.hits[0]!.acceptance).type.toBe() expect(result.transitions.branches.hits[0]!.source).type.toBe<"idle" | "done">() expect(result.transitions.branches.hits[0]!.trigger).type.toBe>() + expect(result.transitions.branches.hits[0]!.acceptance).type.toBe() expect(result.transitions.branches.hits[0]!.branch).type.toBe< Machine.Machine.TransitionBranch<"idle" | "done"> >()