@adatechnology/conversations-ui 0.1.0-rc.8 → 0.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.
- package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/chunk-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -678
- package/dist/index.d.ts +921 -17
- package/dist/index.js +3677 -678
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-73MW5HNT.js +0 -1717
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-B5C1DLu1.d.ts +0 -365
- package/src/preview/AudioRecorderButton.tsx +0 -117
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O que se prova aqui é topologia e posição, não estilo.
|
|
3
|
+
*
|
|
4
|
+
* Aresta com destino errado não pinta errado — ela manda a conversa do cliente para outro lugar, e
|
|
5
|
+
* quem editou nunca vê. Card que o layout joga para fora da área visível se lê como "apaguei sem
|
|
6
|
+
* querer". São os dois modos de falhar que já aconteceram nesta tela, e os dois são silenciosos.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'bun:test'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
buildFlowEdges,
|
|
13
|
+
chainFrameBounds,
|
|
14
|
+
computeMergedLayout,
|
|
15
|
+
countLiveByNode,
|
|
16
|
+
detachedNodeIds,
|
|
17
|
+
newNodeFromSpec,
|
|
18
|
+
portalNodeId,
|
|
19
|
+
type FlowLivePosition,
|
|
20
|
+
findFreeSlot,
|
|
21
|
+
} from './flowCanvasModel'
|
|
22
|
+
import { estimateNodeHeight, type FlowGraphData, type FlowNodeData } from './flowGraph'
|
|
23
|
+
|
|
24
|
+
const ROOT = 'menu'
|
|
25
|
+
|
|
26
|
+
/** Nós mínimos VÁLIDOS pelo contrato — sem `as`, para quebrar quando a forma do nó mudar. */
|
|
27
|
+
function node(id: string, next?: FlowNodeData['next']): FlowNodeData {
|
|
28
|
+
return { id, type: 'question', question: id, ...(next === undefined ? {} : { next }) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function graph(params: { key: string; start: string; nodes: FlowNodeData[] }): FlowGraphData {
|
|
32
|
+
return {
|
|
33
|
+
key: params.key,
|
|
34
|
+
label: params.key,
|
|
35
|
+
version: 1,
|
|
36
|
+
startNodeId: params.start,
|
|
37
|
+
nodes: Object.fromEntries(params.nodes.map((each) => [each.id, each])),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function live(params: Partial<FlowLivePosition>): FlowLivePosition {
|
|
42
|
+
return { currentState: 'x', flow: null, nodeId: null, menuNodeId: null, ...params }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('arestas', () => {
|
|
46
|
+
it('saída única vira aresta linear sem handle', () => {
|
|
47
|
+
const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b')] }) }
|
|
48
|
+
|
|
49
|
+
expect(buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })).toEqual([
|
|
50
|
+
{ id: 'menu::a->menu::b', source: 'menu::a', target: 'menu::b', kind: 'linear', crossFlow: false, live: false },
|
|
51
|
+
])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('ramificação leva o id da opção no handle, e o default vira fallback', () => {
|
|
55
|
+
const graphs = {
|
|
56
|
+
menu: graph({
|
|
57
|
+
key: 'menu',
|
|
58
|
+
start: 'p',
|
|
59
|
+
nodes: [node('p', { byAnswer: { sim: 'x' }, default: 'y' }), node('x'), node('y')],
|
|
60
|
+
}),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })
|
|
64
|
+
|
|
65
|
+
expect(edges.find((each) => each.kind === 'branch')).toMatchObject({ sourceHandle: 'sim', target: 'menu::x' })
|
|
66
|
+
// O handle do fallback é fixo `__default` — é o que `applyConnection` espera de volta quando
|
|
67
|
+
// alguém arrasta esse fio.
|
|
68
|
+
expect(edges.find((each) => each.kind === 'fallback')).toMatchObject({
|
|
69
|
+
sourceHandle: '__default',
|
|
70
|
+
target: 'menu::y',
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('destino vazio NÃO gera aresta', () => {
|
|
75
|
+
// Apagar um nó zera quem apontava para ele. A aresta iria para `menu::`, que não existe, e o
|
|
76
|
+
// React Flow descarta em silêncio — a opção pareceria ligada sem estar.
|
|
77
|
+
const graphs = {
|
|
78
|
+
menu: graph({ key: 'menu', start: 'p', nodes: [node('p', { byAnswer: { sim: '' }, default: '' })] }),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
expect(buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })).toEqual([])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('salto para fluxo MESCLADO liga no nó inicial dele, não num portal', () => {
|
|
85
|
+
const graphs = {
|
|
86
|
+
menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim')] }),
|
|
87
|
+
sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
expect(buildFlowEdges({ openKeys: ['menu', 'sim'], graphs, rootFlowKey: ROOT })[0]).toMatchObject({
|
|
91
|
+
target: 'sim::s1',
|
|
92
|
+
crossFlow: true,
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('salto para fluxo NÃO mesclado cai no portal', () => {
|
|
97
|
+
const graphs = {
|
|
98
|
+
menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim')] }),
|
|
99
|
+
sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
expect(buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })[0]!.target).toBe(
|
|
103
|
+
`menu::${portalNodeId('a', 'flow:sim')}`,
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('duas opções para o mesmo fluxo compartilham UM portal', () => {
|
|
108
|
+
const graphs = {
|
|
109
|
+
menu: graph({
|
|
110
|
+
key: 'menu',
|
|
111
|
+
start: 'p',
|
|
112
|
+
nodes: [node('p', { byAnswer: { um: 'flow:sim', dois: 'flow:sim' }, default: '' })],
|
|
113
|
+
}),
|
|
114
|
+
sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: ROOT })
|
|
118
|
+
|
|
119
|
+
// Duas arestas (uma por opção), um só destino: senão o card fica cercado de caixas iguais.
|
|
120
|
+
expect(edges).toHaveLength(2)
|
|
121
|
+
expect(new Set(edges.map((each) => each.target)).size).toBe(1)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('conversa parada no nó anima a aresta que sai dele, mas nunca o fallback', () => {
|
|
125
|
+
const graphs = {
|
|
126
|
+
menu: graph({
|
|
127
|
+
key: 'menu',
|
|
128
|
+
start: 'p',
|
|
129
|
+
nodes: [node('p', { byAnswer: { sim: 'x' }, default: 'y' }), node('x'), node('y')],
|
|
130
|
+
}),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const edges = buildFlowEdges({
|
|
134
|
+
openKeys: ['menu'],
|
|
135
|
+
graphs,
|
|
136
|
+
rootFlowKey: ROOT,
|
|
137
|
+
livePositions: [live({ menuNodeId: 'p' })],
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
expect(edges.find((each) => each.kind === 'branch')!.live).toBe(true)
|
|
141
|
+
// Fallback piscando sugeria que a conversa estava passando pelo caminho que ninguém escolheu.
|
|
142
|
+
expect(edges.find((each) => each.kind === 'fallback')!.live).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('ids de aresta são únicos entre fluxos com nós homônimos', () => {
|
|
146
|
+
const graphs = {
|
|
147
|
+
menu: graph({ key: 'menu', start: 'inicio', nodes: [node('inicio', 'fim'), node('fim')] }),
|
|
148
|
+
sim: graph({ key: 'sim', start: 'inicio', nodes: [node('inicio', 'fim'), node('fim')] }),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const edges = buildFlowEdges({ openKeys: ['menu', 'sim'], graphs, rootFlowKey: ROOT })
|
|
152
|
+
|
|
153
|
+
expect(new Set(edges.map((each) => each.id)).size).toBe(edges.length)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('contagem ao vivo', () => {
|
|
158
|
+
it('no fluxo RAIZ a posição vem de menuNodeId', () => {
|
|
159
|
+
// O servidor guarda o passo do menu num campo próprio, e conversa no menu não carrega `flow`.
|
|
160
|
+
// Ler `nodeId` aqui daria zero na tela mais visitada do editor.
|
|
161
|
+
const positions = [live({ menuNodeId: 'saudacao' }), live({ menuNodeId: 'saudacao' })]
|
|
162
|
+
|
|
163
|
+
expect(countLiveByNode({ flowKey: ROOT, rootFlowKey: ROOT, positions })).toEqual({ saudacao: 2 })
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('fora da raiz a posição vem de nodeId, e outro fluxo é ignorado', () => {
|
|
167
|
+
const positions = [live({ flow: 'sim', nodeId: 'renda' }), live({ flow: 'outro', nodeId: 'renda' })]
|
|
168
|
+
|
|
169
|
+
expect(countLiveByNode({ flowKey: 'sim', rootFlowKey: ROOT, positions })).toEqual({ renda: 1 })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('sem posições devolve vazio, em vez de estourar', () => {
|
|
173
|
+
expect(countLiveByNode({ flowKey: ROOT, rootFlowKey: ROOT, positions: undefined })).toEqual({})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('linha já agregada pelo servidor soma pelo count, inclusive na raiz', () => {
|
|
177
|
+
// `meta-whatsapp-module` responde `GROUP BY (flowKey, currentNodeId)`. Contando como uma sessão
|
|
178
|
+
// por linha, dez conversas paradas no mesmo nó apareciam como uma — ou como nenhuma, porque a
|
|
179
|
+
// linha agregada não tem `menuNodeId` para a raiz ler.
|
|
180
|
+
const positions = [
|
|
181
|
+
{ flowKey: ROOT, nodeId: 'saudacao', count: 7 },
|
|
182
|
+
{ flowKey: 'sim', nodeId: 'renda', count: 3 },
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
expect(countLiveByNode({ flowKey: ROOT, rootFlowKey: ROOT, positions })).toEqual({ saudacao: 7 })
|
|
186
|
+
expect(countLiveByNode({ flowKey: 'sim', rootFlowKey: ROOT, positions })).toEqual({ renda: 3 })
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('os dois formatos convivem na mesma resposta', () => {
|
|
190
|
+
const positions = [live({ flow: 'sim', nodeId: 'renda' }), { flowKey: 'sim', nodeId: 'renda', count: 2 }]
|
|
191
|
+
|
|
192
|
+
expect(countLiveByNode({ flowKey: 'sim', rootFlowKey: ROOT, positions })).toEqual({ renda: 3 })
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('layout mesclado', () => {
|
|
197
|
+
it('ranqueia da esquerda para a direita a partir do início do fluxo primário', () => {
|
|
198
|
+
const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b', 'c'), node('c')] }) }
|
|
199
|
+
|
|
200
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
201
|
+
|
|
202
|
+
expect(positions.get('menu::a')!.x).toBe(0)
|
|
203
|
+
expect(positions.get('menu::b')!.x).toBeGreaterThan(positions.get('menu::a')!.x)
|
|
204
|
+
expect(positions.get('menu::c')!.x).toBeGreaterThan(positions.get('menu::b')!.x)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('atravessa o salto entre fluxos ao ranquear', () => {
|
|
208
|
+
const graphs = {
|
|
209
|
+
menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim')] }),
|
|
210
|
+
sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const positions = computeMergedLayout({ openKeys: ['menu', 'sim'], graphs, primaryFlowKey: 'menu' })
|
|
214
|
+
|
|
215
|
+
expect(positions.get('sim::s1')!.x).toBeGreaterThan(positions.get('menu::a')!.x)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('TODOS os órfãos ficam na MESMA coluna extra', () => {
|
|
219
|
+
// Uma coluna por órfão empurrava cada um mais para longe da área visível, e desligar um fio se
|
|
220
|
+
// lia como "o card sumiu". É o motivo de existir uma camada única.
|
|
221
|
+
const graphs = {
|
|
222
|
+
menu: graph({ key: 'menu', start: 'a', nodes: [node('a'), node('o1'), node('o2'), node('o3')] }),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
226
|
+
const strayX = ['o1', 'o2', 'o3'].map((id) => positions.get(`menu::${id}`)!.x)
|
|
227
|
+
|
|
228
|
+
expect(new Set(strayX).size).toBe(1)
|
|
229
|
+
expect(strayX[0]).toBeGreaterThan(positions.get('menu::a')!.x)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('órfãos na mesma coluna não se sobrepõem', () => {
|
|
233
|
+
const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a'), node('o1'), node('o2')] }) }
|
|
234
|
+
|
|
235
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
236
|
+
|
|
237
|
+
expect(positions.get('menu::o1')!.y).not.toBe(positions.get('menu::o2')!.y)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('ciclo não estoura a pilha', () => {
|
|
241
|
+
const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b', 'a')] }) }
|
|
242
|
+
|
|
243
|
+
expect(computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' }).size).toBe(2)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('fluxo primário ausente ainda posiciona todo mundo, em vez de devolver vazio', () => {
|
|
247
|
+
const graphs = { sim: graph({ key: 'sim', start: 's1', nodes: [node('s1')] }) }
|
|
248
|
+
|
|
249
|
+
// Acontece entre o clique em "focar" e a chegada do grafo: sem isto o canvas fica em branco.
|
|
250
|
+
expect(computeMergedLayout({ openKeys: ['sim'], graphs, primaryFlowKey: 'menu' }).size).toBe(1)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('nós soltos', () => {
|
|
255
|
+
it('quem ninguém aponta está solto; o nó inicial nunca está', () => {
|
|
256
|
+
const target = graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b'), node('solto')] })
|
|
257
|
+
|
|
258
|
+
expect(detachedNodeIds(target)).toEqual(new Set(['solto']))
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('salto para outro fluxo não conta como ligação local', () => {
|
|
262
|
+
const target = graph({ key: 'menu', start: 'a', nodes: [node('a', 'flow:sim'), node('b')] })
|
|
263
|
+
|
|
264
|
+
expect(detachedNodeIds(target).has('b')).toBe(true)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('moldura da cadeia de coleta', () => {
|
|
269
|
+
it('envolve todos os nós e abre espaço para o rótulo acima', () => {
|
|
270
|
+
const target = graph({ key: 'menu', start: 'a', nodes: [node('a'), node('b')] })
|
|
271
|
+
const positions: Record<string, { x: number; y: number }> = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 } }
|
|
272
|
+
|
|
273
|
+
const bounds = chainFrameBounds({
|
|
274
|
+
nodeIds: ['a', 'b'],
|
|
275
|
+
graph: target,
|
|
276
|
+
positionOf: (id) => positions[id]!,
|
|
277
|
+
padding: 10,
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
expect(bounds.x).toBe(-10)
|
|
281
|
+
// O rótulo é desenhado acima da borda, então o topo sobe além do padding.
|
|
282
|
+
expect(bounds.y).toBeLessThan(-10)
|
|
283
|
+
expect(bounds.width).toBeGreaterThan(100)
|
|
284
|
+
expect(bounds.height).toBeGreaterThan(50)
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('nó novo da paleta', () => {
|
|
289
|
+
it('pergunta nasce com contextKey igual ao id', () => {
|
|
290
|
+
// Sem contextKey o motor faz a pergunta e descarta a resposta, sem erro em lugar nenhum.
|
|
291
|
+
const created = newNodeFromSpec({ kind: 'question', questionType: 'text' }, new Set())
|
|
292
|
+
|
|
293
|
+
expect(created.contextKey).toBe(created.id)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('decisão nasce com duas opções, para já dar o que ligar', () => {
|
|
297
|
+
const created = newNodeFromSpec({ kind: 'decision' }, new Set())
|
|
298
|
+
|
|
299
|
+
expect(created.options).toHaveLength(2)
|
|
300
|
+
expect(created.questionType).toBe('choice')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('id não colide com o que já existe', () => {
|
|
304
|
+
const first = newNodeFromSpec({ kind: 'condition' }, new Set())
|
|
305
|
+
const second = newNodeFromSpec({ kind: 'condition' }, new Set([first.id]))
|
|
306
|
+
|
|
307
|
+
expect(second.id).not.toBe(first.id)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('ação carrega o kind escolhido na paleta', () => {
|
|
311
|
+
expect(newNodeFromSpec({ kind: 'action', actionKind: 'handoff' }, new Set()).actionKind).toBe('handoff')
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* O "+" cria o nó à direita de quem o criou — que é onde costuma estar o card que acabou de perder
|
|
317
|
+
* a ligação. Dois cards no mesmo pixel se leem como um card só, e "sumiu" de novo.
|
|
318
|
+
*/
|
|
319
|
+
describe('findFreeSlot', () => {
|
|
320
|
+
it('lugar vazio é usado como está', () => {
|
|
321
|
+
expect(findFreeSlot({ desired: { x: 100, y: 0 }, taken: [] })).toEqual({ x: 100, y: 0 })
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('lugar ocupado desce até sobrar espaço, sem sair da coluna', () => {
|
|
325
|
+
const slot = findFreeSlot({ desired: { x: 100, y: 0 }, taken: [{ x: 100, y: 0 }] })
|
|
326
|
+
|
|
327
|
+
expect(slot.x).toBe(100)
|
|
328
|
+
expect(slot.y).toBeGreaterThan(0)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('desce quantas vezes precisar quando a coluna está empilhada', () => {
|
|
332
|
+
const taken = [
|
|
333
|
+
{ x: 100, y: 0 },
|
|
334
|
+
{ x: 100, y: 120 },
|
|
335
|
+
{ x: 100, y: 240 },
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
expect(findFreeSlot({ desired: { x: 100, y: 0 }, taken }).y).toBe(360)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('card em outra coluna não empurra ninguém', () => {
|
|
342
|
+
expect(findFreeSlot({ desired: { x: 100, y: 0 }, taken: [{ x: 900, y: 0 }] })).toEqual({ x: 100, y: 0 })
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* A altura do card decide o espaçamento do layout. `send_media` ganhou uma linha de saída (o motor
|
|
348
|
+
* do bot anda para o `next` depois dela), e a altura tem de acompanhar — senão a camada de baixo
|
|
349
|
+
* encosta no card.
|
|
350
|
+
*/
|
|
351
|
+
describe('estimateNodeHeight em nó de ação', () => {
|
|
352
|
+
it('ação de passagem é mais alta que ação terminal, pela linha de saída', () => {
|
|
353
|
+
const passThrough = estimateNodeHeight({ id: 'a', type: 'action', actionKind: 'send_media' })
|
|
354
|
+
const terminal = estimateNodeHeight({ id: 'b', type: 'action', actionKind: 'handoff' })
|
|
355
|
+
|
|
356
|
+
expect(passThrough).toBeGreaterThan(terminal)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('ação sem kind conta como terminal, em vez de estourar', () => {
|
|
360
|
+
expect(estimateNodeHeight({ id: 'c', type: 'action' })).toBe(
|
|
361
|
+
estimateNodeHeight({ id: 'd', type: 'action', actionKind: 'handoff' }),
|
|
362
|
+
)
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A regra que o empilhamento por camada quebrava: dois cards na mesma altura fazem o fio entre eles
|
|
368
|
+
* correr na horizontal, e um fio horizontal passa POR TRÁS de qualquer card que esteja no caminho.
|
|
369
|
+
* Um card por linha transforma toda ligação numa diagonal curta e visível.
|
|
370
|
+
*/
|
|
371
|
+
describe('cascata: um card por linha', () => {
|
|
372
|
+
it('cada card tem altura própria — ninguém divide linha com ninguém', () => {
|
|
373
|
+
const graphs = {
|
|
374
|
+
menu: graph({
|
|
375
|
+
key: 'menu',
|
|
376
|
+
start: 'a',
|
|
377
|
+
nodes: [node('a', { byAnswer: { sim: 'b', nao: 'c' }, default: 'd' }), node('b'), node('c'), node('d')],
|
|
378
|
+
}),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
382
|
+
const ys = [...positions.values()].map((each) => each.y)
|
|
383
|
+
|
|
384
|
+
expect(new Set(ys).size).toBe(ys.length)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('o card seguinte fica à direita E abaixo de quem o alimenta', () => {
|
|
388
|
+
const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b', 'c'), node('c')] }) }
|
|
389
|
+
|
|
390
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
391
|
+
const a = positions.get('menu::a')!
|
|
392
|
+
const b = positions.get('menu::b')!
|
|
393
|
+
const c = positions.get('menu::c')!
|
|
394
|
+
|
|
395
|
+
expect(b.x).toBeGreaterThan(a.x)
|
|
396
|
+
expect(b.y).toBeGreaterThan(a.y)
|
|
397
|
+
expect(c.x).toBeGreaterThan(b.x)
|
|
398
|
+
expect(c.y).toBeGreaterThan(b.y)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('um ramo inteiro sai antes do próximo começar — caminho de conversa não se intercala', () => {
|
|
402
|
+
const graphs = {
|
|
403
|
+
menu: graph({
|
|
404
|
+
key: 'menu',
|
|
405
|
+
start: 'a',
|
|
406
|
+
nodes: [
|
|
407
|
+
node('a', { byAnswer: { sim: 'b1', nao: 'c1' }, default: '' }),
|
|
408
|
+
node('b1', 'b2'),
|
|
409
|
+
node('b2'),
|
|
410
|
+
node('c1'),
|
|
411
|
+
],
|
|
412
|
+
}),
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
|
|
416
|
+
|
|
417
|
+
// b1 e b2 são o mesmo ramo: c1 (o outro ramo) só aparece depois dos dois.
|
|
418
|
+
expect(positions.get('menu::b2')!.y).toBeLessThan(positions.get('menu::c1')!.y)
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Um `next` apontando para o próprio nó não vira aresta: de A para A não há trajeto que caiba —
|
|
424
|
+
* por baixo some atrás do card, por cima o cobre. Quem mostra é o ícone de repetição na linha de
|
|
425
|
+
* saída. O teste existe porque "não desenhar" é fácil de perder numa refatoração de arestas.
|
|
426
|
+
*/
|
|
427
|
+
describe('laço no próprio card', () => {
|
|
428
|
+
it('saída que volta ao mesmo nó não gera aresta', () => {
|
|
429
|
+
const graphs = {
|
|
430
|
+
menu: graph({
|
|
431
|
+
key: 'menu',
|
|
432
|
+
start: 'a',
|
|
433
|
+
nodes: [node('a', { byAnswer: { sim: 'b' }, default: 'a' }), node('b')],
|
|
434
|
+
}),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: 'menu' })
|
|
438
|
+
|
|
439
|
+
expect(edges.map((each) => each.id)).toEqual(['menu::a->menu::b-sim'])
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('as demais saídas do mesmo card continuam desenhadas', () => {
|
|
443
|
+
const graphs = {
|
|
444
|
+
menu: graph({
|
|
445
|
+
key: 'menu',
|
|
446
|
+
start: 'a',
|
|
447
|
+
nodes: [node('a', { byAnswer: { sim: 'b', repete: 'a' }, default: 'b' }), node('b')],
|
|
448
|
+
}),
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: 'menu' })
|
|
452
|
+
|
|
453
|
+
expect(edges).toHaveLength(2)
|
|
454
|
+
expect(edges.every((each) => each.target === 'menu::b')).toBe(true)
|
|
455
|
+
})
|
|
456
|
+
})
|