@adatechnology/conversations-ui 0.8.0 → 0.10.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/README.md +106 -0
- package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
- package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
- package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
- package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
- package/dist/chunk-ZSOD5AQT.js +19 -0
- package/dist/flows/index.js +4 -2
- package/dist/index.d.ts +10 -3
- package/dist/index.js +8 -6
- package/dist/participant/index.d.ts +54 -3
- package/dist/participant/index.js +1722 -418
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/dist/styles.css +281 -23
- package/package.json +3 -2
- package/src/AudioRecorderButton.tsx +4 -28
- package/src/Tooltip.tsx +5 -12
- package/src/TooltipDom.test.tsx +78 -0
- package/src/audioRecorderFormat.ts +27 -0
- package/src/domHarness.test-helper.ts +53 -0
- package/src/participant/ConversationThread.tsx +4 -0
- package/src/participant/ParticipantAttachmentItem.tsx +7 -10
- package/src/participant/ParticipantAudioAttachment.tsx +34 -0
- package/src/participant/ParticipantAudioPlayer.tsx +68 -0
- package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
- package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
- package/src/participant/ParticipantAudioTranscription.tsx +90 -0
- package/src/participant/ParticipantBackButton.tsx +1 -1
- package/src/participant/ParticipantBubbleBody.tsx +54 -0
- package/src/participant/ParticipantBubbleStatus.tsx +22 -28
- package/src/participant/ParticipantChannelBadges.tsx +10 -2
- package/src/participant/ParticipantComposer.tsx +51 -16
- package/src/participant/ParticipantComposerAudio.tsx +95 -0
- package/src/participant/ParticipantComposerControls.tsx +2 -0
- package/src/participant/ParticipantConversations.tsx +3 -0
- package/src/participant/ParticipantCopyableToken.tsx +1 -0
- package/src/participant/ParticipantFailedMenu.tsx +40 -0
- package/src/participant/ParticipantFailedMenuList.tsx +41 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
- package/src/participant/ParticipantMessageBubble.tsx +20 -52
- package/src/participant/ParticipantMessageText.test.tsx +1 -1
- package/src/participant/ParticipantMessageText.tsx +4 -2
- package/src/participant/ParticipantProtocolBadge.tsx +1 -0
- package/src/participant/ParticipantRecorderParts.tsx +118 -0
- package/src/participant/ParticipantScrollToLatest.tsx +1 -1
- package/src/participant/ParticipantThread.tsx +30 -12
- package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
- package/src/participant/ParticipantThreadScreen.tsx +1 -0
- package/src/participant/failedMenuKeyboard.ts +29 -0
- package/src/participant/index.ts +3 -0
- package/src/participant/participantAudio.types.ts +10 -0
- package/src/participant/participantAudioCapture.test.ts +15 -0
- package/src/participant/participantAudioCapture.ts +85 -0
- package/src/participant/participantAudioCoordinator.ts +13 -0
- package/src/participant/participantAudioFormat.test.ts +93 -0
- package/src/participant/participantAudioFormat.ts +72 -0
- package/src/participant/participantAudioGolden.test.tsx +168 -0
- package/src/participant/participantAudioLabels.test.ts +22 -0
- package/src/participant/participantAudioLabels.ts +76 -0
- package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
- package/src/participant/participantAudioPlayerCss.test.ts +63 -0
- package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
- package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
- package/src/participant/participantAudioPlayerState.ts +35 -0
- package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
- package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
- package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
- package/src/participant/participantAudioRecorderState.test.ts +144 -0
- package/src/participant/participantAudioRecorderState.ts +93 -0
- package/src/participant/participantAudioRecorderView.test.tsx +98 -0
- package/src/participant/participantAudioSupport.ts +36 -0
- package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
- package/src/participant/participantComposerAudioDom.test.tsx +185 -0
- package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
- package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
- package/src/participant/participantFailedMenuDom.test.tsx +154 -0
- package/src/participant/participantFailedMenuState.test.ts +61 -0
- package/src/participant/participantFailedMenuState.ts +57 -0
- package/src/participant/participantFailedMessage.test.tsx +83 -0
- package/src/participant/participantFileValidation.ts +1 -1
- package/src/participant/participantGolden.test.tsx +2 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
- package/src/participant/participantLabels.ts +10 -1
- package/src/participant/participantMediaFakes.test-helper.ts +82 -0
- package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
- package/src/participant/participantPublicTypes.test.ts +11 -0
- package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
- package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
- package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
- package/src/participant/participantThemeContrast.test.ts +17 -0
- package/src/participant/participantTooltips.test.tsx +74 -0
- package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -1
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -1
- package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -1
- package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -1
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -1
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -1
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -1
- package/src/participant/useFailedMenuEffects.ts +59 -0
- package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
- package/src/participant/useParticipantAudioPlayer.ts +52 -0
- package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
- package/src/participant/useParticipantAudioRecorder.ts +153 -0
- package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
- package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
- package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
- package/src/participant/useParticipantFailedMenu.ts +60 -0
- package/src/participant/useParticipantSendAudio.test.tsx +85 -0
- package/src/participant/useParticipantSendAudio.ts +28 -0
- package/src/participant/useParticipantThreadController.ts +20 -7
- package/src/participant/useStableAttachmentUrl.ts +71 -0
- package/src/participant/useSuppressTouchTooltips.ts +38 -0
- package/src/participant/useTranscriptionOverflow.ts +25 -0
- package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
- package/src/styles.css +48 -5
- package/src/tooltipLayerRegistry.test.ts +56 -0
- package/src/tooltipLayerRegistry.ts +34 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { act, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantTranscription } from '@adatechnology/conversation-contracts'
|
|
4
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test'
|
|
5
|
+
|
|
6
|
+
import { setupDom, type DomHarness } from '../domHarness.test-helper'
|
|
7
|
+
import { ParticipantAudioTranscription } from './ParticipantAudioTranscription'
|
|
8
|
+
import { ParticipantCopyAnnouncer, ParticipantThreadLive } from './ParticipantCopyAnnouncer'
|
|
9
|
+
import { DEFAULT_PARTICIPANT_AUDIO_LABELS } from './participantAudioLabels'
|
|
10
|
+
|
|
11
|
+
let harness: DomHarness
|
|
12
|
+
let container: HTMLElement
|
|
13
|
+
let setTranscription: (next: ParticipantTranscription | undefined) => void = () => undefined
|
|
14
|
+
let overflow = { scrollHeight: 100, clientHeight: 100 }
|
|
15
|
+
let prototype: Record<string, unknown> = {}
|
|
16
|
+
let originals: Record<string, PropertyDescriptor | undefined> = {}
|
|
17
|
+
|
|
18
|
+
const READY: ParticipantTranscription = { status: 'ready', text: 'Chego em dez minutos.', language: 'pt', generatedAt: '2026-10-01T12:01:00.000Z' }
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
harness = await setupDom()
|
|
22
|
+
prototype = HTMLElement.prototype as unknown as Record<string, unknown>
|
|
23
|
+
originals = {
|
|
24
|
+
scrollHeight: Object.getOwnPropertyDescriptor(prototype, 'scrollHeight'),
|
|
25
|
+
clientHeight: Object.getOwnPropertyDescriptor(prototype, 'clientHeight'),
|
|
26
|
+
}
|
|
27
|
+
Object.defineProperty(prototype, 'scrollHeight', { configurable: true, get: () => overflow.scrollHeight })
|
|
28
|
+
Object.defineProperty(prototype, 'clientHeight', { configurable: true, get: () => overflow.clientHeight })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
for (const [name, descriptor] of Object.entries(originals)) {
|
|
33
|
+
if (descriptor) Object.defineProperty(prototype, name, descriptor)
|
|
34
|
+
else delete prototype[name]
|
|
35
|
+
}
|
|
36
|
+
harness.teardown()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
overflow = { scrollHeight: 100, clientHeight: 100 }
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
function Harness({ initial, attachmentId = 'att-1' }: { initial: ParticipantTranscription | undefined; attachmentId?: string }) {
|
|
44
|
+
const [transcription, update] = useState(initial)
|
|
45
|
+
setTranscription = update
|
|
46
|
+
return (
|
|
47
|
+
<ParticipantCopyAnnouncer>
|
|
48
|
+
<ParticipantThreadLive notice="" />
|
|
49
|
+
<ParticipantAudioTranscription attachmentId={attachmentId} transcription={transcription} labels={DEFAULT_PARTICIPANT_AUDIO_LABELS} />
|
|
50
|
+
</ParticipantCopyAnnouncer>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mount(initial: ParticipantTranscription | undefined, attachmentId?: string): void {
|
|
55
|
+
container = harness.mount(<Harness initial={initial} {...(attachmentId === undefined ? {} : { attachmentId })} />).container
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function liveText(): string {
|
|
59
|
+
return container.querySelector('[role="status"]')?.textContent ?? ''
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function change(next: ParticipantTranscription | undefined): void {
|
|
63
|
+
act(() => setTranscription(next))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('announcement through the existing live region', () => {
|
|
67
|
+
it('announces "Transcription ready" once on the observed pending to ready change', () => {
|
|
68
|
+
mount({ status: 'pending' })
|
|
69
|
+
expect(liveText()).toBe('')
|
|
70
|
+
change(READY)
|
|
71
|
+
expect(liveText()).toBe('Transcription ready')
|
|
72
|
+
expect(container.querySelectorAll('[aria-live]')).toHaveLength(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('does not announce a transcription that is already ready at the first render', () => {
|
|
76
|
+
mount(READY)
|
|
77
|
+
expect(liveText()).toBe('')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('does not announce again on a re-render that stays ready, nor for failed or empty transitions', () => {
|
|
81
|
+
mount({ status: 'pending' })
|
|
82
|
+
change(READY)
|
|
83
|
+
act(() => undefined)
|
|
84
|
+
const announced = liveText()
|
|
85
|
+
change({ ...READY, text: 'Outra coisa' })
|
|
86
|
+
expect(liveText()).toBe(announced)
|
|
87
|
+
|
|
88
|
+
mount({ status: 'pending' })
|
|
89
|
+
change({ status: 'failed' })
|
|
90
|
+
expect(liveText()).toBe('')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('does not announce the full text', () => {
|
|
94
|
+
mount({ status: 'pending' })
|
|
95
|
+
change(READY)
|
|
96
|
+
expect(liveText()).not.toContain('dez minutos')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('states', () => {
|
|
101
|
+
it('pending shows the discreet notice only', () => {
|
|
102
|
+
mount({ status: 'pending' })
|
|
103
|
+
expect(container.querySelector('.cv-p-transcription--pending')?.textContent).toBe('Transcribing…')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('ready keeps the machine notice always next to the text, notice read first', () => {
|
|
107
|
+
mount(READY)
|
|
108
|
+
const text = container.querySelector('.cv-p-transcription__text')
|
|
109
|
+
const notice = container.querySelector('.cv-p-transcription__notice')
|
|
110
|
+
expect(text?.textContent).toBe('Chego em dez minutos.')
|
|
111
|
+
expect(text?.getAttribute('lang')).toBe('pt')
|
|
112
|
+
expect(notice?.textContent).toBe('Transcription generated by machine')
|
|
113
|
+
expect(text?.hasAttribute('aria-describedby')).toBe(false)
|
|
114
|
+
expect(notice?.hasAttribute('id')).toBe(false)
|
|
115
|
+
const order = Array.from(container.querySelector('.cv-p-transcription')?.children ?? [])
|
|
116
|
+
expect(order.indexOf(notice as Element)).toBeLessThan(order.indexOf(text as Element))
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('empty text shows only the empty message and the notice', () => {
|
|
120
|
+
mount({ status: 'ready', text: '', generatedAt: READY.status === 'ready' ? READY.generatedAt : '' })
|
|
121
|
+
expect(container.querySelector('.cv-p-transcription__empty')?.textContent).toBe('Nothing was said in the audio')
|
|
122
|
+
expect(container.querySelector('.cv-p-transcription__notice')).not.toBeNull()
|
|
123
|
+
expect(container.querySelector('.cv-p-transcription__text')).toBeNull()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('too_long computes the minutes from the field and is not an alarm', () => {
|
|
127
|
+
mount({ status: 'failed', reason: 'too_long', maxDurationMs: 600_000 })
|
|
128
|
+
const block = container.querySelector('.cv-p-transcription--too-long')
|
|
129
|
+
expect(block?.textContent).toBe('Transcription only for audios shorter than 10 minutes')
|
|
130
|
+
expect(block?.getAttribute('role')).toBeNull()
|
|
131
|
+
change({ status: 'failed', reason: 'too_long', maxDurationMs: 300_000 })
|
|
132
|
+
expect(container.querySelector('.cv-p-transcription--too-long')?.textContent).toContain('shorter than 5 minutes')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('plain failed, too_long without the limit, a limit without reason, disabled and absent draw nothing', () => {
|
|
136
|
+
for (const transcription of [
|
|
137
|
+
{ status: 'failed' },
|
|
138
|
+
{ status: 'failed', reason: 'too_long' },
|
|
139
|
+
{ status: 'failed', maxDurationMs: 600_000 },
|
|
140
|
+
{ status: 'failed', reason: 'too_long', maxDurationMs: 30_000 },
|
|
141
|
+
{ status: 'disabled' },
|
|
142
|
+
undefined,
|
|
143
|
+
] as (ParticipantTranscription | undefined)[]) {
|
|
144
|
+
mount(transcription)
|
|
145
|
+
expect(container.querySelector('.cv-p-transcription')).toBeNull()
|
|
146
|
+
expect(container.textContent).not.toMatch(/error|fail/i)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('malformed data from a host that did not validate', () => {
|
|
152
|
+
it('draws only the player area (nothing) and never throws', () => {
|
|
153
|
+
for (const transcription of [
|
|
154
|
+
{ status: 'ready', text: { nested: 'object' } },
|
|
155
|
+
{ status: 'ready', text: 42 },
|
|
156
|
+
{ status: 'ready' },
|
|
157
|
+
{ status: 'weird', text: 'x' },
|
|
158
|
+
{ status: 'failed', reason: 'too_long', maxDurationMs: '600000' },
|
|
159
|
+
] as unknown as ParticipantTranscription[]) {
|
|
160
|
+
mount(transcription)
|
|
161
|
+
expect(container.querySelector('.cv-p-transcription')).toBeNull()
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('collapse', () => {
|
|
167
|
+
const LONG: ParticipantTranscription = { status: 'ready', text: 'x '.repeat(500), generatedAt: '2026-10-01T12:01:00.000Z' }
|
|
168
|
+
|
|
169
|
+
it('shows no button when the text fits', () => {
|
|
170
|
+
mount(LONG)
|
|
171
|
+
expect(container.querySelector('.cv-p-transcription__toggle')).toBeNull()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('measures the overflow, then expands and collapses with aria-expanded', () => {
|
|
175
|
+
overflow = { scrollHeight: 200, clientHeight: 100 }
|
|
176
|
+
mount(LONG)
|
|
177
|
+
const toggle = container.querySelector<HTMLButtonElement>('.cv-p-transcription__toggle')
|
|
178
|
+
expect(toggle?.textContent).toBe('Show all')
|
|
179
|
+
expect(toggle?.getAttribute('aria-expanded')).toBe('false')
|
|
180
|
+
expect(container.querySelector('.cv-p-transcription__text')?.textContent).toBe(LONG.text)
|
|
181
|
+
act(() => toggle?.click())
|
|
182
|
+
const expanded = container.querySelector<HTMLButtonElement>('.cv-p-transcription__toggle')
|
|
183
|
+
expect(expanded?.textContent).toBe('Show less')
|
|
184
|
+
expect(expanded?.getAttribute('aria-expanded')).toBe('true')
|
|
185
|
+
act(() => expanded?.click())
|
|
186
|
+
expect(container.querySelector('.cv-p-transcription__toggle')?.getAttribute('aria-expanded')).toBe('false')
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('the transcribed text never leaves the paragraph', () => {
|
|
191
|
+
it('is in no title, data-* attribute or tooltip', () => {
|
|
192
|
+
mount(READY)
|
|
193
|
+
const attributes = Array.from(container.querySelectorAll('*')).flatMap((element) => Array.from(element.attributes))
|
|
194
|
+
for (const attribute of attributes) {
|
|
195
|
+
if (attribute.name === 'title' || attribute.name.startsWith('data-')) expect(attribute.value).not.toContain('dez minutos')
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { act, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest, mock } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { setupDom, type DomHarness } from '../domHarness.test-helper'
|
|
6
|
+
import { ConversationThread } from './ConversationThread'
|
|
7
|
+
import { ParticipantComposer, type ParticipantComposerProps } from './ParticipantComposer'
|
|
8
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
9
|
+
import { installFakeRecording, type FakeRecordingControls } from './participantRecorderFakes.test-helper'
|
|
10
|
+
|
|
11
|
+
let harness: DomHarness
|
|
12
|
+
let fakes: FakeRecordingControls
|
|
13
|
+
let container: HTMLElement
|
|
14
|
+
let sentAudio: File[]
|
|
15
|
+
let draftTouches: string[]
|
|
16
|
+
let captureEvents: boolean[]
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
harness = await setupDom()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterAll(() => harness.teardown())
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.useFakeTimers()
|
|
26
|
+
fakes = installFakeRecording()
|
|
27
|
+
URL.createObjectURL = () => 'blob:fake-recording'
|
|
28
|
+
URL.revokeObjectURL = () => undefined
|
|
29
|
+
sentAudio = []
|
|
30
|
+
draftTouches = []
|
|
31
|
+
captureEvents = []
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
jest.useRealTimers()
|
|
36
|
+
fakes.restore()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
function mountComposer(overrides: Partial<ParticipantComposerProps> = {}): void {
|
|
40
|
+
const props: ParticipantComposerProps = {
|
|
41
|
+
value: '',
|
|
42
|
+
onChange: () => draftTouches.push('onChange'),
|
|
43
|
+
files: [],
|
|
44
|
+
onFilesChange: () => draftTouches.push('onFilesChange'),
|
|
45
|
+
onSend: () => draftTouches.push('onSend'),
|
|
46
|
+
labels: DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
|
|
47
|
+
audioRecording: { onCaptureChange: (isCapturing) => captureEvents.push(isCapturing) },
|
|
48
|
+
onSendAudio: (file) => sentAudio.push(file),
|
|
49
|
+
...overrides,
|
|
50
|
+
}
|
|
51
|
+
container = harness.mount(<ParticipantComposer {...props} />).container
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function find(selector: string): HTMLButtonElement {
|
|
55
|
+
const element = container.querySelector<HTMLButtonElement>(selector)
|
|
56
|
+
if (!element) throw new Error(`missing ${selector}`)
|
|
57
|
+
return element
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function press(selector: string): Promise<void> {
|
|
61
|
+
await act(async () => find(selector).click())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function recordFor(milliseconds: number): Promise<void> {
|
|
65
|
+
await press('.cv-p-composer__record')
|
|
66
|
+
await act(async () => jest.advanceTimersByTime(milliseconds))
|
|
67
|
+
await press('.cv-p-recorder__stop')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('record, review and send from the composer', () => {
|
|
71
|
+
it('sends exactly one audio File and never touches the text draft or the attached files', async () => {
|
|
72
|
+
mountComposer()
|
|
73
|
+
await recordFor(1500)
|
|
74
|
+
expect(find('.cv-p-recorder--review')).toBeTruthy()
|
|
75
|
+
await press('.cv-p-recorder__send')
|
|
76
|
+
|
|
77
|
+
expect(sentAudio).toHaveLength(1)
|
|
78
|
+
expect(sentAudio[0]?.name).toMatch(/^audio-\d{8}-\d{6}\.ogg$/)
|
|
79
|
+
expect(sentAudio[0]?.type).toBe('audio/ogg')
|
|
80
|
+
expect(draftTouches).toEqual([])
|
|
81
|
+
expect(container.querySelector('.cv-p-composer__record')).not.toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('shows the recorder row in place of the field while recording, with the error above the row when idle', async () => {
|
|
85
|
+
mountComposer()
|
|
86
|
+
await press('.cv-p-composer__record')
|
|
87
|
+
expect(container.querySelector('.cv-p-composer__input')).toBeNull()
|
|
88
|
+
expect(find('.cv-p-recorder[role="group"]')).toBeTruthy()
|
|
89
|
+
expect(container.querySelector('[role="timer"]')?.textContent).toBe('0:00 of 5:00')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Guard held here: recorder.confirm() returns the File once, because statusRef leaves 'review' synchronously
|
|
93
|
+
// (the Set<File> of useParticipantSendAudio is pinned by its own test file).
|
|
94
|
+
it('turns two synchronous clicks on "Send audio" into one message', async () => {
|
|
95
|
+
mountComposer()
|
|
96
|
+
await recordFor(1500)
|
|
97
|
+
const sendButton = find('.cv-p-recorder__send')
|
|
98
|
+
await act(async () => {
|
|
99
|
+
sendButton.click()
|
|
100
|
+
sendButton.click()
|
|
101
|
+
})
|
|
102
|
+
expect(sentAudio).toHaveLength(1)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('refuses an audio over the size ceiling before sending, with the message above the microphone', async () => {
|
|
106
|
+
mountComposer({ audioRecording: { maxSizeBytes: 1000 } })
|
|
107
|
+
await recordFor(1500)
|
|
108
|
+
expect(sentAudio).toHaveLength(0)
|
|
109
|
+
expect(container.querySelector('[role="alert"]')?.textContent).toBe(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS.audioTooLarge)
|
|
110
|
+
expect(container.querySelector('.cv-p-composer__record')).not.toBeNull()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('refuses an empty capture before sending', async () => {
|
|
114
|
+
fakes.chunkBytes = 0
|
|
115
|
+
mountComposer()
|
|
116
|
+
await recordFor(1500)
|
|
117
|
+
expect(sentAudio).toHaveLength(0)
|
|
118
|
+
expect(container.querySelector('[role="alert"]')?.textContent).toBe(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS.audioEmpty)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('keeps the microphone disabled together with the composer', async () => {
|
|
122
|
+
mountComposer({ disabled: true })
|
|
123
|
+
expect(find('.cv-p-composer__record').disabled).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('disables "Send audio" in review when the conversation becomes disabled, and still allows discarding', async () => {
|
|
127
|
+
let setDisabled: (next: boolean) => void = () => undefined
|
|
128
|
+
function Wrapper() {
|
|
129
|
+
const [disabled, update] = useState(false)
|
|
130
|
+
setDisabled = update
|
|
131
|
+
return (
|
|
132
|
+
<ParticipantComposer
|
|
133
|
+
value=""
|
|
134
|
+
onChange={() => undefined}
|
|
135
|
+
files={[]}
|
|
136
|
+
onFilesChange={() => undefined}
|
|
137
|
+
onSend={() => undefined}
|
|
138
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
139
|
+
audioRecording={{}}
|
|
140
|
+
onSendAudio={(file) => sentAudio.push(file)}
|
|
141
|
+
disabled={disabled}
|
|
142
|
+
/>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
container = harness.mount(<Wrapper />).container
|
|
146
|
+
await recordFor(1500)
|
|
147
|
+
expect(find('.cv-p-recorder__send').disabled).toBe(false)
|
|
148
|
+
act(() => setDisabled(true))
|
|
149
|
+
expect(find('.cv-p-recorder__send').disabled).toBe(true)
|
|
150
|
+
expect(find('.cv-p-recorder__discard').disabled).toBe(false)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('reports the capture to the host from the request until the audio is sent', async () => {
|
|
154
|
+
mountComposer()
|
|
155
|
+
await press('.cv-p-composer__record')
|
|
156
|
+
expect(captureEvents).toEqual([true])
|
|
157
|
+
await act(async () => jest.advanceTimersByTime(1500))
|
|
158
|
+
await press('.cv-p-recorder__stop')
|
|
159
|
+
expect(captureEvents).toEqual([true])
|
|
160
|
+
await press('.cv-p-recorder__send')
|
|
161
|
+
expect(captureEvents).toEqual([true, false])
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('the whole thread: the audio leaves as its own message through the attachment path', () => {
|
|
166
|
+
// Same guard as above (statusRef of confirm), now observed end to end at api.sendMessage.
|
|
167
|
+
it('calls api.sendMessage once with one File and no text, even on two synchronous clicks', async () => {
|
|
168
|
+
const sendMessage = mock(async () => ({ outcome: 'queued' as const }))
|
|
169
|
+
const api = { fetchMessages: async () => [], sendMessage, resolveAttachmentUrl: async () => 'https://files.example/a' }
|
|
170
|
+
container = harness.mount(
|
|
171
|
+
<ConversationThread api={api} subject={{ subjectType: 'case', subjectId: '7' }} title="Case 7" audioRecording={{}} />,
|
|
172
|
+
).container
|
|
173
|
+
await recordFor(1500)
|
|
174
|
+
const sendButton = find('.cv-p-recorder__send')
|
|
175
|
+
await act(async () => {
|
|
176
|
+
sendButton.click()
|
|
177
|
+
sendButton.click()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(sendMessage).toHaveBeenCalledTimes(1)
|
|
181
|
+
const [request] = sendMessage.mock.calls[0] as unknown as [{ text?: string; files?: File[] }]
|
|
182
|
+
expect(request.text).toBeUndefined()
|
|
183
|
+
expect(request.files).toHaveLength(1)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it } from 'bun:test'
|
|
5
|
+
import type { ComponentType } from 'react'
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
7
|
+
|
|
8
|
+
import { ParticipantAudioRecorderView } from './ParticipantAudioRecorderView'
|
|
9
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
10
|
+
import { buildThreadProps } from './participantGoldenScenarios.test-helper'
|
|
11
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
12
|
+
import { installFakeRecording } from './participantRecorderFakes.test-helper'
|
|
13
|
+
|
|
14
|
+
function readGolden(name: string): string {
|
|
15
|
+
return readFileSync(join(import.meta.dir, 'test-fixtures', `${name}.golden.html`), 'utf8')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const Thread = ParticipantThread as unknown as ComponentType<Record<string, unknown>>
|
|
19
|
+
|
|
20
|
+
function renderThreadWithMicrophone(): string {
|
|
21
|
+
const props = {
|
|
22
|
+
...buildThreadProps(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS),
|
|
23
|
+
draft: { value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined },
|
|
24
|
+
audioRecording: {},
|
|
25
|
+
onSendAudio: () => undefined,
|
|
26
|
+
}
|
|
27
|
+
return renderToStaticMarkup(<Thread {...props} />)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('the real composer with the microphone (contract 2.2), not only the pure view', () => {
|
|
31
|
+
let restore: (() => void) | undefined
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
process.env.TZ = 'UTC'
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => restore?.())
|
|
38
|
+
|
|
39
|
+
it('thread-composer-microphone-idle is byte-identical to its golden', () => {
|
|
40
|
+
restore = installFakeRecording().restore
|
|
41
|
+
const markup = renderThreadWithMicrophone()
|
|
42
|
+
expect(markup).toBe(readGolden('thread-composer-microphone-idle'))
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('composer-audio-*: the pure recorder view (contract 2.2-2.3)', () => {
|
|
47
|
+
for (const state of ['idle', 'recording', 'review', 'denied'] as const) {
|
|
48
|
+
it(`composer-audio-${state} is byte-identical to its golden`, () => {
|
|
49
|
+
const markup = renderToStaticMarkup(
|
|
50
|
+
<ParticipantAudioRecorderView state={state} elapsedMs={12_000} labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS} />,
|
|
51
|
+
)
|
|
52
|
+
expect(markup).toBe(readGolden(`composer-audio-${state}`))
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('the microphone in the composer (contract 2.2)', () => {
|
|
58
|
+
let restore: (() => void) | undefined
|
|
59
|
+
|
|
60
|
+
afterEach(() => restore?.())
|
|
61
|
+
|
|
62
|
+
it('takes the place of send only with the prop, an empty field and a recording browser', () => {
|
|
63
|
+
restore = installFakeRecording().restore
|
|
64
|
+
const withProp = renderThreadWithMicrophone()
|
|
65
|
+
expect(withProp).toContain('cv-p-composer__record')
|
|
66
|
+
expect(withProp).not.toContain('cv-p-composer__send')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { ParticipantComposer, type ParticipantComposerProps } from './ParticipantComposer'
|
|
5
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
6
|
+
import { installFakeRecording, type FakeRecordingControls } from './participantRecorderFakes.test-helper'
|
|
7
|
+
|
|
8
|
+
const BASE: ParticipantComposerProps = {
|
|
9
|
+
value: '',
|
|
10
|
+
onChange: () => undefined,
|
|
11
|
+
files: [],
|
|
12
|
+
onFilesChange: () => undefined,
|
|
13
|
+
onSend: () => undefined,
|
|
14
|
+
labels: DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
|
|
15
|
+
audioRecording: {},
|
|
16
|
+
onSendAudio: () => undefined,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function render(overrides: Partial<ParticipantComposerProps> = {}): string {
|
|
20
|
+
return renderToStaticMarkup(<ParticipantComposer {...BASE} {...overrides} />)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('the microphone in the composer (contract 2.2)', () => {
|
|
24
|
+
let environment: FakeRecordingControls | undefined
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
environment?.restore()
|
|
28
|
+
environment = undefined
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('takes the place of send with the prop, an empty field and a recording browser', () => {
|
|
32
|
+
environment = installFakeRecording()
|
|
33
|
+
const markup = render()
|
|
34
|
+
expect(markup).toContain('type="button" class="cv-p-button cv-p-button--primary cv-p-composer__record"')
|
|
35
|
+
expect(markup).not.toContain('cv-p-composer__send')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('never appears without the prop', () => {
|
|
39
|
+
environment = installFakeRecording()
|
|
40
|
+
const markup = render({ audioRecording: undefined })
|
|
41
|
+
expect(markup).not.toContain('cv-p-composer__record')
|
|
42
|
+
expect(markup).toContain('cv-p-composer__send')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('gives way to send when there is text or an attached file', () => {
|
|
46
|
+
environment = installFakeRecording()
|
|
47
|
+
for (const overrides of [{ value: 'oi' }, { files: [new File(['x'], 'a.txt')] }]) {
|
|
48
|
+
const markup = render(overrides)
|
|
49
|
+
expect(markup).not.toContain('cv-p-composer__record')
|
|
50
|
+
expect(markup).toContain('cv-p-composer__send')
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('stays off when the browser cannot record', () => {
|
|
55
|
+
expect(render()).not.toContain('cv-p-composer__record')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('stays off on the portal channel: the channel only switches the microphone off', () => {
|
|
59
|
+
environment = installFakeRecording()
|
|
60
|
+
expect(render({ channel: 'portal' })).not.toContain('cv-p-composer__record')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('stays off when the host accepts no audio type or gives no way to send it', () => {
|
|
64
|
+
environment = installFakeRecording()
|
|
65
|
+
expect(render({ acceptedTypes: ['image/*'] })).not.toContain('cv-p-composer__record')
|
|
66
|
+
expect(render({ acceptedTypes: ['audio/*'] })).toContain('cv-p-composer__record')
|
|
67
|
+
expect(render({ onSendAudio: undefined })).not.toContain('cv-p-composer__record')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('is disabled together with the composer', () => {
|
|
71
|
+
environment = installFakeRecording()
|
|
72
|
+
expect(render({ disabled: true })).toMatch(/cv-p-composer__record"[^>]*disabled=""/)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
|
|
4
|
+
import { setupDom, pressKey, type DomHarness } from '../domHarness.test-helper'
|
|
5
|
+
import { ParticipantFailedMenu } from './ParticipantFailedMenu'
|
|
6
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as LABELS } from './participantLabels'
|
|
7
|
+
|
|
8
|
+
let harness: DomHarness
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
harness = await setupDom()
|
|
12
|
+
})
|
|
13
|
+
afterAll(() => harness.teardown())
|
|
14
|
+
|
|
15
|
+
const noop = (): void => undefined
|
|
16
|
+
|
|
17
|
+
function mountMenu() {
|
|
18
|
+
const tree = harness.mount(
|
|
19
|
+
<div className="cv-p-thread__scroll">
|
|
20
|
+
<div className="cv-p-bubble">
|
|
21
|
+
<ParticipantFailedMenu labels={LABELS} onEdit={noop} onDiscard={noop} />
|
|
22
|
+
</div>
|
|
23
|
+
</div>,
|
|
24
|
+
)
|
|
25
|
+
const trigger = tree.container.querySelector<HTMLButtonElement>('.cv-p-failed-menu__trigger')!
|
|
26
|
+
return { ...tree, trigger }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function click(element: Element): void {
|
|
30
|
+
act(() => element.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function items(container: HTMLElement): HTMLElement[] {
|
|
34
|
+
return Array.from(container.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('failed menu in a DOM', () => {
|
|
38
|
+
it('opens on click, names the menu by the trigger and focuses the first item', () => {
|
|
39
|
+
const { container, trigger } = mountMenu()
|
|
40
|
+
click(trigger)
|
|
41
|
+
|
|
42
|
+
const menu = container.querySelector('[role="menu"]')!
|
|
43
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
|
44
|
+
expect(menu.getAttribute('aria-labelledby')).toBe(trigger.id)
|
|
45
|
+
expect(document.activeElement).toBe(items(container)[0])
|
|
46
|
+
expect(items(container).every((item) => item.tabIndex === -1)).toBe(true)
|
|
47
|
+
expect(trigger.hasAttribute('data-cv-tooltip')).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('ArrowUp on the trigger opens the menu on the last item', () => {
|
|
51
|
+
const { container, trigger } = mountMenu()
|
|
52
|
+
act(() => trigger.focus())
|
|
53
|
+
pressKey(trigger, 'ArrowUp')
|
|
54
|
+
|
|
55
|
+
expect(document.activeElement).toBe(items(container)[1])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('Escape closes, gives the focus back to the trigger and does not reach the host', () => {
|
|
59
|
+
const { container, trigger } = mountMenu()
|
|
60
|
+
click(trigger)
|
|
61
|
+
let hostSawEscape = false
|
|
62
|
+
const hostListener = (): void => void (hostSawEscape = true)
|
|
63
|
+
document.addEventListener('keydown', hostListener)
|
|
64
|
+
|
|
65
|
+
pressKey(items(container)[0]!, 'Escape')
|
|
66
|
+
document.removeEventListener('keydown', hostListener)
|
|
67
|
+
|
|
68
|
+
expect(container.querySelector('[role="menu"]')).toBeNull()
|
|
69
|
+
expect(document.activeElement).toBe(trigger)
|
|
70
|
+
expect(hostSawEscape).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('Tab closes the menu without pulling the focus back to the trigger', () => {
|
|
74
|
+
const { container, trigger } = mountMenu()
|
|
75
|
+
click(trigger)
|
|
76
|
+
pressKey(items(container)[0]!, 'Tab')
|
|
77
|
+
|
|
78
|
+
expect(container.querySelector('[role="menu"]')).toBeNull()
|
|
79
|
+
expect(document.activeElement).not.toBe(trigger)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('closes on a pointer press outside and keeps open on one inside', () => {
|
|
83
|
+
const { container, trigger } = mountMenu()
|
|
84
|
+
click(trigger)
|
|
85
|
+
act(() => void items(container)[0]!.dispatchEvent(new Event('pointerdown', { bubbles: true })))
|
|
86
|
+
expect(container.querySelector('[role="menu"]')).not.toBeNull()
|
|
87
|
+
|
|
88
|
+
act(() => void document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })))
|
|
89
|
+
expect(container.querySelector('[role="menu"]')).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('removes its document listener when unmounted with the menu open', () => {
|
|
93
|
+
const added: string[] = []
|
|
94
|
+
const removed: string[] = []
|
|
95
|
+
const add = document.addEventListener.bind(document)
|
|
96
|
+
const remove = document.removeEventListener.bind(document)
|
|
97
|
+
document.addEventListener = ((type: string, ...rest: unknown[]) => {
|
|
98
|
+
added.push(type)
|
|
99
|
+
return (add as (...args: unknown[]) => void)(type, ...rest)
|
|
100
|
+
}) as typeof document.addEventListener
|
|
101
|
+
document.removeEventListener = ((type: string, ...rest: unknown[]) => {
|
|
102
|
+
removed.push(type)
|
|
103
|
+
return (remove as (...args: unknown[]) => void)(type, ...rest)
|
|
104
|
+
}) as typeof document.removeEventListener
|
|
105
|
+
|
|
106
|
+
const { unmount, trigger } = mountMenu()
|
|
107
|
+
click(trigger)
|
|
108
|
+
unmount()
|
|
109
|
+
document.addEventListener = add
|
|
110
|
+
document.removeEventListener = remove
|
|
111
|
+
|
|
112
|
+
expect(added.filter((type) => type === 'pointerdown')).toHaveLength(1)
|
|
113
|
+
expect(removed.filter((type) => type === 'pointerdown')).toHaveLength(1)
|
|
114
|
+
expect(() => document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))).not.toThrow()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('failed menu placement in a DOM', () => {
|
|
119
|
+
let original: typeof Element.prototype.getBoundingClientRect
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
original = Element.prototype.getBoundingClientRect
|
|
123
|
+
})
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
Element.prototype.getBoundingClientRect = original
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
function stubLayout(bubbleTop: number): void {
|
|
129
|
+
Element.prototype.getBoundingClientRect = function (this: Element) {
|
|
130
|
+
const top = this.classList.contains('cv-p-thread__scroll') ? 100 : this.classList.contains('cv-p-bubble') ? bubbleTop : 0
|
|
131
|
+
const height = this.getAttribute('role') === 'menu' ? 90 : 20
|
|
132
|
+
return { top, bottom: top + height, left: 0, right: 0, width: 0, height, x: 0, y: top, toJSON: () => ({}) }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
it('flips below when the bubble is the first of the conversation', () => {
|
|
137
|
+
stubLayout(108)
|
|
138
|
+
const { container, trigger } = mountMenu()
|
|
139
|
+
click(trigger)
|
|
140
|
+
Element.prototype.getBoundingClientRect = original
|
|
141
|
+
|
|
142
|
+
expect(container.querySelector('.cv-p-failed-menu__list--below')).not.toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('stays above when there is room', () => {
|
|
146
|
+
stubLayout(500)
|
|
147
|
+
const { container, trigger } = mountMenu()
|
|
148
|
+
click(trigger)
|
|
149
|
+
Element.prototype.getBoundingClientRect = original
|
|
150
|
+
|
|
151
|
+
expect(container.querySelector('[role="menu"]')).not.toBeNull()
|
|
152
|
+
expect(container.querySelector('.cv-p-failed-menu__list--below')).toBeNull()
|
|
153
|
+
})
|
|
154
|
+
})
|