@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.
@@ -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
- const visited = new Set<string>()
146
- const queue = [rootKey]
147
- while (queue.length > 0) {
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(ns(key, node.id), node)
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(ns(targetFlowKey, targetGraph.startNodeId))
171
+ if (targetGraph) result.push(namespaceNodeId(targetFlowKey, targetGraph.startNodeId))
184
172
  } else if (workingGraphs[flowKey]?.nodes[target]) {
185
- result.push(ns(flowKey, target))
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 ? ns(primaryFlowKey, primaryGraph.startNodeId) : undefined
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(parseNs(id).flowKey, node)) {
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 = ns(flowKey, id)
287
- const edgeTarget = ns(targetFlowKey, rawTargetId)
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 = ns(flowKey, nodeId)
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: ns(flowKey, node.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: ns(flowKey, portalNodeId(node.id, target)),
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: ns(flowKey, `__chain__${chain.actionNodeId}`),
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: ns(flowKey, '__group_header__'),
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 } = parseNs(node.id)
746
+ const { flowKey, nodeId } = parseNamespacedId(node.id)
782
747
  const openFlow = openFlows.find((flow) => flow.key === flowKey)
783
748
  if (!openFlow) return
784
- const localPosition = { x: node.position.x - openFlow.offset.x, y: node.position.y - openFlow.offset.y }
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
- const { source, sourceHandle, target } = connection
797
- if (!source || !target || source === target) return
798
- const sourceRef = parseNs(source)
799
- const targetRef = parseNs(target)
800
-
801
- // Conectar num nó de outro fluxo só faz sentido se for o nó inicial dele — vira um salto
802
- // "flow:<key>" (o motor do bot não sabe pular pra um nó específico de outro fluxo, só
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(sourceRef.flowKey, (graph) => {
814
- const node = graph.nodes[sourceRef.nodeId]
780
+ updateFlow(resolved.flowKey, (graph) => {
781
+ const node = graph.nodes[resolved.nodeId]
815
782
  if (!node) return graph
816
- const handle = sourceHandle ?? 'next'
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(ns(primaryFlowKey, newNode.id))
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(ns(key, id))
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
+ })