Skip to content

Commit 8c437ab

Browse files
authoredJun 28, 2020
Merge pull request #1240 from davidkpiano/davidkpiano/scxml-tests-1
2 parents 9d44d9d + 6043a1c commit 8c437ab

11 files changed

+248
-89
lines changed
 

‎.changeset/hip-ways-rush.md

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'xstate': major
3+
---
4+
5+
The `in: '...'` transition property can now be replaced with `stateIn(...)` and `stateNotIn(...)` guards, imported from `xstate/guards`:
6+
7+
```diff
8+
import {
9+
createMachine,
10+
+ stateIn
11+
} from 'xstate/guards';
12+
13+
const machine = createMachine({
14+
// ...
15+
on: {
16+
SOME_EVENT: {
17+
target: 'anotherState',
18+
- in: '#someState',
19+
+ cond: stateIn('#someState')
20+
}
21+
}
22+
})
23+
```
24+
25+
The `stateIn(...)` and `stateNotIn(...)` guards also can be used the same way as `state.matches(...)`:
26+
27+
```js
28+
// ...
29+
SOME_EVENT: {
30+
target: 'anotherState',
31+
cond: stateNotIn({ red: 'stop' })
32+
}
33+
```
34+
35+
---
36+
37+
An error will now be thrown if the `assign(...)` action is executed when the `context` is `undefined`. Previously, there was only a warning.
38+
39+
---
40+
41+
The SCXML event `error.execution` will be raised if assignment in an `assign(...)` action fails.
42+
43+
---
44+
45+
Error events raised by the machine will be _thrown_ if there are no error listeners registered on a service via `service.onError(...)`.

‎packages/core/guards/package.json

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"main": "dist/xstate.cjs.js",
3+
"module": "dist/xstate.esm.js",
4+
"preconstruct": {
5+
"source": "../src/guards"
6+
}
7+
}

‎packages/core/src/guards.ts

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { EventObject, StateValue, GuardPredicate } from './types';
2+
import { isStateId } from './stateUtils';
3+
import { isString } from 'util';
4+
5+
export function stateIn<TContext, TEvent extends EventObject>(
6+
stateValue: StateValue
7+
): GuardPredicate<TContext, TEvent> {
8+
return {
9+
type: 'xstate.guard',
10+
name: 'In',
11+
predicate: (_, __, { state }) => {
12+
if (isString(stateValue) && isStateId(stateValue)) {
13+
return state.configuration.some((sn) => sn.id === stateValue.slice(1));
14+
}
15+
16+
return state.matches(stateValue);
17+
}
18+
};
19+
}
20+
21+
export function stateNotIn<TContext, TEvent extends EventObject>(
22+
stateValue: StateValue
23+
): GuardPredicate<TContext, TEvent> {
24+
return {
25+
type: 'xstate.guard',
26+
name: '!In',
27+
predicate: (_, __, { state }) => {
28+
if (isString(stateValue) && isStateId(stateValue)) {
29+
return state.configuration.every((sn) => sn.id !== stateValue.slice(1));
30+
}
31+
32+
return !state.matches(stateValue);
33+
}
34+
};
35+
}

‎packages/core/src/interpreter.ts

+23-15
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import {
2121
BehaviorCreator,
2222
InvokeActionObject,
2323
AnyEventObject,
24-
ActorRef
24+
ActorRef,
25+
SCXMLErrorEvent
2526
} from './types';
2627
import { State, bindActionToState, isState } from './State';
2728
import * as actionTypes from './actionTypes';
@@ -34,7 +35,8 @@ import {
3435
isArray,
3536
isFunction,
3637
toSCXMLEvent,
37-
symbolObservable
38+
symbolObservable,
39+
isSCXMLErrorEvent
3840
} from './utils';
3941
import { Scheduler } from './scheduler';
4042
import { isActorRef, fromService, ObservableActorRef } from './Actor';
@@ -330,6 +332,16 @@ export class Interpreter<
330332
return this;
331333
}
332334

