@cratis/scene.engine 3.7.0 → 4.1.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.
Files changed (53) hide show
  1. package/ActionDispatcher.ts +87 -0
  2. package/buildStarterProfile.ts +2 -2
  3. package/dist/cjs/buildStarterProfile.js.map +1 -1
  4. package/dist/cjs/index.js +9 -0
  5. package/dist/cjs/index.js.map +1 -1
  6. package/dist/cjs/resolveBehaviors.js +104 -0
  7. package/dist/cjs/resolveBehaviors.js.map +1 -0
  8. package/dist/cjs/runActions.js +219 -0
  9. package/dist/cjs/runActions.js.map +1 -0
  10. package/dist/esm/ActionDispatcher.d.ts +27 -0
  11. package/dist/esm/ActionDispatcher.d.ts.map +1 -0
  12. package/dist/esm/ActionDispatcher.js +2 -0
  13. package/dist/esm/ActionDispatcher.js.map +1 -0
  14. package/dist/esm/buildStarterProfile.d.ts +2 -2
  15. package/dist/esm/buildStarterProfile.d.ts.map +1 -1
  16. package/dist/esm/buildStarterProfile.js.map +1 -1
  17. package/dist/esm/for_resolveBehaviors/when_resolving_additive_attachments.d.ts +2 -0
  18. package/dist/esm/for_resolveBehaviors/when_resolving_additive_attachments.d.ts.map +1 -0
  19. package/dist/esm/for_resolveBehaviors/when_resolving_additive_attachments.js +68 -0
  20. package/dist/esm/for_resolveBehaviors/when_resolving_additive_attachments.js.map +1 -0
  21. package/dist/esm/for_runActions/given/a_recording_dispatcher.d.ts +21 -0
  22. package/dist/esm/for_runActions/given/a_recording_dispatcher.d.ts.map +1 -0
  23. package/dist/esm/for_runActions/given/a_recording_dispatcher.js +45 -0
  24. package/dist/esm/for_runActions/given/a_recording_dispatcher.js.map +1 -0
  25. package/dist/esm/for_runActions/when_running_a_sequence_of_actions.d.ts +2 -0
  26. package/dist/esm/for_runActions/when_running_a_sequence_of_actions.d.ts.map +1 -0
  27. package/dist/esm/for_runActions/when_running_a_sequence_of_actions.js +126 -0
  28. package/dist/esm/for_runActions/when_running_a_sequence_of_actions.js.map +1 -0
  29. package/dist/esm/for_runActions/when_running_against_the_shared_fixture_corpus.d.ts +2 -0
  30. package/dist/esm/for_runActions/when_running_against_the_shared_fixture_corpus.d.ts.map +1 -0
  31. package/dist/esm/for_runActions/when_running_against_the_shared_fixture_corpus.js +61 -0
  32. package/dist/esm/for_runActions/when_running_against_the_shared_fixture_corpus.js.map +1 -0
  33. package/dist/esm/index.d.ts +3 -0
  34. package/dist/esm/index.d.ts.map +1 -1
  35. package/dist/esm/index.js +2 -0
  36. package/dist/esm/index.js.map +1 -1
  37. package/dist/esm/resolveBehaviors.d.ts +16 -0
  38. package/dist/esm/resolveBehaviors.d.ts.map +1 -0
  39. package/dist/esm/resolveBehaviors.js +99 -0
  40. package/dist/esm/resolveBehaviors.js.map +1 -0
  41. package/dist/esm/runActions.d.ts +26 -0
  42. package/dist/esm/runActions.d.ts.map +1 -0
  43. package/dist/esm/runActions.js +215 -0
  44. package/dist/esm/runActions.js.map +1 -0
  45. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  46. package/for_resolveBehaviors/when_resolving_additive_attachments.ts +100 -0
  47. package/for_runActions/given/a_recording_dispatcher.ts +72 -0
  48. package/for_runActions/when_running_a_sequence_of_actions.ts +226 -0
  49. package/for_runActions/when_running_against_the_shared_fixture_corpus.ts +124 -0
  50. package/index.ts +3 -0
  51. package/package.json +3 -3
  52. package/resolveBehaviors.ts +137 -0
  53. 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.7.0",
3
+ "version": "4.1.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": "3.7.0"
50
+ "@cratis/scene.model": "4.1.0"
51
51
  },
52
52
  "devDependencies": {
53
- "@cratis/scene.model": "3.7.0"
53
+ "@cratis/scene.model": "4.1.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
+ }