Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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);
}



Expand Down
20 changes: 10 additions & 10 deletions src/mp3Util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
})
Expand All @@ -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);
});
});
Expand All @@ -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;
Expand All @@ -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));
}
Expand Down
60 changes: 60 additions & 0 deletions tests/TEST_PLAN.md
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 0 additions & 5 deletions tests/basic.test.js

This file was deleted.

199 changes: 199 additions & 0 deletions tests/gcpTextToSpeech.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading