@cratis/scene.engine 3.7.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ActionDispatcher.ts +87 -0
- package/buildStarterProfile.ts +2 -2
- package/dist/cjs/buildStarterProfile.js.map +1 -1
- package/dist/cjs/index.js +9 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/resolveBehaviors.js +104 -0
- package/dist/cjs/resolveBehaviors.js.map +1 -0
- package/dist/cjs/runActions.js +219 -0
- package/dist/cjs/runActions.js.map +1 -0
- package/dist/esm/ActionDispatcher.d.ts +27 -0
- package/dist/esm/ActionDispatcher.d.ts.map +1 -0
- package/dist/esm/ActionDispatcher.js +2 -0
- package/dist/esm/ActionDispatcher.js.map +1 -0
- package/dist/esm/buildStarterProfile.d.ts +2 -2
- package/dist/esm/buildStarterProfile.d.ts.map +1 -1
- package/dist/esm/buildStarterProfile.js.map +1 -1
- package/dist/esm/for_resolveBehaviors/when_resolving_additive_attachments.d.ts +2 -0
- package/dist/esm/for_resolveBehaviors/when_resolving_additive_attachments.d.ts.map +1 -0
- package/dist/esm/for_resolveBehaviors/when_resolving_additive_attachments.js +68 -0
- package/dist/esm/for_resolveBehaviors/when_resolving_additive_attachments.js.map +1 -0
- package/dist/esm/for_runActions/given/a_recording_dispatcher.d.ts +21 -0
- package/dist/esm/for_runActions/given/a_recording_dispatcher.d.ts.map +1 -0
- package/dist/esm/for_runActions/given/a_recording_dispatcher.js +45 -0
- package/dist/esm/for_runActions/given/a_recording_dispatcher.js.map +1 -0
- package/dist/esm/for_runActions/when_running_a_sequence_of_actions.d.ts +2 -0
- package/dist/esm/for_runActions/when_running_a_sequence_of_actions.d.ts.map +1 -0
- package/dist/esm/for_runActions/when_running_a_sequence_of_actions.js +126 -0
- package/dist/esm/for_runActions/when_running_a_sequence_of_actions.js.map +1 -0
- package/dist/esm/for_runActions/when_running_against_the_shared_fixture_corpus.d.ts +2 -0
- package/dist/esm/for_runActions/when_running_against_the_shared_fixture_corpus.d.ts.map +1 -0
- package/dist/esm/for_runActions/when_running_against_the_shared_fixture_corpus.js +61 -0
- package/dist/esm/for_runActions/when_running_against_the_shared_fixture_corpus.js.map +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/resolveBehaviors.d.ts +16 -0
- package/dist/esm/resolveBehaviors.d.ts.map +1 -0
- package/dist/esm/resolveBehaviors.js +99 -0
- package/dist/esm/resolveBehaviors.js.map +1 -0
- package/dist/esm/runActions.d.ts +26 -0
- package/dist/esm/runActions.d.ts.map +1 -0
- package/dist/esm/runActions.js +215 -0
- package/dist/esm/runActions.js.map +1 -0
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/for_resolveBehaviors/when_resolving_additive_attachments.ts +100 -0
- package/for_runActions/given/a_recording_dispatcher.ts +72 -0
- package/for_runActions/when_running_a_sequence_of_actions.ts +226 -0
- package/for_runActions/when_running_against_the_shared_fixture_corpus.ts +124 -0
- package/index.ts +3 -0
- package/package.json +3 -3
- package/resolveBehaviors.ts +137 -0
- package/runActions.ts +284 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { Behavior, InteractionActionKind, InteractionTriggerKind } from '@cratis/scene.model';
|
|
5
|
+
import { BehaviorAttachment, resolveApplicationTriggerBehaviors, resolveBehaviors } from '../resolveBehaviors';
|
|
6
|
+
|
|
7
|
+
const behavior = (name: string | undefined, kind: InteractionTriggerKind, order?: number): Behavior => ({
|
|
8
|
+
name,
|
|
9
|
+
order,
|
|
10
|
+
bindings: [
|
|
11
|
+
{
|
|
12
|
+
trigger: { kind } as never,
|
|
13
|
+
actions: [{ kind: InteractionActionKind.RefreshQuery, query: name ?? 'inline' } as never],
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('when resolving additive attachments', () => {
|
|
19
|
+
const attachments: BehaviorAttachment[] = [
|
|
20
|
+
{ level: 'layout', behaviors: [behavior('LayoutWide', InteractionTriggerKind.Click)] },
|
|
21
|
+
{ level: 'module', behaviors: [behavior('ModuleWide', InteractionTriggerKind.Click)] },
|
|
22
|
+
{ level: 'element', behaviors: [behavior(undefined, InteractionTriggerKind.Click)] },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// The point of the additive rule: a module saying 'confirm every destructive action' does not need every
|
|
26
|
+
// screen beneath it to cooperate, and an element attaching its own does not silently cancel it.
|
|
27
|
+
it('should run every matching attachment rather than only the most specific', () => {
|
|
28
|
+
const resolved = resolveBehaviors(attachments, InteractionTriggerKind.Click);
|
|
29
|
+
|
|
30
|
+
resolved.should.have.lengthOf(3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should run the outermost attachment first', () => {
|
|
34
|
+
const resolved = resolveBehaviors(attachments, InteractionTriggerKind.Click);
|
|
35
|
+
|
|
36
|
+
resolved.map(candidate => candidate.level).should.eql(['layout', 'module', 'element']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should keep an inline behavior anonymous', () => {
|
|
40
|
+
const resolved = resolveBehaviors(attachments, InteractionTriggerKind.Click);
|
|
41
|
+
|
|
42
|
+
(resolved[2].behaviorName === undefined).should.be.true;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should ignore bindings for a different trigger', () => {
|
|
46
|
+
const resolved = resolveBehaviors(
|
|
47
|
+
[{ level: 'element', behaviors: [behavior('OnSubmitOnly', InteractionTriggerKind.Submit)] }],
|
|
48
|
+
InteractionTriggerKind.Click
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
resolved.should.be.empty;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should let an explicit order win over position', () => {
|
|
55
|
+
const resolved = resolveBehaviors(
|
|
56
|
+
[
|
|
57
|
+
{ level: 'layout', behaviors: [behavior('LayoutWide', InteractionTriggerKind.Click)] },
|
|
58
|
+
{ level: 'element', behaviors: [behavior('RunsFirst', InteractionTriggerKind.Click, 1)] },
|
|
59
|
+
],
|
|
60
|
+
InteractionTriggerKind.Click
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
resolved.map(candidate => candidate.behaviorName).should.eql(['RunsFirst', 'LayoutWide']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Determinism is the reason this is a pure function over an ordered list: the same document has to produce
|
|
67
|
+
// the same order on every run, or an interaction becomes intermittently wrong rather than wrong.
|
|
68
|
+
it('should order deterministically across repeated resolutions', () => {
|
|
69
|
+
const once = resolveBehaviors(attachments, InteractionTriggerKind.Click).map(c => c.level);
|
|
70
|
+
const twice = resolveBehaviors(attachments, InteractionTriggerKind.Click).map(c => c.level);
|
|
71
|
+
|
|
72
|
+
once.should.eql(twice);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should resolve a named application trigger separately from the built-in kinds', () => {
|
|
76
|
+
const resolved = resolveApplicationTriggerBehaviors(
|
|
77
|
+
[
|
|
78
|
+
{
|
|
79
|
+
level: 'screen',
|
|
80
|
+
behaviors: [
|
|
81
|
+
{
|
|
82
|
+
name: undefined,
|
|
83
|
+
bindings: [
|
|
84
|
+
{ trigger: { triggerName: 'InvoiceImported' } as never, actions: [] },
|
|
85
|
+
{ trigger: { triggerName: 'SomethingElse' } as never, actions: [] },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
'InvoiceImported'
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
resolved.should.have.lengthOf(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should tolerate an attachment with no behaviors at all', () => {
|
|
98
|
+
resolveBehaviors([{ level: 'element', behaviors: [] }], InteractionTriggerKind.Click).should.be.empty;
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { BindingExpression, NotificationLevel } from '@cratis/scene.model';
|
|
5
|
+
import { ActionDispatcher, CommandOutcome, DialogOutcome } from '../../ActionDispatcher';
|
|
6
|
+
import { InteractionContext } from '../../runActions';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A dispatcher that records what it was asked to do instead of doing it.
|
|
10
|
+
*
|
|
11
|
+
* The whole point of the engine owning the sequencing is that this is possible: no DOM, no React, no transport -
|
|
12
|
+
* the rules are assertable as a list of calls.
|
|
13
|
+
*/
|
|
14
|
+
export class RecordingDispatcher implements ActionDispatcher {
|
|
15
|
+
readonly calls: string[] = [];
|
|
16
|
+
|
|
17
|
+
commandSucceeds = true;
|
|
18
|
+
dialogConfirmed = true;
|
|
19
|
+
confirmAnswer = true;
|
|
20
|
+
|
|
21
|
+
async executeCommand(command: string, args: Record<string, unknown>): Promise<CommandOutcome> {
|
|
22
|
+
this.calls.push(`executeCommand:${command}:${JSON.stringify(args)}`);
|
|
23
|
+
return this.commandSucceeds
|
|
24
|
+
? { isSuccess: true }
|
|
25
|
+
: { isSuccess: false, validationErrors: [{ member: 'amount', message: 'Must be positive' }] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
navigate(screen: string): void {
|
|
29
|
+
this.calls.push(`navigate:${screen}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
navigateBack(): void {
|
|
33
|
+
this.calls.push('navigateBack');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async openDialog(dialogTemplate: string, args: Record<string, unknown>): Promise<DialogOutcome> {
|
|
37
|
+
this.calls.push(`openDialog:${dialogTemplate}:${JSON.stringify(args)}`);
|
|
38
|
+
return { isConfirmed: this.dialogConfirmed, result: 'the-result' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
closeDialog(): void {
|
|
42
|
+
this.calls.push('closeDialog');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async refreshQuery(query: string): Promise<void> {
|
|
46
|
+
this.calls.push(`refreshQuery:${query}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setState(target: string, value: unknown): void {
|
|
50
|
+
this.calls.push(`setState:${target}:${String(value)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
notify(level: NotificationLevel, message: string): void {
|
|
54
|
+
this.calls.push(`notify:${level}:${message}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async confirm(message: string): Promise<boolean> {
|
|
58
|
+
this.calls.push(`confirm:${message}`);
|
|
59
|
+
return this.confirmAnswer;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async raise(trigger: string): Promise<void> {
|
|
63
|
+
this.calls.push(`raise:${trigger}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A context that resolves a binding to its path, so an assertion can see which binding was used.
|
|
69
|
+
*/
|
|
70
|
+
export const pathEchoingContext: InteractionContext = {
|
|
71
|
+
resolve: (binding: BindingExpression) => `<${binding.path}>`,
|
|
72
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { InteractionAction, InteractionActionKind, NotificationLevel } from '@cratis/scene.model';
|
|
5
|
+
import { InteractionStop, runActions } from '../runActions';
|
|
6
|
+
import { pathEchoingContext, RecordingDispatcher } from './given/a_recording_dispatcher';
|
|
7
|
+
|
|
8
|
+
const action = (kind: InteractionActionKind, extra: Record<string, unknown> = {}): InteractionAction =>
|
|
9
|
+
({ kind, ...extra }) as InteractionAction;
|
|
10
|
+
|
|
11
|
+
describe('when running a sequence of actions', () => {
|
|
12
|
+
it('should run them in the order they were declared', async () => {
|
|
13
|
+
const dispatcher = new RecordingDispatcher();
|
|
14
|
+
|
|
15
|
+
await runActions(
|
|
16
|
+
[
|
|
17
|
+
action(InteractionActionKind.RefreshQuery, { query: 'Invoices' }),
|
|
18
|
+
action(InteractionActionKind.Notify, { level: NotificationLevel.Info, message: { text: 'Done' } }),
|
|
19
|
+
],
|
|
20
|
+
dispatcher,
|
|
21
|
+
pathEchoingContext
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
dispatcher.calls.should.eql(['refreshQuery:Invoices', 'notify:Info:Done']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should resolve an action\'s arguments through the binding resolver', async () => {
|
|
28
|
+
const dispatcher = new RecordingDispatcher();
|
|
29
|
+
|
|
30
|
+
await runActions(
|
|
31
|
+
[
|
|
32
|
+
action(InteractionActionKind.ExecuteCommand, {
|
|
33
|
+
command: 'CancelInvoice',
|
|
34
|
+
arguments: [{ name: 'invoiceId', value: { path: 'item.invoiceId' } }],
|
|
35
|
+
}),
|
|
36
|
+
],
|
|
37
|
+
dispatcher,
|
|
38
|
+
pathEchoingContext
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
dispatcher.calls.should.eql(['executeCommand:CancelInvoice:{"invoiceId":"<item.invoiceId>"}']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should take the success branch when a command succeeds', async () => {
|
|
45
|
+
const dispatcher = new RecordingDispatcher();
|
|
46
|
+
|
|
47
|
+
const run = await runActions(
|
|
48
|
+
[
|
|
49
|
+
action(InteractionActionKind.ExecuteCommand, {
|
|
50
|
+
command: 'RegisterInvoice',
|
|
51
|
+
onSuccess: [action(InteractionActionKind.CloseDialog)],
|
|
52
|
+
onFailure: [action(InteractionActionKind.Notify, { level: NotificationLevel.Error, message: { text: 'Failed' } })],
|
|
53
|
+
}),
|
|
54
|
+
],
|
|
55
|
+
dispatcher,
|
|
56
|
+
pathEchoingContext
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
dispatcher.calls.should.eql(['executeCommand:RegisterInvoice:{}', 'closeDialog']);
|
|
60
|
+
run.stop.should.equal(InteractionStop.Completed);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should take the failure branch when a command fails', async () => {
|
|
64
|
+
const dispatcher = new RecordingDispatcher();
|
|
65
|
+
dispatcher.commandSucceeds = false;
|
|
66
|
+
|
|
67
|
+
await runActions(
|
|
68
|
+
[
|
|
69
|
+
action(InteractionActionKind.ExecuteCommand, {
|
|
70
|
+
command: 'RegisterInvoice',
|
|
71
|
+
onSuccess: [action(InteractionActionKind.CloseDialog)],
|
|
72
|
+
onFailure: [action(InteractionActionKind.Notify, { level: NotificationLevel.Error, message: { text: 'Failed' } })],
|
|
73
|
+
}),
|
|
74
|
+
],
|
|
75
|
+
dispatcher,
|
|
76
|
+
pathEchoingContext
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
dispatcher.calls.should.eql(['executeCommand:RegisterInvoice:{}', 'notify:Error:Failed']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// A confirm exists to gate what follows it. If a decline let the rest run, the gate would be decorative.
|
|
83
|
+
it('should stop the sequence when a confirm is declined', async () => {
|
|
84
|
+
const dispatcher = new RecordingDispatcher();
|
|
85
|
+
dispatcher.confirmAnswer = false;
|
|
86
|
+
|
|
87
|
+
const run = await runActions(
|
|
88
|
+
[
|
|
89
|
+
action(InteractionActionKind.Confirm, {
|
|
90
|
+
message: { text: 'Are you sure?' },
|
|
91
|
+
onSuccess: [action(InteractionActionKind.ExecuteCommand, { command: 'CancelInvoice' })],
|
|
92
|
+
}),
|
|
93
|
+
action(InteractionActionKind.Notify, { level: NotificationLevel.Info, message: { text: 'Cancelled' } }),
|
|
94
|
+
],
|
|
95
|
+
dispatcher,
|
|
96
|
+
pathEchoingContext
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
dispatcher.calls.should.eql(['confirm:Are you sure?']);
|
|
100
|
+
run.stop.should.equal(InteractionStop.Declined);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should run the gated actions when a confirm is accepted', async () => {
|
|
104
|
+
const dispatcher = new RecordingDispatcher();
|
|
105
|
+
|
|
106
|
+
await runActions(
|
|
107
|
+
[
|
|
108
|
+
action(InteractionActionKind.Confirm, {
|
|
109
|
+
message: { text: 'Are you sure?' },
|
|
110
|
+
onSuccess: [action(InteractionActionKind.ExecuteCommand, { command: 'CancelInvoice' })],
|
|
111
|
+
}),
|
|
112
|
+
],
|
|
113
|
+
dispatcher,
|
|
114
|
+
pathEchoingContext
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
dispatcher.calls.should.eql(['confirm:Are you sure?', 'executeCommand:CancelInvoice:{}']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// The screen the remaining actions were written for is gone. The compiler reports this as unreachable, so
|
|
121
|
+
// reaching it at runtime means the document changed underneath - still not a reason to run them.
|
|
122
|
+
it('should stop the sequence after a navigation', async () => {
|
|
123
|
+
const dispatcher = new RecordingDispatcher();
|
|
124
|
+
|
|
125
|
+
const run = await runActions(
|
|
126
|
+
[
|
|
127
|
+
action(InteractionActionKind.Navigate, { screen: 'InvoiceList' }),
|
|
128
|
+
action(InteractionActionKind.Notify, { level: NotificationLevel.Info, message: { text: 'Never runs' } }),
|
|
129
|
+
],
|
|
130
|
+
dispatcher,
|
|
131
|
+
pathEchoingContext
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
dispatcher.calls.should.eql(['navigate:InvoiceList']);
|
|
135
|
+
run.stop.should.equal(InteractionStop.Navigated);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should stop the sequence after navigating back', async () => {
|
|
139
|
+
const dispatcher = new RecordingDispatcher();
|
|
140
|
+
|
|
141
|
+
const run = await runActions(
|
|
142
|
+
[action(InteractionActionKind.NavigateBack), action(InteractionActionKind.CloseDialog)],
|
|
143
|
+
dispatcher,
|
|
144
|
+
pathEchoingContext
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
dispatcher.calls.should.eql(['navigateBack']);
|
|
148
|
+
run.stop.should.equal(InteractionStop.Navigated);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should run a dialog\'s result continuation after it closes', async () => {
|
|
152
|
+
const dispatcher = new RecordingDispatcher();
|
|
153
|
+
|
|
154
|
+
await runActions(
|
|
155
|
+
[
|
|
156
|
+
action(InteractionActionKind.OpenDialog, {
|
|
157
|
+
dialogTemplate: 'InvoiceDetails',
|
|
158
|
+
arguments: [{ name: 'invoiceId', value: { path: 'item.invoiceId' } }],
|
|
159
|
+
onResult: [action(InteractionActionKind.RefreshQuery, { query: 'Invoices' })],
|
|
160
|
+
}),
|
|
161
|
+
],
|
|
162
|
+
dispatcher,
|
|
163
|
+
pathEchoingContext
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
dispatcher.calls.should.eql([
|
|
167
|
+
'openDialog:InvoiceDetails:{"invoiceId":"<item.invoiceId>"}',
|
|
168
|
+
'refreshQuery:Invoices',
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should resolve a message from a binding when it has one', async () => {
|
|
173
|
+
const dispatcher = new RecordingDispatcher();
|
|
174
|
+
|
|
175
|
+
await runActions(
|
|
176
|
+
[action(InteractionActionKind.Notify, { level: NotificationLevel.Info, message: { binding: { path: 'state.message' } } })],
|
|
177
|
+
dispatcher,
|
|
178
|
+
pathEchoingContext
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
dispatcher.calls.should.eql(['notify:Info:<state.message>']);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should localize a message given a strings key', async () => {
|
|
185
|
+
const dispatcher = new RecordingDispatcher();
|
|
186
|
+
|
|
187
|
+
await runActions(
|
|
188
|
+
[action(InteractionActionKind.Notify, { level: NotificationLevel.Info, message: { stringsKey: 'invoiceRegistered' } })],
|
|
189
|
+
dispatcher,
|
|
190
|
+
{ resolve: () => undefined, localize: key => `localized(${key})` }
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
dispatcher.calls.should.eql(['notify:Info:localized(invoiceRegistered)']);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Fail closed and visible: an action kind this engine does not know is reported, and the rest of the
|
|
197
|
+
// sequence is abandoned rather than half-applied silently.
|
|
198
|
+
it('should report an action kind it does not know instead of ignoring it', async () => {
|
|
199
|
+
const dispatcher = new RecordingDispatcher();
|
|
200
|
+
|
|
201
|
+
const run = await runActions(
|
|
202
|
+
[action('SomethingFromALaterVersion' as InteractionActionKind)],
|
|
203
|
+
dispatcher,
|
|
204
|
+
pathEchoingContext
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
dispatcher.calls.should.be.empty;
|
|
208
|
+
run.stop.should.equal(InteractionStop.Reported);
|
|
209
|
+
run.findings.should.have.lengthOf(1);
|
|
210
|
+
run.findings[0].kind.should.equal('unknownAction');
|
|
211
|
+
run.findings[0].detail.should.contain('SomethingFromALaterVersion');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should report a message binding that resolved to nothing', async () => {
|
|
215
|
+
const dispatcher = new RecordingDispatcher();
|
|
216
|
+
|
|
217
|
+
const run = await runActions(
|
|
218
|
+
[action(InteractionActionKind.Confirm, { message: { binding: { path: 'state.missing' } } })],
|
|
219
|
+
dispatcher,
|
|
220
|
+
{ resolve: () => undefined }
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
run.findings[0].kind.should.equal('unresolvedMessage');
|
|
224
|
+
run.findings[0].detail.should.contain('state.missing');
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { InteractionAction, InteractionActionKind, InteractionTriggerKind, NotificationLevel } from '@cratis/scene.model';
|
|
7
|
+
import { BehaviorAttachment, resolveBehaviors } from '../resolveBehaviors';
|
|
8
|
+
import { InteractionStop, runActions } from '../runActions';
|
|
9
|
+
import { pathEchoingContext, RecordingDispatcher } from './given/a_recording_dispatcher';
|
|
10
|
+
|
|
11
|
+
interface FixtureBehavior {
|
|
12
|
+
name: string | null;
|
|
13
|
+
trigger: keyof typeof InteractionTriggerKind;
|
|
14
|
+
order?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface FixtureAttachment {
|
|
18
|
+
level: string;
|
|
19
|
+
depth?: number;
|
|
20
|
+
behaviors: FixtureBehavior[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ResolutionCase {
|
|
24
|
+
name: string;
|
|
25
|
+
trigger: keyof typeof InteractionTriggerKind;
|
|
26
|
+
attachments: FixtureAttachment[];
|
|
27
|
+
expected: (string | null)[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface FixtureAction {
|
|
31
|
+
kind: string;
|
|
32
|
+
command?: string;
|
|
33
|
+
query?: string;
|
|
34
|
+
screen?: string;
|
|
35
|
+
dialogTemplate?: string;
|
|
36
|
+
level?: keyof typeof NotificationLevel;
|
|
37
|
+
text?: string;
|
|
38
|
+
onSuccess?: FixtureAction[];
|
|
39
|
+
onFailure?: FixtureAction[];
|
|
40
|
+
onResult?: FixtureAction[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface SequencingCase {
|
|
44
|
+
name: string;
|
|
45
|
+
actions: FixtureAction[];
|
|
46
|
+
commandSucceeds: boolean;
|
|
47
|
+
confirmAnswer: boolean;
|
|
48
|
+
expectedCalls: string[];
|
|
49
|
+
expectedStop: keyof typeof InteractionStop;
|
|
50
|
+
expectedFindings?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface FixtureCorpus {
|
|
54
|
+
resolutionCases: ResolutionCase[];
|
|
55
|
+
sequencingCases: SequencingCase[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const manifestPath = join(import.meta.dirname, '..', '..', '..', '..', 'interaction-fixtures.json');
|
|
59
|
+
const corpus = JSON.parse(readFileSync(manifestPath, 'utf-8')) as FixtureCorpus;
|
|
60
|
+
|
|
61
|
+
function attachment(fixture: FixtureAttachment): BehaviorAttachment {
|
|
62
|
+
return {
|
|
63
|
+
level: fixture.level,
|
|
64
|
+
depth: fixture.depth,
|
|
65
|
+
behaviors: fixture.behaviors.map(behavior => ({
|
|
66
|
+
name: behavior.name ?? undefined,
|
|
67
|
+
order: behavior.order,
|
|
68
|
+
bindings: [
|
|
69
|
+
{
|
|
70
|
+
trigger: { kind: InteractionTriggerKind[behavior.trigger] } as never,
|
|
71
|
+
actions: [],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
})),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function action(fixture: FixtureAction): InteractionAction {
|
|
79
|
+
return {
|
|
80
|
+
kind: InteractionActionKind[fixture.kind as keyof typeof InteractionActionKind] ?? fixture.kind,
|
|
81
|
+
command: fixture.command,
|
|
82
|
+
query: fixture.query,
|
|
83
|
+
screen: fixture.screen,
|
|
84
|
+
dialogTemplate: fixture.dialogTemplate,
|
|
85
|
+
level: fixture.level === undefined ? undefined : NotificationLevel[fixture.level],
|
|
86
|
+
message: fixture.text === undefined ? undefined : { text: fixture.text },
|
|
87
|
+
onSuccess: (fixture.onSuccess ?? []).map(action),
|
|
88
|
+
onFailure: (fixture.onFailure ?? []).map(action),
|
|
89
|
+
onResult: (fixture.onResult ?? []).map(action),
|
|
90
|
+
} as InteractionAction;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The dispatcher records the command name without its arguments, because the corpus pins the sequencing rule
|
|
94
|
+
// and not how a binding resolves - that is asserted by the unit specs next to this file.
|
|
95
|
+
function callsWithoutArguments(calls: string[]): string[] {
|
|
96
|
+
return calls.map(call => call.replace(/:\{.*\}$/, ''));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('when running against the shared fixture corpus', () => {
|
|
100
|
+
for (const fixture of corpus.resolutionCases) {
|
|
101
|
+
it(`should resolve the case where ${fixture.name}`, () => {
|
|
102
|
+
const resolved = resolveBehaviors(
|
|
103
|
+
fixture.attachments.map(attachment),
|
|
104
|
+
InteractionTriggerKind[fixture.trigger]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
resolved.map(candidate => candidate.behaviorName ?? null).should.eql(fixture.expected);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const fixture of corpus.sequencingCases) {
|
|
112
|
+
it(`should sequence the case where ${fixture.name}`, async () => {
|
|
113
|
+
const dispatcher = new RecordingDispatcher();
|
|
114
|
+
dispatcher.commandSucceeds = fixture.commandSucceeds;
|
|
115
|
+
dispatcher.confirmAnswer = fixture.confirmAnswer;
|
|
116
|
+
|
|
117
|
+
const run = await runActions(fixture.actions.map(action), dispatcher, pathEchoingContext);
|
|
118
|
+
|
|
119
|
+
callsWithoutArguments(dispatcher.calls).should.eql(fixture.expectedCalls);
|
|
120
|
+
run.stop.should.equal(InteractionStop[fixture.expectedStop]);
|
|
121
|
+
run.findings.map(finding => finding.kind).should.eql(fixture.expectedFindings ?? []);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
});
|
package/index.ts
CHANGED
|
@@ -22,3 +22,6 @@ export * from './packageCatalog';
|
|
|
22
22
|
export * from './resolveScreenTemplates';
|
|
23
23
|
export * from './buildStarterProfile';
|
|
24
24
|
export * from './incompatibleStarterThemes';
|
|
25
|
+
export * from './ActionDispatcher';
|
|
26
|
+
export * from './runActions';
|
|
27
|
+
export * from './resolveBehaviors';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cratis/scene.engine",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "The Scene runtime: walks a Scene element tree and drives whichever renderer is plugged in. Does not know React, or any other rendering platform, exists.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cratis",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"up": "yarn g:up"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@cratis/scene.model": "
|
|
50
|
+
"@cratis/scene.model": "4.0.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@cratis/scene.model": "
|
|
53
|
+
"@cratis/scene.model": "4.0.0"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { Behavior, InteractionBinding, InteractionTrigger, InteractionTriggerKind } from '@cratis/scene.model';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One level of the containment tree a behavior can be attached to, outermost first.
|
|
8
|
+
*
|
|
9
|
+
* Named rather than positional so a finding can say *where* a behavior came from - 'the module's behavior ran
|
|
10
|
+
* before the element's' is only a useful thing to report if the level has a name.
|
|
11
|
+
*/
|
|
12
|
+
export interface BehaviorAttachment {
|
|
13
|
+
level: string;
|
|
14
|
+
behaviors: Behavior[];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How deeply nested this attachment is, with 0 the outermost.
|
|
18
|
+
*
|
|
19
|
+
* Optional, defaulting to the position in the list. Stating it explicitly is what lets a caller assemble the
|
|
20
|
+
* list in whatever order is convenient - walking a tree upwards from the element, for instance - without the
|
|
21
|
+
* resolved order silently depending on that choice.
|
|
22
|
+
*/
|
|
23
|
+
depth?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One binding to run, with the attachment it came from.
|
|
28
|
+
*/
|
|
29
|
+
export interface ResolvedBinding {
|
|
30
|
+
level: string;
|
|
31
|
+
behaviorName?: string;
|
|
32
|
+
binding: InteractionBinding;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether the trigger is the built-in kind asked for.
|
|
37
|
+
*/
|
|
38
|
+
function isKind(trigger: InteractionTrigger, kind: InteractionTriggerKind): boolean {
|
|
39
|
+
return (trigger as { kind?: InteractionTriggerKind }).kind === kind;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Collects the bindings that respond to a built-in interaction kind, in the order they run.
|
|
44
|
+
*
|
|
45
|
+
* Attachments are **additive**: a behavior on a template and a behavior on an element both run, rather than the
|
|
46
|
+
* more specific one replacing the less specific one. The order is outermost attachment first, declaration order
|
|
47
|
+
* within a level, and any behavior that declares an `order` sorts by it.
|
|
48
|
+
*
|
|
49
|
+
* The additive rule is what lets a module say 'confirm every destructive action' without every screen beneath it
|
|
50
|
+
* having to cooperate - but it also means the order has to be deterministic, or the same document behaves
|
|
51
|
+
* differently between runs. That is why this is a pure function over an ordered list rather than something
|
|
52
|
+
* assembled while rendering.
|
|
53
|
+
*
|
|
54
|
+
* @param attachments The attachment levels, outermost first.
|
|
55
|
+
* @param kind The built-in interaction kind that occurred.
|
|
56
|
+
* @returns The bindings to run, in order.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveBehaviors(attachments: BehaviorAttachment[], kind: InteractionTriggerKind): ResolvedBinding[] {
|
|
59
|
+
const resolved: { entry: ResolvedBinding; level: number; declaration: number; order?: number }[] = [];
|
|
60
|
+
|
|
61
|
+
attachments.forEach((attachment, level) => {
|
|
62
|
+
let declaration = 0;
|
|
63
|
+
for (const behavior of (attachment.behaviors ?? [])) {
|
|
64
|
+
for (const binding of (behavior.bindings ?? [])) {
|
|
65
|
+
if (!isKind(binding.trigger, kind)) continue;
|
|
66
|
+
|
|
67
|
+
resolved.push({
|
|
68
|
+
entry: { level: attachment.level, behaviorName: behavior.name, binding },
|
|
69
|
+
level: attachment.depth ?? level,
|
|
70
|
+
declaration: declaration++,
|
|
71
|
+
order: behavior.order,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// An explicit order wins over position; everything else keeps the order it was written in. Sorting is
|
|
78
|
+
// stable on (order, level, declaration) so the result never depends on the sort implementation.
|
|
79
|
+
return resolved
|
|
80
|
+
.sort((left, right) => {
|
|
81
|
+
if (left.order !== right.order) {
|
|
82
|
+
if (left.order === undefined) return 1;
|
|
83
|
+
if (right.order === undefined) return -1;
|
|
84
|
+
return left.order - right.order;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return left.level !== right.level ? left.level - right.level : left.declaration - right.declaration;
|
|
88
|
+
})
|
|
89
|
+
.map(candidate => candidate.entry);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Collects the bindings that respond to a named application trigger firing.
|
|
94
|
+
*
|
|
95
|
+
* @param attachments The attachment levels, outermost first.
|
|
96
|
+
* @param triggerName The application trigger that fired.
|
|
97
|
+
* @returns The bindings to run, in order.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveApplicationTriggerBehaviors(attachments: BehaviorAttachment[], triggerName: string): ResolvedBinding[] {
|
|
100
|
+
return collect(attachments, trigger => (trigger as { triggerName?: string }).triggerName === triggerName);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Collects the bindings that respond to a modeled domain event being observed.
|
|
105
|
+
*
|
|
106
|
+
* @param attachments The attachment levels, outermost first.
|
|
107
|
+
* @param eventName The event that was observed.
|
|
108
|
+
* @returns The bindings to run, in order.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveEventBehaviors(attachments: BehaviorAttachment[], eventName: string): ResolvedBinding[] {
|
|
111
|
+
return collect(attachments, trigger => (trigger as { eventName?: string }).eventName === eventName);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Collects every interval binding, so a host can schedule them.
|
|
116
|
+
*
|
|
117
|
+
* @param attachments The attachment levels, outermost first.
|
|
118
|
+
* @returns The interval bindings, in order.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveIntervalBehaviors(attachments: BehaviorAttachment[]): ResolvedBinding[] {
|
|
121
|
+
return collect(attachments, trigger => typeof (trigger as { seconds?: number }).seconds === 'number');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function collect(attachments: BehaviorAttachment[], matches: (trigger: InteractionTrigger) => boolean): ResolvedBinding[] {
|
|
125
|
+
const resolved: ResolvedBinding[] = [];
|
|
126
|
+
for (const attachment of attachments) {
|
|
127
|
+
for (const behavior of (attachment.behaviors ?? [])) {
|
|
128
|
+
for (const binding of (behavior.bindings ?? [])) {
|
|
129
|
+
if (matches(binding.trigger)) {
|
|
130
|
+
resolved.push({ level: attachment.level, behaviorName: behavior.name, binding });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return resolved;
|
|
137
|
+
}
|