492 lines
20 KiB
Zig
492 lines
20 KiB
Zig
//! Unit tests for STT library components
|
|
//!
|
|
//! This file contains comprehensive tests for:
|
|
//! - Session initialization and cleanup
|
|
//! - Audio buffer management and threading
|
|
//! - Error handling and recovery mechanisms
|
|
//! - Callback invocation and event handling
|
|
|
|
const std = @import("std");
|
|
const testing = std.testing;
|
|
const stt = @import("stt.zig");
|
|
|
|
// Test allocator for memory leak detection
|
|
var test_allocator = std.testing.allocator;
|
|
|
|
/// Mock event handler for testing callback functionality
|
|
const TestEventHandler = struct {
|
|
speech_events: std.ArrayList([]const u8),
|
|
error_events: std.ArrayList(TestError),
|
|
detailed_error_events: std.ArrayList(stt.ErrorInfo),
|
|
allocator: std.mem.Allocator,
|
|
|
|
const TestError = struct {
|
|
error_code: stt.Error,
|
|
message: []const u8,
|
|
};
|
|
|
|
fn init(allocator: std.mem.Allocator) TestEventHandler {
|
|
return TestEventHandler{
|
|
.speech_events = std.ArrayList([]const u8){},
|
|
.error_events = std.ArrayList(TestError){},
|
|
.detailed_error_events = std.ArrayList(stt.ErrorInfo){},
|
|
.allocator = allocator,
|
|
};
|
|
}
|
|
|
|
fn deinit(self: *TestEventHandler) void {
|
|
// Free stored strings
|
|
for (self.speech_events.items) |text| {
|
|
self.allocator.free(text);
|
|
}
|
|
for (self.error_events.items) |error_event| {
|
|
self.allocator.free(error_event.message);
|
|
}
|
|
for (self.detailed_error_events.items) |error_info| {
|
|
self.allocator.free(error_info.message);
|
|
if (error_info.context) |context| {
|
|
self.allocator.free(context);
|
|
}
|
|
if (error_info.recovery_suggestion) |suggestion| {
|
|
self.allocator.free(suggestion);
|
|
}
|
|
}
|
|
|
|
self.speech_events.deinit(self.allocator);
|
|
self.error_events.deinit(self.allocator);
|
|
self.detailed_error_events.deinit(self.allocator);
|
|
}
|
|
|
|
fn onSpeech(ctx: *anyopaque, event: stt.SpeechEvent) void {
|
|
const self: *TestEventHandler = @ptrCast(@alignCast(ctx));
|
|
const owned_text = self.allocator.dupe(u8, event.text) catch return;
|
|
self.speech_events.append(self.allocator, owned_text) catch return;
|
|
}
|
|
|
|
fn onError(ctx: *anyopaque, error_code: stt.Error, message: []const u8) void {
|
|
const self: *TestEventHandler = @ptrCast(@alignCast(ctx));
|
|
const owned_message = self.allocator.dupe(u8, message) catch return;
|
|
const error_event = TestError{
|
|
.error_code = error_code,
|
|
.message = owned_message,
|
|
};
|
|
self.error_events.append(self.allocator, error_event) catch return;
|
|
}
|
|
|
|
fn onDetailedError(ctx: *anyopaque, error_info: stt.ErrorInfo) void {
|
|
const self: *TestEventHandler = @ptrCast(@alignCast(ctx));
|
|
|
|
// Create owned copies of strings
|
|
const owned_message = self.allocator.dupe(u8, error_info.message) catch return;
|
|
const owned_context = if (error_info.context) |context|
|
|
self.allocator.dupe(u8, context) catch null
|
|
else
|
|
null;
|
|
const owned_suggestion = if (error_info.recovery_suggestion) |suggestion|
|
|
self.allocator.dupe(u8, suggestion) catch null
|
|
else
|
|
null;
|
|
|
|
var owned_error_info = error_info;
|
|
owned_error_info.message = owned_message;
|
|
owned_error_info.context = owned_context;
|
|
owned_error_info.recovery_suggestion = owned_suggestion;
|
|
|
|
self.detailed_error_events.append(self.allocator, owned_error_info) catch return;
|
|
}
|
|
|
|
fn getSpeechEventHandler(self: *TestEventHandler) stt.SpeechEventHandler {
|
|
return stt.SpeechEventHandler{
|
|
.onSpeechFn = TestEventHandler.onSpeech,
|
|
.onErrorFn = TestEventHandler.onError,
|
|
.onDetailedErrorFn = TestEventHandler.onDetailedError,
|
|
.ctx = self,
|
|
};
|
|
}
|
|
|
|
fn clearEvents(self: *TestEventHandler) void {
|
|
// Free existing events
|
|
for (self.speech_events.items) |text| {
|
|
self.allocator.free(text);
|
|
}
|
|
for (self.error_events.items) |error_event| {
|
|
self.allocator.free(error_event.message);
|
|
}
|
|
for (self.detailed_error_events.items) |error_info| {
|
|
self.allocator.free(error_info.message);
|
|
if (error_info.context) |context| {
|
|
self.allocator.free(context);
|
|
}
|
|
if (error_info.recovery_suggestion) |suggestion| {
|
|
self.allocator.free(suggestion);
|
|
}
|
|
}
|
|
|
|
self.speech_events.clearAndFree(self.allocator);
|
|
self.error_events.clearAndFree(self.allocator);
|
|
self.detailed_error_events.clearAndFree(self.allocator);
|
|
}
|
|
};
|
|
|
|
test "Error types and ErrorInfo" {
|
|
// Test basic error info creation
|
|
const basic_error = stt.ErrorInfo.init(stt.Error.AudioDeviceError, "Test error message");
|
|
try testing.expect(basic_error.error_code == stt.Error.AudioDeviceError);
|
|
try testing.expectEqualStrings("Test error message", basic_error.message);
|
|
try testing.expect(basic_error.system_error == null);
|
|
try testing.expect(basic_error.context == null);
|
|
try testing.expect(basic_error.recoverable == false);
|
|
|
|
// Test error info with context
|
|
const context_error = stt.ErrorInfo.initWithContext(stt.Error.ModelLoadError, "Context error", "/path/to/model");
|
|
try testing.expectEqualStrings("/path/to/model", context_error.context.?);
|
|
|
|
// Test recoverable error info
|
|
const recoverable_error = stt.ErrorInfo.initRecoverable(stt.Error.AudioDeviceBusy, "Recoverable error", "Try again later");
|
|
try testing.expect(recoverable_error.recoverable == true);
|
|
try testing.expectEqualStrings("Try again later", recoverable_error.recovery_suggestion.?);
|
|
}
|
|
|
|
test "SpeechEventHandler callback invocation" {
|
|
var test_handler = TestEventHandler.init(test_allocator);
|
|
defer test_handler.deinit();
|
|
|
|
const speech_handler = test_handler.getSpeechEventHandler();
|
|
|
|
// Test speech callback
|
|
const event = stt.SpeechEvent{ .text = "Hello world", .max_amplitude = 1000 };
|
|
speech_handler.onSpeech(event);
|
|
try testing.expect(test_handler.speech_events.items.len == 1);
|
|
try testing.expectEqualStrings("Hello world", test_handler.speech_events.items[0]);
|
|
|
|
// Test error callback
|
|
speech_handler.onError(stt.Error.AudioDeviceError, "Test error");
|
|
try testing.expect(test_handler.error_events.items.len == 1);
|
|
try testing.expect(test_handler.error_events.items[0].error_code == stt.Error.AudioDeviceError);
|
|
try testing.expectEqualStrings("Test error", test_handler.error_events.items[0].message);
|
|
|
|
// Test detailed error callback
|
|
const error_info = stt.ErrorInfo.initWithContext(stt.Error.ModelLoadError, "Detailed error", "test context");
|
|
speech_handler.onDetailedError(error_info);
|
|
try testing.expect(test_handler.detailed_error_events.items.len == 1);
|
|
try testing.expect(test_handler.detailed_error_events.items[0].error_code == stt.Error.ModelLoadError);
|
|
try testing.expectEqualStrings("Detailed error", test_handler.detailed_error_events.items[0].message);
|
|
try testing.expectEqualStrings("test context", test_handler.detailed_error_events.items[0].context.?);
|
|
}
|
|
|
|
test "AudioBuffer management" {
|
|
const buffer_size = 1024;
|
|
var audio_buffer = try stt.AudioBuffer.init(test_allocator, buffer_size);
|
|
defer audio_buffer.deinit();
|
|
|
|
// Test initial state
|
|
try testing.expect(audio_buffer.available() == 0);
|
|
try testing.expect(audio_buffer.capacity() == buffer_size);
|
|
|
|
// Test writing samples
|
|
const test_samples = [_]i16{ 100, 200, 300, 400, 500 };
|
|
const written = audio_buffer.write(&test_samples);
|
|
try testing.expect(written == test_samples.len);
|
|
try testing.expect(audio_buffer.available() == test_samples.len);
|
|
try testing.expect(audio_buffer.capacity() == buffer_size - test_samples.len);
|
|
|
|
// Test reading samples
|
|
var read_buffer: [10]i16 = undefined;
|
|
const read_count = audio_buffer.read(&read_buffer);
|
|
try testing.expect(read_count == test_samples.len);
|
|
try testing.expectEqualSlices(i16, &test_samples, read_buffer[0..read_count]);
|
|
try testing.expect(audio_buffer.available() == 0);
|
|
|
|
// Test buffer overflow handling
|
|
const large_samples = [_]i16{1} ** (buffer_size + 100);
|
|
const written_overflow = audio_buffer.write(&large_samples);
|
|
try testing.expect(written_overflow == buffer_size); // Should only write what fits
|
|
try testing.expect(audio_buffer.available() == buffer_size);
|
|
|
|
// Test clearing buffer
|
|
audio_buffer.clear();
|
|
try testing.expect(audio_buffer.available() == 0);
|
|
try testing.expect(audio_buffer.capacity() == buffer_size);
|
|
}
|
|
|
|
test "AudioConverter stereo to mono conversion" {
|
|
// Test stereo to mono conversion
|
|
const stereo_samples = [_]i16{ 100, 200, 300, 400, 500, 600 }; // 3 stereo frames
|
|
var mono_samples: [3]i16 = undefined;
|
|
|
|
const converted_frames = stt.AudioConverter.stereoToMono(&stereo_samples, &mono_samples);
|
|
try testing.expect(converted_frames == 3);
|
|
|
|
// Check averaged values: (100+200)/2=150, (300+400)/2=350, (500+600)/2=550
|
|
try testing.expect(mono_samples[0] == 150);
|
|
try testing.expect(mono_samples[1] == 350);
|
|
try testing.expect(mono_samples[2] == 550);
|
|
|
|
// Test with overflow protection
|
|
const overflow_stereo = [_]i16{ std.math.maxInt(i16), std.math.maxInt(i16) };
|
|
var overflow_mono: [1]i16 = undefined;
|
|
_ = stt.AudioConverter.stereoToMono(&overflow_stereo, &overflow_mono);
|
|
try testing.expect(overflow_mono[0] == std.math.maxInt(i16)); // Should clamp to max
|
|
}
|
|
|
|
test "Session initialization error handling" {
|
|
var test_handler = TestEventHandler.init(test_allocator);
|
|
defer test_handler.deinit();
|
|
|
|
const speech_handler = test_handler.getSpeechEventHandler();
|
|
|
|
// Test with invalid model path - but don't actually call init to avoid segfault
|
|
const invalid_options = stt.Options{
|
|
.model_path = "/nonexistent/path",
|
|
.audio_device = "hw:999,0", // Non-existent device
|
|
.event_handler = speech_handler,
|
|
};
|
|
|
|
// Test that the options structure is properly formed (without calling init)
|
|
try testing.expectEqualStrings("/nonexistent/path", invalid_options.model_path);
|
|
try testing.expectEqualStrings("hw:999,0", invalid_options.audio_device);
|
|
try testing.expect(invalid_options.sample_rate == 16000);
|
|
try testing.expect(invalid_options.buffer_size == 256);
|
|
}
|
|
|
|
test "Session mock initialization and cleanup" {
|
|
// Note: This test would require mocking the Vosk and ALSA dependencies
|
|
// For now, we test the structure and error handling paths
|
|
|
|
var test_handler = TestEventHandler.init(test_allocator);
|
|
defer test_handler.deinit();
|
|
|
|
const speech_handler = test_handler.getSpeechEventHandler();
|
|
|
|
// Test options validation
|
|
const valid_options = stt.Options{
|
|
.model_path = "test/model/path",
|
|
.audio_device = "hw:0,0",
|
|
.event_handler = speech_handler,
|
|
.sample_rate = 16000,
|
|
.buffer_size = 256,
|
|
};
|
|
|
|
// Test that options structure is properly formed
|
|
try testing.expectEqualStrings("test/model/path", valid_options.model_path);
|
|
try testing.expectEqualStrings("hw:0,0", valid_options.audio_device);
|
|
try testing.expect(valid_options.sample_rate == 16000);
|
|
try testing.expect(valid_options.buffer_size == 256);
|
|
}
|
|
|
|
test "AudioBuffer thread safety" {
|
|
const buffer_size = 1024;
|
|
var audio_buffer = try stt.AudioBuffer.init(test_allocator, buffer_size);
|
|
defer audio_buffer.deinit();
|
|
|
|
// Test concurrent access simulation
|
|
const test_samples = [_]i16{ 1, 2, 3, 4, 5 };
|
|
var read_buffer: [10]i16 = undefined;
|
|
|
|
// Write and read in sequence (simulating thread safety)
|
|
const written1 = audio_buffer.write(&test_samples);
|
|
const read1 = audio_buffer.read(&read_buffer);
|
|
|
|
try testing.expect(written1 == test_samples.len);
|
|
try testing.expect(read1 == test_samples.len);
|
|
try testing.expectEqualSlices(i16, &test_samples, read_buffer[0..read1]);
|
|
|
|
// Test multiple writes and reads
|
|
_ = audio_buffer.write(&test_samples);
|
|
_ = audio_buffer.write(&test_samples);
|
|
|
|
const available_before = audio_buffer.available();
|
|
try testing.expect(available_before == test_samples.len * 2);
|
|
|
|
const read2 = audio_buffer.read(&read_buffer);
|
|
try testing.expect(read2 <= read_buffer.len);
|
|
|
|
const available_after = audio_buffer.available();
|
|
try testing.expect(available_after == available_before - read2);
|
|
}
|
|
|
|
test "Error recovery and handling" {
|
|
var test_handler = TestEventHandler.init(test_allocator);
|
|
defer test_handler.deinit();
|
|
|
|
const speech_handler = test_handler.getSpeechEventHandler();
|
|
|
|
// Test different error types and their recovery suggestions
|
|
const errors_to_test = [_]struct {
|
|
error_code: stt.Error,
|
|
message: []const u8,
|
|
should_be_recoverable: bool,
|
|
}{
|
|
.{ .error_code = stt.Error.AudioDeviceBusy, .message = "Device busy", .should_be_recoverable = true },
|
|
.{ .error_code = stt.Error.OutOfMemory, .message = "Out of memory", .should_be_recoverable = false },
|
|
.{ .error_code = stt.Error.ModelLoadError, .message = "Model load failed", .should_be_recoverable = false },
|
|
.{ .error_code = stt.Error.AudioDeviceNotFound, .message = "Device not found", .should_be_recoverable = true },
|
|
};
|
|
|
|
for (errors_to_test) |error_test| {
|
|
test_handler.clearEvents();
|
|
|
|
// Create error info based on error type
|
|
const error_info = if (error_test.should_be_recoverable)
|
|
stt.ErrorInfo.initRecoverable(error_test.error_code, error_test.message, "Try again later")
|
|
else
|
|
stt.ErrorInfo.init(error_test.error_code, error_test.message);
|
|
|
|
speech_handler.onDetailedError(error_info);
|
|
|
|
try testing.expect(test_handler.detailed_error_events.items.len == 1);
|
|
const received_error = test_handler.detailed_error_events.items[0];
|
|
try testing.expect(received_error.error_code == error_test.error_code);
|
|
try testing.expectEqualStrings(error_test.message, received_error.message);
|
|
try testing.expect(received_error.recoverable == error_test.should_be_recoverable);
|
|
}
|
|
}
|
|
|
|
test "Callback error handling robustness" {
|
|
var test_handler = TestEventHandler.init(test_allocator);
|
|
defer test_handler.deinit();
|
|
|
|
const speech_handler = test_handler.getSpeechEventHandler();
|
|
|
|
// Test multiple rapid callbacks
|
|
for (0..100) |i| {
|
|
const text = std.fmt.allocPrint(test_allocator, "Speech event {}", .{i}) catch continue;
|
|
defer test_allocator.free(text);
|
|
const event = stt.SpeechEvent{ .text = text, .max_amplitude = @intCast(i) };
|
|
speech_handler.onSpeech(event);
|
|
}
|
|
|
|
try testing.expect(test_handler.speech_events.items.len == 100);
|
|
|
|
// Test mixed callback types
|
|
const final_event = stt.SpeechEvent{ .text = "Final speech", .max_amplitude = 800 };
|
|
speech_handler.onSpeech(final_event);
|
|
speech_handler.onError(stt.Error.CallbackError, "Callback error");
|
|
|
|
const final_error = stt.ErrorInfo.init(stt.Error.InternalError, "Internal error");
|
|
speech_handler.onDetailedError(final_error);
|
|
|
|
try testing.expect(test_handler.speech_events.items.len == 101);
|
|
try testing.expect(test_handler.error_events.items.len == 1);
|
|
try testing.expect(test_handler.detailed_error_events.items.len == 1);
|
|
}
|
|
|
|
test "Memory management and resource cleanup" {
|
|
// Test AudioBuffer memory management
|
|
{
|
|
var audio_buffer = try stt.AudioBuffer.init(test_allocator, 1024);
|
|
defer audio_buffer.deinit(); // Should not leak memory
|
|
|
|
const test_samples = [_]i16{1} ** 100;
|
|
_ = audio_buffer.write(&test_samples);
|
|
|
|
var read_buffer: [50]i16 = undefined;
|
|
_ = audio_buffer.read(&read_buffer);
|
|
}
|
|
|
|
// Test TestEventHandler memory management
|
|
{
|
|
var test_handler = TestEventHandler.init(test_allocator);
|
|
defer test_handler.deinit(); // Should not leak memory
|
|
|
|
const speech_handler = test_handler.getSpeechEventHandler();
|
|
const event = stt.SpeechEvent{ .text = "Test speech", .max_amplitude = 500 };
|
|
speech_handler.onSpeech(event);
|
|
speech_handler.onError(stt.Error.AudioDeviceError, "Test error");
|
|
|
|
const error_info = stt.ErrorInfo.initWithContext(stt.Error.ModelLoadError, "Test detailed error", "test context");
|
|
speech_handler.onDetailedError(error_info);
|
|
}
|
|
}
|
|
|
|
test "Edge cases and boundary conditions" {
|
|
// Test AudioBuffer with zero capacity (should handle gracefully)
|
|
// Note: Zero capacity might be allowed by the allocator, so we test behavior instead
|
|
if (stt.AudioBuffer.init(test_allocator, 0)) |zero_buffer| {
|
|
var zero_buf = zero_buffer;
|
|
defer zero_buf.deinit();
|
|
try testing.expect(zero_buf.capacity() == 0);
|
|
try testing.expect(zero_buf.available() == 0);
|
|
} else |_| {
|
|
// If it fails, that's also acceptable behavior
|
|
}
|
|
|
|
// Test AudioBuffer with very small capacity
|
|
var small_buffer = try stt.AudioBuffer.init(test_allocator, 1);
|
|
defer small_buffer.deinit();
|
|
|
|
const single_sample = [_]i16{42};
|
|
const written = small_buffer.write(&single_sample);
|
|
try testing.expect(written == 1);
|
|
try testing.expect(small_buffer.available() == 1);
|
|
try testing.expect(small_buffer.capacity() == 0);
|
|
|
|
// Test reading more than available
|
|
var large_read_buffer: [10]i16 = undefined;
|
|
const read_count = small_buffer.read(&large_read_buffer);
|
|
try testing.expect(read_count == 1);
|
|
try testing.expect(large_read_buffer[0] == 42);
|
|
|
|
// Test AudioConverter with empty input
|
|
const empty_input: [0]i16 = .{};
|
|
var empty_output: [10]i16 = undefined;
|
|
const converted = stt.AudioConverter.stereoToMono(&empty_input, &empty_output);
|
|
try testing.expect(converted == 0);
|
|
|
|
// Test AudioConverter with mismatched buffer sizes
|
|
const small_stereo = [_]i16{ 100, 200 };
|
|
var tiny_mono: [0]i16 = .{};
|
|
const tiny_converted = stt.AudioConverter.stereoToMono(&small_stereo, &tiny_mono);
|
|
try testing.expect(tiny_converted == 0);
|
|
}
|
|
|
|
test "Complete workflow simulation" {
|
|
var test_handler = TestEventHandler.init(test_allocator);
|
|
defer test_handler.deinit();
|
|
|
|
const speech_handler = test_handler.getSpeechEventHandler();
|
|
|
|
// Simulate a complete speech recognition workflow
|
|
|
|
// 1. Initialization phase
|
|
const init_error = stt.ErrorInfo.initRecoverable(stt.Error.InternalError, "STT library initialized", "Ready for speech recognition");
|
|
speech_handler.onDetailedError(init_error);
|
|
|
|
// 2. Audio processing phase
|
|
var audio_buffer = try stt.AudioBuffer.init(test_allocator, 1024);
|
|
defer audio_buffer.deinit();
|
|
|
|
// Simulate audio data processing
|
|
const audio_samples = [_]i16{ 100, 200, 300, 400, 500 };
|
|
_ = audio_buffer.write(&audio_samples);
|
|
|
|
var processed_samples: [10]i16 = undefined;
|
|
const processed_count = audio_buffer.read(&processed_samples);
|
|
try testing.expect(processed_count == audio_samples.len);
|
|
|
|
// 3. Speech detection phase
|
|
const event1 = stt.SpeechEvent{ .text = "Hello world", .max_amplitude = 2000 };
|
|
speech_handler.onSpeech(event1);
|
|
const event2 = stt.SpeechEvent{ .text = "This is a test", .max_amplitude = 1500 };
|
|
speech_handler.onSpeech(event2);
|
|
|
|
// 4. Error handling phase
|
|
const recoverable_error = stt.ErrorInfo.initRecoverable(stt.Error.AudioDeviceBusy, "Audio device temporarily busy", "Retrying in 100ms");
|
|
speech_handler.onDetailedError(recoverable_error);
|
|
|
|
// 5. Recovery phase
|
|
const resume_event = stt.SpeechEvent{ .text = "Speech recognition resumed", .max_amplitude = 1800 };
|
|
speech_handler.onSpeech(resume_event);
|
|
|
|
// 6. Cleanup phase
|
|
const cleanup_info = stt.ErrorInfo.initRecoverable(stt.Error.InternalError, "STT session cleanup completed", "All resources freed");
|
|
speech_handler.onDetailedError(cleanup_info);
|
|
|
|
// Verify the complete workflow
|
|
try testing.expect(test_handler.speech_events.items.len == 3);
|
|
try testing.expect(test_handler.detailed_error_events.items.len == 3);
|
|
|
|
try testing.expectEqualStrings("Hello world", test_handler.speech_events.items[0]);
|
|
try testing.expectEqualStrings("This is a test", test_handler.speech_events.items[1]);
|
|
try testing.expectEqualStrings("Speech recognition resumed", test_handler.speech_events.items[2]);
|
|
}
|