From 565b0a13813c3585bba6d1111f0ac00ac638ac55 Mon Sep 17 00:00:00 2001 From: ClementLagasse Date: Sat, 1 Nov 2025 22:36:52 +0100 Subject: [PATCH 1/2] Implement hang-up functionality in ActiveCallPage and ActiveCallContext - Added hangUpCall method to ActiveCallContext for managing call termination. - Integrated hang-up button in ActiveCallPage, allowing users to end calls directly. - Updated UI to reflect changes in call status and provide user feedback upon call termination. - Removed redundant WebSocket connection status display to streamline the interface. This commit enhances the user experience by providing a clear and functional way to end calls. --- src/app/(protected)/active-call/page.tsx | 44 +++++------------------- src/contexts/active-call-context.tsx | 27 +++++++++++++++ src/services/audio-manager.ts | 17 +++++++++ 3 files changed, 53 insertions(+), 35 deletions(-) diff --git a/src/app/(protected)/active-call/page.tsx b/src/app/(protected)/active-call/page.tsx index b15ff4b..16647e6 100644 --- a/src/app/(protected)/active-call/page.tsx +++ b/src/app/(protected)/active-call/page.tsx @@ -86,6 +86,7 @@ export default function ActiveCallPage() { setIsSpeakerOn, isInCall, takeCall, + hangUpCall, isAudioConnected, } = useActiveCall(); @@ -425,9 +426,7 @@ export default function ActiveCallPage() {
- - Auto-translating from Spanish - + Auto-translating
@@ -465,36 +464,6 @@ export default function ActiveCallPage() { {/* Insights List */}
- {/* WebSocket Connection Status */} -
-
-
- - - {wsConnectionState.isConnected - ? "Real-time Updates Active" - : "Disconnected"} - -
- {wsConnectionState.sessionId && ( - - {wsConnectionState.sessionId.slice(0, 8)} - - )} -
-
- - {aiInsights.map((insight, index) => ( - - ))} - {/* Caller Info Card - Real Data from WebSocket */}
@@ -539,7 +508,9 @@ export default function ActiveCallPage() {
- + {aiInsights.map((insight, index) => ( + + ))} {/* Connection Events Log (for debugging) */} {connectionEvents.length > 0 && (
@@ -600,7 +571,10 @@ export default function ActiveCallPage() { )} - diff --git a/src/contexts/active-call-context.tsx b/src/contexts/active-call-context.tsx index a00957f..f60139a 100644 --- a/src/contexts/active-call-context.tsx +++ b/src/contexts/active-call-context.tsx @@ -36,6 +36,7 @@ interface ActiveCallContextType { setIsSpeakerOn: (speakerOn: boolean) => void; isInCall: boolean; takeCall: () => Promise; + hangUpCall: () => void; isAudioConnected: boolean; } @@ -210,6 +211,31 @@ export function ActiveCallProvider({ children }: ActiveCallProviderProps) { } }; + const hangUpCall = () => { + if (!audioManagerRef.current) { + toast.error("No active call to hang up"); + return; + } + + try { + // Send hang up message via WebSocket + audioManagerRef.current.hangUp(); + + // Update UI state + setIsInCall(false); + setIsAudioConnected(false); + setCallStatus("completed"); + + toast.success("Call ended"); + + // Clear the audio manager reference + audioManagerRef.current = null; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to hang up call"; + toast.error(errorMessage); + } + }; + // WebSocket connection state monitoring useEffect(() => { console.log("🔌 [ACTIVE-CALL] Setting up connection state monitoring"); @@ -327,6 +353,7 @@ export function ActiveCallProvider({ children }: ActiveCallProviderProps) { setIsSpeakerOn: handleSetSpeakerOn, isInCall, takeCall, + hangUpCall, isAudioConnected, }; diff --git a/src/services/audio-manager.ts b/src/services/audio-manager.ts index 976016e..6047974 100644 --- a/src/services/audio-manager.ts +++ b/src/services/audio-manager.ts @@ -314,6 +314,23 @@ export class AudioManager { return this.ws !== null && this.ws.readyState === WebSocket.OPEN; } + /** + * Hang up the call - sends end_call message to backend + */ + hangUp(): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send( + JSON.stringify({ + type: "end_call", + reason: "Operator ended call", + }) + ); + } + + // Disconnect after sending end_call message + this.disconnect(); + } + /** * Disconnect and cleanup */ From fb8935051e7576eab24ed3aa0c3e3617dca85e73 Mon Sep 17 00:00:00 2001 From: ClementLagasse Date: Sat, 1 Nov 2025 23:04:07 +0100 Subject: [PATCH 2/2] Add tests for hang-up functionality in ActiveCallContext and AudioManager - Implemented comprehensive tests for the hangUpCall method in ActiveCallContext, ensuring proper state updates and error handling during call termination. - Enhanced AudioManager tests to verify the behavior of the hangUp method under various WebSocket states, including sending messages and handling multiple calls gracefully. - Improved test coverage for scenarios involving active calls, error handling, and UI updates related to call status. This commit strengthens the testing framework for call management functionalities, ensuring robustness and reliability. --- .../__tests__/active-call-context.test.tsx | 288 ++++++++++++++++++ src/services/__tests__/audio-manager.test.ts | 60 ++++ 2 files changed, 348 insertions(+) diff --git a/src/contexts/__tests__/active-call-context.test.tsx b/src/contexts/__tests__/active-call-context.test.tsx index a767353..ad4e3cd 100644 --- a/src/contexts/__tests__/active-call-context.test.tsx +++ b/src/contexts/__tests__/active-call-context.test.tsx @@ -1342,4 +1342,292 @@ describe("ActiveCallContext", () => { consoleWarn.mockRestore(); }); + + describe("hangUpCall", () => { + it("should hang up call successfully and update state", async () => { + mockSearchParams.set("callId", "call-123"); + + mockHandoffApi.takeControl.mockResolvedValue({ + success: true, + handoffId: "handoff-456", + message: "Control taken", + aiTerminated: true, + }); + + const mockAudioManagerInstance = { + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + requestMicrophonePermission: jest.fn().mockResolvedValue(true), + setMuted: jest.fn(), + setSpeakerOn: jest.fn(), + isConnected: jest.fn().mockReturnValue(true), + hangUp: jest.fn(), + }; + + (mockAudioManager as unknown as jest.Mock).mockImplementation(() => mockAudioManagerInstance); + + function HangUpComponent() { + const { takeCall, hangUpCall, isInCall, callStatus } = useActiveCall(); + return ( +
+ + +
{isInCall ? "In Call" : "Not In Call"}
+
{callStatus || "Unknown"}
+
+ ); + } + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("take-call-btn")).toBeInTheDocument(); + }); + + // Take call first + fireEvent.click(screen.getByTestId("take-call-btn")); + + await waitFor(() => { + expect(mockHandoffApi.takeControl).toHaveBeenCalled(); + expect(screen.getByTestId("in-call")).toHaveTextContent("In Call"); + }); + + // Now hang up + fireEvent.click(screen.getByTestId("hang-up-btn")); + + await waitFor(() => { + expect(mockAudioManagerInstance.hangUp).toHaveBeenCalled(); + expect(screen.getByTestId("in-call")).toHaveTextContent("Not In Call"); + expect(screen.getByTestId("call-status")).toHaveTextContent("completed"); + }); + }); + + it("should show error when hanging up without active call", () => { + mockSearchParams.set("callId", "call-123"); + + function HangUpComponent() { + const { hangUpCall } = useActiveCall(); + return ( + + ); + } + + render( + + + + ); + + // Try to hang up without taking call first + fireEvent.click(screen.getByTestId("hang-up-btn")); + + // Should not crash, error handling is done via toast + expect(screen.getByTestId("hang-up-btn")).toBeInTheDocument(); + }); + + it("should handle hangUp errors gracefully", async () => { + mockSearchParams.set("callId", "call-123"); + + mockHandoffApi.takeControl.mockResolvedValue({ + success: true, + handoffId: "handoff-456", + message: "Control taken", + aiTerminated: true, + }); + + const mockAudioManagerInstance = { + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + requestMicrophonePermission: jest.fn().mockResolvedValue(true), + setMuted: jest.fn(), + setSpeakerOn: jest.fn(), + isConnected: jest.fn().mockReturnValue(true), + hangUp: jest.fn().mockImplementation(() => { + throw new Error("Failed to hang up"); + }), + }; + + (mockAudioManager as unknown as jest.Mock).mockImplementation(() => mockAudioManagerInstance); + + function HangUpComponent() { + const { takeCall, hangUpCall } = useActiveCall(); + return ( +
+ + +
+ ); + } + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("take-call-btn")).toBeInTheDocument(); + }); + + // Take call first + fireEvent.click(screen.getByTestId("take-call-btn")); + + await waitFor(() => { + expect(mockHandoffApi.takeControl).toHaveBeenCalled(); + }); + + // Try to hang up (should handle error) + fireEvent.click(screen.getByTestId("hang-up-btn")); + + // Should not crash + expect(screen.getByTestId("hang-up-btn")).toBeInTheDocument(); + }); + + it("should clear audio manager reference after hang up", async () => { + mockSearchParams.set("callId", "call-123"); + + mockHandoffApi.takeControl.mockResolvedValue({ + success: true, + handoffId: "handoff-456", + message: "Control taken", + aiTerminated: true, + }); + + const mockAudioManagerInstance = { + connect: jest.fn().mockImplementation(async (_handoffId, _onMessage, onConnected) => { + // Simulate successful connection + if (onConnected) { + onConnected(); + } + }), + disconnect: jest.fn(), + requestMicrophonePermission: jest.fn().mockResolvedValue(true), + setMuted: jest.fn(), + setSpeakerOn: jest.fn(), + isConnected: jest.fn().mockReturnValue(true), + hangUp: jest.fn(), + }; + + (mockAudioManager as unknown as jest.Mock).mockImplementation(() => mockAudioManagerInstance); + + function HangUpComponent() { + const { takeCall, hangUpCall, isAudioConnected } = useActiveCall(); + return ( +
+ + +
+ {isAudioConnected ? "Audio Connected" : "Audio Disconnected"} +
+
+ ); + } + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("take-call-btn")).toBeInTheDocument(); + }); + + // Take call first + await act(async () => { + fireEvent.click(screen.getByTestId("take-call-btn")); + }); + + await waitFor(() => { + expect(screen.getByTestId("audio-connected")).toHaveTextContent("Audio Connected"); + }); + + // Hang up + fireEvent.click(screen.getByTestId("hang-up-btn")); + + await waitFor(() => { + expect(screen.getByTestId("audio-connected")).toHaveTextContent("Audio Disconnected"); + }); + }); + + it("should update call status to completed after hang up", async () => { + mockSearchParams.set("callId", "call-123"); + + mockHandoffApi.takeControl.mockResolvedValue({ + success: true, + handoffId: "handoff-456", + message: "Control taken", + aiTerminated: true, + }); + + const mockAudioManagerInstance = { + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + requestMicrophonePermission: jest.fn().mockResolvedValue(true), + setMuted: jest.fn(), + setSpeakerOn: jest.fn(), + isConnected: jest.fn().mockReturnValue(true), + hangUp: jest.fn(), + }; + + (mockAudioManager as unknown as jest.Mock).mockImplementation(() => mockAudioManagerInstance); + + function StatusComponent() { + const { takeCall, hangUpCall, callStatus } = useActiveCall(); + return ( +
+ + +
{callStatus || "No Status"}
+
+ ); + } + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("take-call-btn")).toBeInTheDocument(); + }); + + // Take call + fireEvent.click(screen.getByTestId("take-call-btn")); + + await waitFor(() => { + expect(screen.getByTestId("status")).toHaveTextContent("active"); + }); + + // Hang up + fireEvent.click(screen.getByTestId("hang-up-btn")); + + await waitFor(() => { + expect(screen.getByTestId("status")).toHaveTextContent("completed"); + }); + }); + }); }); diff --git a/src/services/__tests__/audio-manager.test.ts b/src/services/__tests__/audio-manager.test.ts index 82304c2..9eac3ef 100644 --- a/src/services/__tests__/audio-manager.test.ts +++ b/src/services/__tests__/audio-manager.test.ts @@ -554,4 +554,64 @@ describe("AudioManager", () => { expect(result).toBe(false); }); }); + + describe("hangUp", () => { + it("should send end_call message and disconnect when WebSocket is open", async () => { + const connectPromise = audioManager.connect("handoff-123"); + mockWsInstance.simulateOpen(); + await connectPromise; + + expect(audioManager.isConnected()).toBe(true); + + audioManager.hangUp(); + + // Should have sent end_call message + expect(mockWsInstance.send).toHaveBeenCalledWith( + JSON.stringify({ + type: "end_call", + reason: "Operator ended call", + }) + ); + + // Should disconnect after sending message + expect(mockWsInstance.close).toHaveBeenCalled(); + expect(audioManager.isConnected()).toBe(false); + }); + + it("should only disconnect when WebSocket is not open", () => { + // Don't connect, just call hangUp + audioManager.hangUp(); + + // Should not have sent any message + expect(mockWsInstance.send).not.toHaveBeenCalled(); + + // Should still call disconnect (which is safe when not connected) + expect(audioManager.isConnected()).toBe(false); + }); + + it("should handle hangUp when WebSocket is in CONNECTING state", () => { + audioManager.connect("handoff-123"); + // Don't simulate open, leave in CONNECTING state + + audioManager.hangUp(); + + // Should not send message when not OPEN + expect(mockWsInstance.send).not.toHaveBeenCalled(); + + // Should disconnect immediately + expect(mockWsInstance.close).toHaveBeenCalled(); + }); + + it("should handle multiple hangUp calls gracefully", async () => { + const connectPromise = audioManager.connect("handoff-123"); + mockWsInstance.simulateOpen(); + await connectPromise; + + audioManager.hangUp(); + audioManager.hangUp(); // Call again + + // Should only close once + expect(mockWsInstance.close).toHaveBeenCalledTimes(1); + }); + }); });