@adatechnology/conversations-ui 0.1.0-rc.27 → 0.1.0-rc.28
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/flows/index.d.ts +73 -2
- package/dist/flows/index.js +115 -77
- package/package.json +2 -2
- package/src/buildOutput.test.ts +79 -0
- package/src/flows/FlowsWorkspace.tsx +56 -98
- package/src/flows/flowEditorOps.test.ts +241 -0
- package/src/flows/flowEditorOps.ts +177 -0
- package/src/flows/flowGraph.ts +1 -1
- package/src/flows/index.ts +16 -0
- package/src/flows/workspaceContract.test.ts +95 -0
|
@@ -24,6 +24,16 @@ import { FlowNodePanel } from './FlowNodePanel'
|
|
|
24
24
|
import { FlowPalette, type FlowPaletteActionOption, type NewNodeSpec } from './FlowPalette'
|
|
25
25
|
import { FlowMapCanvas } from './FlowMapCanvas'
|
|
26
26
|
import { mergeFlowEditorLabels, type FlowEditorLabels } from './labels'
|
|
27
|
+
// Operações puras do grafo, com teste próprio. As decisões que elas tomam não dão erro quando estão
|
|
28
|
+
// erradas: dão aresta apontando para nó apagado, ou salto que o motor do bot ignora.
|
|
29
|
+
import {
|
|
30
|
+
applyConnection,
|
|
31
|
+
mergedFlowKeysFrom,
|
|
32
|
+
namespaceNodeId,
|
|
33
|
+
parseNamespacedId,
|
|
34
|
+
removeNodeAndCleanRefs,
|
|
35
|
+
resolveConnection,
|
|
36
|
+
} from './flowEditorOps'
|
|
27
37
|
import {
|
|
28
38
|
computeAutoLayout,
|
|
29
39
|
slugifyNodeId,
|
|
@@ -119,20 +129,6 @@ function portalNodeId(sourceId: string, target: string): string {
|
|
|
119
129
|
return `__portal__${sourceId}__${target}`
|
|
120
130
|
}
|
|
121
131
|
|
|
122
|
-
// Namespacing de ids: com fusão editável, nós de fluxos diferentes convivem no mesmo canvas
|
|
123
|
-
// React Flow, que exige ids únicos globalmente — "flowKey::nodeId" evita colisão entre fluxos
|
|
124
|
-
// que reutilizem o mesmo id de nó (ex.: vários fluxos com um nó "root").
|
|
125
|
-
function ns(flowKey: string, nodeId: string): string {
|
|
126
|
-
return `${flowKey}${NS_SEP}${nodeId}`
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function parseNs(id: string): { flowKey: string; nodeId: string } {
|
|
130
|
-
const index = id.indexOf(NS_SEP)
|
|
131
|
-
return index === -1
|
|
132
|
-
? { flowKey: '', nodeId: id }
|
|
133
|
-
: { flowKey: id.slice(0, index), nodeId: id.slice(index + NS_SEP.length) }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
132
|
// Um fluxo aberto no canvas de detalhe — o primeiro da lista é o "primário" (dono da paleta,
|
|
137
133
|
// organizar, publicar e excluir-fluxo); os demais chegaram por fusão editável (clique num portal)
|
|
138
134
|
// e ficam com um cabeçalho flutuante pra focar neles sozinhos ou fechar.
|
|
@@ -142,17 +138,9 @@ type OpenFlow = { key: string; offset: { x: number; y: number } }
|
|
|
142
138
|
// fluxo já traz junto tudo que ele referencia (e o que essas referências referenciam), sem
|
|
143
139
|
// precisar clicar em cada portal manualmente.
|
|
144
140
|
function autoMergeAll(rootKey: string, graphsSource: Record<string, FlowGraphData>): OpenFlow[] {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const key = queue.shift()!
|
|
149
|
-
if (visited.has(key) || !graphsSource[key]) continue
|
|
150
|
-
visited.add(key)
|
|
151
|
-
for (const target of crossFlowTargetsOf(graphsSource[key]!)) {
|
|
152
|
-
if (!visited.has(target) && graphsSource[target]) queue.push(target)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return [...visited].map((key) => ({ key, offset: { x: 0, y: 0 } }))
|
|
141
|
+
// O fecho em si vem de `flowEditorOps`, que é onde ele tem teste — inclusive para o caso mais banal
|
|
142
|
+
// que existe, o fluxo que volta a si mesmo.
|
|
143
|
+
return mergedFlowKeysFrom(rootKey, graphsSource).map((key) => ({ key, offset: { x: 0, y: 0 } }))
|
|
156
144
|
}
|
|
157
145
|
|
|
158
146
|
// Layout único pra TODOS os nós de TODOS os fluxos abertos juntos — ao contrário de posicionar
|
|
@@ -171,7 +159,7 @@ function computeMergedLayout(
|
|
|
171
159
|
for (const { key } of openFlows) {
|
|
172
160
|
const graph = workingGraphs[key]
|
|
173
161
|
if (!graph) continue
|
|
174
|
-
for (const node of Object.values(graph.nodes)) nodeByNsId.set(
|
|
162
|
+
for (const node of Object.values(graph.nodes)) nodeByNsId.set(namespaceNodeId(key, node.id), node)
|
|
175
163
|
}
|
|
176
164
|
|
|
177
165
|
function forwardEdges(flowKey: string, node: FlowNodeData): string[] {
|
|
@@ -180,9 +168,9 @@ function computeMergedLayout(
|
|
|
180
168
|
if (isCrossFlowTarget(target)) {
|
|
181
169
|
const targetFlowKey = crossFlowKey(target)
|
|
182
170
|
const targetGraph = openKeys.has(targetFlowKey) ? workingGraphs[targetFlowKey] : undefined
|
|
183
|
-
if (targetGraph) result.push(
|
|
171
|
+
if (targetGraph) result.push(namespaceNodeId(targetFlowKey, targetGraph.startNodeId))
|
|
184
172
|
} else if (workingGraphs[flowKey]?.nodes[target]) {
|
|
185
|
-
result.push(
|
|
173
|
+
result.push(namespaceNodeId(flowKey, target))
|
|
186
174
|
}
|
|
187
175
|
}
|
|
188
176
|
return result
|
|
@@ -190,7 +178,7 @@ function computeMergedLayout(
|
|
|
190
178
|
|
|
191
179
|
const rank = new Map<string, number>()
|
|
192
180
|
const primaryGraph = workingGraphs[primaryFlowKey]
|
|
193
|
-
const rootId = primaryGraph ?
|
|
181
|
+
const rootId = primaryGraph ? namespaceNodeId(primaryFlowKey, primaryGraph.startNodeId) : undefined
|
|
194
182
|
if (rootId && nodeByNsId.has(rootId)) {
|
|
195
183
|
rank.set(rootId, 0)
|
|
196
184
|
const queue = [rootId]
|
|
@@ -198,7 +186,7 @@ function computeMergedLayout(
|
|
|
198
186
|
const id = queue.shift()!
|
|
199
187
|
const node = nodeByNsId.get(id)
|
|
200
188
|
if (!node) continue
|
|
201
|
-
for (const nextId of forwardEdges(
|
|
189
|
+
for (const nextId of forwardEdges(parseNamespacedId(id).flowKey, node)) {
|
|
202
190
|
if (!rank.has(nextId)) {
|
|
203
191
|
rank.set(nextId, rank.get(id)! + 1)
|
|
204
192
|
queue.push(nextId)
|
|
@@ -270,6 +258,12 @@ function buildAllEdges(
|
|
|
270
258
|
for (const [id, node] of Object.entries(graph.nodes)) {
|
|
271
259
|
const isLive = (liveCounts[id] ?? 0) > 0
|
|
272
260
|
for (const { target, optionId, isDefault } of targetsOf(node)) {
|
|
261
|
+
// Destino vazio é estado NORMAL, não anomalia: apagar um nó zera quem apontava para ele, e
|
|
262
|
+
// `targetsOf` devolve o `default` mesmo em branco. Sem esta linha a aresta ia para um nó que
|
|
263
|
+
// não existe, o React Flow a descartava em silêncio, e a opção parecia ligada sem estar — a
|
|
264
|
+
// conversa do cliente para ali e quem editou não vê nada de errado.
|
|
265
|
+
if (!target) continue
|
|
266
|
+
|
|
273
267
|
const crossFlow = isCrossFlowTarget(target)
|
|
274
268
|
let targetFlowKey = flowKey
|
|
275
269
|
let rawTargetId = target
|
|
@@ -283,8 +277,8 @@ function buildAllEdges(
|
|
|
283
277
|
rawTargetId = portalNodeId(id, target)
|
|
284
278
|
}
|
|
285
279
|
}
|
|
286
|
-
const source =
|
|
287
|
-
const edgeTarget =
|
|
280
|
+
const source = namespaceNodeId(flowKey, id)
|
|
281
|
+
const edgeTarget = namespaceNodeId(targetFlowKey, rawTargetId)
|
|
288
282
|
|
|
289
283
|
if (optionId === undefined && !isDefault) {
|
|
290
284
|
const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : isLive ? EDGE_COLOR_LIVE : EDGE_COLOR_LINEAR
|
|
@@ -358,35 +352,6 @@ function newNodeFromSpec(spec: NewNodeSpec, existingIds: Set<string>): FlowNodeD
|
|
|
358
352
|
return { id, type: 'action', actionKind: spec.actionKind }
|
|
359
353
|
}
|
|
360
354
|
|
|
361
|
-
// Remove referências ao nó excluído: destinos apontando para ele viram '' (destino vazio),
|
|
362
|
-
// para a validação sinalizar claramente em vez de manter uma string órfã silenciosa.
|
|
363
|
-
function removeNodeAndCleanRefs(
|
|
364
|
-
nodes: Record<string, FlowNodeData>,
|
|
365
|
-
nodeId: string,
|
|
366
|
-
): Record<string, FlowNodeData> {
|
|
367
|
-
const { [nodeId]: _removed, ...rest } = nodes
|
|
368
|
-
return Object.fromEntries(
|
|
369
|
-
Object.entries(rest).map(([id, node]) => {
|
|
370
|
-
if (!node.next) return [id, node]
|
|
371
|
-
if (typeof node.next === 'string') {
|
|
372
|
-
return [id, node.next === nodeId ? { ...node, next: undefined } : node]
|
|
373
|
-
}
|
|
374
|
-
return [
|
|
375
|
-
id,
|
|
376
|
-
{
|
|
377
|
-
...node,
|
|
378
|
-
next: {
|
|
379
|
-
byAnswer: Object.fromEntries(
|
|
380
|
-
Object.entries(node.next.byAnswer).map(([key, value]) => [key, value === nodeId ? '' : value]),
|
|
381
|
-
),
|
|
382
|
-
default: node.next.default === nodeId ? '' : node.next.default,
|
|
383
|
-
},
|
|
384
|
-
},
|
|
385
|
-
]
|
|
386
|
-
}),
|
|
387
|
-
)
|
|
388
|
-
}
|
|
389
|
-
|
|
390
355
|
function extractErrorMessage(error: unknown): string | undefined {
|
|
391
356
|
if (error instanceof Error) return error.message
|
|
392
357
|
return undefined
|
|
@@ -638,7 +603,7 @@ export function FlowsWorkspace({
|
|
|
638
603
|
|
|
639
604
|
function resolvePosition(nodeId: string): { x: number; y: number } {
|
|
640
605
|
if (mergedPositions) {
|
|
641
|
-
const nsId =
|
|
606
|
+
const nsId = namespaceNodeId(flowKey, nodeId)
|
|
642
607
|
return renderedPositionsRef.current.get(nsId) ?? mergedPositions.get(nsId) ?? { x: 0, y: 0 }
|
|
643
608
|
}
|
|
644
609
|
const local = graph!.nodes[nodeId]?.position ?? fallbackPositions[nodeId] ?? { x: 0, y: 0 }
|
|
@@ -648,7 +613,7 @@ export function FlowsWorkspace({
|
|
|
648
613
|
for (const node of Object.values(graph.nodes)) {
|
|
649
614
|
const position = resolvePosition(node.id)
|
|
650
615
|
allNodes.push({
|
|
651
|
-
id:
|
|
616
|
+
id: namespaceNodeId(flowKey, node.id),
|
|
652
617
|
type: 'flowNode',
|
|
653
618
|
position,
|
|
654
619
|
draggable: true,
|
|
@@ -673,7 +638,7 @@ export function FlowsWorkspace({
|
|
|
673
638
|
const targetFlowKey = crossFlowKey(target)
|
|
674
639
|
if (openFlows.some((flow) => flow.key === targetFlowKey)) return
|
|
675
640
|
allNodes.push({
|
|
676
|
-
id:
|
|
641
|
+
id: namespaceNodeId(flowKey, portalNodeId(node.id, target)),
|
|
677
642
|
type: 'flowPortal',
|
|
678
643
|
draggable: false,
|
|
679
644
|
selectable: false,
|
|
@@ -697,7 +662,7 @@ export function FlowsWorkspace({
|
|
|
697
662
|
const minY = Math.min(...positions.map((point) => point.y))
|
|
698
663
|
const maxY = Math.max(...positions.map((point) => point.y)) + estimateNodeHeight(graph.nodes[chain.actionNodeId]!)
|
|
699
664
|
allNodes.push({
|
|
700
|
-
id:
|
|
665
|
+
id: namespaceNodeId(flowKey, `__chain__${chain.actionNodeId}`),
|
|
701
666
|
type: 'flowGroupFrame',
|
|
702
667
|
draggable: false,
|
|
703
668
|
selectable: false,
|
|
@@ -718,7 +683,7 @@ export function FlowsWorkspace({
|
|
|
718
683
|
if (!isPrimary) {
|
|
719
684
|
const startPosition = resolvePosition(graph.startNodeId)
|
|
720
685
|
allNodes.push({
|
|
721
|
-
id:
|
|
686
|
+
id: namespaceNodeId(flowKey, '__group_header__'),
|
|
722
687
|
type: 'flowGroupHeader',
|
|
723
688
|
draggable: false,
|
|
724
689
|
selectable: false,
|
|
@@ -778,10 +743,21 @@ export function FlowsWorkspace({
|
|
|
778
743
|
const onNodeDragStop = useCallback(
|
|
779
744
|
(_event: unknown, node: Node) => {
|
|
780
745
|
renderedPositionsRef.current.set(node.id, node.position)
|
|
781
|
-
const { flowKey, nodeId } =
|
|
746
|
+
const { flowKey, nodeId } = parseNamespacedId(node.id)
|
|
782
747
|
const openFlow = openFlows.find((flow) => flow.key === flowKey)
|
|
783
748
|
if (!openFlow) return
|
|
784
|
-
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* O deslocamento só entra na conta quando ele foi usado para DESENHAR.
|
|
752
|
+
*
|
|
753
|
+
* Com mais de um fluxo aberto o layout mesclado posiciona tudo em coordenadas absolutas e
|
|
754
|
+
* ignora `offset` — subtrair aqui gravava no grafo uma posição que nunca foi a do card. O
|
|
755
|
+
* sintoma aparecia só depois: recarregar a tela e achar o nó deslocado sozinho.
|
|
756
|
+
*/
|
|
757
|
+
const drawnWithOffset = openFlows.length === 1
|
|
758
|
+
const localPosition = drawnWithOffset
|
|
759
|
+
? { x: node.position.x - openFlow.offset.x, y: node.position.y - openFlow.offset.y }
|
|
760
|
+
: node.position
|
|
785
761
|
updateFlow(flowKey, (graph) =>
|
|
786
762
|
graph.nodes[nodeId]
|
|
787
763
|
? { ...graph, nodes: { ...graph.nodes, [nodeId]: { ...graph.nodes[nodeId]!, position: localPosition } } }
|
|
@@ -793,36 +769,18 @@ export function FlowsWorkspace({
|
|
|
793
769
|
|
|
794
770
|
const onConnect = useCallback(
|
|
795
771
|
(connection: Connection) => {
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
// pro início). Conectar num nó do meio de outro fluxo é ignorado silenciosamente.
|
|
804
|
-
let targetValue: string
|
|
805
|
-
if (targetRef.flowKey === sourceRef.flowKey) {
|
|
806
|
-
targetValue = targetRef.nodeId
|
|
807
|
-
} else {
|
|
808
|
-
const targetGraph = workingGraphs[targetRef.flowKey]
|
|
809
|
-
if (!targetGraph || targetGraph.startNodeId !== targetRef.nodeId) return
|
|
810
|
-
targetValue = `${CROSS_FLOW_PREFIX}${targetRef.flowKey}`
|
|
811
|
-
}
|
|
772
|
+
// Traduzir o arraste e aplicar no nó são as duas decisões que mandam a conversa do cliente
|
|
773
|
+
// para o lugar certo ou errado, sem erro no meio — por isso vivem em `flowEditorOps`, testadas.
|
|
774
|
+
const resolved = resolveConnection({
|
|
775
|
+
connection: { source: connection.source, target: connection.target, sourceHandle: connection.sourceHandle },
|
|
776
|
+
graphs: workingGraphs,
|
|
777
|
+
})
|
|
778
|
+
if (!resolved) return
|
|
812
779
|
|
|
813
|
-
updateFlow(
|
|
814
|
-
const node = graph.nodes[
|
|
780
|
+
updateFlow(resolved.flowKey, (graph) => {
|
|
781
|
+
const node = graph.nodes[resolved.nodeId]
|
|
815
782
|
if (!node) return graph
|
|
816
|
-
|
|
817
|
-
const currentByAnswer = typeof node.next === 'object' && node.next ? node.next.byAnswer : {}
|
|
818
|
-
const currentDefault = typeof node.next === 'object' && node.next ? node.next.default : ''
|
|
819
|
-
const updatedNode: FlowNodeData =
|
|
820
|
-
handle === 'next'
|
|
821
|
-
? { ...node, next: targetValue }
|
|
822
|
-
: handle === '__default'
|
|
823
|
-
? { ...node, next: { byAnswer: currentByAnswer, default: targetValue } }
|
|
824
|
-
: { ...node, next: { byAnswer: { ...currentByAnswer, [handle]: targetValue }, default: currentDefault } }
|
|
825
|
-
return { ...graph, nodes: { ...graph.nodes, [sourceRef.nodeId]: updatedNode } }
|
|
783
|
+
return { ...graph, nodes: { ...graph.nodes, [resolved.nodeId]: applyConnection(node, resolved) } }
|
|
826
784
|
})
|
|
827
785
|
},
|
|
828
786
|
[workingGraphs, updateFlow],
|
|
@@ -835,7 +793,7 @@ export function FlowsWorkspace({
|
|
|
835
793
|
newNode.position = { x: 0, y: maxY + 170 }
|
|
836
794
|
updateFlow(primaryFlowKey, (graph) => ({ ...graph, nodes: { ...graph.nodes, [newNode.id]: newNode } }))
|
|
837
795
|
setEditingRef({ flowKey: primaryFlowKey, nodeId: newNode.id })
|
|
838
|
-
setPendingFocusNodeId(
|
|
796
|
+
setPendingFocusNodeId(namespaceNodeId(primaryFlowKey, newNode.id))
|
|
839
797
|
}
|
|
840
798
|
|
|
841
799
|
function handleNodePanelChange(updated: FlowNodeData) {
|
|
@@ -863,7 +821,7 @@ export function FlowsWorkspace({
|
|
|
863
821
|
...graph,
|
|
864
822
|
nodes: Object.fromEntries(
|
|
865
823
|
Object.entries(graph.nodes).map(([id, node]) => {
|
|
866
|
-
const position = positions.get(
|
|
824
|
+
const position = positions.get(namespaceNodeId(key, id))
|
|
867
825
|
return [
|
|
868
826
|
id,
|
|
869
827
|
position ? { ...node, position: { x: position.x - offset.x, y: position.y - offset.y } } : node,
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Estas funções decidem o que acontece com o fluxo que alguém desenhou, e o modo de falhar delas é
|
|
5
|
+
* silencioso: aresta apontando para nó apagado não dá erro no editor — dá conversa travada no meio
|
|
6
|
+
* para o cliente. Salto para nó do meio de outro fluxo não dá erro — o motor simplesmente ignora, e
|
|
7
|
+
* o atendimento para sem ninguém entender.
|
|
8
|
+
*
|
|
9
|
+
* São puras de propósito: rodam sem navegador, sem React e sem estado, o que é exatamente o que
|
|
10
|
+
* faltava enquanto elas viviam dentro de uma página de 973 linhas.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from 'bun:test'
|
|
14
|
+
import type { FlowGraphData, FlowNodeData } from '@adatechnology/meta-whatsapp-contracts'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
applyConnection,
|
|
18
|
+
isGraphDirty,
|
|
19
|
+
mergedFlowKeysFrom,
|
|
20
|
+
namespaceNodeId,
|
|
21
|
+
parseNamespacedId,
|
|
22
|
+
removeNodeAndCleanRefs,
|
|
23
|
+
resolveConnection,
|
|
24
|
+
} from './flowEditorOps'
|
|
25
|
+
|
|
26
|
+
/** Nó mínimo VÁLIDO pelo contrato — sem `as`, para o teste falhar se 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
|
+
/**
|
|
32
|
+
* Grafo mínimo VÁLIDO. `key` e `version` são obrigatórios no contrato, e sem `as` o teste passa a
|
|
33
|
+
* quebrar quando o contrato ganhar campo novo — que é exatamente o drift que o comentário do
|
|
34
|
+
* `flowGraph.ts` registra ter acontecido antes com o `version`.
|
|
35
|
+
*/
|
|
36
|
+
function graph(params: { key: string; start: string; nodes: FlowNodeData[] }): FlowGraphData {
|
|
37
|
+
return {
|
|
38
|
+
key: params.key,
|
|
39
|
+
label: params.key,
|
|
40
|
+
version: 1,
|
|
41
|
+
startNodeId: params.start,
|
|
42
|
+
nodes: Object.fromEntries(params.nodes.map((each) => [each.id, each])),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('id no canvas mesclado', () => {
|
|
47
|
+
it('vai e volta', () => {
|
|
48
|
+
expect(parseNamespacedId(namespaceNodeId('menu', 'inicio'))).toEqual({ flowKey: 'menu', nodeId: 'inicio' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('nó com separador no próprio id não parte errado', () => {
|
|
52
|
+
// O separador aparece no PRIMEIRO índice: um id que contenha `::` continua íntegro.
|
|
53
|
+
expect(parseNamespacedId('menu::passo::dois')).toEqual({ flowKey: 'menu', nodeId: 'passo::dois' })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('id sem prefixo devolve fluxo vazio, em vez de adivinhar', () => {
|
|
57
|
+
expect(parseNamespacedId('inicio')).toEqual({ flowKey: '', nodeId: 'inicio' })
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('apagar nó limpa quem apontava para ele', () => {
|
|
62
|
+
it('saída única que ia para o apagado fica vazia, não pendurada', () => {
|
|
63
|
+
const nodes = { a: node('a', 'b'), b: node('b') }
|
|
64
|
+
|
|
65
|
+
const result = removeNodeAndCleanRefs(nodes, 'b')
|
|
66
|
+
|
|
67
|
+
expect(Object.keys(result)).toEqual(['a'])
|
|
68
|
+
// Vazio e não `'b'`: aresta para nó inexistente trava a conversa no motor do bot.
|
|
69
|
+
expect(result.a?.next).toBe('')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('ramificação perde o destino mas MANTÉM a opção', () => {
|
|
73
|
+
const nodes = {
|
|
74
|
+
pergunta: node('pergunta', { byAnswer: { sim: 'aprovado', nao: 'recusado' }, default: 'aprovado' }),
|
|
75
|
+
aprovado: node('aprovado'),
|
|
76
|
+
recusado: node('recusado'),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const result = removeNodeAndCleanRefs(nodes, 'aprovado')
|
|
80
|
+
const next = result.pergunta?.next as { byAnswer: Record<string, string>; default: string }
|
|
81
|
+
|
|
82
|
+
// A chave `sim` continua existindo com destino vazio: removê-la esconderia da tela que a opção
|
|
83
|
+
// existe e não leva a lugar nenhum, e alguém publicaria assim.
|
|
84
|
+
expect(next.byAnswer).toEqual({ sim: '', nao: 'recusado' })
|
|
85
|
+
expect(next.default).toBe('')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('não mexe em quem não apontava para o apagado', () => {
|
|
89
|
+
const nodes = { a: node('a', 'c'), b: node('b'), c: node('c') }
|
|
90
|
+
|
|
91
|
+
expect(removeNodeAndCleanRefs(nodes, 'b').a?.next).toBe('c')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('nó sem next segue sem next, e não ganha um vazio', () => {
|
|
95
|
+
expect(removeNodeAndCleanRefs({ a: node('a'), b: node('b') }, 'b').a?.next).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('fecho transitivo dos fluxos abertos juntos', () => {
|
|
100
|
+
const graphs = {
|
|
101
|
+
menu: graph({ key: 'menu', start: 'inicio', nodes: [node('inicio', 'flow:simulacao')] }),
|
|
102
|
+
simulacao: graph({ key: 'simulacao', start: 's1', nodes: [node('s1', 'flow:documentos')] }),
|
|
103
|
+
documentos: graph({ key: 'documentos', start: 'd1', nodes: [node('d1')] }),
|
|
104
|
+
orfao: graph({ key: 'orfao', start: 'o1', nodes: [node('o1')] }),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it('alcança em profundidade e ignora quem ninguém referencia', () => {
|
|
108
|
+
expect([...mergedFlowKeysFrom('menu', graphs)].sort()).toEqual(['documentos', 'menu', 'simulacao'])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('ciclo não estoura — menu que volta ao menu é o caso mais comum que existe', () => {
|
|
112
|
+
const cyclic = {
|
|
113
|
+
menu: graph({ key: 'menu', start: 'i', nodes: [node('i', 'flow:sub')] }),
|
|
114
|
+
sub: graph({ key: 'sub', start: 's', nodes: [node('s', 'flow:menu')] }),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
expect([...mergedFlowKeysFrom('menu', cyclic)].sort()).toEqual(['menu', 'sub'])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('referência para fluxo que não existe é ignorada em vez de abrir aba vazia', () => {
|
|
121
|
+
const dangling = { menu: graph({ key: 'menu', start: 'i', nodes: [node('i', 'flow:apagado')] }) }
|
|
122
|
+
|
|
123
|
+
expect(mergedFlowKeysFrom('menu', dangling)).toEqual(['menu'])
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('conectar aresta', () => {
|
|
128
|
+
const graphs = {
|
|
129
|
+
menu: graph({ key: 'menu', start: 'inicio', nodes: [node('inicio'), node('meio')] }),
|
|
130
|
+
simulacao: graph({ key: 'simulacao', start: 's1', nodes: [node('s1'), node('s2')] }),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it('dentro do mesmo fluxo grava o id local', () => {
|
|
134
|
+
const resolved = resolveConnection({
|
|
135
|
+
connection: { source: 'menu::inicio', target: 'menu::meio' },
|
|
136
|
+
graphs,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
expect(resolved).toMatchObject({ flowKey: 'menu', nodeId: 'inicio', targetValue: 'meio' })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('para o nó INICIAL de outro fluxo grava o salto `flow:`', () => {
|
|
143
|
+
const resolved = resolveConnection({
|
|
144
|
+
connection: { source: 'menu::inicio', target: 'simulacao::s1' },
|
|
145
|
+
graphs,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
expect(resolved?.targetValue).toBe('flow:simulacao')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('para o MEIO de outro fluxo é RECUSADO — o motor não sabe pular para lá', () => {
|
|
152
|
+
// Recusa silenciosa é melhor que gravar um salto que o bot ignora em produção: o sintoma seria
|
|
153
|
+
// conversa parada, longe de quem editou.
|
|
154
|
+
const resolved = resolveConnection({
|
|
155
|
+
connection: { source: 'menu::inicio', target: 'simulacao::s2' },
|
|
156
|
+
graphs,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
expect(resolved).toBeUndefined()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('nó em si mesmo é recusado', () => {
|
|
163
|
+
expect(
|
|
164
|
+
resolveConnection({ connection: { source: 'menu::inicio', target: 'menu::inicio' }, graphs }),
|
|
165
|
+
).toBeUndefined()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('fluxo de destino inexistente é recusado', () => {
|
|
169
|
+
expect(resolveConnection({ connection: { source: 'menu::inicio', target: 'sumiu::x' }, graphs })).toBeUndefined()
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('aplicar a conexão no nó', () => {
|
|
174
|
+
it('handle `next` vira saída única', () => {
|
|
175
|
+
const result = applyConnection(node('a'), { flowKey: 'menu', nodeId: 'a', handle: 'next', targetValue: 'b' })
|
|
176
|
+
|
|
177
|
+
expect(result.next).toBe('b')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('handle de resposta acrescenta no byAnswer e PRESERVA o resto', () => {
|
|
181
|
+
const existing = node('a', { byAnswer: { sim: 'x' }, default: 'y' })
|
|
182
|
+
|
|
183
|
+
const result = applyConnection(existing, { flowKey: 'menu', nodeId: 'a', handle: 'nao', targetValue: 'z' })
|
|
184
|
+
const next = result.next as { byAnswer: Record<string, string>; default: string }
|
|
185
|
+
|
|
186
|
+
expect(next.byAnswer).toEqual({ sim: 'x', nao: 'z' })
|
|
187
|
+
// O default não pode ser perdido ao ligar um ramo: seria mudança silenciosa de comportamento.
|
|
188
|
+
expect(next.default).toBe('y')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('handle `__default` troca só o default', () => {
|
|
192
|
+
const existing = node('a', { byAnswer: { sim: 'x' }, default: 'y' })
|
|
193
|
+
|
|
194
|
+
const next = applyConnection(existing, { flowKey: 'menu', nodeId: 'a', handle: '__default', targetValue: 'z' })
|
|
195
|
+
.next as { byAnswer: Record<string, string>; default: string }
|
|
196
|
+
|
|
197
|
+
expect(next).toEqual({ byAnswer: { sim: 'x' }, default: 'z' })
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('ramificar um nó que tinha saída única não carrega a antiga como default', () => {
|
|
201
|
+
// A saída única vira ramificação: o default nasce vazio, e a validação do grafo cobra. Herdar a
|
|
202
|
+
// antiga faria o ramo não configurado seguir para onde o nó ia antes, sem ninguém pedir.
|
|
203
|
+
const next = applyConnection(node('a', 'antigo'), {
|
|
204
|
+
flowKey: 'menu',
|
|
205
|
+
nodeId: 'a',
|
|
206
|
+
handle: 'sim',
|
|
207
|
+
targetValue: 'novo',
|
|
208
|
+
}).next as { byAnswer: Record<string, string>; default: string }
|
|
209
|
+
|
|
210
|
+
expect(next).toEqual({ byAnswer: { sim: 'novo' }, default: '' })
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('rascunho sujo', () => {
|
|
215
|
+
const published = graph({ key: 'menu', start: 'i', nodes: [node('i', 'b'), node('b')] })
|
|
216
|
+
|
|
217
|
+
it('igual ao publicado não está sujo', () => {
|
|
218
|
+
expect(isGraphDirty(structuredClone(published), published)).toBe(false)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('texto mudado está sujo', () => {
|
|
222
|
+
const working = structuredClone(published)
|
|
223
|
+
working.nodes.i!.question = 'outro'
|
|
224
|
+
|
|
225
|
+
expect(isGraphDirty(working, published)).toBe(true)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('posição mudada TAMBÉM está sujo — arrastar card é edição que se publica', () => {
|
|
229
|
+
const working = structuredClone(published)
|
|
230
|
+
working.nodes.i!.position = { x: 10, y: 20 }
|
|
231
|
+
|
|
232
|
+
expect(isGraphDirty(working, published)).toBe(true)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('sem rascunho ou sem publicado não está sujo, em vez de sujo por ausência', () => {
|
|
236
|
+
// Durante o carregamento os dois lados chegam separados; sujo aqui faria a tela pedir
|
|
237
|
+
// confirmação de descarte antes de o usuário ter tocado em nada.
|
|
238
|
+
expect(isGraphDirty(undefined, published)).toBe(false)
|
|
239
|
+
expect(isGraphDirty(published, undefined)).toBe(false)
|
|
240
|
+
})
|
|
241
|
+
})
|