diff --git a/packages/lib/package.json b/packages/lib/package.json index 01f5154..8bb5a86 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -15,6 +15,7 @@ "dev": "vite build --watch", "build": "pnpm test && pnpm lint && vite build", "test": "vitest run", + "test-coverage": "vitest run --coverage", "lint": "eslint src --ext .ts,.js,.mjs,.mts" }, "dependencies": { @@ -26,6 +27,7 @@ "@types/node": "^22.14.0", "@typescript-eslint/eslint-plugin": "^8.34.0", "@typescript-eslint/parser": "^8.34.0", + "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.28.0", "events": "^3.0.0", "typescript": "^5.8.3", diff --git a/packages/lib/src/PalabraClient.model.ts b/packages/lib/src/PalabraClient.model.ts index 7850188..c85724c 100644 --- a/packages/lib/src/PalabraClient.model.ts +++ b/packages/lib/src/PalabraClient.model.ts @@ -15,7 +15,7 @@ export interface PalabraClientData { translateFrom: SourceLangCode; translateTo: TargetLangCode; handleOriginalTrack: () => Promise; - transportType?: 'webrtc'; + transportType?: 'webrtc'; // TODO: add websocket transport | 'websocket' apiBaseUrl?: string; } diff --git a/packages/lib/src/PalabraClient.ts b/packages/lib/src/PalabraClient.ts index d321213..8bded7c 100644 --- a/packages/lib/src/PalabraClient.ts +++ b/packages/lib/src/PalabraClient.ts @@ -149,13 +149,13 @@ export class PalabraClient extends PalabraBaseEventEmitter { this.sessionData = null; } - public async setTranslateFrom(code:PalabraClientData['translateFrom']) { + public async setTranslateFrom(code: PalabraClientData['translateFrom']) { this.translateFrom = code; this.configManager.setSourceLanguage(code as SourceLangCode); await this.transport.setTask(this.configManager.getConfig()); } - public async setTranslateTo(code:PalabraClientData['translateTo']) { + public async setTranslateTo(code: PalabraClientData['translateTo']) { this.translateTo = code; this.configManager.getConfig().pipeline.translations diff --git a/packages/lib/src/__tests__/PalabraClient.test.ts b/packages/lib/src/__tests__/PalabraClient.test.ts index b597e6c..8e433d5 100644 --- a/packages/lib/src/__tests__/PalabraClient.test.ts +++ b/packages/lib/src/__tests__/PalabraClient.test.ts @@ -1,17 +1,213 @@ -import { describe, it, expect } from 'vitest'; -import { PalabraClient } from '~/PalabraClient'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PalabraClient } from '../PalabraClient'; +import type { TargetLangCode } from '../utils/target'; +import type { SourceLangCode } from '../utils/source'; +import { EVENT_START_TRANSLATION, EVENT_STOP_TRANSLATION } from '../transport/PalabraWebRtcTransport.model'; + +// Mock MediaStreamTrack for tests +class MockMediaStreamTrack { + enabled = true; + id = ''; + kind = 'audio'; + label = ''; + contentHint = ''; + muted = false; + onended = null; + onmute = null; + onunmute = null; + readyState = 'live'; + getConstraints() { return {}; } + stop() { /* mock */ } + addEventListener() { /* mock */ } + removeEventListener() { /* mock */ } + applyConstraints() { return Promise.resolve(); } + clone() { return this; } + getCapabilities() { return {}; } + getSettings() { return {}; } + dispatchEvent() { return true; } +} +if (typeof global.MediaStreamTrack === 'undefined') { + // @ts-expect-error: Assigning mock class to global.MediaStreamTrack for test environment compatibility + global.MediaStreamTrack = MockMediaStreamTrack; +} + +class MockMediaStream { + active = true; + id = 'mock-stream-id'; + onaddtrack = null; + onremovetrack = null; + addTrack() { /* mock */ } + removeTrack() { /* mock */ } + getTracks() { return this.getAudioTracks(); } + getAudioTracks() { return [new MockMediaStreamTrack()]; } + getVideoTracks() { return []; } + dispatchEvent() { return true; } + addEventListener() { /* mock */ } + removeEventListener() { /* mock*/ } + clone() { return this; } + getTrackById(id: string) { return this.getAudioTracks().find(track => track.id === id) || null; } +} + +// ReferenceError: MediaStream is not defined +(globalThis as unknown as { MediaStream: typeof MockMediaStream }).MediaStream = MockMediaStream; + +// Mock AudioContext +if (typeof global.AudioContext === 'undefined') { + // @ts-expect-error: mock for test environment + global.AudioContext = class { + close() { return Promise.resolve(); } + }; +} + +// Mock PalabraApiClient +vi.mock('../api/api', () => ({ + PalabraApiClient: vi.fn().mockImplementation(() => ({ + createStreamingSession: vi.fn().mockResolvedValue({ + ok: true, + data: { + id: 'session-id', + webrtc_url: 'wss://test', + publisher: 'token', + }, + }), + deleteStreamingSession: vi.fn().mockResolvedValue({ ok: true }), + })), +})); + +vi.mock('../transport/PalabraWebRtcTransport', () => ({ + PalabraWebRtcTransport: vi.fn().mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + setTask: vi.fn().mockResolvedValue(undefined), + on: vi.fn().mockImplementation(() => { /* mock */ }), + })), +})); + +const baseConstructorData = { + auth: { + clientId: 'test', + clientSecret: 'test', + }, + translateFrom: 'en' as SourceLangCode, + translateTo: 'es' as TargetLangCode, + handleOriginalTrack: () => Promise.resolve(new MediaStreamTrack()), +}; describe('PalabraClient', () => { + let client: PalabraClient; + + beforeEach(() => { + client = new PalabraClient(baseConstructorData); + }); + it('should create a new PalabraClient', () => { - const client = new PalabraClient({ - auth: { - clientId: 'test', - clientSecret: 'test', - }, - translateFrom: 'en', - translateTo: 'es', - handleOriginalTrack: () => Promise.resolve(new MediaStreamTrack()), - }); expect(client).toBeDefined(); }); + + it('should startTranslation and emit EVENT_START_TRANSLATION', async () => { + const emitSpy = vi.spyOn(client as unknown as { emit: (...args: unknown[]) => void }, 'emit'); + const result = await client.startTranslation(); + expect(result).toBe(true); + expect(emitSpy).toHaveBeenCalledWith(EVENT_START_TRANSLATION); + expect((client as unknown as { transport: unknown }).transport).toBeDefined(); + }); + + it('should stopTranslation and emit EVENT_STOP_TRANSLATION', async () => { + const emitSpy = vi.spyOn(client as unknown as { emit: (...args: unknown[]) => void }, 'emit'); + await client.startTranslation(); + await client.stopTranslation(); + expect(emitSpy).toHaveBeenCalledWith(EVENT_STOP_TRANSLATION); + expect((client as unknown as { transport: unknown }).transport).toBeNull(); + }); + + it('should startPlayback and call playTracks', async () => { + const playTracksSpy = vi.spyOn(client as unknown as { playTracks: () => void }, 'playTracks').mockImplementation(() => undefined); + const initAudioContextSpy = vi.spyOn(client as unknown as { initAudioContext: () => void }, 'initAudioContext').mockImplementation(() => undefined); + await client.startPlayback(); + expect(playTracksSpy).toHaveBeenCalled(); + expect(initAudioContextSpy).toHaveBeenCalled(); + expect((client as unknown as { shouldPlayTranslation: boolean }).shouldPlayTranslation).toBe(true); + }); + + it('should stopPlayback and reset context', async () => { + const resetSpy = vi.spyOn(client as unknown as { resetPlayTranslationContext: () => void }, 'resetPlayTranslationContext').mockImplementation(() => undefined); + await client.stopPlayback(); + expect(resetSpy).toHaveBeenCalled(); + expect((client as unknown as { shouldPlayTranslation: boolean }).shouldPlayTranslation).toBe(false); + }); + + it('should mute and unmute original track', async () => { + await client.startTranslation(); + (client as unknown as { originalTrack: MockMediaStreamTrack }).originalTrack = new MockMediaStreamTrack(); + client.muteOriginalTrack(); + expect((client as unknown as { originalTrack: MockMediaStreamTrack }).originalTrack.enabled).toBe(false); + client.unmuteOriginalTrack(); + expect((client as unknown as { originalTrack: MockMediaStreamTrack }).originalTrack.enabled).toBe(true); + }); + + it('should get config', () => { + const config = client.getConfig(); + expect(config).toBeDefined(); + expect(config.pipeline).toBeDefined(); + }); + + it('should delete session', async () => { + await client.startTranslation(); + expect((client as unknown as { sessionData: unknown }).sessionData).not.toBeNull(); + await client.deleteSession(); + expect((client as unknown as { sessionData: unknown }).sessionData).toBeNull(); + }); + + it('should setTranslateFrom and call setTask', async () => { + await client.startTranslation(); + const setTaskSpy = vi.spyOn((client as unknown as { transport: { setTask: (...args: unknown[]) => Promise } }).transport, 'setTask').mockResolvedValue(undefined); + await client.setTranslateFrom('fr' as SourceLangCode); + expect(setTaskSpy).toHaveBeenCalled(); + }); + + it('should setTranslateTo and call setTask', async () => { + await client.startTranslation(); + const setTaskSpy = vi.spyOn((client as unknown as { transport: { setTask: (...args: unknown[]) => Promise } }).transport, 'setTask').mockResolvedValue(undefined); + await client.setTranslateTo('fr' as TargetLangCode); + expect(setTaskSpy).toHaveBeenCalled(); + }); + + it('should addTranslationTarget and call setTask', async () => { + await client.startTranslation(); + const setTaskSpy = vi.spyOn((client as unknown as { transport: { setTask: (...args: unknown[]) => Promise } }).transport, 'setTask').mockResolvedValue(undefined); + await client.addTranslationTarget('de' as TargetLangCode); + expect(setTaskSpy).toHaveBeenCalled(); + expect(client.getConfig().pipeline.translations[1].target_language).toBe('de'); + }); + + it('should removeTranslationTarget (single) and call setTask', async () => { + await client.startTranslation(); + expect(client.getConfig().pipeline.translations.length).toBe(1); + const setTaskSpy = vi.spyOn((client as unknown as { transport: { setTask: (...args: unknown[]) => Promise } }).transport, 'setTask').mockResolvedValue(undefined); + await client.removeTranslationTarget('es' as TargetLangCode); + expect(setTaskSpy).toHaveBeenCalled(); + expect(client.getConfig().pipeline.translations.length).toBe(0); + }); + + it('should removeTranslationTarget (array) and call setTask', async () => { + await client.startTranslation(); + expect(client.getConfig().pipeline.translations.length).toBe(1); + await client.addTranslationTarget('de' as TargetLangCode); + await client.addTranslationTarget('fr' as TargetLangCode); + expect(client.getConfig().pipeline.translations.length).toBe(3); + const setTaskSpy = vi.spyOn((client as unknown as { transport: { setTask: (...args: unknown[]) => Promise } }).transport, 'setTask').mockResolvedValue(undefined); + await client.removeTranslationTarget(['es', 'fr'] as TargetLangCode[]); + expect(setTaskSpy).toHaveBeenCalled(); + expect(client.getConfig().pipeline.translations.length).toBe(1); + }); + + it('should cleanup call stopTranslation, stopPlayback, and initConfig', async () => { + const stopTranslationSpy = vi.spyOn(client, 'stopTranslation').mockResolvedValue(undefined); + const stopPlaybackSpy = vi.spyOn(client, 'stopPlayback').mockResolvedValue(undefined); + const initConfigSpy = vi.spyOn(client as unknown as { initConfig: () => void }, 'initConfig').mockImplementation(() => undefined); + await client.cleanup(); + expect(stopTranslationSpy).toHaveBeenCalled(); + expect(stopPlaybackSpy).toHaveBeenCalled(); + expect(initConfigSpy).toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/packages/lib/src/__tests__/PalabraWebRtcTransport.test.ts b/packages/lib/src/__tests__/PalabraWebRtcTransport.test.ts new file mode 100644 index 0000000..28bebe5 --- /dev/null +++ b/packages/lib/src/__tests__/PalabraWebRtcTransport.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { PalabraWebRtcTransport } from '../transport/PalabraWebRtcTransport'; +import { EVENT_CONNECTION_STATE_CHANGED, EVENT_DATA_RECEIVED, EVENT_REMOTE_TRACKS_UPDATE, EVENT_ROOM_CONNECTED, EVENT_ROOM_DISCONNECTED, type PalabraWebRtcTransportConstructor } from '../transport/PalabraWebRtcTransport.model'; +import { vi, type Mock } from 'vitest'; +import { PipelineConfigManager } from '../config/PipelineConfigManager'; +import { PalabraBaseEventEmitter } from '../PalabraBaseEventEmitter'; +import { RemoteParticipant, RemoteTrack, RoomEvent, TrackPublication } from 'livekit-client'; +import * as dataFilters from '../utils/data-filters'; + +class MockMediaStream { + active = true; + id = 'mock-stream-id'; + onaddtrack = null; + onremovetrack = null; + addTrack() { /* mock addTrack */ } + removeTrack() { /* mock removeTrack */ } + getTracks() { return this.getAudioTracks(); } + getAudioTracks() { return [new MockMediaStreamTrack()]; } + getVideoTracks() { return []; } + dispatchEvent() { return true; } + addEventListener() { /* mock addEventListener */ } + removeEventListener() { /* mock removeEventListener */ } + clone() { return this; } + getTrackById(id: string) { return this.getAudioTracks().find(track => track.id === id) || null; } +} + +// ReferenceError: MediaStream is not defined +(globalThis as unknown as { MediaStream: typeof MockMediaStream }).MediaStream = MockMediaStream; + +// For storing RoomEvent handlers +const roomEventHandlers: Record void> = {}; + +vi.mock('livekit-client', async () => { + const actual = await vi.importActual('livekit-client') as Record; + return { + ...actual, + Room: vi.fn().mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + prepareConnection: vi.fn(), + on: vi.fn((event, handler) => { + roomEventHandlers[event] = handler; + }), + localParticipant: { + publishTrack: vi.fn().mockResolvedValue(undefined), + publishData: vi.fn().mockResolvedValue(undefined), + }, + remoteParticipants: new Map(), + state: 'disconnected', + })), + RoomEvent: actual.RoomEvent, + }; +}); + +class MockMediaStreamTrack implements MediaStreamTrack { + id = 'mock-track-id'; + contentHint = ''; + enabled = true; + kind = 'audio'; + label = 'mock-label'; + muted = false; + onended: EventListener | null = null; + onmute: EventListener | null = null; + onunmute: EventListener | null = null; + readyState: MediaStreamTrackState = 'live'; + getConstraints() { return {}; } + stop() { /* mock stop */ } + addEventListener() { /* mock addEventListener */ } + removeEventListener() { /* mock removeEventListener */ } + applyConstraints() { return Promise.resolve(); } + clone() { return this; } + getCapabilities() { return {}; } + getSettings() { return {}; } + dispatchEvent() { return true; } +} + +describe('PalabraWebRtcTransport', () => { + const data: PalabraWebRtcTransportConstructor = { + streamUrl: 'wss://test', + accessToken: 'token', + inputStream: new MockMediaStream() as MediaStream, + configManager: new PipelineConfigManager(), + }; + let transport: PalabraWebRtcTransport; + + beforeEach(() => { + transport = new PalabraWebRtcTransport(data); + }); + + it('should create a new PalabraWebRtcTransport', () => { + expect(transport).toBeDefined(); + expect(transport.getRoom()).toBeDefined(); + expect(transport.getConnectionState()).toBeDefined(); + expect(transport.getParticipants()).toBeDefined(); + expect(transport.getParticipants()).toEqual([]); + expect(transport.isConnected()).toBeDefined(); + }); + + it('should call connect and its dependencies', async () => { + const setTaskSpy = vi.spyOn(transport, 'setTask').mockResolvedValue(undefined); + // @ts-expect-error: mock publishInputAudio, which is not visible to the TS types + const publishInputAudioSpy = vi.spyOn(transport, 'publishInputAudio').mockResolvedValue(undefined); + + await expect(transport.connect()).resolves.not.toThrow(); + + expect(transport.getRoom().connect).toHaveBeenCalledWith('wss://test', 'token', { autoSubscribe: true }); + expect(setTaskSpy).toHaveBeenCalled(); + expect(publishInputAudioSpy).toHaveBeenCalled(); + }); + + it('should call disconnect and its dependencies', async () => { + const mockEndTask = vi.spyOn(transport, 'endTask').mockResolvedValue(undefined); + // @ts-expect-error: mock cleanupAudioResources, which is not visible to the TS types + const mockCleanupAudioResources = vi.spyOn(transport, 'cleanupAudioResources').mockResolvedValue(undefined); + + await expect(transport.disconnect()).resolves.not.toThrow(); + + expect(transport.getRoom().disconnect).toHaveBeenCalled(); + expect(mockEndTask).toHaveBeenCalled(); + expect(mockCleanupAudioResources).toHaveBeenCalled(); + + expect(mockEndTask.mock.invocationCallOrder[0]).toBeLessThan((transport.getRoom().disconnect as Mock).mock.invocationCallOrder[0]); + expect((transport.getRoom().disconnect as Mock).mock.invocationCallOrder[0]).toBeLessThan(mockCleanupAudioResources.mock.invocationCallOrder[0]); + }); + + it('should call setTask and its dependencies', async () => { + const mockSendCommand = vi.spyOn(transport, 'sendCommand').mockResolvedValue(undefined); + // @ts-expect-error: mock createHashForAllowedMessageTypes, which is not visible to the TS types + const mockCreateHashForAllowedMessageTypes = vi.spyOn(transport, 'createHashForAllowedMessageTypes').mockResolvedValue(undefined); + const config = new PipelineConfigManager().getConfig(); + await expect(transport.setTask(config)).resolves.not.toThrow(); + expect(mockSendCommand).toHaveBeenCalledWith('set_task', config); + expect(mockCreateHashForAllowedMessageTypes).toHaveBeenCalled(); + expect(mockCreateHashForAllowedMessageTypes).toHaveBeenCalledWith(config.pipeline.allowed_message_types); + expect(mockCreateHashForAllowedMessageTypes.mock.invocationCallOrder[0]).toBeLessThan(mockSendCommand.mock.invocationCallOrder[0]); + }); + + it('should call endTask and its dependencies', async () => { + const mockSendCommand = vi.spyOn(transport, 'sendCommand').mockResolvedValue(undefined); + await expect(transport.endTask()).resolves.not.toThrow(); + expect(mockSendCommand).toHaveBeenCalledWith('end_task', { 'force': false }); + }); + + it('should call publishInputAudio and its dependencies', async () => { + // @ts-expect-error: mock publishInputAudio, which is not visible to the TS types + await expect(transport.publishInputAudio()).resolves.not.toThrow(); + expect(transport.getRoom().localParticipant.publishTrack).toHaveBeenCalled(); + }); + + it('should publishInputAudio on connect', async () => { + await expect(transport.connect()).resolves.not.toThrow(); + expect(transport.getRoom().localParticipant.publishTrack).toHaveBeenCalled(); + }); + + it('transport should call emit EVENT_ROOM_CONNECTED when room emit RoomEvent.Connected', async () => { + const emitSpy = vi.spyOn(transport as unknown as PalabraBaseEventEmitter, 'emit'); + roomEventHandlers[RoomEvent.Connected](); + expect(emitSpy).toHaveBeenCalledWith(EVENT_ROOM_CONNECTED); + }); + it('transport should call emit EVENT_ROOM_DISCONNECTED when room emit RoomEvent.Disconnected', async () => { + const emitSpy = vi.spyOn(transport as unknown as PalabraBaseEventEmitter, 'emit'); + roomEventHandlers[RoomEvent.Disconnected](); + expect(emitSpy).toHaveBeenCalledWith(EVENT_ROOM_DISCONNECTED); + }); + it('transport should call emit EVENT_REMOTE_TRACKS_UPDATE when room emit RoomEvent.TrackSubscribed', async () => { + const emitSpy = vi.spyOn(transport as unknown as PalabraBaseEventEmitter, 'emit'); + const mockTrack = { sid: 'mock-sid', kind: 'audio', mediaStreamTrack: {} } as RemoteTrack; + const mockPublication = { trackName: 'audio_en', trackSid: 'mock-sid' } as TrackPublication; + const mockParticipant = { identity: 'user1' } as RemoteParticipant; + // @ts-expect-error: mock RoomEvent.TrackSubscribed + roomEventHandlers[RoomEvent.TrackSubscribed](mockTrack, mockPublication, mockParticipant); + expect(emitSpy).toHaveBeenCalledWith( + EVENT_REMOTE_TRACKS_UPDATE, + [ + { + track: {}, + language: 'en', + participant: 'user1', + }, + ], + ); + expect(transport.remoteTracks.size).toBe(1); + }); + it('transport should call emit EVENT_REMOTE_TRACKS_UPDATE when room emit RoomEvent.TrackUnsubscribed', async () => { + const emitSpy = vi.spyOn(transport as unknown as PalabraBaseEventEmitter, 'emit'); + const mockTrack = { sid: 'mock-sid', kind: 'audio', mediaStreamTrack: {} } as RemoteTrack; + const mockPublication = { trackName: 'audio_en', trackSid: 'mock-sid' } as TrackPublication; + const mockParticipant = { identity: 'user1' } as RemoteParticipant; + // @ts-expect-error: mock RoomEvent.TrackSubscribed + roomEventHandlers[RoomEvent.TrackUnsubscribed](mockTrack, mockPublication, mockParticipant); + expect(emitSpy).toHaveBeenCalledWith(EVENT_REMOTE_TRACKS_UPDATE, []); + }); + + it('transport should call emit EVENT_REMOTE_TRACKS_UPDATE when room emit RoomEvent.ParticipantDisconnected', async () => { + const emitSpy = vi.spyOn(transport as unknown as PalabraBaseEventEmitter, 'emit'); + const mockTrack = { sid: 'mock-sid', kind: 'audio', mediaStreamTrack: {} } as RemoteTrack; + const mockPublication = { trackName: 'audio_en', trackSid: 'mock-sid' } as TrackPublication; + const mockParticipant = { identity: 'user1' } as RemoteParticipant; + // @ts-expect-error: mock RoomEvent.TrackSubscribed + roomEventHandlers[RoomEvent.ParticipantDisconnected](mockTrack, mockPublication, mockParticipant); + expect(emitSpy).toHaveBeenCalledWith(EVENT_REMOTE_TRACKS_UPDATE, []); + }); + + it('transport should call emit EVENT_CONNECTION_STATE_CHANGED when room emit RoomEvent.ConnectionStateChanged', async () => { + const emitSpy = vi.spyOn(transport as unknown as PalabraBaseEventEmitter, 'emit'); + // @ts-expect-error: mock RoomEvent.ConnectionStateChanged + roomEventHandlers[RoomEvent.ConnectionStateChanged]('connected'); + expect(emitSpy).toHaveBeenCalledWith(EVENT_CONNECTION_STATE_CHANGED, 'connected'); + }); + + it('transport should emit EVENT_DATA_RECEIVED when room emit RoomEvent.DataReceived and handleReceivedData from dataFilters', async () => { + const emitSpy = vi.spyOn(transport as unknown as PalabraBaseEventEmitter, 'emit'); + const handleReceivedDataSpy = vi.spyOn(dataFilters, 'handleReceivedData').mockImplementation(() => { /** mock */ }); + + const payload = { message_type: 'translated_transcription' }; + const encoded = new TextEncoder().encode(JSON.stringify(payload)); + const participant = 'participant'; + const topic = 'topic'; + + // Allow the message type so the handler emits + // @ts-expect-error: access private property for test + transport.allowedMessageTypesHash.set('translated_transcription', 1); + + // @ts-expect-error: mock RoomEvent.DataReceived + roomEventHandlers[RoomEvent.DataReceived](encoded, participant, topic); + + expect(emitSpy).toHaveBeenCalledWith( + EVENT_DATA_RECEIVED, + { payload, participant, topic }, + ); + expect(handleReceivedDataSpy).toHaveBeenCalledWith(transport, payload); + }); +}); diff --git a/packages/lib/src/__tests__/data-filters.test.ts b/packages/lib/src/__tests__/data-filters.test.ts new file mode 100644 index 0000000..3ac2222 --- /dev/null +++ b/packages/lib/src/__tests__/data-filters.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + filterTranslationData, + filterTranscriptionData, + filterPartialTranslatedTranscriptionData, + filterPartialTranscriptionData, + filterPipelineTimingsData, + filterErrorData, + handleReceivedData, +} from '../utils/data-filters'; +import type { DataReceivedEventPayload } from '../transport/PalabraWebRtcTransport.model'; +import { PalabraBaseEventEmitter } from '../PalabraBaseEventEmitter'; +import { + EVENT_ERROR_RECEIVED, + EVENT_PARTIAL_TRANSCRIPTION_RECEIVED, + EVENT_PARTIAL_TRANSLATED_TRANSCRIPTION_RECEIVED, + EVENT_PIPELINE_TIMINGS_RECEIVED, + EVENT_TRANSCRIPTION_RECEIVED, + EVENT_TRANSLATION_RECEIVED, +} from '../transport/PalabraWebRtcTransport.model'; + +describe('Data Filters', () => { + const mockTranscriptionData = { text: 'Hello world' }; + const mockPipelineTimings = { total: 100 }; + const mockErrorData = { code: 500, message: 'Internal Server Error' }; + + describe('filterTranslationData', () => { + it('should return translation data if message_type is translated_transcription', () => { + const payload: DataReceivedEventPayload = { + message_type: 'translated_transcription', + data: JSON.stringify(mockTranscriptionData), + }; + expect(filterTranslationData(payload)).toEqual(mockTranscriptionData); + }); + it('should return translation data if message_type is translated_transcription and data is not a string', () => { + const payload: DataReceivedEventPayload = { + message_type: 'translated_transcription', + data: mockTranscriptionData, + }; + expect(filterTranslationData(payload)).toEqual(mockTranscriptionData); + }); + it('should return given data if message_type is translated_transcription and data is invalid string', () => { + const payload: DataReceivedEventPayload = { + message_type: 'translated_transcription', + data: '{invalid}', + }; + expect(filterTranslationData(payload)).toEqual(payload.data); + }); + + it('should return null if message_type is not translated_transcription', () => { + const payload: DataReceivedEventPayload = { + message_type: 'validated_transcription', + data: JSON.stringify(mockTranscriptionData), + }; + expect(filterTranslationData(payload)).toBeNull(); + }); + }); + + describe('filterTranscriptionData', () => { + it('should return transcription data if message_type is validated_transcription', () => { + const payload: DataReceivedEventPayload = { + message_type: 'validated_transcription', + data: mockTranscriptionData, + }; + expect(filterTranscriptionData(payload)).toEqual(mockTranscriptionData); + }); + + it('should return null if message_type is not validated_transcription', () => { + const payload: DataReceivedEventPayload = { + message_type: 'translated_transcription', + data: mockTranscriptionData, + }; + expect(filterTranscriptionData(payload)).toBeNull(); + }); + }); + + describe('filterPartialTranslatedTranscriptionData', () => { + it('should return partial translated transcription data if message_type is partial_translated_transcription', () => { + const payload: DataReceivedEventPayload = { + message_type: 'partial_translated_transcription', + data: JSON.stringify(mockTranscriptionData), + }; + expect(filterPartialTranslatedTranscriptionData(payload)).toEqual(mockTranscriptionData); + }); + + it('should return null if message_type is not partial_translated_transcription', () => { + const payload: DataReceivedEventPayload = { + message_type: 'validated_transcription', + data: JSON.stringify(mockTranscriptionData), + }; + expect(filterPartialTranslatedTranscriptionData(payload)).toBeNull(); + }); + }); + + describe('filterPartialTranscriptionData', () => { + it('should return partial transcription data if message_type is partial_transcription', () => { + const payload: DataReceivedEventPayload = { + message_type: 'partial_transcription', + data: mockTranscriptionData, + }; + expect(filterPartialTranscriptionData(payload)).toEqual(mockTranscriptionData); + }); + + it('should return null if message_type is not partial_transcription', () => { + const payload: DataReceivedEventPayload = { + message_type: 'validated_transcription', + data: mockTranscriptionData, + }; + expect(filterPartialTranscriptionData(payload)).toBeNull(); + }); + }); + + describe('filterPipelineTimingsData', () => { + it('should return pipeline timings data if message_type is pipeline_timings', () => { + const payload: DataReceivedEventPayload = { + message_type: 'pipeline_timings', + data: JSON.stringify(mockPipelineTimings), + }; + expect(filterPipelineTimingsData(payload)).toEqual(mockPipelineTimings); + }); + + it('should return null if message_type is not pipeline_timings', () => { + const payload: DataReceivedEventPayload = { + message_type: 'validated_transcription', + data: JSON.stringify(mockPipelineTimings), + }; + expect(filterPipelineTimingsData(payload)).toBeNull(); + }); + }); + + describe('filterErrorData', () => { + it('should return error data if message_type is error', () => { + const payload: DataReceivedEventPayload = { + message_type: 'error', + data: JSON.stringify(mockErrorData), + }; + expect(filterErrorData(payload)).toEqual(mockErrorData); + }); + + it('should return null if message_type is not error', () => { + const payload: DataReceivedEventPayload = { + message_type: 'validated_transcription', + data: JSON.stringify(mockErrorData), + }; + expect(filterErrorData(payload)).toBeNull(); + }); + }); + + describe('handleReceivedData', () => { + let palabraEventEmitter: PalabraBaseEventEmitter; + + beforeEach(() => { + palabraEventEmitter = new PalabraBaseEventEmitter(); + palabraEventEmitter.emit = vi.fn(); + }); + + it('should emit EVENT_TRANSLATION_RECEIVED for translated_transcription', () => { + const payload: DataReceivedEventPayload = { message_type: 'translated_transcription', data: JSON.stringify(mockTranscriptionData) }; + handleReceivedData(palabraEventEmitter, payload); + expect(palabraEventEmitter.emit).toHaveBeenCalledWith(EVENT_TRANSLATION_RECEIVED, mockTranscriptionData); + }); + + it('should emit EVENT_TRANSCRIPTION_RECEIVED for validated_transcription', () => { + const payload: DataReceivedEventPayload = { message_type: 'validated_transcription', data: mockTranscriptionData }; + handleReceivedData(palabraEventEmitter, payload); + expect(palabraEventEmitter.emit).toHaveBeenCalledWith(EVENT_TRANSCRIPTION_RECEIVED, mockTranscriptionData); + }); + + it('should emit EVENT_PARTIAL_TRANSLATED_TRANSCRIPTION_RECEIVED for partial_translated_transcription', () => { + const payload: DataReceivedEventPayload = { message_type: 'partial_translated_transcription', data: JSON.stringify(mockTranscriptionData) }; + handleReceivedData(palabraEventEmitter, payload); + expect(palabraEventEmitter.emit).toHaveBeenCalledWith(EVENT_PARTIAL_TRANSLATED_TRANSCRIPTION_RECEIVED, mockTranscriptionData); + }); + + it('should emit EVENT_PARTIAL_TRANSCRIPTION_RECEIVED for partial_transcription', () => { + const payload: DataReceivedEventPayload = { message_type: 'partial_transcription', data: mockTranscriptionData }; + handleReceivedData(palabraEventEmitter, payload); + expect(palabraEventEmitter.emit).toHaveBeenCalledWith(EVENT_PARTIAL_TRANSCRIPTION_RECEIVED, mockTranscriptionData); + }); + + it('should emit EVENT_PIPELINE_TIMINGS_RECEIVED for pipeline_timings', () => { + const payload: DataReceivedEventPayload = { message_type: 'pipeline_timings', data: JSON.stringify(mockPipelineTimings) }; + handleReceivedData(palabraEventEmitter, payload); + expect(palabraEventEmitter.emit).toHaveBeenCalledWith(EVENT_PIPELINE_TIMINGS_RECEIVED, mockPipelineTimings); + }); + + it('should emit EVENT_ERROR_RECEIVED for error', () => { + const payload: DataReceivedEventPayload = { message_type: 'error', data: JSON.stringify(mockErrorData) }; + handleReceivedData(palabraEventEmitter, payload); + expect(palabraEventEmitter.emit).toHaveBeenCalledWith(EVENT_ERROR_RECEIVED, mockErrorData); + }); + + it('should not emit any event for an unknown message_type', () => { + const payload: DataReceivedEventPayload = { message_type: 'unknown' as unknown, data: {} }; + handleReceivedData(palabraEventEmitter, payload); + expect(palabraEventEmitter.emit).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/packages/lib/src/transport/PalabraWebRtcTransport.model.ts b/packages/lib/src/transport/PalabraWebRtcTransport.model.ts index 05c9abd..73da722 100644 --- a/packages/lib/src/transport/PalabraWebRtcTransport.model.ts +++ b/packages/lib/src/transport/PalabraWebRtcTransport.model.ts @@ -2,7 +2,7 @@ import { AllowedMessageTypes } from '~/config/PipelineConfig.model'; import { ConnectionState, RemoteParticipant, TrackPublishOptions } from 'livekit-client'; import { PipelineConfigManager } from '~/config/PipelineConfigManager'; import { filterErrorData, filterPartialTranscriptionData, filterPartialTranslatedTranscriptionData, filterPipelineTimingsData, filterTranscriptionData, filterTranslationData } from '~/utils/data-filters'; -export interface PalabraWebRtcTransportConstructor{ +export interface PalabraWebRtcTransportConstructor { streamUrl: string; accessToken: string; inputStream: MediaStream; diff --git a/packages/lib/src/transport/PalabraWebRtcTransport.ts b/packages/lib/src/transport/PalabraWebRtcTransport.ts index ce49608..6898d0d 100644 --- a/packages/lib/src/transport/PalabraWebRtcTransport.ts +++ b/packages/lib/src/transport/PalabraWebRtcTransport.ts @@ -158,7 +158,7 @@ export class PalabraWebRtcTransport extends PalabraBaseEventEmitter implements R this.room.on(RoomEvent.TrackSubscribed, (track: RemoteTrack, publication: TrackPublication, participant: RemoteParticipant) => { console.log(`🎵 Track subscribed: ${publication.trackName} ${publication.trackSid}`, track.sid, track, 'from', participant.identity); if (track.kind === Track.Kind.Audio) { - this.handleRemoteAudioTrack(track, publication, participant); + this.handleRemoteAudioTrack(track, publication, participant); } }); @@ -181,7 +181,7 @@ export class PalabraWebRtcTransport extends PalabraBaseEventEmitter implements R try { const decoder = new TextDecoder(); const message = decoder.decode(payload); - const data:DataReceivedEventPayload = JSON.parse(message); + const data: DataReceivedEventPayload = JSON.parse(message); this.handleTranslationData(data, participant, topic); } catch (error) { console.error('❌ Failed to parse data message:', error, 'Raw payload:', payload); diff --git a/packages/lib/src/transport/__tests__/PalabraWebRtcTransport.test.ts b/packages/lib/src/transport/__tests__/PalabraWebRtcTransport.test.ts deleted file mode 100644 index bc4e474..0000000 --- a/packages/lib/src/transport/__tests__/PalabraWebRtcTransport.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { PalabraWebRtcTransport } from '../PalabraWebRtcTransport'; -import type { PalabraWebRtcTransportConstructor } from '../PalabraWebRtcTransport.model'; -import { vi } from 'vitest'; -import { PipelineConfigManager } from '~/config/PipelineConfigManager'; - -class MockMediaStream { - getAudioTracks() { - return [new MockMediaStreamTrack()]; - } -} - -class MockMediaStreamTrack { - id = 'mock-track-id'; -} - -describe('PalabraWebRtcTransport', () => { - const data: PalabraWebRtcTransportConstructor = { - streamUrl: 'wss://test', - accessToken: 'token', - inputStream: new MockMediaStream() as MediaStream, - configManager: new PipelineConfigManager(), - }; - let transport: PalabraWebRtcTransport; - - beforeEach(() => { - transport = new PalabraWebRtcTransport(data); - }); - - it('should create a new PalabraWebRtcTransport', () => { - expect(transport).toBeDefined(); - expect(transport.getRoom()).toBeDefined(); - expect(transport.getConnectionState()).toBeDefined(); - expect(transport.getParticipants()).toBeDefined(); - expect(transport.getParticipants()).toEqual([]); - expect(transport.isConnected()).toBeDefined(); - }); - - it('should call connect and its dependencies', async () => { - const mockRoomConnect = vi.fn().mockResolvedValue(undefined); - transport.getRoom().connect = mockRoomConnect; - - const setTaskSpy = vi.spyOn(transport, 'setTask').mockResolvedValue(undefined); - // @ts-expect-error: mock publishInputAudio, which is not visible to the TS types - const publishInputAudioSpy = vi.spyOn(transport, 'publishInputAudio').mockResolvedValue(undefined); - - await expect(transport.connect()).resolves.not.toThrow(); - - expect(mockRoomConnect).toHaveBeenCalledWith('wss://test', 'token', { autoSubscribe: true }); - expect(setTaskSpy).toHaveBeenCalled(); - expect(publishInputAudioSpy).toHaveBeenCalled(); - }); - - it('should call disconnect and its dependencies', async () => { - const mockRoomDisconnect = vi.fn().mockResolvedValue(undefined); - transport.getRoom().disconnect = mockRoomDisconnect; - - const mockEndTask = vi.spyOn(transport, 'endTask').mockResolvedValue(undefined); - // @ts-expect-error: mock cleanupAudioResources, which is not visible to the TS types - const mockCleanupAudioResources = vi.spyOn(transport, 'cleanupAudioResources').mockResolvedValue(undefined); - - await expect(transport.disconnect()).resolves.not.toThrow(); - - expect(mockRoomDisconnect).toHaveBeenCalled(); - expect(mockEndTask).toHaveBeenCalled(); - expect(mockCleanupAudioResources).toHaveBeenCalled(); - - expect(mockEndTask.mock.invocationCallOrder[0]).toBeLessThan(mockRoomDisconnect.mock.invocationCallOrder[0]); - expect(mockRoomDisconnect.mock.invocationCallOrder[0]).toBeLessThan(mockCleanupAudioResources.mock.invocationCallOrder[0]); - }); - - it('should call setTask and its dependencies', async () => { - const mockSendCommand = vi.spyOn(transport, 'sendCommand').mockResolvedValue(undefined); - // @ts-expect-error: mock createHashForAllowedMessageTypes, which is not visible to the TS types - const mockCreateHashForAllowedMessageTypes = vi.spyOn(transport, 'createHashForAllowedMessageTypes').mockResolvedValue(undefined); - const config = new PipelineConfigManager().getConfig(); - await expect(transport.setTask(config)).resolves.not.toThrow(); - expect(mockSendCommand).toHaveBeenCalledWith('set_task', config); - expect(mockCreateHashForAllowedMessageTypes).toHaveBeenCalled(); - expect(mockCreateHashForAllowedMessageTypes).toHaveBeenCalledWith(config.pipeline.allowed_message_types); - expect(mockCreateHashForAllowedMessageTypes.mock.invocationCallOrder[0]).toBeLessThan(mockSendCommand.mock.invocationCallOrder[0]); - }); - - it('should call endTask and its dependencies', async () => { - const mockSendCommand = vi.spyOn(transport, 'sendCommand').mockResolvedValue(undefined); - await expect(transport.endTask()).resolves.not.toThrow(); - expect(mockSendCommand).toHaveBeenCalledWith('end_task', { 'force': false }); - }); -});