@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,93 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ PLAYBACK_RATES,
5
+ formatAudioClock,
6
+ formatPlaybackRate,
7
+ formatPositionValueText,
8
+ nextRateIndex,
9
+ resolveMediaDurationSeconds,
10
+ resolveSeekKeyTarget,
11
+ resolveSeekMax,
12
+ resolveServerDurationSeconds,
13
+ } from './participantAudioFormat'
14
+
15
+ describe('formatAudioClock', () => {
16
+ it('formats m:ss and pads the seconds', () => {
17
+ expect(formatAudioClock(0)).toBe('0:00')
18
+ expect(formatAudioClock(12.9)).toBe('0:12')
19
+ expect(formatAudioClock(65)).toBe('1:05')
20
+ expect(formatAudioClock(300)).toBe('5:00')
21
+ })
22
+
23
+ it('shows a placeholder when the duration is unknown or infinite', () => {
24
+ expect(formatAudioClock(undefined)).toBe('--:--')
25
+ expect(formatAudioClock(Number.POSITIVE_INFINITY)).toBe('--:--')
26
+ expect(formatAudioClock(Number.NaN)).toBe('--:--')
27
+ })
28
+ })
29
+
30
+ describe('playback rate', () => {
31
+ it('cycles 1x, 1.5x, 2x and back', () => {
32
+ expect(PLAYBACK_RATES).toEqual([1, 1.5, 2])
33
+ expect(nextRateIndex(0)).toBe(1)
34
+ expect(nextRateIndex(1)).toBe(2)
35
+ expect(nextRateIndex(2)).toBe(0)
36
+ })
37
+
38
+ it('names the rate with the multiplication sign', () => {
39
+ expect(formatPlaybackRate(1.5)).toBe('1.5×')
40
+ expect(formatPlaybackRate(2)).toBe('2×')
41
+ })
42
+ })
43
+
44
+ describe('durations', () => {
45
+ it('takes the server duration in seconds only when positive and finite', () => {
46
+ expect(resolveServerDurationSeconds(65_000)).toBe(65)
47
+ expect(resolveServerDurationSeconds(undefined)).toBeUndefined()
48
+ expect(resolveServerDurationSeconds(0)).toBeUndefined()
49
+ })
50
+
51
+ it('ignores an infinite or NaN media duration (Chromium WebM without a header)', () => {
52
+ expect(resolveMediaDurationSeconds(42)).toBe(42)
53
+ expect(resolveMediaDurationSeconds(Number.POSITIVE_INFINITY)).toBeUndefined()
54
+ expect(resolveMediaDurationSeconds(Number.NaN)).toBeUndefined()
55
+ })
56
+ })
57
+
58
+ describe('keyboard step', () => {
59
+ it('moves 5 seconds with the arrows and clamps at the start and the end', () => {
60
+ expect(resolveSeekKeyTarget({ key: 'ArrowRight', position: 10, duration: 65 })).toBe(15)
61
+ expect(resolveSeekKeyTarget({ key: 'ArrowUp', position: 10, duration: 65 })).toBe(15)
62
+ expect(resolveSeekKeyTarget({ key: 'ArrowLeft', position: 10, duration: 65 })).toBe(5)
63
+ expect(resolveSeekKeyTarget({ key: 'ArrowDown', position: 2, duration: 65 })).toBe(0)
64
+ expect(resolveSeekKeyTarget({ key: 'ArrowRight', position: 63, duration: 65 })).toBe(65)
65
+ })
66
+
67
+ it('jumps to the start with Home and to the end with End', () => {
68
+ expect(resolveSeekKeyTarget({ key: 'Home', position: 30, duration: 65 })).toBe(0)
69
+ expect(resolveSeekKeyTarget({ key: 'End', position: 30, duration: 65 })).toBe(65)
70
+ })
71
+
72
+ it('does not know the end without a finite duration and leaves other keys alone', () => {
73
+ expect(resolveSeekKeyTarget({ key: 'End', position: 30, duration: undefined })).toBeUndefined()
74
+ expect(resolveSeekKeyTarget({ key: 'ArrowRight', position: 30, duration: undefined })).toBe(35)
75
+ expect(resolveSeekKeyTarget({ key: 'a', position: 30, duration: 65 })).toBeUndefined()
76
+ })
77
+ })
78
+
79
+ describe('range bounds and value text', () => {
80
+ it('uses the rounded-up duration as max when known', () => {
81
+ expect(resolveSeekMax({ duration: 64.2, maxSeen: 3, position: 3 })).toBe(65)
82
+ })
83
+
84
+ it('uses the largest position seen as a provisional max when the duration is unknown', () => {
85
+ expect(resolveSeekMax({ duration: undefined, maxSeen: 12, position: 12 })).toBe(12)
86
+ expect(resolveSeekMax({ duration: undefined, maxSeen: 0, position: 0 })).toBe(0)
87
+ })
88
+
89
+ it('writes position and duration, or only the position without a duration', () => {
90
+ expect(formatPositionValueText({ template: '{position} of {duration}', position: 12, duration: 65 })).toBe('0:12 of 1:05')
91
+ expect(formatPositionValueText({ template: '{position} of {duration}', position: 12, duration: undefined })).toBe('0:12')
92
+ })
93
+ })
@@ -0,0 +1,72 @@
1
+ export const PLAYBACK_RATES = [1, 1.5, 2] as const
2
+ export const SEEK_STEP_SECONDS = 5
3
+
4
+ const UNKNOWN_CLOCK = '--:--'
5
+
6
+ export function formatAudioClock(seconds: number | undefined): string {
7
+ if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) return UNKNOWN_CLOCK
8
+ const wholeSeconds = Math.floor(seconds)
9
+ const remainder = String(wholeSeconds % 60).padStart(2, '0')
10
+ return `${Math.floor(wholeSeconds / 60)}:${remainder}`
11
+ }
12
+
13
+ export function nextRateIndex(index: number): number {
14
+ return (index + 1) % PLAYBACK_RATES.length
15
+ }
16
+
17
+ export function formatPlaybackRate(rate: number): string {
18
+ return `${rate}×`
19
+ }
20
+
21
+ export function resolveServerDurationSeconds(durationMs: number | undefined): number | undefined {
22
+ if (durationMs === undefined || !Number.isFinite(durationMs) || durationMs <= 0) return undefined
23
+ return durationMs / 1000
24
+ }
25
+
26
+ export function resolveMediaDurationSeconds(duration: number): number | undefined {
27
+ return Number.isFinite(duration) && duration > 0 ? duration : undefined
28
+ }
29
+
30
+ export type SeekKeyParams = {
31
+ readonly key: string
32
+ readonly position: number
33
+ readonly duration: number | undefined
34
+ }
35
+
36
+ function clampPosition(position: number, duration: number | undefined): number {
37
+ const floored = Math.max(0, position)
38
+ return duration === undefined ? floored : Math.min(duration, floored)
39
+ }
40
+
41
+ /** The position a key moves the bar to, or undefined when the key is not ours to handle. */
42
+ export function resolveSeekKeyTarget({ key, position, duration }: SeekKeyParams): number | undefined {
43
+ if (key === 'ArrowRight' || key === 'ArrowUp') return clampPosition(position + SEEK_STEP_SECONDS, duration)
44
+ if (key === 'ArrowLeft' || key === 'ArrowDown') return clampPosition(position - SEEK_STEP_SECONDS, duration)
45
+ if (key === 'Home') return 0
46
+ if (key === 'End') return duration
47
+ return undefined
48
+ }
49
+
50
+ export type SeekMaxParams = {
51
+ readonly duration: number | undefined
52
+ readonly maxSeen: number
53
+ readonly position: number
54
+ }
55
+
56
+ /** Known duration wins; otherwise the largest position seen so far keeps the bar coherent while it grows. */
57
+ export function resolveSeekMax({ duration, maxSeen, position }: SeekMaxParams): number {
58
+ if (duration !== undefined) return Math.ceil(duration)
59
+ return Math.ceil(Math.max(maxSeen, position))
60
+ }
61
+
62
+ export type PositionValueTextParams = {
63
+ readonly template: string
64
+ readonly position: number
65
+ readonly duration: number | undefined
66
+ }
67
+
68
+ export function formatPositionValueText({ template, position, duration }: PositionValueTextParams): string {
69
+ const positionText = formatAudioClock(position)
70
+ if (duration === undefined) return positionText
71
+ return template.replace('{position}', positionText).replace('{duration}', formatAudioClock(duration))
72
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Goldens of the participant thread (264, contract.md §2.4, §3): the empty composer must not change, and the
3
+ * audio player and transcription blocks are pinned byte for byte. The composer goldens live in
4
+ * `participantComposerAudioGolden.test.tsx`.
5
+ */
6
+ import { readFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ import { afterEach, beforeAll, describe, expect, it } from 'bun:test'
10
+ import type { ComponentType } from 'react'
11
+ import { renderToStaticMarkup } from 'react-dom/server'
12
+
13
+ import { ParticipantConversations } from './ParticipantConversations'
14
+ import { ParticipantThread } from './ParticipantThread'
15
+ import {
16
+ buildThreadProps,
17
+ renderGoldenScenarios,
18
+ type GoldenComponents,
19
+ } from './participantGoldenScenarios.test-helper'
20
+ import { buildMessage } from './participantFixtures.test-helper'
21
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
22
+ import { installFakeRecording } from './participantRecorderFakes.test-helper'
23
+
24
+ const FIXTURES_DIRECTORY = join(import.meta.dir, 'test-fixtures')
25
+ const PRE_EXISTING_GOLDENS = [
26
+ 'thread-default',
27
+ 'thread-plain',
28
+ 'thread-avatars-initials',
29
+ 'thread-tail-off',
30
+ 'thread-error',
31
+ 'thread-loading',
32
+ 'thread-closed',
33
+ 'conversations-list',
34
+ 'conversations-selected',
35
+ ]
36
+
37
+ function readGolden(name: string): string {
38
+ return readFileSync(join(FIXTURES_DIRECTORY, `${name}.golden.html`), 'utf8')
39
+ }
40
+
41
+ const UntypedThread = ParticipantThread as unknown as ComponentType<Record<string, unknown>>
42
+
43
+ function renderThread(extra: Record<string, unknown>): string {
44
+ const props = { ...buildThreadProps(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS), ...extra }
45
+ return renderToStaticMarkup(<UntypedThread {...props} />)
46
+ }
47
+
48
+ describe('empty composer stays byte-identical to origin/main a7c5c85 (contract 2.4)', () => {
49
+ let restoreEnvironment: (() => void) | undefined
50
+
51
+ beforeAll(() => {
52
+ process.env.TZ = 'UTC'
53
+ })
54
+
55
+ afterEach(() => {
56
+ restoreEnvironment?.()
57
+ restoreEnvironment = undefined
58
+ })
59
+
60
+ it('(a) without the audioRecording prop', () => {
61
+ expect(renderThread({ draft: { value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined } })).toBe(
62
+ readGolden('thread-empty-draft'),
63
+ )
64
+ })
65
+
66
+ it('(b) with audioRecording={{}} on the server render (no MediaRecorder)', () => {
67
+ const markup = renderThread({
68
+ draft: { value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined },
69
+ audioRecording: {},
70
+ })
71
+ expect(markup).toBe(readGolden('thread-empty-draft'))
72
+ })
73
+
74
+ it('(c) with audioRecording={{}}, a fake MediaRecorder and channel portal (portal never records)', () => {
75
+ restoreEnvironment = installFakeRecording().restore
76
+ const markup = renderThread({
77
+ draft: { value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined },
78
+ audioRecording: {},
79
+ channel: 'portal',
80
+ })
81
+ expect(markup).toBe(readGolden('thread-empty-draft'))
82
+ })
83
+ })
84
+
85
+ describe('the existing goldens carry no audio; the audio goldens document the player that replaced the native one', () => {
86
+ it('none of the 9 recorded goldens has an <audio> element', () => {
87
+ expect(PRE_EXISTING_GOLDENS).toHaveLength(9)
88
+ for (const name of PRE_EXISTING_GOLDENS) expect(readGolden(name).match(/<audio/g)?.length ?? 0).toBe(0)
89
+ })
90
+
91
+ it('thread-audio-native was regrouped by 264 T1.5: same attachment without durationMs, now the player (no native controls)', () => {
92
+ const markup = readGolden('thread-audio-native')
93
+ expect(markup.match(/<audio/g)).toHaveLength(1)
94
+ expect(markup).not.toContain('controls')
95
+ expect(markup).not.toContain('cv-p-attachment__audio')
96
+ expect(markup).toContain('0:00 / --:--')
97
+ })
98
+
99
+ it('the helper renders the new scenarios and the 9 existing ones', () => {
100
+ const rendered = renderGoldenScenarios({
101
+ ParticipantThread,
102
+ ParticipantConversations,
103
+ labels: DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
104
+ } as unknown as GoldenComponents)
105
+ for (const name of [...PRE_EXISTING_GOLDENS, 'thread-empty-draft', 'thread-audio-native', 'thread-audio-player'])
106
+ expect(rendered[name]).toBe(readGolden(name))
107
+ })
108
+ })
109
+
110
+ function renderThreadGoldens(): Record<string, string> {
111
+ return renderGoldenScenarios({
112
+ ParticipantThread,
113
+ ParticipantConversations,
114
+ labels: DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
115
+ } as unknown as GoldenComponents)
116
+ }
117
+
118
+ const AUDIO_ATTACHMENT = { id: 'att-1', kind: 'audio', filename: 'voice.webm', mimeType: 'audio/webm', sizeBytes: 2048, durationMs: 65_000 }
119
+ const GENERATED_AT = '2026-10-01T12:01:00.000Z'
120
+
121
+ function audioItems(transcription?: Record<string, unknown>): Record<string, unknown>[] {
122
+ const attachments = [{ ...AUDIO_ATTACHMENT, ...(transcription === undefined ? {} : { transcription }) }]
123
+ return [{ kind: 'server', message: buildMessage({ id: 'au', direction: 'inbound', text: '', attachments: attachments as never, createdAt: '2026-10-01T12:00:00.000Z' }) }]
124
+ }
125
+
126
+ describe('[T1.5] thread-audio-player: the custom player replaces <audio controls> (contract 3.1-3.2)', () => {
127
+ it('thread-audio-player is byte-identical to its golden', () => {
128
+ expect(renderThread({ items: audioItems() })).toBe(readGolden('thread-audio-player'))
129
+ })
130
+
131
+ it('renders the contract 3.1 markup: group, toggle, range, time, rate and a preload="none" audio after which comes the file name', () => {
132
+ const markup = readGolden('thread-audio-player')
133
+ expect(markup).toContain('<div class="cv-p-audio" role="group" aria-label="Audio: voice.webm">')
134
+ expect(markup).toContain('type="range" class="cv-p-audio__seek"')
135
+ expect(markup).toContain('0:00 / 1:05')
136
+ expect(markup).toContain('<audio class="cv-p-audio__media" preload="none"></audio>')
137
+ expect(markup.indexOf('cv-p-audio__media')).toBeLessThan(markup.indexOf('cv-p-attachment__name'))
138
+ })
139
+
140
+ it('no other recorded golden changes: the 9 old ones and the empty draft stay without the player', () => {
141
+ for (const name of [...PRE_EXISTING_GOLDENS, 'thread-empty-draft']) expect(readGolden(name)).not.toContain('cv-p-audio')
142
+ expect(Object.keys(renderThreadGoldens()).sort()).toEqual(
143
+ [...PRE_EXISTING_GOLDENS, 'thread-empty-draft', 'thread-audio-native', 'thread-audio-player'].sort(),
144
+ )
145
+ })
146
+ })
147
+
148
+ describe('[T1.6] thread-audio-transcription-*: the block under the player (contract 3.3)', () => {
149
+ const scenarios: ReadonlyArray<readonly [string, Record<string, unknown>]> = [
150
+ ['pending', { status: 'pending' }],
151
+ ['ready', { status: 'ready', text: 'Chego em dez minutos.', language: 'pt', engine: 'whisper-local', generatedAt: GENERATED_AT }],
152
+ ['empty', { status: 'ready', text: '', generatedAt: GENERATED_AT }],
153
+ ['too_long', { status: 'failed', reason: 'too_long', maxDurationMs: 300_000 }],
154
+ ]
155
+
156
+ for (const [state, transcription] of scenarios) {
157
+ it(`thread-audio-transcription-${state} is byte-identical to its golden`, () => {
158
+ expect(renderThread({ items: audioItems(transcription) })).toBe(readGolden(`thread-audio-transcription-${state}`))
159
+ })
160
+ }
161
+
162
+ it('absent, disabled and plain failed transcriptions render only the player', () => {
163
+ const playerOnly = renderThread({ items: audioItems() })
164
+ for (const transcription of [{ status: 'disabled' }, { status: 'failed' }]) {
165
+ expect(renderThread({ items: audioItems(transcription) })).toBe(playerOnly)
166
+ }
167
+ })
168
+ })
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { DEFAULT_PARTICIPANT_AUDIO_LABELS, formatRecordingElapsedLabel, resolveParticipantAudioLabels } from './participantAudioLabels'
4
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
5
+
6
+ describe('participant audio labels', () => {
7
+ it('fills every missing key with the English default', () => {
8
+ expect(resolveParticipantAudioLabels(undefined)).toEqual(DEFAULT_PARTICIPANT_AUDIO_LABELS)
9
+ expect(resolveParticipantAudioLabels({ sendAudio: 'Enviar áudio' }).stopRecording).toBe('Stop recording')
10
+ expect(resolveParticipantAudioLabels({ sendAudio: 'Enviar áudio' }).sendAudio).toBe('Enviar áudio')
11
+ })
12
+
13
+ it('is carried by the default map of the whole view', () => {
14
+ for (const key of Object.keys(DEFAULT_PARTICIPANT_AUDIO_LABELS) as (keyof typeof DEFAULT_PARTICIPANT_AUDIO_LABELS)[]) {
15
+ expect(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS[key]).toBe(DEFAULT_PARTICIPANT_AUDIO_LABELS[key])
16
+ }
17
+ })
18
+
19
+ it('fills the elapsed and limit placeholders', () => {
20
+ expect(formatRecordingElapsedLabel('{elapsed} of {limit}', '0:12', '5:00')).toBe('0:12 of 5:00')
21
+ })
22
+ })
@@ -0,0 +1,76 @@
1
+ import type { ParticipantConversationsLabels } from './participantLabels'
2
+
3
+ export type ParticipantAudioLabels = {
4
+ readonly recordAudio: string
5
+ readonly recordingInProgress: string
6
+ readonly stopRecording: string
7
+ /** Uses the `{elapsed}` and `{limit}` placeholders. */
8
+ readonly recordingElapsed: string
9
+ readonly reviewAudio: string
10
+ readonly sendAudio: string
11
+ readonly discardAudio: string
12
+ readonly microphoneDenied: string
13
+ readonly recordingUnsupported: string
14
+ readonly audioEmpty: string
15
+ readonly audioTooLarge: string
16
+ readonly audioPlayer: string
17
+ readonly playAudio: string
18
+ readonly pauseAudio: string
19
+ readonly audioPosition: string
20
+ /** Uses the `{position}` and `{duration}` placeholders; without a duration only the position is spoken. */
21
+ readonly audioPositionValue: string
22
+ /** Uses the `{rate}` placeholder. */
23
+ readonly playbackRate: string
24
+ readonly audioUnavailable: string
25
+ readonly transcribing: string
26
+ /** Spoken by the thread's live region when a transcription finishes. */
27
+ readonly transcriptionReady: string
28
+ readonly machineTranscription: string
29
+ readonly emptyTranscription: string
30
+ /** Uses the `{minutes}` placeholder, filled from the limit the server applied. */
31
+ readonly transcriptionTooLong: string
32
+ readonly showFullTranscription: string
33
+ readonly collapseTranscription: string
34
+ }
35
+
36
+ export const DEFAULT_PARTICIPANT_AUDIO_LABELS: ParticipantAudioLabels = {
37
+ recordAudio: 'Record audio',
38
+ recordingInProgress: 'Recording',
39
+ stopRecording: 'Stop recording',
40
+ recordingElapsed: '{elapsed} of {limit}',
41
+ reviewAudio: 'Listen before sending',
42
+ sendAudio: 'Send audio',
43
+ discardAudio: 'Discard audio',
44
+ microphoneDenied: 'No permission to use the microphone.',
45
+ recordingUnsupported: 'This device cannot record audio.',
46
+ audioEmpty: 'Nothing was captured by the microphone.',
47
+ audioTooLarge: 'Audio too large to send.',
48
+ audioPlayer: 'Audio',
49
+ playAudio: 'Play audio',
50
+ pauseAudio: 'Pause audio',
51
+ audioPosition: 'Audio position',
52
+ audioPositionValue: '{position} of {duration}',
53
+ playbackRate: 'Playback speed {rate}',
54
+ audioUnavailable: 'Audio unavailable',
55
+ transcribing: 'Transcribing…',
56
+ transcriptionReady: 'Transcription ready',
57
+ machineTranscription: 'Transcription generated by machine',
58
+ emptyTranscription: 'Nothing was said in the audio',
59
+ transcriptionTooLong: 'Transcription only for audios shorter than {minutes} minutes',
60
+ showFullTranscription: 'Show all',
61
+ collapseTranscription: 'Show less',
62
+ }
63
+
64
+ export function resolveParticipantAudioLabels(
65
+ labels: Partial<ParticipantAudioLabels> | ParticipantConversationsLabels | undefined,
66
+ ): ParticipantAudioLabels {
67
+ const resolved = { ...DEFAULT_PARTICIPANT_AUDIO_LABELS }
68
+ for (const key of Object.keys(resolved) as (keyof ParticipantAudioLabels)[]) {
69
+ resolved[key] = labels?.[key] ?? DEFAULT_PARTICIPANT_AUDIO_LABELS[key]
70
+ }
71
+ return resolved
72
+ }
73
+
74
+ export function formatRecordingElapsedLabel(template: string, elapsed: string, limit: string): string {
75
+ return template.replace('{elapsed}', elapsed).replace('{limit}', limit)
76
+ }
@@ -0,0 +1,123 @@
1
+ import { act } from 'react'
2
+
3
+ import { describe, expect, it } from 'bun:test'
4
+
5
+ import { pressKey, wait } from '../domHarness.test-helper'
6
+ import { ParticipantAudioRecorderView } from './ParticipantAudioRecorderView'
7
+ import { DEFAULT_PARTICIPANT_AUDIO_LABELS } from './participantAudioLabels'
8
+ import { buildAttachment, setupPlayerDom } from './participantAudioPlayerDomKit.test-helper'
9
+
10
+ const kit = setupPlayerDom()
11
+ const { mountAttachments, audioOf, toggleOf, find, tap } = kit
12
+
13
+ describe('time and range', () => {
14
+ it('shows the server duration before the first tap', async () => {
15
+ await mountAttachments([buildAttachment('a', 65_000)])
16
+ expect(find('.cv-p-audio__time').textContent).toBe('0:00 / 1:05')
17
+ expect(find<HTMLInputElement>('.cv-p-audio__seek').getAttribute('max')).toBe('65')
18
+ })
19
+
20
+ it('uses loadedmetadata when the server sent no duration', async () => {
21
+ await mountAttachments([buildAttachment('a')])
22
+ expect(find('.cv-p-audio__time').textContent).toBe('0:00 / --:--')
23
+ kit.media.stateOf(audioOf()).duration = 42
24
+ act(() => kit.media.emit(audioOf(), 'loadedmetadata'))
25
+ expect(find('.cv-p-audio__time').textContent).toBe('0:00 / 0:42')
26
+ })
27
+
28
+ it('follows timeupdate and seeks by dragging the range', async () => {
29
+ await mountAttachments([buildAttachment('a', 65_000)])
30
+ kit.media.stateOf(audioOf()).currentTime = 12
31
+ act(() => kit.media.emit(audioOf(), 'timeupdate'))
32
+ expect(find('.cv-p-audio__time').textContent).toBe('0:12 / 1:05')
33
+ expect(find('.cv-p-audio__seek').getAttribute('aria-valuetext')).toBe('0:12 of 1:05')
34
+ const range = find<HTMLInputElement>('.cv-p-audio__seek')
35
+ act(() => {
36
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(range, '30')
37
+ range.dispatchEvent(new Event('input', { bubbles: true }))
38
+ })
39
+ expect(kit.media.stateOf(audioOf()).currentTime).toBe(30)
40
+ })
41
+
42
+ it('steps 5 seconds with the arrows and jumps with Home and End', async () => {
43
+ await mountAttachments([buildAttachment('a', 65_000)])
44
+ const range = find('.cv-p-audio__seek')
45
+ pressKey(range, 'ArrowRight')
46
+ expect(kit.media.stateOf(audioOf()).currentTime).toBe(5)
47
+ pressKey(range, 'ArrowRight')
48
+ pressKey(range, 'ArrowLeft')
49
+ expect(kit.media.stateOf(audioOf()).currentTime).toBe(5)
50
+ pressKey(range, 'End')
51
+ expect(kit.media.stateOf(audioOf()).currentTime).toBe(65)
52
+ pressKey(range, 'Home')
53
+ expect(kit.media.stateOf(audioOf()).currentTime).toBe(0)
54
+ })
55
+
56
+ it('tolerates an infinite duration: no broken max, value text with the position only', async () => {
57
+ await mountAttachments([buildAttachment('a')])
58
+ kit.media.stateOf(audioOf()).duration = Number.POSITIVE_INFINITY
59
+ act(() => kit.media.emit(audioOf(), 'durationchange'))
60
+ kit.media.stateOf(audioOf()).currentTime = 12
61
+ act(() => kit.media.emit(audioOf(), 'timeupdate'))
62
+ const range = find('.cv-p-audio__seek')
63
+ expect(range.getAttribute('max')).toBe('12')
64
+ expect(range.getAttribute('aria-valuetext')).toBe('0:12')
65
+ expect(find('.cv-p-audio__time').textContent).toBe('0:12 / --:--')
66
+ })
67
+ })
68
+
69
+ describe('speed', () => {
70
+ it('cycles 1x, 1.5x, 2x applying defaultPlaybackRate and playbackRate', async () => {
71
+ await mountAttachments([buildAttachment('a')])
72
+ const rate = find('.cv-p-audio__rate')
73
+ expect(rate.textContent).toBe('1×')
74
+ tap(rate)
75
+ expect(rate.textContent).toBe('1.5×')
76
+ expect(rate.getAttribute('aria-label')).toBe('Playback speed 1.5×')
77
+ expect(kit.media.stateOf(audioOf()).defaultPlaybackRate).toBe(1.5)
78
+ expect(kit.media.stateOf(audioOf()).playbackRate).toBe(1.5)
79
+ tap(rate)
80
+ expect(kit.media.stateOf(audioOf()).playbackRate).toBe(2)
81
+ tap(rate)
82
+ expect(kit.media.stateOf(audioOf()).playbackRate).toBe(1)
83
+ })
84
+ })
85
+
86
+ describe('expired signed URL', () => {
87
+ it('re-resolves once on a media error, swaps the src and resumes from the same position', async () => {
88
+ await mountAttachments([buildAttachment('a', 65_000)])
89
+ tap(toggleOf())
90
+ kit.media.stateOf(audioOf()).currentTime = 12
91
+ act(() => kit.media.emit(audioOf(), 'error'))
92
+ await wait(0)
93
+ expect(kit.resolverCalls).toEqual(['a', 'a'])
94
+ expect(audioOf().getAttribute('src')).toBe('https://files.test/a-2.webm')
95
+ expect(kit.media.stateOf(audioOf()).currentTime).toBe(12)
96
+ expect(kit.media.playCalls.length).toBe(2)
97
+ })
98
+
99
+ it('gives up after the single retry and says the audio is unavailable', async () => {
100
+ await mountAttachments([buildAttachment('a', 65_000)])
101
+ tap(toggleOf())
102
+ act(() => kit.media.emit(audioOf(), 'error'))
103
+ await wait(0)
104
+ act(() => kit.media.emit(audioOf(), 'error'))
105
+ await wait(0)
106
+ expect(kit.resolverCalls).toEqual(['a', 'a'])
107
+ expect(kit.container.textContent).toContain(DEFAULT_PARTICIPANT_AUDIO_LABELS.audioUnavailable)
108
+ })
109
+ })
110
+
111
+ describe('recorder review reuses the same player', () => {
112
+ it('draws the player with the object URL, hands the audio element to the recorder ref and shows the recorded duration', () => {
113
+ const audioRef: { current: HTMLAudioElement | null } = { current: null }
114
+ kit.container = kit.harness.mount(
115
+ <ParticipantAudioRecorderView state="review" elapsedMs={0} review={{ url: 'blob:fake', audioRef, durationMs: 12_000 }} />,
116
+ ).container
117
+ expect(kit.container.querySelector('.cv-p-audio')).not.toBeNull()
118
+ expect(audioOf().getAttribute('src')).toBe('blob:fake')
119
+ expect(audioOf().getAttribute('preload')).toBe('none')
120
+ expect(audioRef.current).toBe(audioOf())
121
+ expect(find('.cv-p-audio__time').textContent).toBe('0:00 / 0:12')
122
+ })
123
+ })
@@ -0,0 +1,63 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { describe, expect, it } from 'bun:test'
5
+
6
+ import { declarationsIn, mediaBody, PARTICIPANT_CSS } from './participantCss.test-helper'
7
+
8
+ describe('player styles (.cv-p-audio*)', () => {
9
+ it('keeps both buttons and the bar at the touch size, from the existing tokens', () => {
10
+ for (const selector of ['.cv-p-audio__seek']) expect(declarationsIn(selector).get('min-height')).toBe('var(--cv-p-i-touch)')
11
+ expect(PARTICIPANT_CSS).toContain('min-width: var(--cv-p-i-touch); min-height: var(--cv-p-i-touch)')
12
+ })
13
+
14
+ it('has a width floor that never exceeds the bubble, so the play button stays visible at 375 px', () => {
15
+ expect(declarationsIn('.cv-p-audio').get('min-width')).toBe('min(15rem, 100%)')
16
+ })
17
+
18
+ it('does not draw the media element and defines no utility-class dependency', () => {
19
+ expect(declarationsIn('.cv-p-audio__media').get('display')).toBe('none')
20
+ expect(PARTICIPANT_CSS).not.toMatch(/@apply|\btw-/)
21
+ })
22
+ })
23
+
24
+ describe('transcription styles (.cv-p-transcription*)', () => {
25
+ it('clamps the text at six lines and keeps it selectable', () => {
26
+ const text = declarationsIn('.cv-p-transcription__text')
27
+ expect(text.get('-webkit-line-clamp')).toBe('6')
28
+ expect(text.get('user-select')).toBe('text')
29
+ expect(declarationsIn(".cv-p-transcription__text[data-expanded='true']").get('overflow')).toBe('visible')
30
+ })
31
+
32
+ it('gives the see-all button the touch size and a forced-colors outline, from existing tokens', () => {
33
+ const toggle = declarationsIn('.cv-p-transcription__toggle')
34
+ expect(toggle.get('min-height')).toBe('var(--cv-p-i-touch)')
35
+ expect(toggle.get('min-width')).toBe('var(--cv-p-i-touch)')
36
+ expect(mediaBody('(forced-colors: active)')).toContain('.cv-p-transcription__toggle')
37
+ })
38
+
39
+ it('has no motion, so reduced-motion is respected by construction', () => {
40
+ expect(PARTICIPANT_CSS).not.toMatch(/\.cv-p-transcription[^{]*\{[^}]*(transition|animation)/)
41
+ })
42
+
43
+ it('uses only existing tokens and no utility-class dependency', () => {
44
+ const rules = PARTICIPANT_CSS.split('\n').filter((line) => line.startsWith('.cv-p-transcription'))
45
+ expect(rules.length).toBeGreaterThan(5)
46
+ for (const variable of rules.join('\n').match(/var\(--cv-p-i-[a-z-]+\)/g) ?? []) {
47
+ expect(PARTICIPANT_CSS).toContain(`${variable.slice(4, -1)}:`)
48
+ }
49
+ expect(rules.join('\n')).not.toMatch(/@apply|\btw-/)
50
+ })
51
+ })
52
+
53
+ describe('no dead audio selectors', () => {
54
+ it('every .cv-p-recorder*, .cv-p-audio* and .cv-p-transcription* class is rendered by a component', () => {
55
+ const sources = readdirSync(import.meta.dir)
56
+ .filter((name) => name.endsWith('.tsx') && !name.includes('.test'))
57
+ .map((name) => readFileSync(join(import.meta.dir, name), 'utf8'))
58
+ .join('\n')
59
+ const classNames = new Set(PARTICIPANT_CSS.match(/\.cv-p-(?:recorder|audio|transcription)[a-z_-]*/g) ?? [])
60
+ expect(classNames.size).toBeGreaterThan(10)
61
+ for (const selector of classNames) expect(sources).toContain(selector.slice(1))
62
+ })
63
+ })