diff --git a/src/index.js b/src/index.js index 5134733..775e1a9 100644 --- a/src/index.js +++ b/src/index.js @@ -70,7 +70,7 @@ function addContentToResult(blocks, blockId, textContent) { // temporarily disabling, although it should work as a fix // the problem was, that line contained "." as request by Google TTS, - // but some "." were followred by further characters + // but some "." were followed by further characters // e.g. "xx xx xx.12 sss sss sss.13 xx xx xx" // then the "." was not recognised as end of sentence @@ -375,7 +375,19 @@ async function main(argv) { } -main(process.argv); +// Export functions for testing +module.exports = { + fixLongLines, + addContentToResult, + parseVoice, + parseCommandLine, + importTxtFile +}; + +// Only run main if this file is executed directly +if (require.main === module) { + main(process.argv); +} diff --git a/src/mp3Util.js b/src/mp3Util.js index f4002e3..1da428d 100644 --- a/src/mp3Util.js +++ b/src/mp3Util.js @@ -4,14 +4,14 @@ const const CONCAT_CHUNK_SIZE = 35; -async function concatMp3FilesInt(mp3Files, concatedMp3Filename) { +async function concatMp3FilesInt(mp3Files, concatenatedMp3Filename) { return new Promise((resolve, reject) => { - // console.log(`Concat ${mp3Files.join(',')} => ${concatedMp3Filename}`); + // console.log(`Concat ${mp3Files.join(',')} => ${concatenatedMp3Filename}`); // mp3 concat (using ffmpeg): https://www.npmjs.com/package/audioconcat audioconcat(mp3Files) - .concat(concatedMp3Filename) + .concat(concatenatedMp3Filename) .on('start', function(command) { // console.log('ffmpeg process started:', command); }) @@ -21,7 +21,7 @@ async function concatMp3FilesInt(mp3Files, concatedMp3Filename) { resolve(false); }) .on('end', function() { - // console.log(`Audio created ${concatedMp3Filename}`); + // console.log(`Audio created ${concatenatedMp3Filename}`); resolve(true); }); }); @@ -36,16 +36,16 @@ function unlinkIfExists(filename) { } /** - * Concat passed mp3 files into result file. + * Concat passed mp3 files into a result file. * @param mp3Files Array with mp3's to merge - * @param concatedMp3Filename Name for concatenated mp3 + * @param concatenatedMp3Filename Name for concatenated mp3 * @param internals Allows replace callbacks for easier testing; not used for normal calls. * * @return promise resolves to true on OK, and to false on failure */ -exports.concatMp3Files = async function concatMp3Files1(mp3Files, concatedMp3Filename, internals) { +exports.concatMp3Files = async function concatMp3Files1(mp3Files, concatenatedMp3Filename, internals) { - // bit weird but, this make it testable + // a bit weird, but this makes function testable const concatMp3FilesIntL = (internals && internals.concatMp3FilesInt) || concatMp3FilesInt; const unlinkIfExistsL = (internals && internals.unlinkIfExists) || unlinkIfExists; const chunkSize = (internals && internals.chunkSize) || CONCAT_CHUNK_SIZE; @@ -69,8 +69,8 @@ exports.concatMp3Files = async function concatMp3Files1(mp3Files, concatedMp3Fil mp3Files = [tempFile].concat(mp3Files.slice(chunkSize)); } - // this is final concat into result file - const isOK = await concatMp3FilesIntL(mp3Files, concatedMp3Filename); + // this is the final concat into the result file + const isOK = await concatMp3FilesIntL(mp3Files, concatenatedMp3Filename); if (isOK) { tempFiles.forEach(file => unlinkIfExistsL(file)); } diff --git a/tests/TEST_PLAN.md b/tests/TEST_PLAN.md new file mode 100644 index 0000000..c504ca4 --- /dev/null +++ b/tests/TEST_PLAN.md @@ -0,0 +1,60 @@ +# Test Implementation Plan + +## Current State +- Jest is configured and working with a basic test +- Test command: `npm test` +- Test directory: `tests/` + +## Testing Strategy + +### 1. Unit Tests for Core Functions + +#### `src/index.js` +- `fixLongLines(textContent)` - Text processing logic +- `addContentToResult(blocks, blockId, textContent)` - Block management +- `parseVoice(paramVoice)` - Voice parameter parsing +- `parseCommandLine(argv)` - CLI argument validation +- `importTxtFile(fileName, options)` - File reading and processing + +#### `src/gcpTextToSpeech.js` +- `synthesize(text, outputFile, voice, speakingRate)` - Mock Google API calls +- `listVoices()` - Mock Google API calls + +#### `src/mp3Util.js` +- `concatMp3Files(mp3Files, concatedMp3Filename, internals)` - Already has dependency injection structure for testing + +### 2. Integration Tests +- File processing workflow (txt → blocks → mp3) +- Command line argument parsing end-to-end +- Error handling scenarios +- File cleanup operations + +### 3. Mocking Strategy +- Mock Google Cloud TTS API calls (expensive/external dependency) +- Mock filesystem operations for test safety +- Mock ffmpeg/audioconcat for mp3 operations +- Use Jest's built-in mocking capabilities + +### 4. Test File Structure +``` +tests/ +├── basic.test.js (existing) +├── index.test.js (CLI and main logic) +├── gcpTextToSpeech.test.js (Google TTS API) +├── mp3Util.test.js (MP3 operations) +└── integration.test.js (end-to-end workflows) +``` + +### 5. Priority Implementation Order +1. Text processing logic (block splitting, line breaking) - `index.test.js` +2. CLI option parsing and validation - `index.test.js` +3. MP3 concatenation logic - `mp3Util.test.js` +4. Google TTS API mocking - `gcpTextToSpeech.test.js` +5. Integration tests - `integration.test.js` +6. Error handling paths - across all test files + +### 6. Test Data Requirements +- Sample text files for testing +- Mock audio data for MP3 operations +- Various CLI argument combinations +- Edge cases (empty files, long lines, special characters) \ No newline at end of file diff --git a/tests/basic.test.js b/tests/basic.test.js deleted file mode 100644 index 12c9d2f..0000000 --- a/tests/basic.test.js +++ /dev/null @@ -1,5 +0,0 @@ -// Simple test for demonstration - -test('basic math works', () => { - expect(1 + 1).toBe(2); -}); diff --git a/tests/gcpTextToSpeech.test.js b/tests/gcpTextToSpeech.test.js new file mode 100644 index 0000000..7e1c681 --- /dev/null +++ b/tests/gcpTextToSpeech.test.js @@ -0,0 +1,199 @@ +// Mock the Google Cloud Text-to-Speech client +const mockSynthesizeSpeech = jest.fn(); +const mockListVoices = jest.fn(); + +jest.mock('@google-cloud/text-to-speech', () => { + return { + TextToSpeechClient: jest.fn().mockImplementation(() => ({ + synthesizeSpeech: mockSynthesizeSpeech, + listVoices: mockListVoices + })) + }; +}); + +// Mock fs.writeFile using promisified version +const mockWriteFile = jest.fn(); +jest.mock('util', () => ({ + promisify: jest.fn((fn) => { + if (fn.name === 'writeFile') { + return mockWriteFile; + } + return fn; + }) +})); + +const { synthesize, listVoices, DEFAULT_SPEAKING_RATE } = require('../src/gcpTextToSpeech'); + +describe('Google Cloud Text-to-Speech API', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('synthesize', () => { + test('should synthesize text with default voice and settings', async () => { + const mockAudioContent = Buffer.from('fake audio data'); + mockSynthesizeSpeech.mockResolvedValue([{ audioContent: mockAudioContent }]); + mockWriteFile.mockResolvedValue(); + + const result = await synthesize('Hello world', 'output.mp3'); + + expect(result).toBe(true); + expect(mockSynthesizeSpeech).toHaveBeenCalledWith({ + input: { text: 'Hello world' }, + voice: { + name: 'en-US-Chirp3-HD-Aoede', + ssmlGender: 'FEMALE', + languageCode: 'en-US' + }, + audioConfig: { + audioEncoding: 'MP3', + speakingRate: DEFAULT_SPEAKING_RATE + } + }); + expect(mockWriteFile).toHaveBeenCalledWith('output.mp3', mockAudioContent, 'binary'); + }); + + test('should synthesize text with custom voice', async () => { + const mockAudioContent = Buffer.from('fake audio data'); + mockSynthesizeSpeech.mockResolvedValue([{ audioContent: mockAudioContent }]); + mockWriteFile.mockResolvedValue(); + + const customVoice = { + name: 'en-US-Wavenet-D', + ssmlGender: 'MALE', + languageCode: 'en-US' + }; + + const result = await synthesize('Hello world', 'output.mp3', customVoice); + + expect(result).toBe(true); + expect(mockSynthesizeSpeech).toHaveBeenCalledWith({ + input: { text: 'Hello world' }, + voice: customVoice, + audioConfig: { + audioEncoding: 'MP3', + speakingRate: DEFAULT_SPEAKING_RATE + } + }); + }); + + test('should synthesize text with custom speaking rate', async () => { + const mockAudioContent = Buffer.from('fake audio data'); + mockSynthesizeSpeech.mockResolvedValue([{ audioContent: mockAudioContent }]); + mockWriteFile.mockResolvedValue(); + + const customRate = 1.2; + const result = await synthesize('Hello world', 'output.mp3', null, customRate); + + expect(result).toBe(true); + expect(mockSynthesizeSpeech).toHaveBeenCalledWith({ + input: { text: 'Hello world' }, + voice: { + name: 'en-US-Chirp3-HD-Aoede', + ssmlGender: 'FEMALE', + languageCode: 'en-US' + }, + audioConfig: { + audioEncoding: 'MP3', + speakingRate: customRate + } + }); + }); + + test('should return false when API call fails', async () => { + mockSynthesizeSpeech.mockRejectedValue(new Error('API Error')); + + const result = await synthesize('Hello world', 'output.mp3'); + + expect(result).toBe(false); + expect(mockSynthesizeSpeech).toHaveBeenCalled(); + }); + + test('should return false when file write fails', async () => { + const mockAudioContent = Buffer.from('fake audio data'); + mockSynthesizeSpeech.mockResolvedValue([{ audioContent: mockAudioContent }]); + mockWriteFile.mockRejectedValue(new Error('Write error')); + + const result = await synthesize('Hello world', 'output.mp3'); + + expect(result).toBe(false); + }); + }); + + describe('listVoices', () => { + test('should list and sort voices', async () => { + const mockVoices = [ + { + name: 'en-US-Wavenet-D', + ssmlGender: 'MALE', + languageCodes: ['en-US'] + }, + { + name: 'en-US-Wavenet-A', + ssmlGender: 'FEMALE', + languageCodes: ['en-US'] + }, + { + name: 'fr-FR-Wavenet-A', + ssmlGender: 'FEMALE', + languageCodes: ['fr-FR'] + } + ]; + + mockListVoices.mockResolvedValue([{ voices: mockVoices }]); + + // Mock console.log to capture output + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + await listVoices(); + + expect(mockListVoices).toHaveBeenCalledWith({}); + + // Verify voices are sorted alphabetically + expect(consoleSpy).toHaveBeenCalledWith('en-US-Wavenet-A, FEMALE, en-US'); + expect(consoleSpy).toHaveBeenCalledWith('en-US-Wavenet-D, MALE, en-US'); + expect(consoleSpy).toHaveBeenCalledWith('fr-FR-Wavenet-A, FEMALE, fr-FR'); + + consoleSpy.mockRestore(); + }); + + test('should handle voices with multiple language codes', async () => { + const mockVoices = [ + { + name: 'en-US-Wavenet-A', + ssmlGender: 'FEMALE', + languageCodes: ['en-US', 'en-CA'] + } + ]; + + mockListVoices.mockResolvedValue([{ voices: mockVoices }]); + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + await listVoices(); + + expect(consoleSpy).toHaveBeenCalledWith('en-US-Wavenet-A, FEMALE, en-US en-CA'); + + consoleSpy.mockRestore(); + }); + + test('should handle empty voices list', async () => { + mockListVoices.mockResolvedValue([{ voices: [] }]); + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + await listVoices(); + + expect(mockListVoices).toHaveBeenCalledWith({}); + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + test('should handle API errors gracefully', async () => { + mockListVoices.mockRejectedValue(new Error('API Error')); + + await expect(listVoices()).rejects.toThrow('API Error'); + }); + }); +}); \ No newline at end of file diff --git a/tests/index.test.js b/tests/index.test.js new file mode 100644 index 0000000..6ee5c2f --- /dev/null +++ b/tests/index.test.js @@ -0,0 +1,165 @@ +const { fixLongLines, addContentToResult, parseVoice, parseCommandLine } = require('../src/index'); + +describe('Text Processing Logic', () => { + describe('fixLongLines', () => { + test('should break long lines at sentence boundaries', () => { + const longText = 'This is a sentence. '.repeat(20) + 'Final sentence'; + const result = fixLongLines(longText); + + expect(result).toContain('.\n'); + expect(result.length).toBeGreaterThan(0); + }); + + test('should handle short text without breaking', () => { + const shortText = 'This is a short sentence.'; + const result = fixLongLines(shortText); + + expect(result).toBe(shortText); + }); + + test('should handle empty text', () => { + const result = fixLongLines(''); + expect(result).toBe(''); + }); + + test('should preserve sentence structure when breaking', () => { + const text = 'First sentence. Second sentence. Third sentence.'; + const result = fixLongLines(text); + + expect(result).toContain('First sentence'); + expect(result).toContain('Second sentence'); + expect(result).toContain('Third sentence'); + }); + }); + + describe('addContentToResult', () => { + test('should add new block when blocks array is empty', () => { + const blocks = []; + addContentToResult(blocks, 1, 'Test content'); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toEqual({ + id: 1, + blockContent: 'Test content' + }); + }); + + test('should append to last block when combined length is under target', () => { + const blocks = [{ + id: 1, + blockContent: 'Short content' + }]; + + addContentToResult(blocks, 2, 'More content'); + + expect(blocks).toHaveLength(1); + expect(blocks[0].blockContent).toBe('Short content\nMore content'); + }); + + test('should create new block when combined length exceeds target', () => { + const blocks = [{ + id: 1, + blockContent: 'x'.repeat(2000) + }]; + + addContentToResult(blocks, 2, 'x'.repeat(600)); + + expect(blocks).toHaveLength(2); + expect(blocks[1].id).toBe(2); + expect(blocks[1].blockContent).toBe('x'.repeat(600)); + }); + + test('should throw error when content exceeds maximum block length', () => { + const blocks = []; + const longContent = 'x'.repeat(5001); // MAX_BLOCK_LEN + 1 + + expect(() => { + addContentToResult(blocks, 1, longContent); + }).toThrow('block longer then API maximum - ABORT'); + }); + }); + + describe('parseVoice', () => { + test('should parse valid voice string', () => { + const voiceString = 'en-US-Wavenet-D, MALE, en-US'; + const result = parseVoice(voiceString); + + expect(result).toEqual({ + name: 'en-US-Wavenet-D', + ssmlGender: 'MALE', + languageCode: 'en-US' + }); + }); + + test('should handle voice string with extra spaces', () => { + const voiceString = ' en-US-Wavenet-D , MALE , en-US '; + const result = parseVoice(voiceString); + + expect(result).toEqual({ + name: 'en-US-Wavenet-D', + ssmlGender: 'MALE', + languageCode: 'en-US' + }); + }); + + test('should return undefined for invalid voice string', () => { + expect(parseVoice('invalid')).toBeUndefined(); + expect(parseVoice('one, two')).toBeUndefined(); + expect(parseVoice('one, two, three, four')).toBeUndefined(); + }); + + test('should return undefined for non-string input', () => { + expect(parseVoice(null)).toBeUndefined(); + expect(parseVoice(undefined)).toBeUndefined(); + expect(parseVoice(123)).toBeUndefined(); + expect(parseVoice([])).toBeUndefined(); + }); + }); + + describe('parseCommandLine', () => { + test('should parse basic help option', () => { + const argv = ['node', 'index.js', '--help']; + const result = parseCommandLine(argv); + + expect(result.help).toBe(true); + expect(result.displayHelpAndQuit).toBe(true); + }); + + test('should parse listVoices option', () => { + const argv = ['node', 'index.js', '--listVoices']; + const result = parseCommandLine(argv); + + expect(result.listVoices).toBe(true); + }); + + test('should parse input file', () => { + const argv = ['node', 'index.js', 'test.txt']; + const result = parseCommandLine(argv); + + expect(result.import).toBe('test.txt'); + expect(result.displayHelpAndQuit).toBe(false); + }); + + test('should reject non-txt files', () => { + const argv = ['node', 'index.js', 'test.mp3']; + const result = parseCommandLine(argv); + + expect(result.displayHelpAndQuit).toBe(true); + }); + + test('should parse voice option', () => { + const argv = ['node', 'index.js', 'test.txt', '--voice', 'en-US-Wavenet-D, MALE, en-US']; + const result = parseCommandLine(argv); + + expect(result.voice).toBe('en-US-Wavenet-D, MALE, en-US'); + }); + + test('should parse line range options', () => { + const argv = ['node', 'index.js', 'test.txt', '--startLine', '10', '--endLine', '20']; + const result = parseCommandLine(argv); + + expect(result.startLine).toBe(10); + expect(result.endLine).toBe(20); + }); + }); +}); \ No newline at end of file diff --git a/tests/integration.test.js b/tests/integration.test.js new file mode 100644 index 0000000..8c8fed6 --- /dev/null +++ b/tests/integration.test.js @@ -0,0 +1,155 @@ +const fs = require('fs'); +const path = require('path'); +const { importTxtFile } = require('../src/index'); + +describe('Integration Tests', () => { + const testDir = path.join(__dirname, 'test-files'); + const testFile = path.join(testDir, 'test-input.txt'); + + beforeAll(() => { + // Create test directory if it doesn't exist + if (!fs.existsSync(testDir)) { + fs.mkdirSync(testDir, { recursive: true }); + } + }); + + afterAll(() => { + // Clean up test files + if (fs.existsSync(testFile)) { + fs.unlinkSync(testFile); + } + if (fs.existsSync(testDir)) { + fs.rmdirSync(testDir); + } + }); + + describe('importTxtFile', () => { + test('should process a simple text file', async () => { + const testContent = 'First line.\nSecond line.\nThird line.'; + fs.writeFileSync(testFile, testContent); + + const blocks = await importTxtFile(testFile, {}); + + expect(blocks).toHaveLength(1); + expect(blocks[0].blockContent).toContain('First line.'); + expect(blocks[0].blockContent).toContain('Second line.'); + expect(blocks[0].blockContent).toContain('Third line.'); + }); + + test('should handle line range options', async () => { + const testContent = 'Line 1.\nLine 2.\nLine 3.\nLine 4.\nLine 5.'; + fs.writeFileSync(testFile, testContent); + + const blocks = await importTxtFile(testFile, { + startLine: 2, + endLine: 4 + }); + + expect(blocks).toHaveLength(1); + expect(blocks[0].blockContent).toContain('Line 2.'); + expect(blocks[0].blockContent).toContain('Line 3.'); + expect(blocks[0].blockContent).toContain('Line 4.'); + expect(blocks[0].blockContent).not.toContain('Line 1.'); + expect(blocks[0].blockContent).not.toContain('Line 5.'); + }); + + test('should split large content into multiple blocks', async () => { + // Create content that will exceed TARGET_BLOCK_LEN (2500 chars) + const longLine = 'This is a long line that will be repeated many times. '.repeat(50); + const testContent = [longLine, longLine, longLine].join('\n'); + fs.writeFileSync(testFile, testContent); + + const blocks = await importTxtFile(testFile, {}); + + expect(blocks.length).toBeGreaterThan(1); + blocks.forEach(block => { + expect(block.blockContent.length).toBeLessThanOrEqual(5000); // MAX_BLOCK_LEN + }); + }); + + test('should handle empty lines correctly', async () => { + const testContent = 'Line 1.\n\nLine 3.\n\n\nLine 6.'; + fs.writeFileSync(testFile, testContent); + + const blocks = await importTxtFile(testFile, {}); + + expect(blocks).toHaveLength(1); + expect(blocks[0].blockContent).toContain('Line 1.'); + expect(blocks[0].blockContent).toContain('Line 3.'); + expect(blocks[0].blockContent).toContain('Line 6.'); + }); + + test('should assign correct block IDs based on line numbers', async () => { + const testContent = 'Line 1.\nLine 2.\nLine 3.'; + fs.writeFileSync(testFile, testContent); + + const blocks = await importTxtFile(testFile, {}); + + expect(blocks[0].id).toBe(1); // First line number + }); + + test('should handle files with only startLine option', async () => { + const testContent = 'Line 1.\nLine 2.\nLine 3.\nLine 4.\nLine 5.'; + fs.writeFileSync(testFile, testContent); + + const blocks = await importTxtFile(testFile, { + startLine: 3 + }); + + expect(blocks).toHaveLength(1); + expect(blocks[0].blockContent).toContain('Line 3.'); + expect(blocks[0].blockContent).toContain('Line 4.'); + expect(blocks[0].blockContent).toContain('Line 5.'); + expect(blocks[0].blockContent).not.toContain('Line 1.'); + expect(blocks[0].blockContent).not.toContain('Line 2.'); + }); + + test('should handle files with only endLine option', async () => { + const testContent = 'Line 1.\nLine 2.\nLine 3.\nLine 4.\nLine 5.'; + fs.writeFileSync(testFile, testContent); + + const blocks = await importTxtFile(testFile, { + endLine: 3 + }); + + expect(blocks).toHaveLength(1); + expect(blocks[0].blockContent).toContain('Line 1.'); + expect(blocks[0].blockContent).toContain('Line 2.'); + expect(blocks[0].blockContent).toContain('Line 3.'); + expect(blocks[0].blockContent).not.toContain('Line 4.'); + expect(blocks[0].blockContent).not.toContain('Line 5.'); + }); + + // Note: File error handling test skipped due to timing issues with stream error handling + }); + + describe('End-to-End Text Processing Workflow', () => { + test('should process text through the complete pipeline', async () => { + const testContent = 'This is a test sentence. Another sentence follows. And a third sentence.'; + fs.writeFileSync(testFile, testContent); + + const blocks = await importTxtFile(testFile, {}); + + // Verify the blocks structure + expect(blocks).toHaveLength(1); + expect(blocks[0]).toHaveProperty('id'); + expect(blocks[0]).toHaveProperty('blockContent'); + expect(typeof blocks[0].id).toBe('number'); + expect(typeof blocks[0].blockContent).toBe('string'); + expect(blocks[0].blockContent.length).toBeGreaterThan(0); + }); + + test('should maintain text integrity through processing', async () => { + const originalText = 'First sentence. Second sentence. Third sentence.'; + fs.writeFileSync(testFile, originalText); + + const blocks = await importTxtFile(testFile, {}); + const processedText = blocks.map(block => block.blockContent).join('\n'); + + // The processed text should contain all original content + expect(processedText).toContain('First sentence.'); + expect(processedText).toContain('Second sentence.'); + expect(processedText).toContain('Third sentence.'); + }); + }); +}); \ No newline at end of file diff --git a/tests/mp3Util.test.js b/tests/mp3Util.test.js new file mode 100644 index 0000000..026b72b --- /dev/null +++ b/tests/mp3Util.test.js @@ -0,0 +1,165 @@ +const { concatMp3Files } = require('../src/mp3Util'); + +describe('MP3 Concatenation', () => { + describe('concatMp3Files', () => { + test('should handle small number of files without chunking', async () => { + const mockConcatMp3FilesInt = jest.fn().mockResolvedValue(true); + const mockUnlinkIfExists = jest.fn(); + + const mp3Files = ['file1.mp3', 'file2.mp3', 'file3.mp3']; + const outputFile = 'output.mp3'; + + const internals = { + concatMp3FilesInt: mockConcatMp3FilesInt, + unlinkIfExists: mockUnlinkIfExists, + chunkSize: 35 + }; + + const result = await concatMp3Files(mp3Files, outputFile, internals); + + expect(result).toBe(true); + expect(mockConcatMp3FilesInt).toHaveBeenCalledWith(mp3Files, outputFile); + expect(mockConcatMp3FilesInt).toHaveBeenCalledTimes(1); + }); + + test('should handle large number of files with chunking', async () => { + const mockConcatMp3FilesInt = jest.fn().mockResolvedValue(true); + const mockUnlinkIfExists = jest.fn(); + + // Create array with more than chunkSize files + const mp3Files = Array.from({ length: 40 }, (_, i) => `file${i + 1}.mp3`); + const outputFile = 'output.mp3'; + + const internals = { + concatMp3FilesInt: mockConcatMp3FilesInt, + unlinkIfExists: mockUnlinkIfExists, + chunkSize: 35 + }; + + const result = await concatMp3Files(mp3Files, outputFile, internals); + + expect(result).toBe(true); + // Should be called twice: once for chunk, once for final concat + expect(mockConcatMp3FilesInt).toHaveBeenCalledTimes(2); + + // First call should be for chunking + expect(mockConcatMp3FilesInt).toHaveBeenNthCalledWith(1, mp3Files.slice(0, 35), 'tmp-1.mp3'); + + // Second call should be for final concat with temp file + remaining files + const finalFiles = ['tmp-1.mp3'].concat(mp3Files.slice(35)); + expect(mockConcatMp3FilesInt).toHaveBeenNthCalledWith(2, finalFiles, outputFile); + + // Temp file should be cleaned up + expect(mockUnlinkIfExists).toHaveBeenCalledWith('tmp-1.mp3'); + }); + + test('should handle multiple chunking levels', async () => { + const mockConcatMp3FilesInt = jest.fn().mockResolvedValue(true); + const mockUnlinkIfExists = jest.fn(); + + // Create array with many more files to trigger multiple chunking + const mp3Files = Array.from({ length: 80 }, (_, i) => `file${i + 1}.mp3`); + const outputFile = 'output.mp3'; + + const internals = { + concatMp3FilesInt: mockConcatMp3FilesInt, + unlinkIfExists: mockUnlinkIfExists, + chunkSize: 35 + }; + + const result = await concatMp3Files(mp3Files, outputFile, internals); + + expect(result).toBe(true); + // Should be called multiple times for chunking + expect(mockConcatMp3FilesInt).toHaveBeenCalledTimes(3); + + // Check temp files are cleaned up + expect(mockUnlinkIfExists).toHaveBeenCalledWith('tmp-1.mp3'); + expect(mockUnlinkIfExists).toHaveBeenCalledWith('tmp-2.mp3'); + }); + + test('should return false when concatenation fails', async () => { + const mockConcatMp3FilesInt = jest.fn().mockResolvedValue(false); + const mockUnlinkIfExists = jest.fn(); + + const mp3Files = ['file1.mp3', 'file2.mp3']; + const outputFile = 'output.mp3'; + + const internals = { + concatMp3FilesInt: mockConcatMp3FilesInt, + unlinkIfExists: mockUnlinkIfExists, + chunkSize: 35 + }; + + const result = await concatMp3Files(mp3Files, outputFile, internals); + + expect(result).toBe(false); + expect(mockConcatMp3FilesInt).toHaveBeenCalledWith(mp3Files, outputFile); + }); + + test('should return false when chunking fails', async () => { + const mockConcatMp3FilesInt = jest.fn() + .mockResolvedValueOnce(false) // First chunk fails + .mockResolvedValue(true); // Others would succeed + const mockUnlinkIfExists = jest.fn(); + + const mp3Files = Array.from({ length: 40 }, (_, i) => `file${i + 1}.mp3`); + const outputFile = 'output.mp3'; + + const internals = { + concatMp3FilesInt: mockConcatMp3FilesInt, + unlinkIfExists: mockUnlinkIfExists, + chunkSize: 35 + }; + + const result = await concatMp3Files(mp3Files, outputFile, internals); + + expect(result).toBe(false); + expect(mockConcatMp3FilesInt).toHaveBeenCalledTimes(1); + }); + + test('should not clean up temp files when final concat fails', async () => { + const mockConcatMp3FilesInt = jest.fn() + .mockResolvedValueOnce(true) // Chunk succeeds + .mockResolvedValueOnce(false); // Final concat fails + const mockUnlinkIfExists = jest.fn(); + + const mp3Files = Array.from({ length: 40 }, (_, i) => `file${i + 1}.mp3`); + const outputFile = 'output.mp3'; + + const internals = { + concatMp3FilesInt: mockConcatMp3FilesInt, + unlinkIfExists: mockUnlinkIfExists, + chunkSize: 35 + }; + + const result = await concatMp3Files(mp3Files, outputFile, internals); + + expect(result).toBe(false); + // Temp files should NOT be cleaned up when final concat fails + expect(mockUnlinkIfExists).not.toHaveBeenCalled(); + }); + + test('should handle edge case with exactly chunkSize + 2 files', async () => { + const mockConcatMp3FilesInt = jest.fn().mockResolvedValue(true); + const mockUnlinkIfExists = jest.fn(); + + // Exactly 37 files (35 + 2) + const mp3Files = Array.from({ length: 37 }, (_, i) => `file${i + 1}.mp3`); + const outputFile = 'output.mp3'; + + const internals = { + concatMp3FilesInt: mockConcatMp3FilesInt, + unlinkIfExists: mockUnlinkIfExists, + chunkSize: 35 + }; + + const result = await concatMp3Files(mp3Files, outputFile, internals); + + expect(result).toBe(true); + // Should only be called once (no chunking needed) + expect(mockConcatMp3FilesInt).toHaveBeenCalledTimes(1); + expect(mockConcatMp3FilesInt).toHaveBeenCalledWith(mp3Files, outputFile); + }); + }); +}); \ No newline at end of file