From b5db7172cfc7e92dcfc8fbcafdf823e3108fec25 Mon Sep 17 00:00:00 2001 From: Steve White Date: Thu, 5 Jun 2025 16:47:47 -0500 Subject: [PATCH] Working minimum interface for js and api --- frontend/css/style.css | 92 ++++++++++ frontend/index.html | 87 +++++++++ frontend/js/api.js | 131 ++++++++++++++ frontend/js/app.js | 353 +++++++++++++++++++++++++++++++++++++ frontend/tests/api.test.js | 196 ++++++++++++++++++++ 5 files changed, 859 insertions(+) create mode 100644 frontend/css/style.css create mode 100644 frontend/index.html create mode 100644 frontend/js/api.js create mode 100644 frontend/js/app.js create mode 100644 frontend/tests/api.test.js diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..af95928 --- /dev/null +++ b/frontend/css/style.css @@ -0,0 +1,92 @@ +/* Basic styles - to be expanded */ +body { + font-family: sans-serif; + line-height: 1.6; + margin: 0; + padding: 0; + background-color: #f4f4f4; + color: #333; +} + +header { + background: #333; + color: #fff; + padding: 1rem 0; + text-align: center; +} + +main { + padding: 20px; + max-width: 960px; + margin: auto; +} + +section { + background: #fff; + padding: 20px; + margin-bottom: 20px; + border-radius: 5px; +} + +hr { + margin: 20px 0; + border: 0; + border-top: 1px solid #eee; +} + +button { + padding: 10px 15px; + background: #333; + color: #fff; + border: none; + border-radius: 5px; + cursor: pointer; + margin-right: 5px; /* Add some margin between buttons */ +} + +button:hover { + background: #555; +} + +input[type='text'], input[type='file'] { + padding: 8px; + margin-bottom: 10px; + border: 1px solid #ddd; + border-radius: 4px; + width: calc(100% - 20px); /* Adjust width considering padding */ +} + +label { + display: block; + margin-bottom: 5px; +} + +#speaker-list { + list-style: none; + padding: 0; +} + +#speaker-list li { + padding: 5px 0; + border-bottom: 1px dotted #eee; +} + +#speaker-list li:last-child { + border-bottom: none; +} + +pre { + background: #eee; + padding: 10px; + border-radius: 4px; + white-space: pre-wrap; /* Allow wrapping */ + word-wrap: break-word; /* Break long words */ +} + +footer { + text-align: center; + padding: 20px; + background: #333; + color: #fff; + margin-top: 30px; +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4b8472c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,87 @@ + + + + + + Chatterbox TTS Frontend + + + +
+

Chatterbox TTS

+
+ +
+
+

Speaker Management

+
+

Available Speakers

+
    + +
+
+
+

Add New Speaker

+
+
+ + +
+
+ + +
+ +
+
+
+ +
+ +
+

Dialog Editor

+
+ +
+
+ +
+
+ + +
+
+ + +
+ +
+ +
+ +
+

Results

+
+

Log:

+
(Generation log will appear here)
+
+
+

Concatenated Audio:

+ +
+
+

Download Archive:

+ +

(ZIP download link will appear here)

+
+
+
+ + + + + + + diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..9c33263 --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,131 @@ +// frontend/js/api.js + +const API_BASE_URL = 'http://localhost:8000/api'; // Assuming backend runs on port 8000 + +/** + * Fetches the list of available speakers. + * @returns {Promise>} A promise that resolves to an array of speaker objects. + * @throws {Error} If the network response is not ok. + */ +export async function getSpeakers() { + const response = await fetch(`${API_BASE_URL}/speakers/`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`Failed to fetch speakers: ${errorData.detail || errorData.message || response.statusText}`); + } + return response.json(); +} + +// We will add more functions here: addSpeaker, deleteSpeaker, generateDialog + +// ... (keep API_BASE_URL and getSpeakers) + +/** + * Adds a new speaker. + * @param {FormData} formData - The form data containing speaker name and audio file. + * Example: formData.append('name', 'New Speaker'); + * formData.append('audio_sample_file', fileInput.files[0]); + * @returns {Promise} A promise that resolves to the new speaker object. + * @throws {Error} If the network response is not ok. + */ +export async function addSpeaker(formData) { + const response = await fetch(`${API_BASE_URL}/speakers/`, { + method: 'POST', + body: formData, // FormData sets Content-Type to multipart/form-data automatically + }); + if (!response.ok) { + console.log('API_JS_ADD_SPEAKER: Entered !response.ok block. Status:', response.status, 'StatusText:', response.statusText); + let errorPayload = { detail: `Request failed with status ${response.status}` }; // Default payload + try { + console.log('API_JS_ADD_SPEAKER: Attempting to parse error response as JSON...'); + errorPayload = await response.json(); + console.log('API_JS_ADD_SPEAKER: Successfully parsed error JSON:', errorPayload); + } catch (e) { + console.warn('API_JS_ADD_SPEAKER: Failed to parse error response as JSON. Error:', e); + // Use statusText if JSON parsing fails + errorPayload = { detail: response.statusText || `Request failed with status ${response.status} and no JSON body.`, parseError: e.toString() }; + } + + console.error('--- BEGIN SERVER ERROR PAYLOAD (addSpeaker) ---'); + console.error('Status:', response.status); + console.error('Status Text:', response.statusText); + console.error('Parsed Payload:', errorPayload); + console.error('--- END SERVER ERROR PAYLOAD (addSpeaker) ---'); + + let detailedMessage = "Unknown error"; + if (errorPayload && errorPayload.detail) { + if (typeof errorPayload.detail === 'string') { + detailedMessage = errorPayload.detail; + } else { + // If detail is an array (FastAPI validation errors) or object, stringify it. + detailedMessage = JSON.stringify(errorPayload.detail); + } + } else if (errorPayload && errorPayload.message) { + detailedMessage = errorPayload.message; + } else if (response.statusText) { + detailedMessage = response.statusText; + } else { + detailedMessage = `HTTP error ${response.status}`; + } + + console.log(`API_JS_ADD_SPEAKER: Constructed detailedMessage: "${detailedMessage}"`); + console.log(`API_JS_ADD_SPEAKER: Throwing error with message: "Failed to add speaker: ${detailedMessage}"`); + throw new Error(`Failed to add speaker: ${detailedMessage}`); + } + return response.json(); +} + +// ... (keep API_BASE_URL, getSpeakers, addSpeaker) + +/** + * Deletes a speaker by their ID. + * @param {string} speakerId - The ID of the speaker to delete. + * @returns {Promise} A promise that resolves to the response data (e.g., success message). + * @throws {Error} If the network response is not ok. + */ +export async function deleteSpeaker(speakerId) { + const response = await fetch(`${API_BASE_URL}/speakers/${speakerId}/`, { + method: 'DELETE', + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`Failed to delete speaker ${speakerId}: ${errorData.detail || errorData.message || response.statusText}`); + } + // Handle 204 No Content specifically, as .json() would fail + if (response.status === 204) { + return { message: `Speaker ${speakerId} deleted successfully.` }; + } + return response.json(); +} + +// ... (keep API_BASE_URL, getSpeakers, addSpeaker, deleteSpeaker) + +/** + * Generates a dialog by sending a payload to the backend. + * @param {Object} dialogPayload - The payload for dialog generation. + * Example: + * { + * output_base_name: "my_dialog", + * dialog_items: [ + * { type: "speech", speaker_id: "speaker1", text: "Hello world.", exaggeration: 1.0, cfg_weight: 2.0, temperature: 0.7 }, + * { type: "silence", duration_ms: 500 }, + * { type: "speech", speaker_id: "speaker2", text: "How are you?" } + * ] + * } + * @returns {Promise} A promise that resolves to the dialog generation response (log, file URLs). + * @throws {Error} If the network response is not ok. + */ +export async function generateDialog(dialogPayload) { + const response = await fetch(`${API_BASE_URL}/dialog/generate/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dialogPayload), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`Failed to generate dialog: ${errorData.detail || errorData.message || response.statusText}`); + } + return response.json(); +} diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..35d3edb --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,353 @@ +import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from './api.js'; + +const API_BASE_URL = 'http://localhost:8000'; // Assuming backend runs here + +// This should match the base URL from which FastAPI serves static files +// If your main app is at http://localhost:8000, and static files are served from /generated_audio relative to that, +// then this should be http://localhost:8000. The backend will return paths like /generated_audio/... +const API_BASE_URL_FOR_FILES = 'http://localhost:8000'; + +document.addEventListener('DOMContentLoaded', () => { + console.log('DOM fully loaded and parsed'); + initializeSpeakerManagement(); + initializeDialogEditor(); // Placeholder for now + initializeResultsDisplay(); // Placeholder for now +}); + +// --- Speaker Management --- // +const speakerListUL = document.getElementById('speaker-list'); +const addSpeakerForm = document.getElementById('add-speaker-form'); + +function initializeSpeakerManagement() { + loadSpeakers(); + + if (addSpeakerForm) { + addSpeakerForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const formData = new FormData(addSpeakerForm); + const speakerName = formData.get('name'); + const audioFile = formData.get('audio_file'); + + if (!speakerName || !audioFile || audioFile.size === 0) { + alert('Please provide a speaker name and an audio file.'); + return; + } + + try { + const newSpeaker = await addSpeaker(formData); + alert(`Speaker added: ${newSpeaker.name} (ID: ${newSpeaker.id})`); + addSpeakerForm.reset(); + loadSpeakers(); // Refresh speaker list + } catch (error) { + console.error('Failed to add speaker:', error); + alert('Error adding speaker: ' + error.message); + } + }); + } +} + +async function loadSpeakers() { + if (!speakerListUL) return; + try { + const speakers = await getSpeakers(); + speakerListUL.innerHTML = ''; // Clear existing list + if (speakers.length === 0) { + const listItem = document.createElement('li'); + listItem.textContent = 'No speakers available.'; + speakerListUL.appendChild(listItem); + return; + } + speakers.forEach(speaker => { + const listItem = document.createElement('li'); + listItem.textContent = `${speaker.name} (ID: ${speaker.id || 'N/A'}) `; + + const deleteBtn = document.createElement('button'); + deleteBtn.textContent = 'Delete'; + deleteBtn.classList.add('delete-speaker-btn'); + deleteBtn.onclick = () => handleDeleteSpeaker(speaker.id); + listItem.appendChild(deleteBtn); + + speakerListUL.appendChild(listItem); + }); + } catch (error) { + console.error('Failed to load speakers:', error); + speakerListUL.innerHTML = '
  • Error loading speakers. See console for details.
  • '; + alert('Error loading speakers: ' + error.message); + } +} + +async function handleDeleteSpeaker(speakerId) { + if (!speakerId) { + alert('Cannot delete speaker: Speaker ID is missing.'); + return; + } + if (!confirm(`Are you sure you want to delete speaker ${speakerId}?`)) return; + try { + await deleteSpeaker(speakerId); + alert(`Speaker ${speakerId} deleted successfully.`); + loadSpeakers(); // Refresh speaker list + } catch (error) { + console.error(`Failed to delete speaker ${speakerId}:`, error); + alert(`Error deleting speaker: ${error.message}`); + } +} + +// --- Dialog Editor --- // +let dialogItems = []; // Holds the sequence of speech/silence items +let availableSpeakersCache = []; // To populate speaker dropdown + +function initializeDialogEditor() { + const dialogItemsContainer = document.getElementById('dialog-items-container'); + const addSpeechLineBtn = document.getElementById('add-speech-line-btn'); + const addSilenceLineBtn = document.getElementById('add-silence-line-btn'); + const outputBaseNameInput = document.getElementById('output-base-name'); + const generateDialogBtn = document.getElementById('generate-dialog-btn'); + + // Results Display Elements + const generationLogPre = document.getElementById('generation-log-content'); // Corrected ID + const audioPlayer = document.getElementById('concatenated-audio-player'); // Corrected ID + // audioSource will be the audioPlayer itself, no separate element by default in the HTML + const downloadZipLink = document.getElementById('zip-archive-link'); // Corrected ID + const zipArchivePlaceholder = document.getElementById('zip-archive-placeholder'); + const resultsDisplaySection = document.getElementById('results-display'); + + let dialogItems = []; + let availableSpeakersCache = []; // Cache for speaker names and IDs + + // Function to render the current dialogItems array to the DOM + function renderDialogItems() { + if (!dialogItemsContainer) return; + dialogItemsContainer.innerHTML = ''; // Clear existing items + dialogItems.forEach((item, index) => { + const li = document.createElement('li'); + li.classList.add('dialog-item'); + if (item.type === 'speech') { + const speaker = availableSpeakersCache.find(s => s.id === item.speaker_id); + const speakerName = speaker ? speaker.name : 'Unknown Speaker'; + li.textContent = `Speech: [${speakerName}] "${item.text.substring(0, 30)}${item.text.length > 30 ? '...' : ''}"`; + } else if (item.type === 'silence') { + li.textContent = `Silence: ${item.duration}s`; + } + + const removeBtn = document.createElement('button'); + removeBtn.textContent = 'Remove'; + removeBtn.classList.add('remove-dialog-item-btn'); + removeBtn.onclick = () => { + dialogItems.splice(index, 1); + renderDialogItems(); + }; + li.appendChild(removeBtn); + dialogItemsContainer.appendChild(li); + }); + } + + const tempInputArea = document.getElementById('temp-input-area'); + + function clearTempInputArea() { + if (tempInputArea) tempInputArea.innerHTML = ''; + } + + if (addSpeechLineBtn) { + addSpeechLineBtn.addEventListener('click', async () => { + clearTempInputArea(); // Clear any previous inputs + + if (availableSpeakersCache.length === 0) { + try { + availableSpeakersCache = await getSpeakers(); + } catch (error) { + alert('Could not load speakers. Please try again.'); + console.error('Error fetching speakers for dialog:', error); + return; + } + } + if (availableSpeakersCache.length === 0) { + alert('No speakers available. Please add a speaker first.'); + return; + } + + const speakerSelectLabel = document.createElement('label'); + speakerSelectLabel.textContent = 'Speaker: '; + speakerSelectLabel.htmlFor = 'temp-speaker-select'; + const speakerSelect = document.createElement('select'); + speakerSelect.id = 'temp-speaker-select'; + availableSpeakersCache.forEach(speaker => { + const option = document.createElement('option'); + option.value = speaker.id; + option.textContent = speaker.name; + speakerSelect.appendChild(option); + }); + + const textInputLabel = document.createElement('label'); + textInputLabel.textContent = ' Text: '; + textInputLabel.htmlFor = 'temp-speech-text'; + const textInput = document.createElement('textarea'); + textInput.id = 'temp-speech-text'; + textInput.rows = 2; + textInput.placeholder = 'Enter speech text'; + + const addButton = document.createElement('button'); + addButton.textContent = 'Add Speech'; + addButton.onclick = () => { + const speakerId = speakerSelect.value; + const text = textInput.value.trim(); + if (!speakerId || !text) { + alert('Please select a speaker and enter text.'); + return; + } + dialogItems.push({ type: 'speech', speaker_id: speakerId, text: text }); + renderDialogItems(); + clearTempInputArea(); + }; + + const cancelButton = document.createElement('button'); + cancelButton.textContent = 'Cancel'; + cancelButton.onclick = clearTempInputArea; + + if (tempInputArea) { + tempInputArea.appendChild(speakerSelectLabel); + tempInputArea.appendChild(speakerSelect); + tempInputArea.appendChild(textInputLabel); + tempInputArea.appendChild(textInput); + tempInputArea.appendChild(addButton); + tempInputArea.appendChild(cancelButton); + } + }); + } + + if (addSilenceLineBtn) { + addSilenceLineBtn.addEventListener('click', () => { + clearTempInputArea(); // Clear any previous inputs + + const durationInputLabel = document.createElement('label'); + durationInputLabel.textContent = 'Duration (s): '; + durationInputLabel.htmlFor = 'temp-silence-duration'; + const durationInput = document.createElement('input'); + durationInput.type = 'number'; + durationInput.id = 'temp-silence-duration'; + durationInput.step = '0.1'; + durationInput.min = '0.1'; + durationInput.placeholder = 'e.g., 0.5'; + + const addButton = document.createElement('button'); + addButton.textContent = 'Add Silence'; + addButton.onclick = () => { + const duration = parseFloat(durationInput.value); + if (isNaN(duration) || duration <= 0) { + alert('Invalid duration. Please enter a positive number.'); + return; + } + dialogItems.push({ type: 'silence', duration: duration }); + renderDialogItems(); + clearTempInputArea(); + }; + + const cancelButton = document.createElement('button'); + cancelButton.textContent = 'Cancel'; + cancelButton.onclick = clearTempInputArea; + + if (tempInputArea) { + tempInputArea.appendChild(durationInputLabel); + tempInputArea.appendChild(durationInput); + tempInputArea.appendChild(addButton); + tempInputArea.appendChild(cancelButton); + } + }); + } + + if (generateDialogBtn && outputBaseNameInput) { + generateDialogBtn.addEventListener('click', async () => { + const outputBaseName = outputBaseNameInput.value.trim(); + if (!outputBaseName) { + alert('Please enter an output base name.'); + outputBaseNameInput.focus(); + return; + } + if (dialogItems.length === 0) { + alert('Please add at least one speech or silence line to the dialog.'); + return; + } + + // Clear previous results and show loading/status + if (generationLogPre) generationLogPre.textContent = 'Generating dialog...'; + if (audioPlayer) { + audioPlayer.style.display = 'none'; + audioPlayer.src = ''; // Clear previous audio source + } + if (downloadZipLink) { + downloadZipLink.style.display = 'none'; + downloadZipLink.href = '#'; + downloadZipLink.textContent = ''; + } + if (zipArchivePlaceholder) zipArchivePlaceholder.style.display = 'block'; // Show placeholder + if (resultsDisplaySection) resultsDisplaySection.style.display = 'block'; // Make sure it's visible + + const payload = { + output_base_name: outputBaseName, + dialog_items: dialogItems.map(item => { + // For now, we are not collecting TTS params in the UI for speech items. + // The backend will use defaults. If we add UI for these later, they'd be included here. + if (item.type === 'speech') { + return { + type: item.type, + speaker_id: item.speaker_id, + text: item.text, + // exaggeration: item.exaggeration, // Example for future UI enhancement + // cfg_weight: item.cfg_weight, + // temperature: item.temperature + }; + } + return item; // for silence items + }) + }; + + try { + console.log('Generating dialog with payload:', JSON.stringify(payload, null, 2)); + const result = await generateDialog(payload); + console.log('Dialog generation successful:', result); + + if (generationLogPre) generationLogPre.textContent = result.log || 'No log output.'; + + if (result.concatenated_audio_url && audioPlayer) { // Check audioPlayer, not audioSource + audioPlayer.src = result.concatenated_audio_url.startsWith('http') ? result.concatenated_audio_url : `${API_BASE_URL_FOR_FILES}${result.concatenated_audio_url}`; + audioPlayer.load(); // Call load() after setting new source + audioPlayer.style.display = 'block'; + } else { + if (audioPlayer) audioPlayer.style.display = 'none'; // Ensure it's hidden if no URL + if (generationLogPre) generationLogPre.textContent += '\nNo concatenated audio URL found.'; + } + + if (result.zip_archive_url && downloadZipLink) { + downloadZipLink.href = result.zip_archive_url.startsWith('http') ? result.zip_archive_url : `${API_BASE_URL_FOR_FILES}${result.zip_archive_url}`; + downloadZipLink.textContent = `Download ${outputBaseName}.zip`; + downloadZipLink.style.display = 'block'; + if (zipArchivePlaceholder) zipArchivePlaceholder.style.display = 'none'; // Hide placeholder + } else { + if (downloadZipLink) downloadZipLink.style.display = 'none'; + if (zipArchivePlaceholder) zipArchivePlaceholder.style.display = 'block'; // Show placeholder if no link + if (generationLogPre) generationLogPre.textContent += '\nNo ZIP archive URL found.'; + } + + } catch (error) { + console.error('Dialog generation failed:', error); + if (generationLogPre) generationLogPre.textContent = `Error generating dialog: ${error.message}`; + alert(`Error generating dialog: ${error.message}`); + } + }); + } + + console.log('Dialog Editor Initialized'); + renderDialogItems(); // Initial render (empty) +} + +// --- Results Display --- // +function initializeResultsDisplay() { + const generationLogContent = document.getElementById('generation-log-content'); + const concatenatedAudioPlayer = document.getElementById('concatenated-audio-player'); + const zipArchiveLink = document.getElementById('zip-archive-link'); + const zipArchivePlaceholder = document.getElementById('zip-archive-placeholder'); + + // Functions to update these elements will be called by the generateDialog handler + // e.g., updateLog(message), setAudioSource(url), setZipLink(url) + + console.log('Results Display Initialized'); +} diff --git a/frontend/tests/api.test.js b/frontend/tests/api.test.js new file mode 100644 index 0000000..407e6e1 --- /dev/null +++ b/frontend/tests/api.test.js @@ -0,0 +1,196 @@ +// frontend/tests/api.test.js + +// Import the function to test (adjust path if your structure is different) +// We might need to configure Jest or use Babel for ES module syntax if this causes issues. +import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from '../js/api.js'; + +// Mock the global fetch function +global.fetch = jest.fn(); + +const API_BASE_URL = 'http://localhost:8000/api'; // Centralize for all tests + +describe('API Client - getSpeakers', () => { + beforeEach(() => { + // Clear all instances and calls to constructor and all methods: + fetch.mockClear(); + }); + + it('should fetch speakers successfully', async () => { + const mockSpeakers = [{ id: '1', name: 'Speaker 1' }, { id: '2', name: 'Speaker 2' }]; + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockSpeakers, + }); + + const speakers = await getSpeakers(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/speakers`); + expect(speakers).toEqual(mockSpeakers); + }); + + it('should throw an error if the network response is not ok', async () => { + fetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found', + json: async () => ({ detail: 'Speakers not found' }) // Simulate FastAPI error response + }); + + await expect(getSpeakers()).rejects.toThrow('Failed to fetch speakers: Speakers not found'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('should throw a generic error if parsing error response fails', async () => { + fetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Internal Server Error', + json: async () => { throw new Error('Failed to parse error JSON'); } // Simulate error during .json() + }); + + await expect(getSpeakers()).rejects.toThrow('Failed to fetch speakers: Internal Server Error'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if fetch itself fails (network error)', async () => { + fetch.mockRejectedValueOnce(new TypeError('Network failed')); + + await expect(getSpeakers()).rejects.toThrow('Network failed'); // This will be the original fetch error + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe('API Client - addSpeaker', () => { + beforeEach(() => { + fetch.mockClear(); + }); + + it('should add a speaker successfully', async () => { + const mockFormData = new FormData(); // In a real scenario, this would have data + mockFormData.append('name', 'Test Speaker'); + // mockFormData.append('audio_sample_file', new File([''], 'sample.wav')); // File creation in Node test needs more setup or a mock + + const mockResponse = { id: '3', name: 'Test Speaker', message: 'Speaker added successfully' }; + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const result = await addSpeaker(mockFormData); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/speakers`, { + method: 'POST', + body: mockFormData, + }); + expect(result).toEqual(mockResponse); + }); + + it('should throw an error if adding a speaker fails', async () => { + const mockFormData = new FormData(); + fetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Bad Request', + json: async () => ({ detail: 'Invalid speaker data' }), + }); + + await expect(addSpeaker(mockFormData)).rejects.toThrow('Failed to add speaker: Invalid speaker data'); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe('API Client - deleteSpeaker', () => { + beforeEach(() => { + fetch.mockClear(); + }); + + it('should delete a speaker successfully with JSON response', async () => { + const speakerId = 'test-speaker-id-123'; + const mockResponse = { message: `Speaker ${speakerId} deleted successfully` }; + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, // Or any 2xx status that might return JSON + json: async () => mockResponse, + }); + + const result = await deleteSpeaker(speakerId); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/speakers/${speakerId}`, { + method: 'DELETE', + }); + expect(result).toEqual(mockResponse); + }); + + it('should handle successful deletion with 204 No Content response', async () => { + const speakerId = 'test-speaker-id-204'; + fetch.mockResolvedValueOnce({ + ok: true, + status: 204, + statusText: 'No Content', + // .json() is not called by the function if status is 204 + }); + + const result = await deleteSpeaker(speakerId); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/speakers/${speakerId}`, { + method: 'DELETE', + }); + expect(result).toEqual({ message: `Speaker ${speakerId} deleted successfully.` }); + }); + + it('should throw an error if deleting a speaker fails (e.g., speaker not found)', async () => { + const speakerId = 'non-existent-speaker-id'; + fetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ detail: 'Speaker not found' }), + }); + + await expect(deleteSpeaker(speakerId)).rejects.toThrow(`Failed to delete speaker ${speakerId}: Speaker not found`); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); + +describe('API Client - generateDialog', () => { + beforeEach(() => { + fetch.mockClear(); + }); + + it('should generate dialog successfully', async () => { + const mockPayload = { + output_base_name: "test_dialog", + dialog_items: [ + { type: "speech", speaker_id: "spk_1", text: "Hello.", exaggeration: 1.0, cfg_weight: 3.0, temperature: 0.5 }, + { type: "silence", duration_ms: 250 } + ] + }; + const mockResponse = { + log: "Dialog generated.", + concatenated_audio_url: "/audio/test_dialog_concatenated.wav", + zip_archive_url: "/audio/test_dialog.zip" + }; + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); + + const result = await generateDialog(mockPayload); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/dialog/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mockPayload), + }); + expect(result).toEqual(mockResponse); + }); + + it('should throw an error if dialog generation fails', async () => { + const mockPayload = { output_base_name: "fail_dialog", dialog_items: [] }; // Example invalid payload + fetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Bad Request', + json: async () => ({ detail: 'Invalid dialog data' }), + }); + + await expect(generateDialog(mockPayload)).rejects.toThrow('Failed to generate dialog: Invalid dialog data'); + expect(fetch).toHaveBeenCalledTimes(1); + }); +});