diff --git a/.changeset/graph-traversal-dead-branches.md b/.changeset/graph-traversal-dead-branches.md new file mode 100644 index 0000000..f43a574 --- /dev/null +++ b/.changeset/graph-traversal-dead-branches.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/xstate": none +--- + +The graph traversals in `@systemfsoftware/xstate/graph` drop code that changed no result: a queue compaction shortcut, a shortest-path weight improvement that breadth-first order never reaches, unreachable missing-entry errors, and a serializer fallback that the options spread always replaced. Every path, step and adjacency entry comes out the same as before. diff --git a/packages/xstate/src/graph/adjacency.ts b/packages/xstate/src/graph/adjacency.ts index 3f012df..f58b166 100644 --- a/packages/xstate/src/graph/adjacency.ts +++ b/packages/xstate/src/graph/adjacency.ts @@ -239,41 +239,21 @@ const expand = < enqueueTransitions(value, queue, queued, traversal) } -const pastCompactThreshold = (head: number): boolean => head > 4096 - -const beyondHalf = (head: number, length: number): boolean => head * 2 > length - -const shouldCompact = (head: number, length: number): boolean => pastCompactThreshold(head) && beyondHalf(head, length) - -const compactFrontier = ( - queue: Array>, - head: number, -): number => { - if (!shouldCompact(head, queue.length)) { - return head - } - queue.splice(0, head + 1) - return -1 -} - -const advance = < +const expandLevel = < TSnapshot extends AnySnapshot, TEvent extends EventObject, TInput, TSystem extends AnyActorSystem, >( adj: AdjacencyMap, - queue: Array>, - head: number, + level: ReadonlyArray>, traversal: Traversal, -): number => { - const queued = queue[head] - if (queued === undefined) { - return head + 1 +): Array> => { + const nextLevel: Array> = [] + for (const queued of level) { + expand(adj, nextLevel, queued, traversal) } - const nextHead = compactFrontier(queue, head) - expand(adj, queue, queued, traversal) - return nextHead + 1 + return nextLevel } const drain = < @@ -283,12 +263,12 @@ const drain = < TSystem extends AnyActorSystem, >( adj: AdjacencyMap, - queue: Array>, + start: QueueEntry, traversal: Traversal, ): void => { - let head = 0 - while (head < queue.length) { - head = advance(adj, queue, head, traversal) + let level = [start] + while (level.length !== 0) { + level = expandLevel(adj, level, traversal) } } @@ -312,10 +292,7 @@ function computeAdjacencyMap< } const fromState = config.fromState ?? initialSnapshotOf(logic, options.input, actorScope) const adj: AdjacencyMap = createNullDict>() - const queue: Array> = [ - { nextState: fromState, event: undefined, prevState: undefined }, - ] - drain(adj, queue, traversal) + drain(adj, { nextState: fromState, event: undefined, prevState: undefined }, traversal) return adj } @@ -367,35 +344,18 @@ export function adjacencyMapToArray( event: TEvent nextState: TSnapshot }> { - return Object.keys(adjMap).flatMap((key) => adjacencyRow(adjMap, toSerializedSnapshot(key))) -} - -const requiredValue = ( - dict: Record, - key: TKey, -): TValue => { - const value = dict[key] - if (value === undefined) { - throw new Error('Missing adjacency entry') - } - return value + return Object.values({ ...adjMap }).flatMap((adjValue) => adjacencyRow(adjValue)) } const adjacencyRow = ( - adjMap: AdjacencyMap, - key: SerializedSnapshot, + adjValue: AdjacencyValue, ): Array<{ state: TSnapshot event: TEvent nextState: TSnapshot -}> => { - const adjValue = requiredValue(adjMap, key) - return Object.keys(adjValue.transitions).map((eventKey) => { - const transition = requiredValue(adjValue.transitions, toSerializedEvent(eventKey)) - return { - state: adjValue.state, - event: transition.event, - nextState: transition.state, - } - }) -} +}> => + Object.values({ ...adjValue.transitions }).map((transition) => ({ + state: adjValue.state, + event: transition.event, + nextState: transition.state, + })) diff --git a/packages/xstate/src/graph/alterPath.ts b/packages/xstate/src/graph/alterPath.ts index 67363b1..c71f9ad 100644 --- a/packages/xstate/src/graph/alterPath.ts +++ b/packages/xstate/src/graph/alterPath.ts @@ -11,8 +11,7 @@ const initStepOf = (state: AnySnapshot): ErasedStep => ({ event: { type: XSTATE_INIT }, }) -const previousStepOf = (steps: ErasedSteps, index: number): ErasedStep | undefined => - index === 0 ? undefined : steps[index - 1] +const previousStepOf = (steps: ErasedSteps, index: number): ErasedStep | undefined => steps[index - 1] const eventForStep = (steps: ErasedSteps, index: number): EventObject => { const previous = previousStepOf(steps, index) @@ -30,18 +29,15 @@ const finalStepOf = (path: ErasedStatePath): ErasedStep | undefined => { const appendedSteps = (path: ErasedStatePath): ErasedSteps => { const steps = replaySteps(path) const finalStep = finalStepOf(path) - if (finalStep !== undefined) { - steps.push(finalStep) - } + steps.push(finalStep === undefined ? initStepOf(path.state) : finalStep) return steps } // TODO: rewrite parts of the algorithm leading to this to make this function obsolete export function alterPath(path: T): T export function alterPath(path: T): ErasedStatePath { - const steps = path.steps.length > 0 ? appendedSteps(path) : [initStepOf(path.state)] return { ...path, - steps, + steps: appendedSteps(path), } } diff --git a/packages/xstate/src/graph/graph.ts b/packages/xstate/src/graph/graph.ts index cc142b6..7644394 100644 --- a/packages/xstate/src/graph/graph.ts +++ b/packages/xstate/src/graph/graph.ts @@ -138,19 +138,6 @@ function defaultSerializeState( return JSON.stringify(state) } -function firstDefined( - candidates: readonly (T | undefined)[], - fallback: T, -): T { - return candidates.find((candidate) => candidate !== undefined) ?? fallback -} - -function optionSerializeState( - options: OptionsOf | undefined, -): SerializeState | undefined { - return options?.serializeState -} - function optionFromState( options: OptionsOf | undefined, ): SnapshotFrom | undefined { @@ -278,13 +265,7 @@ export function resolveTraversalOptions( }, ): TraversalConfig, EventFromLogic> { const resolvedDefaultOptions = resolveDefaultOptions(logic, traversalOptions) - const serializeState: SerializeState = firstDefined( - [ - optionSerializeState(traversalOptions), - optionSerializeState(resolvedDefaultOptions), - ], - defaultSerializeState, - ) + const serializeState: SerializeState = defaultSerializeState const fromState = optionFromState(traversalOptions) ?? optionFromState(resolvedDefaultOptions) const traversalConfig: TraversalConfig< diff --git a/packages/xstate/src/graph/shortestPaths.ts b/packages/xstate/src/graph/shortestPaths.ts index 2f21cf1..14f2d19 100644 --- a/packages/xstate/src/graph/shortestPaths.ts +++ b/packages/xstate/src/graph/shortestPaths.ts @@ -31,9 +31,10 @@ import type { TraversalOptions, } from './types.js' -interface WeightEntry { +interface WeightEntry { weight: number - state: SerializedSnapshot | undefined + snapshot: TSnapshot + origin: SerializedSnapshot | undefined event: TEvent | undefined } @@ -45,10 +46,9 @@ interface PathPlan { interface ShortestContext { adjacency: AdjacencyMap serializeState: TraversalConfig['serializeState'] - stateMap: Map - weightMap: Map> + weightMap: Map> visited: Set - unvisited: Set + unvisited: Map> } const createNullDict = (): Record => { @@ -66,53 +66,34 @@ const initialSnapshotOf = ( actorScope: AnyActorScope, ): TSnapshot => logic.getInitialSnapshot(actorScope, input) -const mustGet = (map: Map, key: TKey): TValue => { - const value = map.get(key) - if (value === undefined) { - throw new Error('Missing traversal entry') - } - return value -} - -const improveExisting = ( +const discover = ( context: ShortestContext, - fromSerialized: SerializedSnapshot, - weight: number, nextSerialized: SerializedSnapshot, - event: TEvent, + entry: WeightEntry, ): void => { - const { weight: nextWeight } = mustGet(context.weightMap, nextSerialized) - if (nextWeight > weight + 1) { - context.weightMap.set(nextSerialized, { weight: weight + 1, state: fromSerialized, event }) + if (!context.visited.has(nextSerialized)) { + context.unvisited.set(nextSerialized, entry) } } -const improveWeight = ( +const recordReach = ( context: ShortestContext, - fromSerialized: SerializedSnapshot, - weight: number, nextSerialized: SerializedSnapshot, + weight: number, + origin: SerializedSnapshot, + snapshot: TSnapshot, event: TEvent, ): void => { - if (context.weightMap.has(nextSerialized)) { - improveExisting(context, fromSerialized, weight, nextSerialized, event) - } else { - context.weightMap.set(nextSerialized, { weight: weight + 1, state: fromSerialized, event }) - } -} - -const discover = ( - context: ShortestContext, - nextSerialized: SerializedSnapshot, -): void => { - if (!context.visited.has(nextSerialized)) { - context.unvisited.add(nextSerialized) - } + const existing = context.weightMap.get(nextSerialized) + const entry = existing === undefined ? { weight, snapshot, origin, event } : { ...existing, snapshot } + context.weightMap.set(nextSerialized, entry) + discover(context, nextSerialized, entry) } const relaxTransition = ( context: ShortestContext, fromSerialized: SerializedSnapshot, + fromSnapshot: TSnapshot, weight: number, transitions: AdjacencyMap[SerializedSnapshot]['transitions'], serializedEvent: SerializedEvent, @@ -122,34 +103,33 @@ const relaxTransition = ( context: ShortestContext, fromSerialized: SerializedSnapshot, + fromSnapshot: TSnapshot, weight: number, adjacencyValue: AdjacencyMap[SerializedSnapshot], ): void => { for (const serializedEvent of Object.keys(adjacencyValue.transitions).map(toSerializedEvent)) { - relaxTransition(context, fromSerialized, weight, adjacencyValue.transitions, serializedEvent) + relaxTransition(context, fromSerialized, fromSnapshot, weight, adjacencyValue.transitions, serializedEvent) } } const relaxState = ( context: ShortestContext, fromSerialized: SerializedSnapshot, + entry: WeightEntry, ): void => { - const weightEntry = mustGet(context.weightMap, fromSerialized) const adjacencyValue = context.adjacency[fromSerialized] if (adjacencyValue === undefined) { return } - relaxTransitions(context, fromSerialized, weightEntry.weight, adjacencyValue) + relaxTransitions(context, fromSerialized, entry.snapshot, entry.weight, adjacencyValue) context.visited.add(fromSerialized) context.unvisited.delete(fromSerialized) } @@ -157,13 +137,12 @@ const relaxState = ( const runTraversal = ( context: ShortestContext, ): void => { - for (const fromSerialized of context.unvisited) { - relaxState(context, fromSerialized) + for (const [fromSerialized, entry] of context.unvisited) { + relaxState(context, fromSerialized, entry) } } const firstPlanPath = ( - stateMap: Map, statePlanMap: StatePlanMap, fromSerialized: SerializedSnapshot, ): StatePath | undefined => { @@ -172,42 +151,38 @@ const firstPlanPath = ( - stateMap: Map, - fromSerialized: SerializedSnapshot, + fromState: TSnapshot, event: TEvent | undefined, -): Array> => event === undefined ? [] : [{ state: mustGet(stateMap, fromSerialized), event }] +): Array> => event === undefined ? [] : [{ state: fromState, event }] const stepsToward = ( - stateMap: Map, statePlanMap: StatePlanMap, fromSerialized: SerializedSnapshot, event: TEvent | undefined, ): Steps => { - const path = firstPlanPath(stateMap, statePlanMap, fromSerialized) + const path = firstPlanPath(statePlanMap, fromSerialized) return path === undefined ? [] - : path.steps.concat(stepTo(stateMap, fromSerialized, event)) + : path.steps.concat(stepTo(path.state, event)) } const predecessorSteps = ( - stateMap: Map, statePlanMap: StatePlanMap, - entry: WeightEntry, + entry: WeightEntry, ): Steps => { - const { state: fromSerialized, event } = entry - return fromSerialized === undefined + const origin = entry.origin + return origin === undefined ? [] - : stepsToward(stateMap, statePlanMap, fromSerialized, event) + : stepsToward(statePlanMap, origin, entry.event) } const buildPath = ( - stateMap: Map, statePlanMap: StatePlanMap, - entry: WeightEntry, + entry: WeightEntry, stateSerial: SerializedSnapshot, ): StatePath => { - const state = mustGet(stateMap, stateSerial) - const steps = predecessorSteps(stateMap, statePlanMap, entry) + const state = entry.snapshot + const steps = predecessorSteps(statePlanMap, entry) statePlanMap[stateSerial] = { state, paths: [{ state, steps, weight: entry.weight }], @@ -216,13 +191,12 @@ const buildPath = ( } const collectPaths = ( - stateMap: Map, - weightMap: Map>, + weightMap: Map>, ): Array> => { const statePlanMap: StatePlanMap = createNullDict>() const paths: Array> = [] weightMap.forEach((entry, stateSerial) => { - paths.push(buildPath(stateMap, statePlanMap, entry, stateSerial)) + paths.push(buildPath(statePlanMap, entry, stateSerial)) }) return paths } @@ -243,9 +217,14 @@ const seed = ( fromState: TSnapshot, ): void => { const serialized = toSerializedSnapshot(context.serializeState(fromState, undefined, undefined)) - context.stateMap.set(serialized, fromState) - context.weightMap.set(serialized, { weight: 0, state: undefined, event: undefined }) - context.unvisited.add(serialized) + const entry: WeightEntry = { + weight: 0, + snapshot: fromState, + origin: undefined, + event: undefined, + } + context.weightMap.set(serialized, entry) + context.unvisited.set(serialized, entry) } function computeShortestPaths( @@ -266,14 +245,13 @@ function computeShortestPaths( const context: ShortestContext = { adjacency, serializeState: resolvedOptions.serializeState, - stateMap: new Map(), weightMap: new Map(), visited: new Set(), - unvisited: new Set(), + unvisited: new Map(), } seed(context, fromState) runTraversal(context) - return applyTarget(collectPaths(context.stateMap, context.weightMap), resolvedOptions.toState) + return applyTarget(collectPaths(context.weightMap), resolvedOptions.toState) } export function getShortestPaths( diff --git a/packages/xstate/src/graph/simplePaths.ts b/packages/xstate/src/graph/simplePaths.ts index a2c9a99..5efd586 100644 --- a/packages/xstate/src/graph/simplePaths.ts +++ b/packages/xstate/src/graph/simplePaths.ts @@ -41,10 +41,14 @@ interface Transition { state: TSnapshot } +interface SnapshotBox { + snapshot: TSnapshot +} + interface SimpleContext { adjacency: AdjacencyMap serializeState: TraversalConfig['serializeState'] - stateMap: Map + stateMap: Map> visitCtx: VisitedContext steps: Steps pathMap: StatePlanMap @@ -65,31 +69,35 @@ const initialSnapshotOf = ( actorScope: AnyActorScope, ): TSnapshot => logic.getInitialSnapshot(actorScope, input) -const mustGet = (map: Map, key: TKey): TValue => { - const value = map.get(key) - if (value === undefined) { - throw new Error('Missing traversal entry') - } - return value +const boxFor = ( + context: SimpleContext, + serialized: SerializedSnapshot, + snapshot: TSnapshot, +): SnapshotBox => { + const existing = context.stateMap.get(serialized) + const box = existing === undefined ? { snapshot } : existing + box.snapshot = snapshot + context.stateMap.set(serialized, box) + return box } const createPlan = ( pathMap: StatePlanMap, - stateMap: Map, serialized: SerializedSnapshot, + state: TSnapshot, ): PathPlan => { - const plan: PathPlan = { state: mustGet(stateMap, serialized), paths: [] } + const plan: PathPlan = { state, paths: [] } pathMap[serialized] = plan return plan } const planAt = ( pathMap: StatePlanMap, - stateMap: Map, serialized: SerializedSnapshot, + state: TSnapshot, ): PathPlan => { const existing = pathMap[serialized] - return existing === undefined ? createPlan(pathMap, stateMap, serialized) : existing + return existing === undefined ? createPlan(pathMap, serialized, state) : existing } const recordPath = ( @@ -97,7 +105,7 @@ const recordPath = ( toSerialized: SerializedSnapshot, fromState: TSnapshot, ): void => { - const plan = planAt(context.pathMap, context.stateMap, toSerialized) + const plan = planAt(context.pathMap, toSerialized, fromState) plan.paths.push({ state: fromState, weight: context.steps.length, @@ -107,36 +115,37 @@ const recordPath = ( function expandInto( context: SimpleContext, - fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, serializedEvent: SerializedEvent, event: TEvent, nextSerialized: SerializedSnapshot, + nextBox: SnapshotBox, ): void { context.visitCtx.edges.add(serializedEvent) - context.steps.push({ state: mustGet(context.stateMap, fromSerialized), event }) - visit(context, nextSerialized, toSerialized) + context.steps.push({ state: box.snapshot, event }) + visit(context, nextSerialized, nextBox, toSerialized) } const descendInto = ( context: SimpleContext, - fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, serializedEvent: SerializedEvent, transition: Transition, ): void => { const nextSerialized = toSerializedSnapshot( - context.serializeState(transition.state, transition.event, context.stateMap.get(fromSerialized)), + context.serializeState(transition.state, transition.event, box.snapshot), ) - context.stateMap.set(nextSerialized, transition.state) + const nextBox = boxFor(context, nextSerialized, transition.state) if (!context.visitCtx.vertices.has(nextSerialized)) { - expandInto(context, fromSerialized, toSerialized, serializedEvent, transition.event, nextSerialized) + expandInto(context, box, toSerialized, serializedEvent, transition.event, nextSerialized, nextBox) } } const descendTransition = ( context: SimpleContext, - fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, transitions: AdjacencyMap[SerializedSnapshot]['transitions'], serializedEvent: SerializedEvent, @@ -145,43 +154,44 @@ const descendTransition = ( context: SimpleContext, - fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, adjacencyValue: AdjacencyMap[SerializedSnapshot], ): void => { for (const serializedEvent of Object.keys(adjacencyValue.transitions).map(toSerializedEvent)) { - descendTransition(context, fromSerialized, toSerialized, adjacencyValue.transitions, serializedEvent) + descendTransition(context, box, toSerialized, adjacencyValue.transitions, serializedEvent) } } const descend = ( context: SimpleContext, fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, ): void => { const adjacencyValue = context.adjacency[fromSerialized] if (adjacencyValue === undefined) { return } - descendTransitions(context, fromSerialized, toSerialized, adjacencyValue) + descendTransitions(context, box, toSerialized, adjacencyValue) } function visit( context: SimpleContext, fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, ): void { - const fromState = mustGet(context.stateMap, fromSerialized) context.visitCtx.vertices.add(fromSerialized) if (fromSerialized === toSerialized) { - recordPath(context, toSerialized, fromState) + recordPath(context, toSerialized, box.snapshot) } else { - descend(context, fromSerialized, toSerialized) + descend(context, fromSerialized, box, toSerialized) } context.steps.pop() context.visitCtx.vertices.delete(fromSerialized) @@ -190,18 +200,18 @@ function visit( const seed = ( context: SimpleContext, fromState: TSnapshot, -): SerializedSnapshot => { +): { serialized: SerializedSnapshot; box: SnapshotBox } => { const serialized = toSerializedSnapshot(context.serializeState(fromState, undefined)) - context.stateMap.set(serialized, fromState) - return serialized + return { serialized, box: boxFor(context, serialized, fromState) } } const visitEachStart = ( context: SimpleContext, fromSerialized: SerializedSnapshot, + box: SnapshotBox, ): void => { for (const nextSerialized of Object.keys(context.adjacency).map(toSerializedSnapshot)) { - visit(context, fromSerialized, nextSerialized) + visit(context, fromSerialized, box, nextSerialized) } } @@ -240,8 +250,8 @@ function computeSimplePaths( steps: [], pathMap: createNullDict>(), } - const fromSerialized = seed(context, fromState) - visitEachStart(context, fromSerialized) + const { serialized: fromSerialized, box } = seed(context, fromState) + visitEachStart(context, fromSerialized, box) const simplePaths = Object.values(context.pathMap).flatMap((plan) => plan.paths) return applyTarget(simplePaths, resolvedOptions.toState) } diff --git a/packages/xstate/tests/graph.conformance.test.ts b/packages/xstate/tests/graph.conformance.test.ts index c995283..f949f20 100644 --- a/packages/xstate/tests/graph.conformance.test.ts +++ b/packages/xstate/tests/graph.conformance.test.ts @@ -1,6 +1,7 @@ import { Conformance } from '@systemfsoftware/conformance-spec' import { And, Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' import { + type AnyActorScope, createActor, createLogic, createMachine, @@ -330,6 +331,25 @@ const rootedLogicAdjacencyKeys = (): ReadonlyArray => { return Object.keys(getAdjacencyMap(logic, { serializeState: (state) => `s${state.context}` })) } +const machineShapedMembers = { + root: lightMachine.root, + getStateNodeById: () => undefined, + resolveState: () => undefined, + getTransitionData: () => undefined, +} + +const callableLogicAdjacencyKeys = (): ReadonlyArray => { + const logic = Object.assign( + () => undefined, + createLogic({ context: 0, run: () => undefined }), + machineShapedMembers, + ) + return Object.keys(getAdjacencyMap(logic, { + fromState: { status: 'active', output: undefined, error: undefined, context: 0, input: undefined }, + events: [], + })) +} + const countedCustomLogic = () => { let calls = 0 const logic = createLogic({ @@ -457,6 +477,56 @@ const expectedEntrypointNames = [ 'toDirectedGraph', ] +const mockScopeIdentity = (): object => { + const logic = Object.assign( + createLogic({ context: {}, run: () => undefined }), + { + getInitialSnapshot: (actorScope: AnyActorScope) => ({ + context: { id: actorScope.id, sessionId: actorScope.sessionId }, + }), + }, + ) + const path = getPathsFromEvents(logic, [], {})[0] + return path === undefined ? {} : path.state.context +} + +const candidateReplayMachine = createMachine({ + initial: 'a', + states: { + a: { on: { NEXT: { target: 'b' }, BACK: { target: 'c' } } }, + b: {}, + c: {}, + }, +}) + +const candidateReplay = (): object => { + const run = (event: EventFromLogic): string => { + try { + const path = getPathsFromEvents(candidateReplayMachine, [event], { + events: [{ type: 'NEXT' }, { type: 'BACK' }], + filterEvents: ( + _state: SnapshotFrom, + candidate: EventFromLogic, + ) => candidate.type !== 'BACK', + })[0] + return path === undefined ? 'none' : String(path.state.value) + } catch (error) { + return error instanceof Error ? error.message : 'unrecognized' + } + } + return { next: run({ type: 'NEXT' }), back: run({ type: 'BACK' }) } +} + +const subIntegerLimitReplay = (): string => { + const machine = createMachine({ initial: 'a', states: { a: { on: { GO: { target: 'a' } } } } }) + try { + const paths = getPathsFromEvents(machine, [{ type: 'GO' }, { type: 'GO' }], { limit: 0.5 }) + return `paths:${paths.length}` + } catch (error) { + return error instanceof Error ? error.message : 'unrecognized' + } +} + Feature('Judging the published graph walk against a model of its traversal', { timeout: 0 }) .withLayer(Layer.empty) .live('each scenario drives the simulation kernel itself, and a conformance check cannot run inside a kernel run') @@ -650,16 +720,25 @@ Feature('Judging the published graph walk against a model of its traversal', { t ) scenario( - 'A logic carrying a non-machine root member is traversed as plain logic, not as a machine', + 'A logic the structural machine check rejects is traversed as plain logic, not as a machine', Gherkin.Do.pipe( - Given('the adjacency keys of a custom logic whose root member is not a machine root')( + Given( + 'the adjacency keys of a logic whose root member is not a machine root and of a callable logic carrying machine-shaped members', + )( 'keys', - () => Effect.succeed(rootedLogicAdjacencyKeys()), + () => Effect.succeed({ rooted: rootedLogicAdjacencyKeys(), callable: callableLogicAdjacencyKeys() }), ), - Then('the traversal starts from the plain initial state and derives no event from snapshot nodes')(( + Then( + 'both walk as plain logic: the rooted one from its serialized start, the callable under the plain default serializer', + )(( s, expect, - ) => expect(s.keys, JSON.stringify(s.keys)).toEqual(['s0'])), + ) => + expect(s.keys, JSON.stringify(s.keys)).toEqual({ + rooted: ['s0'], + callable: ['{"status":"active","context":0}'], + }) + ), ), ) @@ -729,4 +808,49 @@ Feature('Judging the published graph walk against a model of its traversal', { t ), ), ) + + scenario( + 'A traversal hands the logic it walks an actor scope identified as the graph mock', + Gherkin.Do.pipe( + Given('the identity a custom logic reads off the actor scope of its initial snapshot')( + 'identity', + () => Effect.succeed(mockScopeIdentity()), + ), + Then('the scope is the graph mock: an empty id and the mock session id')((s, expect) => + expect(s.identity, JSON.stringify(s.identity)).toEqual({ id: '', sessionId: 'mock-actor-scope' }) + ), + ), + ) + + scenario( + 'A replayed event is replaced by the last override candidate whose filter and serial both match it', + Gherkin.Do.pipe( + Given('a one-event replay over two candidate events, the second of which the filter rejects')( + 'observed', + () => Effect.succeed(candidateReplay()), + ), + Then('an accepted event advances to its target, and an event no candidate serial names is refused')(( + s, + expect, + ) => + expect(s.observed, JSON.stringify(s.observed)).toEqual({ + next: 'b', + back: 'Invalid transition from {"value":"a"} with {"type":"BACK"}', + }) + ), + ), + ) + + scenario( + 'A replay refuses the step after the step count reaches a limit below one', + Gherkin.Do.pipe( + Given('two events replayed against a machine with the limit set to one half')( + 'outcome', + () => Effect.succeed(subIntegerLimitReplay()), + ), + Then('the traversal is refused rather than returning the completed path')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toEqual('Traversal limit exceeded') + ), + ), + ) })