@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.
Files changed (91) hide show
  1. package/README.md +107 -0
  2. package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
  3. package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
  4. package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
  5. package/dist/index.d.ts +10 -3
  6. package/dist/index.js +3 -3
  7. package/dist/participant/index.d.ts +55 -3
  8. package/dist/participant/index.js +1369 -319
  9. package/dist/preview/index.d.ts +2 -2
  10. package/dist/preview/index.js +2 -2
  11. package/dist/styles.css +187 -0
  12. package/package.json +2 -2
  13. package/src/AudioRecorderButton.tsx +4 -28
  14. package/src/audioRecorderFormat.ts +27 -0
  15. package/src/participant/ConversationThread.tsx +7 -0
  16. package/src/participant/ParticipantAttachmentItem.tsx +7 -10
  17. package/src/participant/ParticipantAudioAttachment.tsx +34 -0
  18. package/src/participant/ParticipantAudioPlayer.tsx +68 -0
  19. package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
  20. package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
  21. package/src/participant/ParticipantAudioTranscription.tsx +90 -0
  22. package/src/participant/ParticipantComposer.tsx +50 -15
  23. package/src/participant/ParticipantComposerAudio.tsx +95 -0
  24. package/src/participant/ParticipantConversations.tsx +5 -0
  25. package/src/participant/ParticipantRecorderParts.tsx +118 -0
  26. package/src/participant/ParticipantThread.test.tsx +46 -0
  27. package/src/participant/ParticipantThread.tsx +17 -0
  28. package/src/participant/ParticipantThreadScreen.tsx +2 -0
  29. package/src/participant/index.ts +3 -0
  30. package/src/participant/participantAudio.types.ts +10 -0
  31. package/src/participant/participantAudioCapture.test.ts +15 -0
  32. package/src/participant/participantAudioCapture.ts +85 -0
  33. package/src/participant/participantAudioCoordinator.ts +13 -0
  34. package/src/participant/participantAudioFormat.test.ts +93 -0
  35. package/src/participant/participantAudioFormat.ts +72 -0
  36. package/src/participant/participantAudioGolden.test.tsx +168 -0
  37. package/src/participant/participantAudioLabels.test.ts +22 -0
  38. package/src/participant/participantAudioLabels.ts +76 -0
  39. package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
  40. package/src/participant/participantAudioPlayerCss.test.ts +63 -0
  41. package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
  42. package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
  43. package/src/participant/participantAudioPlayerState.ts +35 -0
  44. package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
  45. package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
  46. package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
  47. package/src/participant/participantAudioRecorderState.test.ts +144 -0
  48. package/src/participant/participantAudioRecorderState.ts +93 -0
  49. package/src/participant/participantAudioRecorderView.test.tsx +98 -0
  50. package/src/participant/participantAudioSupport.ts +36 -0
  51. package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
  52. package/src/participant/participantComposerAudioDom.test.tsx +185 -0
  53. package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
  54. package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
  55. package/src/participant/participantFileValidation.ts +1 -1
  56. package/src/participant/participantGolden.test.tsx +2 -0
  57. package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
  58. package/src/participant/participantLabels.ts +4 -1
  59. package/src/participant/participantMediaFakes.test-helper.ts +82 -0
  60. package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
  61. package/src/participant/participantPublicTypes.test.ts +11 -0
  62. package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
  63. package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
  64. package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
  65. package/src/participant/participantThemeContrast.test.ts +9 -0
  66. package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
  67. package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
  68. package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
  69. package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
  70. package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
  71. package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
  72. package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
  73. package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
  74. package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
  75. package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
  76. package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
  77. package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
  78. package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
  79. package/src/participant/useParticipantAudioPlayer.ts +52 -0
  80. package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
  81. package/src/participant/useParticipantAudioRecorder.ts +153 -0
  82. package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
  83. package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
  84. package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
  85. package/src/participant/useParticipantSendAudio.test.tsx +85 -0
  86. package/src/participant/useParticipantSendAudio.ts +28 -0
  87. package/src/participant/useParticipantThreadController.ts +20 -7
  88. package/src/participant/useStableAttachmentUrl.ts +71 -0
  89. package/src/participant/useTranscriptionOverflow.ts +25 -0
  90. package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
  91. 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
+ })