@adatechnology/conversations-ui 0.9.0 → 0.11.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 +107 -0
- package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
- package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
- package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
- package/dist/index.d.ts +10 -3
- package/dist/index.js +3 -3
- package/dist/participant/index.d.ts +55 -3
- package/dist/participant/index.js +1369 -319
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/dist/styles.css +187 -0
- package/package.json +2 -2
- package/src/AudioRecorderButton.tsx +4 -28
- package/src/audioRecorderFormat.ts +27 -0
- package/src/participant/ConversationThread.tsx +7 -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/ParticipantComposer.tsx +50 -15
- package/src/participant/ParticipantComposerAudio.tsx +95 -0
- package/src/participant/ParticipantConversations.tsx +5 -0
- package/src/participant/ParticipantRecorderParts.tsx +118 -0
- package/src/participant/ParticipantThread.test.tsx +46 -0
- package/src/participant/ParticipantThread.tsx +17 -0
- package/src/participant/ParticipantThreadScreen.tsx +2 -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/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 +4 -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 +9 -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-composer-microphone-idle.golden.html +1 -0
- package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -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/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/useTranscriptionOverflow.ts +25 -0
- package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
- package/src/styles.css +30 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_MAX_RECORDING_MILLISECONDS } from '../audioRecorderFormat'
|
|
4
|
+
import {
|
|
5
|
+
canStartRecording,
|
|
6
|
+
classifyCapture,
|
|
7
|
+
classifyMicrophoneError,
|
|
8
|
+
formatRecordingClock,
|
|
9
|
+
MINIMUM_RECORDING_MILLISECONDS,
|
|
10
|
+
reduceRecorderStatus,
|
|
11
|
+
resolveInterruptionAction,
|
|
12
|
+
resolveRecordingLimits,
|
|
13
|
+
type RecorderEvent,
|
|
14
|
+
type RecorderStatus,
|
|
15
|
+
} from './participantAudioRecorderState'
|
|
16
|
+
import { DEFAULT_MAX_RECORDING_BYTES } from './participantAudio.types'
|
|
17
|
+
|
|
18
|
+
const SIXTEEN_MIB = 16 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
describe('resolveRecordingLimits only lowers the defaults (contract 2)', () => {
|
|
21
|
+
it('uses the defaults when nothing is passed', () => {
|
|
22
|
+
expect(resolveRecordingLimits({})).toEqual({ maxDurationMs: DEFAULT_MAX_RECORDING_MILLISECONDS, maxSizeBytes: SIXTEEN_MIB })
|
|
23
|
+
expect(DEFAULT_MAX_RECORDING_BYTES).toBe(SIXTEEN_MIB)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('lowers the duration and the size', () => {
|
|
27
|
+
expect(resolveRecordingLimits({ options: { maxDurationMs: 60_000, maxSizeBytes: 1_000_000 } })).toEqual({
|
|
28
|
+
maxDurationMs: 60_000,
|
|
29
|
+
maxSizeBytes: 1_000_000,
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('cuts a larger duration or size back to the default', () => {
|
|
34
|
+
const limits = resolveRecordingLimits({ options: { maxDurationMs: 3_600_000, maxSizeBytes: 100 * 1024 * 1024 } })
|
|
35
|
+
expect(limits.maxDurationMs).toBe(DEFAULT_MAX_RECORDING_MILLISECONDS)
|
|
36
|
+
expect(limits.maxSizeBytes).toBe(SIXTEEN_MIB)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('takes the smallest of the policy, the channel and the passed value', () => {
|
|
40
|
+
expect(resolveRecordingLimits({ channelMaxBytes: 10 * 1024 * 1024 }).maxSizeBytes).toBe(10 * 1024 * 1024)
|
|
41
|
+
expect(resolveRecordingLimits({ channelMaxBytes: 25 * 1024 * 1024 }).maxSizeBytes).toBe(SIXTEEN_MIB)
|
|
42
|
+
expect(resolveRecordingLimits({ options: { maxSizeBytes: 5_000_000 }, channelMaxBytes: 10_000_000 }).maxSizeBytes).toBe(5_000_000)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('ignores values that are not a positive number', () => {
|
|
46
|
+
for (const bad of [0, -5, Number.NaN]) {
|
|
47
|
+
expect(resolveRecordingLimits({ options: { maxDurationMs: bad, maxSizeBytes: bad } })).toEqual({
|
|
48
|
+
maxDurationMs: DEFAULT_MAX_RECORDING_MILLISECONDS,
|
|
49
|
+
maxSizeBytes: SIXTEEN_MIB,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('reduceRecorderStatus', () => {
|
|
56
|
+
const table: ReadonlyArray<readonly [RecorderStatus, RecorderEvent, RecorderStatus]> = [
|
|
57
|
+
['idle', 'request', 'requesting'],
|
|
58
|
+
['denied', 'request', 'requesting'],
|
|
59
|
+
['unsupported', 'request', 'requesting'],
|
|
60
|
+
['empty', 'request', 'requesting'],
|
|
61
|
+
['tooLarge', 'request', 'requesting'],
|
|
62
|
+
['requesting', 'granted', 'recording'],
|
|
63
|
+
['requesting', 'microphoneDenied', 'denied'],
|
|
64
|
+
['requesting', 'microphoneUnavailable', 'unsupported'],
|
|
65
|
+
['requesting', 'cancel', 'idle'],
|
|
66
|
+
['recording', 'capturedReview', 'review'],
|
|
67
|
+
['recording', 'capturedEmpty', 'empty'],
|
|
68
|
+
['recording', 'capturedTooLarge', 'tooLarge'],
|
|
69
|
+
['recording', 'cancel', 'idle'],
|
|
70
|
+
['review', 'cancel', 'idle'],
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
for (const [from, event, to] of table) {
|
|
74
|
+
it(`${from} + ${event} -> ${to}`, () => {
|
|
75
|
+
expect(reduceRecorderStatus(from, event)).toBe(to)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it('ignores events that do not apply to the current state', () => {
|
|
80
|
+
expect(reduceRecorderStatus('recording', 'request')).toBe('recording')
|
|
81
|
+
expect(reduceRecorderStatus('review', 'request')).toBe('review')
|
|
82
|
+
expect(reduceRecorderStatus('requesting', 'request')).toBe('requesting')
|
|
83
|
+
expect(reduceRecorderStatus('idle', 'granted')).toBe('idle')
|
|
84
|
+
expect(reduceRecorderStatus('review', 'capturedEmpty')).toBe('review')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('resolveInterruptionAction (contract 2.1)', () => {
|
|
89
|
+
it('stops a recording, pauses a review, cancels a pending request', () => {
|
|
90
|
+
expect(resolveInterruptionAction('recording')).toBe('stop')
|
|
91
|
+
expect(resolveInterruptionAction('review')).toBe('pause')
|
|
92
|
+
expect(resolveInterruptionAction('requesting')).toBe('cancel')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('ignores the idle and error states', () => {
|
|
96
|
+
for (const status of ['idle', 'denied', 'unsupported', 'empty', 'tooLarge'] as const) {
|
|
97
|
+
expect(resolveInterruptionAction(status)).toBe('ignore')
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('classifyCapture', () => {
|
|
103
|
+
const limits = { maxDurationMs: 300_000, maxSizeBytes: 1000 }
|
|
104
|
+
|
|
105
|
+
it('is empty without bytes or below the minimum duration', () => {
|
|
106
|
+
expect(classifyCapture({ sizeBytes: 0, durationMs: 5000 }, limits)).toBe('capturedEmpty')
|
|
107
|
+
expect(classifyCapture({ sizeBytes: 500, durationMs: 0 }, limits)).toBe('capturedEmpty')
|
|
108
|
+
expect(classifyCapture({ sizeBytes: 500, durationMs: MINIMUM_RECORDING_MILLISECONDS - 1 }, limits)).toBe('capturedEmpty')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('is too large above the byte ceiling and fine at it', () => {
|
|
112
|
+
expect(classifyCapture({ sizeBytes: 1001, durationMs: 5000 }, limits)).toBe('capturedTooLarge')
|
|
113
|
+
expect(classifyCapture({ sizeBytes: 1000, durationMs: MINIMUM_RECORDING_MILLISECONDS }, limits)).toBe('capturedReview')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('classifyMicrophoneError', () => {
|
|
118
|
+
it('maps the permission errors to denied and the rest to unavailable', () => {
|
|
119
|
+
expect(classifyMicrophoneError({ name: 'NotAllowedError' })).toBe('microphoneDenied')
|
|
120
|
+
expect(classifyMicrophoneError({ name: 'SecurityError' })).toBe('microphoneDenied')
|
|
121
|
+
for (const name of ['NotFoundError', 'NotReadableError', 'OverconstrainedError', 'AbortError']) {
|
|
122
|
+
expect(classifyMicrophoneError({ name })).toBe('microphoneUnavailable')
|
|
123
|
+
}
|
|
124
|
+
expect(classifyMicrophoneError('boom')).toBe('microphoneUnavailable')
|
|
125
|
+
expect(classifyMicrophoneError(undefined)).toBe('microphoneUnavailable')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('formatRecordingClock', () => {
|
|
130
|
+
it('formats m:ss, flooring the seconds', () => {
|
|
131
|
+
expect(formatRecordingClock(0)).toBe('0:00')
|
|
132
|
+
expect(formatRecordingClock(12_999)).toBe('0:12')
|
|
133
|
+
expect(formatRecordingClock(65_000)).toBe('1:05')
|
|
134
|
+
expect(formatRecordingClock(300_000)).toBe('5:00')
|
|
135
|
+
expect(formatRecordingClock(-10)).toBe('0:00')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('canStartRecording', () => {
|
|
140
|
+
it('lets a tap start only from idle and the failure states', () => {
|
|
141
|
+
for (const status of ['idle', 'denied', 'unsupported', 'empty', 'tooLarge'] as const) expect(canStartRecording(status)).toBe(true)
|
|
142
|
+
for (const status of ['requesting', 'recording', 'review'] as const) expect(canStartRecording(status)).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { DEFAULT_MAX_RECORDING_MILLISECONDS } from '../audioRecorderFormat'
|
|
2
|
+
import { DEFAULT_MAX_RECORDING_BYTES, type ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
3
|
+
|
|
4
|
+
export type RecorderStatus =
|
|
5
|
+
| 'idle'
|
|
6
|
+
| 'requesting'
|
|
7
|
+
| 'recording'
|
|
8
|
+
| 'review'
|
|
9
|
+
| 'denied'
|
|
10
|
+
| 'unsupported'
|
|
11
|
+
| 'empty'
|
|
12
|
+
| 'tooLarge'
|
|
13
|
+
|
|
14
|
+
export type RecorderEvent =
|
|
15
|
+
| 'request'
|
|
16
|
+
| 'granted'
|
|
17
|
+
| 'microphoneDenied'
|
|
18
|
+
| 'microphoneUnavailable'
|
|
19
|
+
| 'capturedReview'
|
|
20
|
+
| 'capturedEmpty'
|
|
21
|
+
| 'capturedTooLarge'
|
|
22
|
+
| 'cancel'
|
|
23
|
+
|
|
24
|
+
export type InterruptionAction = 'stop' | 'pause' | 'cancel' | 'ignore'
|
|
25
|
+
export type RecordingLimits = { readonly maxDurationMs: number; readonly maxSizeBytes: number }
|
|
26
|
+
export type CaptureMeasure = { readonly sizeBytes: number; readonly durationMs: number }
|
|
27
|
+
|
|
28
|
+
export const MINIMUM_RECORDING_MILLISECONDS = 500
|
|
29
|
+
export const MUTE_INTERRUPTION_DELAY_MS = 3000
|
|
30
|
+
|
|
31
|
+
const RETRYABLE: readonly RecorderStatus[] = ['idle', 'denied', 'unsupported', 'empty', 'tooLarge']
|
|
32
|
+
|
|
33
|
+
const TRANSITIONS: Readonly<Record<RecorderEvent, ReadonlyArray<readonly [readonly RecorderStatus[], RecorderStatus]>>> = {
|
|
34
|
+
request: [[RETRYABLE, 'requesting']],
|
|
35
|
+
granted: [[['requesting'], 'recording']],
|
|
36
|
+
microphoneDenied: [[['requesting'], 'denied']],
|
|
37
|
+
microphoneUnavailable: [[['requesting'], 'unsupported']],
|
|
38
|
+
capturedReview: [[['recording'], 'review']],
|
|
39
|
+
capturedEmpty: [[['recording'], 'empty']],
|
|
40
|
+
capturedTooLarge: [[['recording'], 'tooLarge']],
|
|
41
|
+
cancel: [[['requesting', 'recording', 'review'], 'idle']],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function canStartRecording(status: RecorderStatus): boolean {
|
|
45
|
+
return RETRYABLE.includes(status)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function reduceRecorderStatus(status: RecorderStatus, event: RecorderEvent): RecorderStatus {
|
|
49
|
+
const match = TRANSITIONS[event].find(([from]) => from.includes(status))
|
|
50
|
+
return match ? match[1] : status
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resolveInterruptionAction(status: RecorderStatus): InterruptionAction {
|
|
54
|
+
if (status === 'recording') return 'stop'
|
|
55
|
+
if (status === 'review') return 'pause'
|
|
56
|
+
if (status === 'requesting') return 'cancel'
|
|
57
|
+
return 'ignore'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function lowerOnly(requested: number | undefined, ceiling: number): number {
|
|
61
|
+
if (requested === undefined || !Number.isFinite(requested) || requested <= 0) return ceiling
|
|
62
|
+
return Math.min(requested, ceiling)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ResolveRecordingLimitsParams = {
|
|
66
|
+
readonly options?: ParticipantAudioRecordingOptions
|
|
67
|
+
readonly channelMaxBytes?: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resolveRecordingLimits({ options, channelMaxBytes }: ResolveRecordingLimitsParams): RecordingLimits {
|
|
71
|
+
const sizeCeiling = lowerOnly(channelMaxBytes, DEFAULT_MAX_RECORDING_BYTES)
|
|
72
|
+
return {
|
|
73
|
+
maxDurationMs: lowerOnly(options?.maxDurationMs, DEFAULT_MAX_RECORDING_MILLISECONDS),
|
|
74
|
+
maxSizeBytes: lowerOnly(options?.maxSizeBytes, sizeCeiling),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function classifyCapture({ sizeBytes, durationMs }: CaptureMeasure, limits: RecordingLimits): RecorderEvent {
|
|
79
|
+
if (sizeBytes === 0 || durationMs < MINIMUM_RECORDING_MILLISECONDS) return 'capturedEmpty'
|
|
80
|
+
if (sizeBytes > limits.maxSizeBytes) return 'capturedTooLarge'
|
|
81
|
+
return 'capturedReview'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function classifyMicrophoneError(error: unknown): 'microphoneDenied' | 'microphoneUnavailable' {
|
|
85
|
+
const name = typeof error === 'object' && error !== null && 'name' in error ? (error as { name: unknown }).name : undefined
|
|
86
|
+
return name === 'NotAllowedError' || name === 'SecurityError' ? 'microphoneDenied' : 'microphoneUnavailable'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatRecordingClock(milliseconds: number): string {
|
|
90
|
+
const totalSeconds = Math.floor(Math.max(0, milliseconds) / 1000)
|
|
91
|
+
const seconds = String(totalSeconds % 60).padStart(2, '0')
|
|
92
|
+
return `${Math.floor(totalSeconds / 60)}:${seconds}`
|
|
93
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { ParticipantAudioRecorderView, type ParticipantAudioRecorderViewProps } from './ParticipantAudioRecorderView'
|
|
6
|
+
import type { RecorderStatus } from './participantAudioRecorderState'
|
|
7
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
8
|
+
|
|
9
|
+
function render(overrides: Partial<ParticipantAudioRecorderViewProps> & { state: RecorderStatus }): string {
|
|
10
|
+
return renderToStaticMarkup(<ParticipantAudioRecorderView elapsedMs={12_000} {...overrides} />)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('idle and requesting', () => {
|
|
14
|
+
it('renders the microphone as a non-submitting primary button named by recordAudio', () => {
|
|
15
|
+
const markup = render({ state: 'idle' })
|
|
16
|
+
expect(markup).toContain('type="button"')
|
|
17
|
+
expect(markup).toContain('class="cv-p-button cv-p-button--primary cv-p-composer__record"')
|
|
18
|
+
expect(markup).toContain('aria-label="Record audio"')
|
|
19
|
+
expect(markup).not.toContain('type="submit"')
|
|
20
|
+
expect(markup).not.toContain('aria-busy')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('is busy and disabled while the permission is pending', () => {
|
|
24
|
+
const markup = render({ state: 'requesting' })
|
|
25
|
+
expect(markup).toContain('aria-busy="true"')
|
|
26
|
+
expect(markup).toContain('disabled=""')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('is disabled when the composer is', () => {
|
|
30
|
+
expect(render({ state: 'idle', disabled: true })).toContain('disabled=""')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('recording', () => {
|
|
35
|
+
it('shows the clock "m:ss of m:ss", discard and stop, and no aria-live', () => {
|
|
36
|
+
const markup = render({ state: 'recording' })
|
|
37
|
+
expect(markup).toContain('role="group"')
|
|
38
|
+
expect(markup).toContain('aria-label="Recording"')
|
|
39
|
+
expect(markup).toContain('role="timer"')
|
|
40
|
+
expect(markup).toContain('0:12 of 5:00')
|
|
41
|
+
expect(markup).toContain('cv-p-recorder__discard')
|
|
42
|
+
expect(markup).toContain('cv-p-recorder__stop')
|
|
43
|
+
expect(markup).not.toContain('aria-live')
|
|
44
|
+
expect(markup).not.toContain('cv-p-composer__record')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('uses the lowered ceiling in the clock', () => {
|
|
48
|
+
expect(render({ state: 'recording', maxDurationMs: 90_000 })).toContain('0:12 of 1:30')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('every button is type="button"', () => {
|
|
52
|
+
const buttons = render({ state: 'recording' }).match(/<button[^>]*>/g) ?? []
|
|
53
|
+
expect(buttons).toHaveLength(2)
|
|
54
|
+
for (const button of buttons) expect(button).toContain('type="button"')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('review', () => {
|
|
59
|
+
it('has the hint, the player over the object URL, discard and send', () => {
|
|
60
|
+
const markup = render({ state: 'review', review: { url: 'blob:abc' } })
|
|
61
|
+
expect(markup).toContain('cv-p-recorder--review')
|
|
62
|
+
expect(markup).toContain('<p class="cv-p-recorder__hint" aria-hidden="true">Listen before sending</p>')
|
|
63
|
+
expect(markup).toContain('<audio')
|
|
64
|
+
expect(markup).toContain('src="blob:abc"')
|
|
65
|
+
expect(markup).toContain('cv-p-recorder__discard')
|
|
66
|
+
expect(markup).toContain('cv-p-recorder__send')
|
|
67
|
+
expect(markup).toContain('aria-label="Send audio"')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('failure states', () => {
|
|
72
|
+
const cases: ReadonlyArray<readonly [RecorderStatus, string]> = [
|
|
73
|
+
['denied', 'No permission to use the microphone.'],
|
|
74
|
+
['unsupported', 'This device cannot record audio.'],
|
|
75
|
+
['empty', 'Nothing was captured by the microphone.'],
|
|
76
|
+
['tooLarge', 'Audio too large to send.'],
|
|
77
|
+
]
|
|
78
|
+
for (const [state, text] of cases) {
|
|
79
|
+
it(`${state} keeps the microphone and announces the phrase as an alert`, () => {
|
|
80
|
+
const markup = render({ state })
|
|
81
|
+
expect(markup).toContain(`<p class="cv-p-composer__error" role="alert">${text}</p>`)
|
|
82
|
+
expect(markup).toContain('cv-p-composer__record')
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('labels', () => {
|
|
88
|
+
it('uses the English defaults when the host map does not carry the new keys', () => {
|
|
89
|
+
const { recordAudio: _omitted, ...withoutAudio } = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
|
|
90
|
+
expect(render({ state: 'idle', labels: withoutAudio })).toContain('aria-label="Record audio"')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('takes the host translation when present', () => {
|
|
94
|
+
const labels = { ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, recordAudio: 'Gravar áudio', recordingElapsed: '{elapsed} de {limit}' }
|
|
95
|
+
expect(render({ state: 'idle', labels })).toContain('aria-label="Gravar áudio"')
|
|
96
|
+
expect(render({ state: 'recording', labels })).toContain('0:12 de 5:00')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { channelCapabilityFor } from '../channelCapability'
|
|
2
|
+
import { resolveRecordingFormat, type RecordingFormat } from '../audioRecorderFormat'
|
|
3
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
4
|
+
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
5
|
+
import { matchesAcceptedType } from './participantFileValidation'
|
|
6
|
+
|
|
7
|
+
export type ComposerAudioSettings = {
|
|
8
|
+
readonly audioRecording: ParticipantAudioRecordingOptions
|
|
9
|
+
readonly channel: ConversationChannel
|
|
10
|
+
readonly onSendAudio: (file: File) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ResolveComposerAudioParams = {
|
|
14
|
+
readonly audioRecording?: ParticipantAudioRecordingOptions
|
|
15
|
+
readonly onSendAudio?: (file: File) => void
|
|
16
|
+
readonly channel?: ConversationChannel
|
|
17
|
+
readonly acceptedTypes?: readonly string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Feature detection only: on a server there is no MediaRecorder, so it answers false. */
|
|
21
|
+
function resolveBrowserRecordingFormat(): RecordingFormat | undefined {
|
|
22
|
+
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) return undefined
|
|
23
|
+
return resolveRecordingFormat()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The host opts in through the prop; the channel and the browser can only switch the microphone off. */
|
|
27
|
+
export function resolveComposerAudio(params: ResolveComposerAudioParams): ComposerAudioSettings | undefined {
|
|
28
|
+
const { audioRecording, onSendAudio, channel = 'app', acceptedTypes } = params
|
|
29
|
+
if (!audioRecording || !onSendAudio) return undefined
|
|
30
|
+
const capability = channelCapabilityFor(channel)
|
|
31
|
+
if (!capability.audio.records || !capability.attachments.accepted) return undefined
|
|
32
|
+
const format = resolveBrowserRecordingFormat()
|
|
33
|
+
if (!format) return undefined
|
|
34
|
+
if (acceptedTypes && !matchesAcceptedType(format.uploadMimeType, acceptedTypes)) return undefined
|
|
35
|
+
return { audioRecording, channel, onSendAudio }
|
|
36
|
+
}
|
|
@@ -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
|
+
})
|