@nyaruka/temba-components 0.139.0 → 0.140.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/.github/workflows/cla.yml +1 -1
- package/.github/workflows/copilot-setup-steps.yml +6 -1
- package/CHANGELOG.md +17 -0
- package/demo/data/flows/sample-flow.json +24 -0
- package/dist/temba-components.js +562 -296
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/display/Chat.js +10 -7
- package/out-tsc/src/display/Chat.js.map +1 -1
- package/out-tsc/src/display/Dropdown.js +3 -1
- package/out-tsc/src/display/Dropdown.js.map +1 -1
- package/out-tsc/src/display/FloatingTab.js +3 -3
- package/out-tsc/src/display/FloatingTab.js.map +1 -1
- package/out-tsc/src/display/Thumbnail.js +163 -5
- package/out-tsc/src/display/Thumbnail.js.map +1 -1
- package/out-tsc/src/flow/CanvasNode.js +64 -22
- package/out-tsc/src/flow/CanvasNode.js.map +1 -1
- package/out-tsc/src/flow/Editor.js +142 -8
- package/out-tsc/src/flow/Editor.js.map +1 -1
- package/out-tsc/src/flow/NodeEditor.js +118 -10
- package/out-tsc/src/flow/NodeEditor.js.map +1 -1
- package/out-tsc/src/flow/StickyNote.js +13 -4
- package/out-tsc/src/flow/StickyNote.js.map +1 -1
- package/out-tsc/src/flow/actions/audio-player.js +112 -0
- package/out-tsc/src/flow/actions/audio-player.js.map +1 -0
- package/out-tsc/src/flow/actions/enter_flow.js +43 -0
- package/out-tsc/src/flow/actions/enter_flow.js.map +1 -0
- package/out-tsc/src/flow/actions/play_audio.js +57 -4
- package/out-tsc/src/flow/actions/play_audio.js.map +1 -1
- package/out-tsc/src/flow/actions/say_msg.js +86 -3
- package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
- package/out-tsc/src/flow/config.js +11 -3
- package/out-tsc/src/flow/config.js.map +1 -1
- package/out-tsc/src/flow/nodes/shared-rules.js +1 -1
- package/out-tsc/src/flow/nodes/shared-rules.js.map +1 -1
- package/out-tsc/src/flow/nodes/terminal.js +7 -0
- package/out-tsc/src/flow/nodes/terminal.js.map +1 -0
- package/out-tsc/src/flow/nodes/wait_for_audio.js +77 -0
- package/out-tsc/src/flow/nodes/wait_for_audio.js.map +1 -0
- package/out-tsc/src/flow/nodes/wait_for_dial.js +151 -0
- package/out-tsc/src/flow/nodes/wait_for_dial.js.map +1 -0
- package/out-tsc/src/flow/nodes/wait_for_digits.js +61 -1
- package/out-tsc/src/flow/nodes/wait_for_digits.js.map +1 -1
- package/out-tsc/src/flow/nodes/wait_for_menu.js +173 -2
- package/out-tsc/src/flow/nodes/wait_for_menu.js.map +1 -1
- package/out-tsc/src/flow/operators.js +21 -5
- package/out-tsc/src/flow/operators.js.map +1 -1
- package/out-tsc/src/flow/types.js.map +1 -1
- package/out-tsc/src/flow/utils.js +79 -3
- package/out-tsc/src/flow/utils.js.map +1 -1
- package/out-tsc/src/form/ArrayEditor.js +4 -2
- package/out-tsc/src/form/ArrayEditor.js.map +1 -1
- package/out-tsc/src/form/FieldRenderer.js +49 -0
- package/out-tsc/src/form/FieldRenderer.js.map +1 -1
- package/out-tsc/src/interfaces.js +1 -0
- package/out-tsc/src/interfaces.js.map +1 -1
- package/out-tsc/src/layout/Dialog.js +52 -7
- package/out-tsc/src/layout/Dialog.js.map +1 -1
- package/out-tsc/src/live/TembaChart.js.map +1 -1
- package/out-tsc/src/simulator/Simulator.js +10 -4
- package/out-tsc/src/simulator/Simulator.js.map +1 -1
- package/out-tsc/src/store/AppState.js +89 -3
- package/out-tsc/src/store/AppState.js.map +1 -1
- package/out-tsc/test/actions/play_audio.test.js +118 -0
- package/out-tsc/test/actions/play_audio.test.js.map +1 -0
- package/out-tsc/test/actions/say_msg.test.js +158 -0
- package/out-tsc/test/actions/say_msg.test.js.map +1 -0
- package/out-tsc/test/nodes/wait_for_audio.test.js +156 -0
- package/out-tsc/test/nodes/wait_for_audio.test.js.map +1 -0
- package/out-tsc/test/nodes/wait_for_dial.test.js +336 -0
- package/out-tsc/test/nodes/wait_for_dial.test.js.map +1 -0
- package/out-tsc/test/nodes/wait_for_digits.test.js +198 -84
- package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -1
- package/out-tsc/test/nodes/wait_for_menu.test.js +340 -0
- package/out-tsc/test/nodes/wait_for_menu.test.js.map +1 -0
- package/out-tsc/test/temba-flow-collision.test.js +261 -6
- package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
- package/out-tsc/test/temba-node-type-selector.test.js +6 -6
- package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
- package/package.json +1 -1
- package/screenshots/truth/actions/play_audio/editor/expression-url.png +0 -0
- package/screenshots/truth/actions/play_audio/editor/static-url.png +0 -0
- package/screenshots/truth/actions/play_audio/render/expression-url.png +0 -0
- package/screenshots/truth/actions/play_audio/render/static-url.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
- package/screenshots/truth/actions/say_msg/render/multiline-text.png +0 -0
- package/screenshots/truth/actions/say_msg/render/simple-text.png +0 -0
- package/screenshots/truth/actions/say_msg/render/text-with-audio-url.png +0 -0
- package/screenshots/truth/editor/router.png +0 -0
- package/screenshots/truth/editor/wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_audio/editor/basic-audio-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_audio/render/basic-audio-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_dial/editor/basic-dial.png +0 -0
- package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
- package/screenshots/truth/nodes/wait_for_dial/render/basic-dial.png +0 -0
- package/screenshots/truth/nodes/wait_for_dial/render/dial-with-limits.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/digits-with-rules.png +0 -0
- package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
- package/screenshots/truth/nodes/wait_for_menu/render/menu-with-digits.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
- package/src/display/Chat.ts +13 -7
- package/src/display/Dropdown.ts +3 -1
- package/src/display/FloatingTab.ts +3 -3
- package/src/display/Thumbnail.ts +162 -2
- package/src/flow/CanvasNode.ts +69 -23
- package/src/flow/Editor.ts +156 -13
- package/src/flow/NodeEditor.ts +137 -9
- package/src/flow/StickyNote.ts +14 -4
- package/src/flow/actions/audio-player.ts +127 -0
- package/src/flow/actions/enter_flow.ts +44 -0
- package/src/flow/actions/play_audio.ts +64 -5
- package/src/flow/actions/say_msg.ts +94 -4
- package/src/flow/config.ts +11 -3
- package/src/flow/nodes/shared-rules.ts +1 -1
- package/src/flow/nodes/terminal.ts +9 -0
- package/src/flow/nodes/wait_for_audio.ts +88 -0
- package/src/flow/nodes/wait_for_dial.ts +176 -0
- package/src/flow/nodes/wait_for_digits.ts +86 -2
- package/src/flow/nodes/wait_for_menu.ts +209 -3
- package/src/flow/operators.ts +23 -5
- package/src/flow/types.ts +23 -1
- package/src/flow/utils.ts +82 -3
- package/src/form/ArrayEditor.ts +4 -2
- package/src/form/FieldRenderer.ts +64 -1
- package/src/interfaces.ts +2 -1
- package/src/layout/Dialog.ts +53 -7
- package/src/live/TembaChart.ts +1 -1
- package/src/simulator/Simulator.ts +13 -4
- package/src/store/AppState.ts +105 -1
- package/src/store/flow-definition.d.ts +2 -0
- package/test/actions/play_audio.test.ts +155 -0
- package/test/actions/say_msg.test.ts +196 -0
- package/test/nodes/wait_for_audio.test.ts +182 -0
- package/test/nodes/wait_for_dial.test.ts +382 -0
- package/test/nodes/wait_for_digits.test.ts +233 -109
- package/test/nodes/wait_for_menu.test.ts +383 -0
- package/test/temba-flow-collision.test.ts +286 -6
- package/test/temba-node-type-selector.test.ts +6 -6
- package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { expect } from '@open-wc/testing';
|
|
2
|
+
import { wait_for_menu } from '../../src/flow/nodes/wait_for_menu';
|
|
3
|
+
import { Node } from '../../src/store/flow-definition';
|
|
4
|
+
import { NodeTest } from '../NodeHelper';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Test suite for the wait_for_menu node configuration.
|
|
8
|
+
*/
|
|
9
|
+
describe('wait_for_menu node config', () => {
|
|
10
|
+
const helper = new NodeTest(wait_for_menu, 'wait_for_menu');
|
|
11
|
+
|
|
12
|
+
describe('basic properties', () => {
|
|
13
|
+
helper.testBasicProperties();
|
|
14
|
+
|
|
15
|
+
it('has correct name', () => {
|
|
16
|
+
expect(wait_for_menu.name).to.equal('Wait for Menu');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('has correct type', () => {
|
|
20
|
+
expect(wait_for_menu.type).to.equal('wait_for_menu');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('is voice-only', () => {
|
|
24
|
+
expect(wait_for_menu.flowTypes).to.deep.equal(['voice']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('has form with 10 digit fields plus result_name', () => {
|
|
28
|
+
expect(wait_for_menu.form).to.exist;
|
|
29
|
+
for (const d of ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']) {
|
|
30
|
+
expect(wait_for_menu.form![`digit_${d}`]).to.exist;
|
|
31
|
+
}
|
|
32
|
+
expect(wait_for_menu.form!.result_name).to.exist;
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('node scenarios', () => {
|
|
37
|
+
it('renders menu with filled digits', async () => {
|
|
38
|
+
await helper.testNode(
|
|
39
|
+
{
|
|
40
|
+
uuid: 'test-menu-node-1',
|
|
41
|
+
actions: [],
|
|
42
|
+
router: {
|
|
43
|
+
type: 'switch',
|
|
44
|
+
operand: '@input.text',
|
|
45
|
+
wait: {
|
|
46
|
+
type: 'msg',
|
|
47
|
+
hint: { type: 'digits', count: 1 }
|
|
48
|
+
},
|
|
49
|
+
result_name: 'menu_choice',
|
|
50
|
+
default_category_uuid: 'other-cat',
|
|
51
|
+
cases: [
|
|
52
|
+
{
|
|
53
|
+
uuid: 'case-1',
|
|
54
|
+
type: 'has_number_eq',
|
|
55
|
+
arguments: ['1'],
|
|
56
|
+
category_uuid: 'sales-cat'
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
uuid: 'case-2',
|
|
60
|
+
type: 'has_number_eq',
|
|
61
|
+
arguments: ['2'],
|
|
62
|
+
category_uuid: 'support-cat'
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
uuid: 'case-0',
|
|
66
|
+
type: 'has_number_eq',
|
|
67
|
+
arguments: ['0'],
|
|
68
|
+
category_uuid: 'operator-cat'
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
categories: [
|
|
72
|
+
{
|
|
73
|
+
uuid: 'sales-cat',
|
|
74
|
+
name: 'Sales',
|
|
75
|
+
exit_uuid: 'sales-exit'
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
uuid: 'support-cat',
|
|
79
|
+
name: 'Support',
|
|
80
|
+
exit_uuid: 'support-exit'
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
uuid: 'operator-cat',
|
|
84
|
+
name: 'Operator',
|
|
85
|
+
exit_uuid: 'operator-exit'
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
uuid: 'other-cat',
|
|
89
|
+
name: 'Other',
|
|
90
|
+
exit_uuid: 'other-exit'
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
},
|
|
94
|
+
exits: [
|
|
95
|
+
{ uuid: 'sales-exit', destination_uuid: null },
|
|
96
|
+
{ uuid: 'support-exit', destination_uuid: null },
|
|
97
|
+
{ uuid: 'operator-exit', destination_uuid: null },
|
|
98
|
+
{ uuid: 'other-exit', destination_uuid: null }
|
|
99
|
+
]
|
|
100
|
+
} as Node,
|
|
101
|
+
{ type: 'wait_for_menu' },
|
|
102
|
+
'menu-with-digits'
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('data transformation', () => {
|
|
108
|
+
it('converts node to form data', () => {
|
|
109
|
+
const node: Node = {
|
|
110
|
+
uuid: 'test-node',
|
|
111
|
+
actions: [],
|
|
112
|
+
router: {
|
|
113
|
+
type: 'switch',
|
|
114
|
+
result_name: 'menu',
|
|
115
|
+
categories: [
|
|
116
|
+
{
|
|
117
|
+
uuid: 'sales-cat',
|
|
118
|
+
name: 'Sales',
|
|
119
|
+
exit_uuid: 'sales-exit'
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
uuid: 'support-cat',
|
|
123
|
+
name: 'Support',
|
|
124
|
+
exit_uuid: 'support-exit'
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
uuid: 'other-cat',
|
|
128
|
+
name: 'Other',
|
|
129
|
+
exit_uuid: 'other-exit'
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
cases: [
|
|
133
|
+
{
|
|
134
|
+
uuid: 'case-1',
|
|
135
|
+
type: 'has_number_eq',
|
|
136
|
+
arguments: ['1'],
|
|
137
|
+
category_uuid: 'sales-cat'
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
uuid: 'case-2',
|
|
141
|
+
type: 'has_number_eq',
|
|
142
|
+
arguments: ['2'],
|
|
143
|
+
category_uuid: 'support-cat'
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
exits: [
|
|
148
|
+
{ uuid: 'sales-exit', destination_uuid: null },
|
|
149
|
+
{ uuid: 'support-exit', destination_uuid: null },
|
|
150
|
+
{ uuid: 'other-exit', destination_uuid: null }
|
|
151
|
+
]
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const formData = wait_for_menu.toFormData!(node);
|
|
155
|
+
|
|
156
|
+
expect(formData.uuid).to.equal('test-node');
|
|
157
|
+
expect(formData.digit_1).to.equal('Sales');
|
|
158
|
+
expect(formData.digit_2).to.equal('Support');
|
|
159
|
+
expect(formData.digit_3).to.equal('');
|
|
160
|
+
expect(formData.digit_0).to.equal('');
|
|
161
|
+
expect(formData.result_name).to.equal('menu');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('handles empty menu', () => {
|
|
165
|
+
const node: Node = {
|
|
166
|
+
uuid: 'test-node',
|
|
167
|
+
actions: [],
|
|
168
|
+
router: {
|
|
169
|
+
type: 'switch',
|
|
170
|
+
categories: []
|
|
171
|
+
},
|
|
172
|
+
exits: []
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const formData = wait_for_menu.toFormData!(node);
|
|
176
|
+
|
|
177
|
+
for (const d of ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']) {
|
|
178
|
+
expect(formData[`digit_${d}`]).to.equal('');
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('creates node from form data with filled digits', () => {
|
|
183
|
+
const formData = {
|
|
184
|
+
uuid: 'test-node',
|
|
185
|
+
digit_1: 'Sales',
|
|
186
|
+
digit_2: 'Support',
|
|
187
|
+
digit_3: '',
|
|
188
|
+
digit_4: '',
|
|
189
|
+
digit_5: '',
|
|
190
|
+
digit_6: '',
|
|
191
|
+
digit_7: '',
|
|
192
|
+
digit_8: '',
|
|
193
|
+
digit_9: '',
|
|
194
|
+
digit_0: 'Operator',
|
|
195
|
+
result_name: 'menu_choice'
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const originalNode: Node = {
|
|
199
|
+
uuid: 'test-node',
|
|
200
|
+
actions: [],
|
|
201
|
+
exits: [],
|
|
202
|
+
router: { type: 'switch', categories: [] }
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const result = wait_for_menu.fromFormData!(formData, originalNode);
|
|
206
|
+
|
|
207
|
+
// Should have 4 categories: Sales, Support, Operator, Other
|
|
208
|
+
expect(result.router?.categories).to.have.length(4);
|
|
209
|
+
const names = result.router!.categories.map((c) => c.name);
|
|
210
|
+
expect(names).to.deep.equal(['Sales', 'Support', 'Operator', 'Other']);
|
|
211
|
+
|
|
212
|
+
// Should have 3 cases
|
|
213
|
+
expect(result.router?.cases).to.have.length(3);
|
|
214
|
+
expect(result.router!.cases[0].arguments).to.deep.equal(['1']);
|
|
215
|
+
expect(result.router!.cases[1].arguments).to.deep.equal(['2']);
|
|
216
|
+
expect(result.router!.cases[2].arguments).to.deep.equal(['0']);
|
|
217
|
+
|
|
218
|
+
// Check wait config
|
|
219
|
+
expect(result.router?.wait?.type).to.equal('msg');
|
|
220
|
+
expect(result.router?.wait?.hint?.type).to.equal('digits');
|
|
221
|
+
expect(result.router?.wait?.hint?.count).to.equal(1);
|
|
222
|
+
|
|
223
|
+
// Check result name
|
|
224
|
+
expect(result.router?.result_name).to.equal('menu_choice');
|
|
225
|
+
|
|
226
|
+
// 4 exits
|
|
227
|
+
expect(result.exits).to.have.length(4);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('merges duplicate category names', () => {
|
|
231
|
+
const formData = {
|
|
232
|
+
uuid: 'test-node',
|
|
233
|
+
digit_1: 'Sales',
|
|
234
|
+
digit_2: 'Sales', // same category name
|
|
235
|
+
digit_3: '',
|
|
236
|
+
digit_4: '',
|
|
237
|
+
digit_5: '',
|
|
238
|
+
digit_6: '',
|
|
239
|
+
digit_7: '',
|
|
240
|
+
digit_8: '',
|
|
241
|
+
digit_9: '',
|
|
242
|
+
digit_0: '',
|
|
243
|
+
result_name: ''
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const originalNode: Node = {
|
|
247
|
+
uuid: 'test-node',
|
|
248
|
+
actions: [],
|
|
249
|
+
exits: [],
|
|
250
|
+
router: { type: 'switch', categories: [] }
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const result = wait_for_menu.fromFormData!(formData, originalNode);
|
|
254
|
+
|
|
255
|
+
// Should have 2 categories: Sales and Other (not duplicate Sales)
|
|
256
|
+
expect(result.router?.categories).to.have.length(2);
|
|
257
|
+
expect(result.router!.categories[0].name).to.equal('Sales');
|
|
258
|
+
expect(result.router!.categories[1].name).to.equal('Other');
|
|
259
|
+
|
|
260
|
+
// Both cases should reference same category
|
|
261
|
+
expect(result.router?.cases).to.have.length(2);
|
|
262
|
+
expect(result.router!.cases[0].category_uuid).to.equal(
|
|
263
|
+
result.router!.cases[1].category_uuid
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Should have 2 exits (not 3)
|
|
267
|
+
expect(result.exits).to.have.length(2);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('preserves existing category UUIDs', () => {
|
|
271
|
+
const formData = {
|
|
272
|
+
uuid: 'test-node',
|
|
273
|
+
digit_1: 'Sales',
|
|
274
|
+
digit_2: 'Support',
|
|
275
|
+
digit_3: '',
|
|
276
|
+
digit_4: '',
|
|
277
|
+
digit_5: '',
|
|
278
|
+
digit_6: '',
|
|
279
|
+
digit_7: '',
|
|
280
|
+
digit_8: '',
|
|
281
|
+
digit_9: '',
|
|
282
|
+
digit_0: '',
|
|
283
|
+
result_name: ''
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const originalNode: Node = {
|
|
287
|
+
uuid: 'test-node',
|
|
288
|
+
actions: [],
|
|
289
|
+
router: {
|
|
290
|
+
type: 'switch',
|
|
291
|
+
categories: [
|
|
292
|
+
{
|
|
293
|
+
uuid: 'orig-sales',
|
|
294
|
+
name: 'Sales',
|
|
295
|
+
exit_uuid: 'orig-sales-exit'
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
uuid: 'orig-support',
|
|
299
|
+
name: 'Support',
|
|
300
|
+
exit_uuid: 'orig-support-exit'
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
uuid: 'orig-other',
|
|
304
|
+
name: 'Other',
|
|
305
|
+
exit_uuid: 'orig-other-exit'
|
|
306
|
+
}
|
|
307
|
+
],
|
|
308
|
+
cases: [
|
|
309
|
+
{
|
|
310
|
+
uuid: 'orig-case-1',
|
|
311
|
+
type: 'has_number_eq',
|
|
312
|
+
arguments: ['1'],
|
|
313
|
+
category_uuid: 'orig-sales'
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
uuid: 'orig-case-2',
|
|
317
|
+
type: 'has_number_eq',
|
|
318
|
+
arguments: ['2'],
|
|
319
|
+
category_uuid: 'orig-support'
|
|
320
|
+
}
|
|
321
|
+
]
|
|
322
|
+
},
|
|
323
|
+
exits: [
|
|
324
|
+
{ uuid: 'orig-sales-exit', destination_uuid: 'dest-1' },
|
|
325
|
+
{ uuid: 'orig-support-exit', destination_uuid: 'dest-2' },
|
|
326
|
+
{ uuid: 'orig-other-exit', destination_uuid: null }
|
|
327
|
+
]
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const result = wait_for_menu.fromFormData!(formData, originalNode);
|
|
331
|
+
|
|
332
|
+
// Category UUIDs preserved
|
|
333
|
+
const sales = result.router!.categories.find((c) => c.name === 'Sales');
|
|
334
|
+
expect(sales?.uuid).to.equal('orig-sales');
|
|
335
|
+
expect(sales?.exit_uuid).to.equal('orig-sales-exit');
|
|
336
|
+
|
|
337
|
+
const other = result.router!.categories.find((c) => c.name === 'Other');
|
|
338
|
+
expect(other?.uuid).to.equal('orig-other');
|
|
339
|
+
|
|
340
|
+
// Case UUIDs preserved
|
|
341
|
+
const case1 = result.router!.cases.find(
|
|
342
|
+
(c: any) => c.arguments[0] === '1'
|
|
343
|
+
);
|
|
344
|
+
expect(case1?.uuid).to.equal('orig-case-1');
|
|
345
|
+
|
|
346
|
+
// Exit destinations preserved
|
|
347
|
+
const salesExit = result.exits.find((e) => e.uuid === 'orig-sales-exit');
|
|
348
|
+
expect(salesExit?.destination_uuid).to.equal('dest-1');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('handles all empty digits', () => {
|
|
352
|
+
const formData = {
|
|
353
|
+
uuid: 'test-node',
|
|
354
|
+
digit_1: '',
|
|
355
|
+
digit_2: '',
|
|
356
|
+
digit_3: '',
|
|
357
|
+
digit_4: '',
|
|
358
|
+
digit_5: '',
|
|
359
|
+
digit_6: '',
|
|
360
|
+
digit_7: '',
|
|
361
|
+
digit_8: '',
|
|
362
|
+
digit_9: '',
|
|
363
|
+
digit_0: '',
|
|
364
|
+
result_name: ''
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const originalNode: Node = {
|
|
368
|
+
uuid: 'test-node',
|
|
369
|
+
actions: [],
|
|
370
|
+
exits: [],
|
|
371
|
+
router: { type: 'switch', categories: [] }
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const result = wait_for_menu.fromFormData!(formData, originalNode);
|
|
375
|
+
|
|
376
|
+
// Should still have Other category
|
|
377
|
+
expect(result.router?.categories).to.have.length(1);
|
|
378
|
+
expect(result.router!.categories[0].name).to.equal('Other');
|
|
379
|
+
expect(result.router?.cases).to.have.length(0);
|
|
380
|
+
expect(result.exits).to.have.length(1);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -454,10 +454,11 @@ describe('Collision Detection Utilities', () => {
|
|
|
454
454
|
expect(newPos.left).to.equal(100); // horizontal position unchanged
|
|
455
455
|
});
|
|
456
456
|
|
|
457
|
-
it('prefers direction with
|
|
457
|
+
it('prefers axis-matching direction even with a cascade', () => {
|
|
458
458
|
// Sacred at (100,100)-(200,200), collider at (100,150)-(200,250)
|
|
459
|
-
//
|
|
460
|
-
//
|
|
459
|
+
// Overlap is 100w x 50h (wide) = vertical collision = prefer down
|
|
460
|
+
// A blocker sits below at (100,280)-(200,380), so down causes a cascade
|
|
461
|
+
// But axis bias still prefers down over moving right
|
|
461
462
|
const allBounds: NodeBounds[] = [
|
|
462
463
|
{
|
|
463
464
|
uuid: 'sacred',
|
|
@@ -491,9 +492,10 @@ describe('Collision Detection Utilities', () => {
|
|
|
491
492
|
const positions = calculateReflowPositions(['sacred'], allBounds);
|
|
492
493
|
|
|
493
494
|
expect(positions.has('collider')).to.be.true;
|
|
494
|
-
|
|
495
|
-
//
|
|
496
|
-
expect(
|
|
495
|
+
const newPos = positions.get('collider')!;
|
|
496
|
+
// Axis bias prefers down (vertical) even though it cascades into blocker
|
|
497
|
+
expect(newPos.top).to.be.greaterThan(200);
|
|
498
|
+
expect(newPos.left).to.equal(100); // horizontal position unchanged
|
|
497
499
|
});
|
|
498
500
|
|
|
499
501
|
it('resolves cascading collisions', () => {
|
|
@@ -696,6 +698,284 @@ describe('Collision Detection Utilities', () => {
|
|
|
696
698
|
expect(newPos.left).to.be.at.least(0);
|
|
697
699
|
expect(newPos.top).to.be.at.least(0);
|
|
698
700
|
});
|
|
701
|
+
|
|
702
|
+
it('does not move a lower node above the sacred node', () => {
|
|
703
|
+
// Collider is below sacred (collider.top > sacred.top)
|
|
704
|
+
// Up should be filtered, so collider goes down or to the side
|
|
705
|
+
const allBounds: NodeBounds[] = [
|
|
706
|
+
{
|
|
707
|
+
uuid: 'sacred',
|
|
708
|
+
left: 100,
|
|
709
|
+
top: 100,
|
|
710
|
+
right: 200,
|
|
711
|
+
bottom: 200,
|
|
712
|
+
width: 100,
|
|
713
|
+
height: 100
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
uuid: 'collider',
|
|
717
|
+
left: 100,
|
|
718
|
+
top: 180,
|
|
719
|
+
right: 200,
|
|
720
|
+
bottom: 280,
|
|
721
|
+
width: 100,
|
|
722
|
+
height: 100
|
|
723
|
+
}
|
|
724
|
+
];
|
|
725
|
+
|
|
726
|
+
const positions = calculateReflowPositions(['sacred'], allBounds);
|
|
727
|
+
|
|
728
|
+
expect(positions.has('collider')).to.be.true;
|
|
729
|
+
const newPos = positions.get('collider')!;
|
|
730
|
+
// Should NOT move above the sacred node's top
|
|
731
|
+
expect(newPos.top).to.be.at.least(100);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it('does not move a right-of node left of the sacred node', () => {
|
|
735
|
+
// Collider is to the right of sacred (collider.left > sacred.left)
|
|
736
|
+
// Left should be filtered
|
|
737
|
+
const allBounds: NodeBounds[] = [
|
|
738
|
+
{
|
|
739
|
+
uuid: 'sacred',
|
|
740
|
+
left: 100,
|
|
741
|
+
top: 100,
|
|
742
|
+
right: 200,
|
|
743
|
+
bottom: 200,
|
|
744
|
+
width: 100,
|
|
745
|
+
height: 100
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
uuid: 'collider',
|
|
749
|
+
left: 180,
|
|
750
|
+
top: 100,
|
|
751
|
+
right: 280,
|
|
752
|
+
bottom: 200,
|
|
753
|
+
width: 100,
|
|
754
|
+
height: 100
|
|
755
|
+
}
|
|
756
|
+
];
|
|
757
|
+
|
|
758
|
+
const positions = calculateReflowPositions(['sacred'], allBounds);
|
|
759
|
+
|
|
760
|
+
expect(positions.has('collider')).to.be.true;
|
|
761
|
+
const newPos = positions.get('collider')!;
|
|
762
|
+
// Should NOT move left of the sacred node's left
|
|
763
|
+
expect(newPos.left).to.be.at.least(100);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('prefers vertical for wide overlap (vertical collision)', () => {
|
|
767
|
+
// Nodes stacked: same horizontal position, slight vertical overlap
|
|
768
|
+
// Overlap: 100w x 30h (wider than tall) = vertical collision = prefer up/down
|
|
769
|
+
const allBounds: NodeBounds[] = [
|
|
770
|
+
{
|
|
771
|
+
uuid: 'sacred',
|
|
772
|
+
left: 100,
|
|
773
|
+
top: 100,
|
|
774
|
+
right: 200,
|
|
775
|
+
bottom: 200,
|
|
776
|
+
width: 100,
|
|
777
|
+
height: 100
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
uuid: 'collider',
|
|
781
|
+
left: 100,
|
|
782
|
+
top: 170,
|
|
783
|
+
right: 200,
|
|
784
|
+
bottom: 270,
|
|
785
|
+
width: 100,
|
|
786
|
+
height: 100
|
|
787
|
+
}
|
|
788
|
+
];
|
|
789
|
+
|
|
790
|
+
const positions = calculateReflowPositions(['sacred'], allBounds);
|
|
791
|
+
|
|
792
|
+
expect(positions.has('collider')).to.be.true;
|
|
793
|
+
const newPos = positions.get('collider')!;
|
|
794
|
+
// Should move vertically (down since collider is below)
|
|
795
|
+
expect(newPos.top).to.be.greaterThan(200);
|
|
796
|
+
expect(newPos.left).to.equal(100); // horizontal position unchanged
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('prefers horizontal for tall overlap (horizontal collision)', () => {
|
|
800
|
+
// Nodes side-by-side: same vertical position, slight horizontal overlap
|
|
801
|
+
// Overlap: 30w x 100h (taller than wide) = horizontal collision = prefer left/right
|
|
802
|
+
const allBounds: NodeBounds[] = [
|
|
803
|
+
{
|
|
804
|
+
uuid: 'sacred',
|
|
805
|
+
left: 100,
|
|
806
|
+
top: 100,
|
|
807
|
+
right: 200,
|
|
808
|
+
bottom: 200,
|
|
809
|
+
width: 100,
|
|
810
|
+
height: 100
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
uuid: 'collider',
|
|
814
|
+
left: 170,
|
|
815
|
+
top: 100,
|
|
816
|
+
right: 270,
|
|
817
|
+
bottom: 200,
|
|
818
|
+
width: 100,
|
|
819
|
+
height: 100
|
|
820
|
+
}
|
|
821
|
+
];
|
|
822
|
+
|
|
823
|
+
const positions = calculateReflowPositions(['sacred'], allBounds);
|
|
824
|
+
|
|
825
|
+
expect(positions.has('collider')).to.be.true;
|
|
826
|
+
const newPos = positions.get('collider')!;
|
|
827
|
+
// Should move horizontally (right since collider is right of sacred)
|
|
828
|
+
expect(newPos.left).to.be.greaterThan(200);
|
|
829
|
+
expect(newPos.top).to.equal(100); // vertical position unchanged
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('axis bias tolerates a few cascading collisions', () => {
|
|
833
|
+
// Sacred at (100,100)-(200,200), collider at (100,170)-(200,270)
|
|
834
|
+
// Overlap: 100w x 30h = vertical collision = prefer down
|
|
835
|
+
// Two blockers below: moving down causes 2 cascades
|
|
836
|
+
// Moving right causes 0 cascades but is axis-mismatched
|
|
837
|
+
// Axis bias should still prefer down with 2 cascades
|
|
838
|
+
const allBounds: NodeBounds[] = [
|
|
839
|
+
{
|
|
840
|
+
uuid: 'sacred',
|
|
841
|
+
left: 100,
|
|
842
|
+
top: 100,
|
|
843
|
+
right: 200,
|
|
844
|
+
bottom: 200,
|
|
845
|
+
width: 100,
|
|
846
|
+
height: 100
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
uuid: 'collider',
|
|
850
|
+
left: 100,
|
|
851
|
+
top: 170,
|
|
852
|
+
right: 200,
|
|
853
|
+
bottom: 270,
|
|
854
|
+
width: 100,
|
|
855
|
+
height: 100
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
uuid: 'blocker1',
|
|
859
|
+
left: 100,
|
|
860
|
+
top: 260,
|
|
861
|
+
right: 200,
|
|
862
|
+
bottom: 360,
|
|
863
|
+
width: 100,
|
|
864
|
+
height: 100
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
uuid: 'blocker2',
|
|
868
|
+
left: 100,
|
|
869
|
+
top: 350,
|
|
870
|
+
right: 200,
|
|
871
|
+
bottom: 450,
|
|
872
|
+
width: 100,
|
|
873
|
+
height: 100
|
|
874
|
+
}
|
|
875
|
+
];
|
|
876
|
+
|
|
877
|
+
const positions = calculateReflowPositions(['sacred'], allBounds);
|
|
878
|
+
|
|
879
|
+
expect(positions.has('collider')).to.be.true;
|
|
880
|
+
const newPos = positions.get('collider')!;
|
|
881
|
+
// Should still prefer down (axis match) despite 2 cascades
|
|
882
|
+
expect(newPos.top).to.be.greaterThan(200);
|
|
883
|
+
expect(newPos.left).to.equal(100);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('sacred node yields to existing top node when dropped below its top', () => {
|
|
887
|
+
// Existing node at top of canvas, sacred dropped overlapping from below
|
|
888
|
+
const allBounds: NodeBounds[] = [
|
|
889
|
+
{
|
|
890
|
+
uuid: 'existing',
|
|
891
|
+
left: 100,
|
|
892
|
+
top: 0,
|
|
893
|
+
right: 200,
|
|
894
|
+
bottom: 100,
|
|
895
|
+
width: 100,
|
|
896
|
+
height: 100
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
uuid: 'dropped',
|
|
900
|
+
left: 100,
|
|
901
|
+
top: 50,
|
|
902
|
+
right: 200,
|
|
903
|
+
bottom: 150,
|
|
904
|
+
width: 100,
|
|
905
|
+
height: 100
|
|
906
|
+
}
|
|
907
|
+
];
|
|
908
|
+
|
|
909
|
+
const positions = calculateReflowPositions(['dropped'], allBounds);
|
|
910
|
+
|
|
911
|
+
// Sacred (dropped) should yield since it didn't drop above existing
|
|
912
|
+
// and existing has no room to move up
|
|
913
|
+
expect(positions.has('dropped')).to.be.true;
|
|
914
|
+
expect(positions.has('existing')).to.be.false;
|
|
915
|
+
|
|
916
|
+
const newPos = positions.get('dropped')!;
|
|
917
|
+
expect(newPos.top).to.be.greaterThanOrEqual(100); // moved below existing
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('sacred keeps position when dropped above existing node', () => {
|
|
921
|
+
// Sacred node dropped above existing node - sacred gets priority
|
|
922
|
+
const allBounds: NodeBounds[] = [
|
|
923
|
+
{
|
|
924
|
+
uuid: 'existing',
|
|
925
|
+
left: 100,
|
|
926
|
+
top: 50,
|
|
927
|
+
right: 200,
|
|
928
|
+
bottom: 150,
|
|
929
|
+
width: 100,
|
|
930
|
+
height: 100
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
uuid: 'dropped',
|
|
934
|
+
left: 100,
|
|
935
|
+
top: 0,
|
|
936
|
+
right: 200,
|
|
937
|
+
bottom: 100,
|
|
938
|
+
width: 100,
|
|
939
|
+
height: 100
|
|
940
|
+
}
|
|
941
|
+
];
|
|
942
|
+
|
|
943
|
+
const positions = calculateReflowPositions(['dropped'], allBounds);
|
|
944
|
+
|
|
945
|
+
// Sacred dropped above existing, so it keeps priority
|
|
946
|
+
expect(positions.has('dropped')).to.be.false;
|
|
947
|
+
expect(positions.has('existing')).to.be.true;
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it('sacred keeps position when dropped at same top as existing node', () => {
|
|
951
|
+
// Both at top=0 - sacred keeps priority since it's not below existing
|
|
952
|
+
const allBounds: NodeBounds[] = [
|
|
953
|
+
{
|
|
954
|
+
uuid: 'existing',
|
|
955
|
+
left: 100,
|
|
956
|
+
top: 0,
|
|
957
|
+
right: 200,
|
|
958
|
+
bottom: 100,
|
|
959
|
+
width: 100,
|
|
960
|
+
height: 100
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
uuid: 'dropped',
|
|
964
|
+
left: 100,
|
|
965
|
+
top: 0,
|
|
966
|
+
right: 200,
|
|
967
|
+
bottom: 100,
|
|
968
|
+
width: 100,
|
|
969
|
+
height: 100
|
|
970
|
+
}
|
|
971
|
+
];
|
|
972
|
+
|
|
973
|
+
const positions = calculateReflowPositions(['dropped'], allBounds);
|
|
974
|
+
|
|
975
|
+
// Sacred at same top keeps priority - existing node moves
|
|
976
|
+
expect(positions.has('dropped')).to.be.false;
|
|
977
|
+
expect(positions.has('existing')).to.be.true;
|
|
978
|
+
});
|
|
699
979
|
});
|
|
700
980
|
|
|
701
981
|
describe('edge cases', () => {
|