@adatechnology/conversations-ui 0.2.0 → 0.3.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.
@@ -5,6 +5,7 @@ import {
5
5
  attachmentKey,
6
6
  canAddAttachments,
7
7
  excludeRetryingItems,
8
+ hasSentEveryStoredUpload,
8
9
  orderOutgoingItems,
9
10
  queuedAttachmentsFromQuickReply,
10
11
  resolveIdempotencyKey,
@@ -12,6 +13,8 @@ import {
12
13
  resolveRetryOutcome,
13
14
  retryStoredAttachments,
14
15
  sendQueuedMessage,
16
+ shouldResetRetryKey,
17
+ type IdempotencyKeyState,
15
18
  } from './quickReplyAttachments'
16
19
  import type { QueuedAttachment } from './quickReply.types'
17
20
 
@@ -261,6 +264,74 @@ describe('resolveIdempotencyKey', () => {
261
264
  })
262
265
  })
263
266
 
267
+ describe('hasSentEveryStoredUpload', () => {
268
+ it('true quando não havia guardado nenhum nesta tentativa', () => {
269
+ expect(hasSentEveryStoredUpload([], [])).toBe(true)
270
+ expect(hasSentEveryStoredUpload([], ['a'])).toBe(true)
271
+ })
272
+
273
+ it('true quando todo uploadId da tentativa saiu', () => {
274
+ expect(hasSentEveryStoredUpload(['a', 'b'], ['a', 'b'])).toBe(true)
275
+ expect(hasSentEveryStoredUpload(['a', 'b'], ['b', 'a'])).toBe(true)
276
+ })
277
+
278
+ it('true mesmo com chave extra em sentAttachmentKeys (ex: anexo local também enviado)', () => {
279
+ expect(hasSentEveryStoredUpload(['a'], ['a', 'local-1'])).toBe(true)
280
+ })
281
+
282
+ it('falso quando um uploadId falhou ou foi pulado', () => {
283
+ expect(hasSentEveryStoredUpload(['a', 'b'], ['a'])).toBe(false)
284
+ })
285
+
286
+ it('falso quando nada saiu', () => {
287
+ expect(hasSentEveryStoredUpload(['a', 'b'], [])).toBe(false)
288
+ })
289
+ })
290
+
291
+ describe('shouldResetRetryKey', () => {
292
+ const stateA: IdempotencyKeyState = { key: 'key-a', uploadIds: ['upload-1'] }
293
+ const stateB: IdempotencyKeyState = { key: 'key-b', uploadIds: ['upload-1'] }
294
+
295
+ it('descarta quando o item saiu e o ref ainda é o mesmo da tentativa (duas tentativas seguidas do mesmo uploadId recebem chaves diferentes)', () => {
296
+ expect(
297
+ shouldResetRetryKey({
298
+ current: stateA,
299
+ attempted: stateA,
300
+ sentAttachmentKeys: ['upload-1'],
301
+ uploadId: 'upload-1',
302
+ }),
303
+ ).toBe(true)
304
+ })
305
+
306
+ it('mantém a chave quando o retry falhou (uploadId não está em sentAttachmentKeys)', () => {
307
+ expect(
308
+ shouldResetRetryKey({ current: stateA, attempted: stateA, sentAttachmentKeys: [], uploadId: 'upload-1' }),
309
+ ).toBe(false)
310
+ })
311
+
312
+ it('mantém a chave quando um retry concorrente já trocou o ref por identidade', () => {
313
+ expect(
314
+ shouldResetRetryKey({
315
+ current: stateB,
316
+ attempted: stateA,
317
+ sentAttachmentKeys: ['upload-1'],
318
+ uploadId: 'upload-1',
319
+ }),
320
+ ).toBe(false)
321
+ })
322
+
323
+ it('mantém a chave quando o ref já foi limpo (undefined) por outra resolução', () => {
324
+ expect(
325
+ shouldResetRetryKey({
326
+ current: undefined,
327
+ attempted: stateA,
328
+ sentAttachmentKeys: ['upload-1'],
329
+ uploadId: 'upload-1',
330
+ }),
331
+ ).toBe(false)
332
+ })
333
+ })
334
+
264
335
  describe('retryStoredAttachments', () => {
265
336
  it('reenvia só o uploadId pedido, sem tocar no resto da fila', async () => {
266
337
  const calls: { uploadIds: readonly string[]; idempotencyKey: string }[] = []
@@ -112,6 +112,45 @@ function sameUploadIds(a: readonly string[], b: readonly string[]): boolean {
112
112
  return a.every((id, index) => id === b[index])
113
113
  }
114
114
 
115
+ /**
116
+ * Decide se a chave de idempotência pode ser descartada depois de um envio (M3-bug): todo
117
+ * `uploadId` da tentativa saiu com sucesso. `setState` com updater NÃO roda de forma síncrona
118
+ * dentro do handler — uma variável `let` atualizada por ele e lida logo em seguida sempre lê o
119
+ * valor antigo. Esta decisão usa só os parâmetros da própria tentativa (nunca o estado da fila),
120
+ * então não depende de nenhum `setState` ter aplicado.
121
+ */
122
+ export function hasSentEveryStoredUpload(
123
+ storedUploadIds: readonly string[],
124
+ sentAttachmentKeys: readonly string[],
125
+ ): boolean {
126
+ if (storedUploadIds.length === 0) return true
127
+ const sentKeys = new Set(sentAttachmentKeys)
128
+ return storedUploadIds.every((uploadId) => sentKeys.has(uploadId))
129
+ }
130
+
131
+ export type ShouldResetRetryKeyParams = {
132
+ /** Valor corrente do ref no momento da checagem — lido depois do `await`. */
133
+ readonly current: IdempotencyKeyState | undefined
134
+ /** Estado usado NESTA tentativa, capturado antes do `await`. */
135
+ readonly attempted: IdempotencyKeyState
136
+ readonly sentAttachmentKeys: readonly string[]
137
+ readonly uploadId: string
138
+ }
139
+
140
+ /**
141
+ * Decide se a chave de idempotência do retry avulso pode ser descartada (M3-retry-bug): o item
142
+ * saiu com sucesso E ninguém trocou o ref por identidade desde então. A checagem de identidade
143
+ * (`current === attempted`) importa porque um retry avulso reusa `uploadId` de itens guardados
144
+ * (mensagens prontas) — um segundo retry do MESMO `uploadId` pode começar e sobrescrever o ref
145
+ * antes desta tentativa terminar; sem a checagem, o `undefined` desta tentativa apagaria a chave
146
+ * da tentativa mais nova, e o próximo envio dela reusaria uma chave já consumida pelo servidor.
147
+ */
148
+ export function shouldResetRetryKey(params: ShouldResetRetryKeyParams): boolean {
149
+ const { current, attempted, sentAttachmentKeys, uploadId } = params
150
+ if (!sentAttachmentKeys.includes(uploadId)) return false
151
+ return current === attempted
152
+ }
153
+
115
154
  export type SendQueuedMessageParams = {
116
155
  readonly text: string
117
156
  readonly queue: readonly QueuedAttachment[]
@@ -0,0 +1,336 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+
5
+ import { FORMATTING_ACTION, WHATSAPP_MARKER_BY_ACTION } from '../lib/composer-formatting'
6
+ import { FlowWhatsAppPreview } from '../flows/FlowWhatsAppPreview'
7
+ import { DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS } from './labels'
8
+ import { QuickRepliesWorkspace } from './QuickRepliesWorkspace'
9
+ import { QuickReplyFormattingToolbar } from './QuickReplyFormattingToolbar'
10
+ import { QuickReplyWhatsAppPreview, resolvePreviewVariables } from './QuickReplyWhatsAppPreview'
11
+ import {
12
+ changedRange,
13
+ computeFormattingEdit,
14
+ toggleLinePrefix,
15
+ formattingActionForShortcut,
16
+ wrapSelection,
17
+ type FormattingShortcutEvent,
18
+ } from './quickReplyFormatting'
19
+
20
+ describe('wrapSelection — regras do WhatsApp', () => {
21
+ it('deixa o espaço das pontas fora do marcador', () => {
22
+ expect(wrapSelection({ text: 'Olá mundo ', start: 3, end: 10, marker: '*' })).toEqual({
23
+ text: 'Olá *mundo* ',
24
+ selectionStart: 5,
25
+ selectionEnd: 10,
26
+ })
27
+ })
28
+
29
+ it('desliga quando o marcador está dentro da seleção', () => {
30
+ expect(wrapSelection({ text: 'a *b* c', start: 2, end: 5, marker: '*' })).toEqual({
31
+ text: 'a b c',
32
+ selectionStart: 2,
33
+ selectionEnd: 3,
34
+ })
35
+ })
36
+
37
+ it('desliga quando o marcador está logo fora da seleção', () => {
38
+ expect(wrapSelection({ text: 'a ```b``` c', start: 5, end: 6, marker: '```' })).toEqual({
39
+ text: 'a b c',
40
+ selectionStart: 2,
41
+ selectionEnd: 3,
42
+ })
43
+ })
44
+
45
+ it('formata cada linha não vazia separadamente', () => {
46
+ expect(wrapSelection({ text: 'um\n\n dois ', start: 0, end: 10, marker: '_' })).toEqual({
47
+ text: '_um_\n\n _dois_ ',
48
+ selectionStart: 0,
49
+ selectionEnd: 14,
50
+ })
51
+ })
52
+
53
+ it('desliga em todas as linhas quando todas já estão formatadas', () => {
54
+ expect(wrapSelection({ text: '~um~\n~dois~', start: 0, end: 11, marker: '~' }).text).toBe('um\ndois')
55
+ })
56
+
57
+ it('só espaço selecionado cai no par vazio', () => {
58
+ expect(wrapSelection({ text: 'a b', start: 1, end: 3, marker: '*' }).text).toBe('a* *b')
59
+ })
60
+ })
61
+
62
+ describe('computeFormattingEdit', () => {
63
+ it('aplica a notação da ação e devolve a seleção nova', () => {
64
+ expect(
65
+ computeFormattingEdit({ text: 'oi', selectionStart: 0, selectionEnd: 2, action: 'bold', maximumLength: 10 }),
66
+ ).toEqual({ text: '*oi*', selectionStart: 1, selectionEnd: 3 })
67
+ })
68
+
69
+ it('não aplica quando passaria do limite', () => {
70
+ expect(
71
+ computeFormattingEdit({ text: 'oi', selectionStart: 0, selectionEnd: 2, action: 'monospace', maximumLength: 7 }),
72
+ ).toBeUndefined()
73
+ })
74
+
75
+ it('aceita chegar exatamente ao limite', () => {
76
+ expect(
77
+ computeFormattingEdit({ text: 'oi', selectionStart: 0, selectionEnd: 2, action: 'italic', maximumLength: 4 })
78
+ ?.text,
79
+ ).toBe('_oi_')
80
+ })
81
+
82
+ it('desligar nunca esbarra no limite', () => {
83
+ expect(
84
+ computeFormattingEdit({ text: '*oi*', selectionStart: 0, selectionEnd: 4, action: 'bold', maximumLength: 4 })
85
+ ?.text,
86
+ ).toBe('oi')
87
+ })
88
+ })
89
+
90
+ describe('formattingActionForShortcut', () => {
91
+ const base: FormattingShortcutEvent = {
92
+ key: 'b',
93
+ ctrlKey: true,
94
+ metaKey: false,
95
+ shiftKey: false,
96
+ altKey: false,
97
+ isComposing: false,
98
+ }
99
+
100
+ it('Ctrl/Cmd+B é negrito, Ctrl/Cmd+I é itálico', () => {
101
+ expect(formattingActionForShortcut(base)).toBe('bold')
102
+ expect(formattingActionForShortcut({ ...base, ctrlKey: false, metaKey: true, key: 'I' })).toBe('italic')
103
+ })
104
+
105
+ it('ignora sem modificador, com shift, alt, composição ou outra tecla', () => {
106
+ expect(formattingActionForShortcut({ ...base, ctrlKey: false })).toBeUndefined()
107
+ expect(formattingActionForShortcut({ ...base, shiftKey: true })).toBeUndefined()
108
+ expect(formattingActionForShortcut({ ...base, altKey: true })).toBeUndefined()
109
+ expect(formattingActionForShortcut({ ...base, isComposing: true })).toBeUndefined()
110
+ expect(formattingActionForShortcut({ ...base, key: 'u' })).toBeUndefined()
111
+ })
112
+ })
113
+
114
+ describe('changedRange', () => {
115
+ it('isola a inserção', () => {
116
+ expect(changedRange('a b', 'a *b*')).toEqual({ start: 2, end: 3, insertedText: '*b*' })
117
+ })
118
+
119
+ it('isola a remoção', () => {
120
+ expect(changedRange('*b*', '*b')).toEqual({ start: 2, end: 3, insertedText: '' })
121
+ })
122
+ })
123
+
124
+ const LABELS = DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS
125
+
126
+ describe('wrapSelection', () => {
127
+ it('envolve a seleção e mantém o trecho selecionado', () => {
128
+ expect(wrapSelection({ text: 'Olá mundo', start: 4, end: 9, marker: '*' })).toEqual({
129
+ text: 'Olá *mundo*',
130
+ selectionStart: 5,
131
+ selectionEnd: 10,
132
+ })
133
+ })
134
+
135
+ it('sem seleção, abre o par com o cursor no meio', () => {
136
+ expect(wrapSelection({ text: 'Olá ', start: 4, end: 4, marker: '_' })).toEqual({
137
+ text: 'Olá __',
138
+ selectionStart: 5,
139
+ selectionEnd: 5,
140
+ })
141
+ })
142
+
143
+ it.each([
144
+ [FORMATTING_ACTION.BOLD, '*oi*'],
145
+ [FORMATTING_ACTION.ITALIC, '_oi_'],
146
+ [FORMATTING_ACTION.STRIKETHROUGH, '~oi~'],
147
+ [FORMATTING_ACTION.MONOSPACE, '```oi```'],
148
+ ])('%s usa a notação do WhatsApp', (action, expected) => {
149
+ const marker = WHATSAPP_MARKER_BY_ACTION[action]
150
+ expect(wrapSelection({ text: 'oi', start: 0, end: 2, marker }).text).toBe(expected)
151
+ })
152
+ })
153
+
154
+ describe('QuickReplyWhatsAppPreview', () => {
155
+ it('renderiza negrito, itálico, tachado e monoespaçado', () => {
156
+ const markup = renderToStaticMarkup(<QuickReplyWhatsAppPreview body={'*a* _b_ ~c~ ```d```'} labels={LABELS} />)
157
+ expect(markup).toContain('<strong>a</strong>')
158
+ expect(markup).toContain('<em>b</em>')
159
+ expect(markup).toMatch(/<(del|s)>c<\/(del|s)>/)
160
+ expect(markup).toMatch(/<code[^>]*>d<\/code>/)
161
+ expect(markup).toContain(LABELS.previewTitle)
162
+ })
163
+
164
+ it('sem texto, mostra o aviso de corpo vazio', () => {
165
+ expect(renderToStaticMarkup(<QuickReplyWhatsAppPreview body="" labels={LABELS} />)).toContain(
166
+ LABELS.previewEmptyBody,
167
+ )
168
+ })
169
+
170
+ it('lista os anexos pelo nome do arquivo', () => {
171
+ const markup = renderToStaticMarkup(
172
+ <QuickReplyWhatsAppPreview
173
+ body="Segue"
174
+ attachments={[{ uploadId: 'u1', filename: 'contrato.pdf', mimeType: 'application/pdf', sizeBytes: 10 }]}
175
+ labels={LABELS}
176
+ />,
177
+ )
178
+ expect(markup).toContain('contrato.pdf')
179
+ })
180
+ })
181
+
182
+ describe('resolvePreviewVariables', () => {
183
+ const variables = [
184
+ { id: 'name', label: 'Nome do cliente', marker: '{{nome}}', value: 'Maria' },
185
+ { id: 'city', label: 'Cidade', marker: '{{cidade}}', value: '' },
186
+ ]
187
+
188
+ it('usa o exemplo, cai no rótulo e deixa marcador desconhecido como está', () => {
189
+ expect(resolvePreviewVariables('Oi {{nome}} de {{cidade}} {{cpf}}', variables)).toBe('Oi Maria de Cidade {{cpf}}')
190
+ })
191
+
192
+ it('ignora variável sem marcador', () => {
193
+ expect(resolvePreviewVariables('Oi', [{ id: 'x', label: 'X', marker: '', value: 'Y' }])).toBe('Oi')
194
+ })
195
+ })
196
+
197
+ describe('QuickReplyFormattingToolbar', () => {
198
+ it('expõe os quatro botões com nome acessível', () => {
199
+ const markup = renderToStaticMarkup(<QuickReplyFormattingToolbar labels={LABELS} onFormat={() => {}} />)
200
+ for (const label of [LABELS.formatBold, LABELS.formatItalic, LABELS.formatStrikethrough, LABELS.formatMonospace]) {
201
+ expect(markup).toContain(`aria-label="${label}"`)
202
+ }
203
+ expect(markup).toContain('role="toolbar"')
204
+ })
205
+
206
+ it('o clique entrega a ação ao dono do campo', () => {
207
+ const received: string[] = []
208
+ const element = QuickReplyFormattingToolbar({ labels: LABELS, onFormat: (action) => received.push(action) })
209
+ const buttons = element.props.children as { props: { onClick: () => void } }[]
210
+ for (const button of buttons) button.props.onClick()
211
+ expect(received).toEqual([
212
+ 'bold',
213
+ 'italic',
214
+ 'strikethrough',
215
+ 'monospace',
216
+ 'inlineCode',
217
+ 'bulletedList',
218
+ 'numberedList',
219
+ 'quote',
220
+ ])
221
+ })
222
+ })
223
+
224
+ describe('QuickRepliesWorkspace com labels antigas', () => {
225
+ it('aceita labels parciais sem as chaves novas', () => {
226
+ expect(() => renderToStaticMarkup(<QuickRepliesWorkspace api={{}} labels={{ title: 'X' }} />)).not.toThrow()
227
+ })
228
+ })
229
+
230
+ describe('FlowWhatsAppPreview sobre o balão compartilhado', () => {
231
+ it('mantém corpo formatado, botões e aviso de modo', () => {
232
+ const markup = renderToStaticMarkup(
233
+ <FlowWhatsAppPreview
234
+ body="*Oi*"
235
+ options={[
236
+ ['1', 'Sim'],
237
+ ['2', 'Não'],
238
+ ]}
239
+ />,
240
+ )
241
+ expect(markup).toContain('bg-[#e5ddd5]')
242
+ expect(markup).toContain('<strong>Oi</strong>')
243
+ expect(markup).toContain('Sim')
244
+ expect(markup).toContain('Não')
245
+ })
246
+
247
+ it('sem corpo nem opções, mostra só o placeholder, fora do balão', () => {
248
+ expect(renderToStaticMarkup(<FlowWhatsAppPreview body="" />)).not.toContain('bg-[#e5ddd5]')
249
+ })
250
+ })
251
+
252
+ describe('toggleLinePrefix', () => {
253
+ it('põe o prefixo na linha do cursor e move o cursor junto', () => {
254
+ expect(toggleLinePrefix({ text: 'a\nbc', start: 3, end: 3, action: 'bulletedList' })).toEqual({
255
+ text: 'a\n- bc',
256
+ selectionStart: 5,
257
+ selectionEnd: 5,
258
+ })
259
+ })
260
+
261
+ it('numera cada linha selecionada em sequência, pulando a linha em branco', () => {
262
+ expect(toggleLinePrefix({ text: 'um\n\ndois\ntrês', start: 1, end: 10, action: 'numberedList' })).toEqual({
263
+ text: '1. um\n\n2. dois\n3. três',
264
+ selectionStart: 0,
265
+ selectionEnd: 22,
266
+ })
267
+ })
268
+
269
+ it('tira o prefixo de todas quando todas já têm', () => {
270
+ expect(toggleLinePrefix({ text: '> a\n> b', start: 0, end: 7, action: 'quote' }).text).toBe('a\nb')
271
+ })
272
+
273
+ it('completa as que faltam quando só algumas têm', () => {
274
+ expect(toggleLinePrefix({ text: '- a\nb', start: 0, end: 5, action: 'bulletedList' }).text).toBe('- a\n- b')
275
+ })
276
+
277
+ it('linha vazia com cursor ganha o prefixo', () => {
278
+ expect(toggleLinePrefix({ text: '', start: 0, end: 0, action: 'quote' })).toEqual({
279
+ text: '> ',
280
+ selectionStart: 2,
281
+ selectionEnd: 2,
282
+ })
283
+ })
284
+
285
+ it('seleção terminando na quebra não puxa a linha de baixo', () => {
286
+ expect(toggleLinePrefix({ text: 'a\nb', start: 0, end: 2, action: 'bulletedList' }).text).toBe('- a\nb')
287
+ })
288
+ })
289
+
290
+ describe('computeFormattingEdit com as ações de linha e código', () => {
291
+ it('código inline usa crase simples', () => {
292
+ expect(
293
+ computeFormattingEdit({ text: 'x', selectionStart: 0, selectionEnd: 1, action: 'inlineCode', maximumLength: 10 })
294
+ ?.text,
295
+ ).toBe('`x`')
296
+ })
297
+
298
+ it('prefixo de linha respeita o limite', () => {
299
+ expect(
300
+ computeFormattingEdit({
301
+ text: 'a\nb',
302
+ selectionStart: 0,
303
+ selectionEnd: 3,
304
+ action: 'bulletedList',
305
+ maximumLength: 6,
306
+ }),
307
+ ).toBeUndefined()
308
+ expect(
309
+ computeFormattingEdit({
310
+ text: 'a\nb',
311
+ selectionStart: 0,
312
+ selectionEnd: 3,
313
+ action: 'bulletedList',
314
+ maximumLength: 7,
315
+ })?.text,
316
+ ).toBe('- a\n- b')
317
+ })
318
+ })
319
+
320
+ describe('QuickRepliesWorkspace — ações da tabela no padrão do pacote', () => {
321
+ // As linhas só chegam depois do carregamento assíncrono; o render estático não as mostra, então o
322
+ // contrato é conferido no fonte, como em flows/workspaceContract.test.ts.
323
+ const source = readFileSync(new URL('./QuickRepliesWorkspace.tsx', import.meta.url), 'utf8')
324
+
325
+ it('editar e excluir são cv-header-icon com Pencil e Trash2, nome acessível com o título', () => {
326
+ expect(source).toContain('className="cv-header-icon"')
327
+ expect(source).toContain('<Pencil size={14}')
328
+ expect(source).toContain('<Trash2 size={14}')
329
+ expect(source).toContain('aria-label={`${text.edit}: ${quickReply.title}`}')
330
+ })
331
+
332
+ it('confirmação usa cv-header-action--danger e o criar usa --primary', () => {
333
+ expect(source).toContain('cv-header-action cv-header-action--danger')
334
+ expect(source).toContain('cv-header-action cv-header-action--primary')
335
+ })
336
+ })