335+
private handleErrorEvent(errorEvent: SCXMLErrorEvent): void {
336+
if (this.errorListeners.size > 0) {
337+
this.errorListeners.forEach((listener) => {
338+
listener(errorEvent.data.data);
339+
});
340+
} else {
341+
throw errorEvent.data.data;
342+
}
343+
}
344+
333345
/**
334346
* Adds a state listener that is notified when the statechart has reached its final state.
335347
* @param listener The state listener
@@ -556,9 +568,14 @@ export class Interpreter<
556568

557569
if (!target) {
558570
if (!isParent) {
559-
throw new Error(
571+
const executionError = new Error(
560572
`Unable to send event to child '${to}' from service '${this.id}'.`
561573
);
574+
this.send(
575+
toSCXMLEvent<TEvent>(actionTypes.errorExecution, {
576+
data: executionError as any // TODO: refine
577+
})
578+
);
562579
}
563580

564581
// tslint:disable-next-line:no-console
@@ -590,19 +607,10 @@ export class Interpreter<
590607
const _event = toSCXMLEvent(event);
591608

592609
if (
593-
_event.name.indexOf(actionTypes.errorPlatform) === 0 &&
594-
!this.state.nextEvents.some(
595-
(nextEvent) => nextEvent.indexOf(actionTypes.errorPlatform) === 0
596-
)
610+
isSCXMLErrorEvent(_event) &&
611+
!this.state.nextEvents.some((nextEvent) => nextEvent === _event.name)
597612
) {
598-
// TODO: refactor into proper error handler
599-
if (this.errorListeners.size > 0) {
600-
this.errorListeners.forEach((listener) => {
601-
listener((_event.data as any).data);
602-
});
603-
} else {
604-
throw (_event.data as any).data;
605-
}
613+
this.handleErrorEvent(_event);
606614
}
607615

608616
const nextState = this.machine.transition(this.state, _event, this.ref);

‎packages/core/src/scxml.ts

+62-26
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { mapValues, keys, isString, flatten } from './utils';
1212
import * as actions from './actions';
1313
import { invokeMachine } from './invoke';
1414
import { MachineNode } from './MachineNode';
15+
import { stateIn, stateNotIn } from './guards';
1516

1617
function getAttribute(
1718
element: XMLElement,
@@ -369,31 +370,62 @@ function toConfig(
369370
initial = stateElements[0].attributes!.id;
370371
}
371372

372-
const on = transitionElements.map((value) => {
373-
const event = getAttribute(value, 'event') || '';
374-
375-
if (event === 'done.invoke') {
376-
throw new Error(
377-
'done.invoke gets often used in SCXML tests as inexact event descriptor.' +
378-
" As long as this stay unimplemented or done.invoke doesn't get a specialcased while converting throw when seeing it to avoid tests using this to pass by accident."
373+
const on = flatten(
374+
transitionElements.map((value) => {
375+
const events = ((getAttribute(value, 'event') as string) || '').split(
376+
/\s+/
379377
);
380-
}
381378

382-
const targets = getAttribute(value, 'target');
383-
const internal = getAttribute(value, 'type') === 'internal';
379+
return events.map((event) => {
380+
if (event === 'done.invoke') {
381+
throw new Error(
382+
'done.invoke gets often used in SCXML tests as inexact event descriptor.' +
383+
" As long as this stay unimplemented or done.invoke doesn't get a specialcased while converting throw when seeing it to avoid tests using this to pass by accident."
384+
);
385+
}
384386

385-
return {
386-
event,
387-
target: getTargets(targets),
388-
...(value.elements ? executableContent(value.elements) : undefined),
389-
...(value.attributes && value.attributes.cond
390-
? {
391-
cond: createCond(value.attributes!.cond as string)
387+
const targets = getAttribute(value, 'target');
388+
const internal = getAttribute(value, 'type') === 'internal';
389+
390+
let condObject = {};
391+
392+
if (value.attributes?.cond) {
393+
const cond = value.attributes!.cond;
394+
if ((cond as string).startsWith('In')) {
395+
const inMatch = (cond as string).trim().match(/^In\('(.*)'\)/);
396+
397+
if (inMatch) {
398+
condObject = {
399+
cond: stateIn(`#${inMatch[1]}`)
400+
};
401+
}
402+
} else if ((cond as string).startsWith('!In')) {
403+
const notInMatch = (cond as string)
404+
.trim()
405+
.match(/^!In\('(.*)'\)/);
406+
407+
if (notInMatch) {
408+
condObject = {
409+
cond: stateNotIn(`#${notInMatch[1]}`)
410+
};
411+
}
412+
} else {
413+
condObject = {
414+
cond: createCond(value.attributes!.cond as string)
415+
};
392416
}
393-
: undefined),
394-
internal
395-
};
396-
});
417+
}
418+
419+
return {
420+
event,
421+
target: getTargets(targets),
422+
...(value.elements ? executableContent(value.elements) : undefined),
423+
...condObject,
424+
internal
425+
};
426+
});
427+
})
428+
);
397429

398430
const onEntry = onEntryElements
399431
? flatten(
@@ -477,15 +509,19 @@ function scxmlToMachine(
477509
? dataModelEl
478510
.elements!.filter((element) => element.name === 'data')
479511
.reduce((acc, element) => {
480-
if (element.attributes!.src) {
512+
const { src, expr, id } = element.attributes!;
513+
if (src) {
481514
throw new Error(
482515
"Conversion of `src` attribute on datamodel's <data> elements is not supported."
483516
);
484517
}
485-
acc[element.attributes!.id!] = element.attributes!.expr
486-
? // tslint:disable-next-line:no-eval
487-
eval(`(${element.attributes!.expr})`)
488-
: undefined;
518+
519+
if (expr === '_sessionid') {
520+
acc[id!] = undefined;
521+
} else {
522+
acc[id!] = eval(`(${expr})`);
523+
}
524+
489525
return acc;
490526
}, {})
491527
: undefined;

‎packages/core/src/stateUtils.ts

+20-9
Original file line numberDiff line numberDiff line change
@@ -1646,15 +1646,26 @@ function resolveActionsAndContext<TContext, TEvent extends EventObject>(
16461646
}
16471647
break;
16481648
case actionTypes.assign:
1649-
const [nextContext, nextActions] = updateContext(
1650-
context,
1651-
_event,
1652-
[actionObject as AssignAction<TContext, TEvent>],
1653-
currentState,
1654-
service
1655-
);
1656-
context = nextContext;
1657-
resActions.push(actionObject, ...nextActions);
1649+
try {
1650+
const [nextContext, nextActions] = updateContext(
1651+
context,
1652+
_event,
1653+
[actionObject as AssignAction<TContext, TEvent>],
1654+
currentState,
1655+
service
1656+
);
1657+
context = nextContext;
1658+
resActions.push(actionObject, ...nextActions);
1659+
} catch (err) {
1660+
// Raise error.execution events for failed assign actions
1661+
raisedActions.push({
1662+
type: actionTypes.raise,
1663+
_event: toSCXMLEvent({
1664+
type: actionTypes.errorExecution,
1665+
error: err
1666+
} as any) // TODO: fix
1667+
});
1668+
}
16581669
break;
16591670
default:
16601671
resActions.push(

‎packages/core/src/types.ts

+8
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,14 @@ export interface ErrorPlatformEvent extends EventObject {
624624
data: any;
625625
}
626626

627+
export interface SCXMLErrorEvent extends SCXML.Event<any> {
628+
name:
629+
| ActionTypes.ErrorExecution
630+
| ActionTypes.ErrorPlatform
631+
| ActionTypes.ErrorCommunication;
632+
data: any;
633+
}
634+
627635
export interface DoneEventObject extends EventObject {
628636
data?: any;
629637
toString(): string;

‎packages/core/src/updateContext.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,9 @@ import {
1010
ActorRef,
1111
ActorRefFrom
1212
} from './types';
13-
import { IS_PRODUCTION } from './environment';
1413
import { State } from '.';
1514
import { ObservableActorRef } from './Actor';
16-
import { warn, isFunction, keys } from './utils';
15+
import { isFunction, keys } from './utils';
1716
import { createBehaviorFrom, Behavior } from './behavior';
1817
import { registry } from './registry';
1918

@@ -24,11 +23,14 @@ export function updateContext<TContext, TEvent extends EventObject>(
2423
state?: State<TContext, TEvent>,
2524
service?: ActorRef<TEvent>
2625
): [TContext, ActionObject<TContext, TEvent>[]] {
27-
if (!IS_PRODUCTION) {
28-
warn(!!context, 'Attempting to update undefined context');
29-
}
3026
const capturedActions: InvokeActionObject[] = [];
3127

28+
if (!context) {
29+
throw new Error(
30+
'Cannot assign to undefined `context`. Ensure that `context` is defined in the machine config.'
31+
);
32+
}
33+
3234
const updatedContext = context
3335
? assignActions.reduce((acc, assignAction) => {
3436
const { assignment } = assignAction as AssignAction<TContext, TEvent>;

‎packages/core/src/utils.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ import {
2424
} from './constants';
2525
import { IS_PRODUCTION } from './environment';
2626
import { StateNode } from './StateNode';
27-
import { InvokeConfig } from '.';
27+
import { InvokeConfig, SCXMLErrorEvent } from '.';
2828
import { MachineNode } from './MachineNode';
2929
import { Behavior } from './behavior';
30+
import { errorExecution, errorPlatform } from './actionTypes';
3031

3132
export function keys<T extends object>(value: T): Array<keyof T & string> {
3233
return Object.keys(value) as Array<keyof T & string>;
@@ -398,6 +399,12 @@ export function isSCXMLEvent<TEvent extends EventObject>(
398399
return !isString(event) && '$$type' in event && event.$$type === 'scxml';
399400
}
400401

402+
export function isSCXMLErrorEvent(
403+
event: SCXML.Event<any>
404+
): event is SCXMLErrorEvent {
405+
return event.name === errorExecution || event.name.startsWith(errorPlatform);
406+
}
407+
401408
export function toSCXMLEvent<TEvent extends EventObject>(
402409
event: Event<TEvent> | SCXML.Event<TEvent>,
403410
scxmlEvent?: Partial<SCXML.Event<TEvent>>

‎packages/core/test/scxml.test.ts

+29-30
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,7 @@ const testGroups = {
2929
'send8b',
3030
'send9'
3131
],
32-
assign: [
33-
// 'assign_invalid', // TODO: handle error.execution event
34-
'assign_obj_literal'
35-
],
32+
assign: ['assign_invalid', 'assign_obj_literal'],
3633
'assign-current-small-step': ['test0', 'test1', 'test2', 'test3', 'test4'],
3734
basic: ['basic0', 'basic1', 'basic2'],
3835
'cond-js': ['test0', 'test1', 'test2', 'TestConditionalTransition'],
@@ -44,7 +41,7 @@ const testGroups = {
4441
delayedSend: ['send1', 'send2', 'send3'],
4542
documentOrder: ['documentOrder0'],
4643
error: [
47-
// 'error', // not implemented
44+
// 'error' // not implemented
4845
],
4946
forEach: [
5047
// 'test1', // not implemented
@@ -61,12 +58,8 @@ const testGroups = {
6158
'history5',
6259
'history6'
6360
],
64-
'if-else': [
65-
// 'test0', // microstep not implemented correctly
66-
],
67-
in: [
68-
// 'TestInPredicate', // conversion of In() predicate not implemented yet
69-
],
61+
'if-else': ['test0'],
62+
in: ['TestInPredicate'],
7063
'internal-transitions': ['test0', 'test1'],
7164
misc: ['deep-initial'],
7265
'more-parallel': [
@@ -86,9 +79,7 @@ const testGroups = {
8679
'test10',
8780
'test10b'
8881
],
89-
'multiple-events-per-transition': [
90-
// 'test1'
91-
],
82+
'multiple-events-per-transition': ['test1'],
9283
parallel: ['test0', 'test1', 'test2', 'test3'],
9384
'parallel+interrupt': [
9485
'test0',
@@ -134,7 +125,7 @@ const testGroups = {
134125
// 'test0',
135126
// 'test1'
136127
],
137-
// 'send-data': ['send1'],
128+
// 'send-data': ['send1'], // <content> conversion not implementd
138129
// 'send-idlocation': ['test0'],
139130
// 'send-internal': ['test0'],
140131
'targetless-transition': ['test0', 'test1', 'test2', 'test3'],
@@ -156,19 +147,19 @@ const testGroups = {
156147
'test174.txml',
157148
'test175.txml',
158149
'test176.txml',
159-
// 'test179.txml', // conversion of <content> in <sens> not implemented yet
150+
// 'test179.txml', // conversion of <content> in <send> not implemented yet
160151
// 'test183.txml', idlocation not implemented yet
161152
'test185.txml',
162-
// 'test186.txml', // not sure yet why
153+
'test186.txml',
163154
'test187.txml',
164155
'test189.txml',
165-
// 'test190.txml', // _sessionid not yet available for expressions
156+
'test190.txml', // note: _sessionid is undefined for expressions
166157
'test191.txml',
167-
// 'test192.txml', // conversion of #_invokeid not implemented yet
158+
// 'test192.txml', // done.invoke inexact event descriptor
168159
'test193.txml',
169-
// 'test194.txml', // illegal target for <send> causes the event error.execution to be raised
160+
'test194.txml',
170161
// 'test198.txml', // origintype not implemented yet
171-
// 'test199.txml', // invalid send type results in error.execution
162+
// 'test199.txml', // send type not checked
172163
'test200.txml',
173164
'test201.txml',
174165
'test205.txml',
@@ -207,7 +198,7 @@ const testGroups = {
207198
// 'test278.txml', // non-root datamodel with early binding not implemented yet
208199
// 'test279.txml', // non-root datamodel with early binding not implemented yet
209200
// 'test280.txml', // non-root datamodel with late binding not implemented yet
210-
// 'test286.txml', // error.execution when evaluating assign
201+
'test286.txml',
211202
'test287.txml',
212203
// 'test294.txml', // conversion of <donedata> not implemented yet
213204
// 'test298.txml', // error.execution when evaluating donedata
@@ -254,16 +245,16 @@ const testGroups = {
254245
// 'test354.txml', // conversion of namelist not implemented yet
255246
'test355.txml',
256247
'test364.txml',
257-
// 'test372.txml', // microstep not implemented correctly
248+
// 'test372.txml', // microstep not implemented correctly for final states
258249
'test375.txml',
259250
// 'test376.txml', // executable blocks not implemented
260251
'test377.txml',
261252
// 'test378.txml', // executable blocks not implemented
262253
'test387.txml',
263-
// 'test388.txml', // computed historyValue not being available immediately after exiting states for the following synchronous enterStates
254+
'test388.txml',
264255
'test396.txml',
265256
// 'test399.txml', // inexact prefix event matching not implemented
266-
// 'test401.txml', // error.execution when evaluating assign
257+
// 'test401.txml', // inexact "error" event (should be "error.execution")
267258
// 'test402.txml', // error.execution when evaluating assign + inexact prefix event matching not implemented
268259
'test403a.txml',
269260
'test403b.txml',
@@ -295,7 +286,7 @@ const testGroups = {
295286
// 'test457.txml', // <foreach> not implemented yet
296287
// 'test459.txml', // <foreach> not implemented yet
297288
// 'test460.txml', // <foreach> not implemented yet
298-
// 'test487.txml', // error.execution when evaluating assign
289+
'test487.txml',
299290
// 'test488.txml', // error.execution when evaluating param
300291
'test495.txml',
301292
// 'test496.txml', // error.communication not implemented yet
@@ -370,7 +361,13 @@ async function runW3TestToCompletion(machine: MachineNode): Promise<void> {
370361
if (nextState.value === 'pass') {
371362
resolve();
372363
} else {
373-
reject(new Error('Reached "fail" state.'));
364+
reject(
365+
new Error(
366+
`Reached "fail" state with event ${JSON.stringify(
367+
nextState.event
368+
)}`
369+
)
370+
);
374371
}
375372
})
376373
.start();
@@ -397,7 +394,9 @@ async function runTestToCompletion(
397394
})
398395
.onDone(() => {
399396
if (nextState.value === 'fail') {
400-
throw new Error('Reached "fail" state.');
397+
throw new Error(
398+
`Reached "fail" state with event ${JSON.stringify(nextState.event)}`
399+
);
401400
}
402401
done = true;
403402
})
@@ -422,11 +421,11 @@ async function runTestToCompletion(
422421

423422
describe('scxml', () => {
424423
const testGroupKeys = Object.keys(testGroups);
425-
// const testGroupKeys = ['assign-current-small-step'];
424+
// const testGroupKeys = ['w3c-ecma'];
426425

427426
testGroupKeys.forEach((testGroupName) => {
428427
const testNames = testGroups[testGroupName];
429-
// const testNames = ['test2'];
428+
// const testNames = ['test372.txml'];
430429

431430
testNames.forEach((testName) => {
432431
const scxmlSource =

‎packages/core/test/stateIn.test.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Machine } from '../src/index';
2+
import { stateIn } from '../src/guards';
23

34
const machine = Machine({
45
type: 'parallel',
@@ -14,11 +15,11 @@ const machine = Machine({
1415
},
1516
EVENT2: {
1617
target: 'a2',
17-
in: { b: 'b2' }
18+
cond: stateIn({ b: 'b2' })
1819
},
1920
EVENT3: {
2021
target: 'a2',
21-
in: '#b_b2'
22+
cond: stateIn('#b_b2')
2223
}
2324
}
2425
},
@@ -82,7 +83,7 @@ const lightMachine = Machine({
8283
TIMER: [
8384
{
8485
target: 'green',
85-
in: { red: 'stop' }
86+
cond: stateIn({ red: 'stop' })
8687
}
8788
]
8889
}

0 commit comments

Comments
 (0)
Please sign in to comment.