@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,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
|
+
})
|