This commit is contained in:
Emil Lerch 2025-09-10 13:31:34 -07:00
parent 01265a887e
commit db963784b0
Signed by: lobo
GPG key ID: A7B62D657EF764F8

513
src/test.zig Normal file
View file

@ -0,0 +1,513 @@
//! Unit tests for STT library components
//!
//! This file contains comprehensive tests for:
//! - SttSession 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("root.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.SttErrorInfo),
allocator: std.mem.Allocator,
const TestError = struct {
error_code: stt.SttError,
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.SttErrorInfo){},
.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, text: []const u8) void {
const self: *TestEventHandler = @ptrCast(@alignCast(ctx));
const owned_text = self.allocator.dupe(u8, text) catch return;
self.speech_events.append(self.allocator, owned_text) catch return;
}
fn onError(ctx: *anyopaque, error_code: stt.SttError, 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.SttErrorInfo) 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 "SttError types and SttErrorInfo" {
// Test basic error info creation
const basic_error = stt.SttErrorInfo.init(stt.SttError.AudioDeviceError, "Test error message");
try testing.expect(basic_error.error_code == stt.SttError.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 system error
const system_error = stt.SttErrorInfo.initWithSystemError(stt.SttError.AudioDeviceError, "System error", -1);
try testing.expect(system_error.system_error.? == -1);
// Test error info with context
const context_error = stt.SttErrorInfo.initWithContext(stt.SttError.ModelLoadError, "Context error", "/path/to/model");
try testing.expectEqualStrings("/path/to/model", context_error.context.?);
// Test recoverable error info
const recoverable_error = stt.SttErrorInfo.initRecoverable(stt.SttError.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
speech_handler.onSpeech("Hello world");
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.SttError.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.SttError.AudioDeviceError);
try testing.expectEqualStrings("Test error", test_handler.error_events.items[0].message);
// Test detailed error callback
const error_info = stt.SttErrorInfo.initWithContext(stt.SttError.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.SttError.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 "AudioConverter sample rate conversion" {
// Test same sample rate (no conversion)
const input_samples = [_]i16{ 100, 200, 300, 400 };
var output_samples: [4]i16 = undefined;
const converted = stt.AudioConverter.resample(&input_samples, &output_samples, 44100, 44100);
try testing.expect(converted == 4);
try testing.expectEqualSlices(i16, &input_samples, output_samples[0..converted]);
// Test downsampling (44100 -> 22050, 2:1 ratio)
var downsampled: [2]i16 = undefined;
const downsampled_count = stt.AudioConverter.resample(&input_samples, &downsampled, 44100, 22050);
try testing.expect(downsampled_count == 2);
// Test upsampling (22050 -> 44100, 1:2 ratio)
const small_input = [_]i16{ 100, 200 };
var upsampled: [4]i16 = undefined;
const upsampled_count = stt.AudioConverter.resample(&small_input, &upsampled, 22050, 44100);
try testing.expect(upsampled_count == 4);
}
test "SttSession 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.SttOptions{
.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.channels == 2);
try testing.expect(invalid_options.buffer_size == 256);
}
test "SttSession 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.SttOptions{
.model_path = "test/model/path",
.audio_device = "hw:0,0",
.event_handler = speech_handler,
.sample_rate = 16000,
.channels = 2,
.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.channels == 2);
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.SttError,
message: []const u8,
should_be_recoverable: bool,
}{
.{ .error_code = stt.SttError.AudioDeviceBusy, .message = "Device busy", .should_be_recoverable = true },
.{ .error_code = stt.SttError.OutOfMemory, .message = "Out of memory", .should_be_recoverable = false },
.{ .error_code = stt.SttError.ModelLoadError, .message = "Model load failed", .should_be_recoverable = false },
.{ .error_code = stt.SttError.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.SttErrorInfo.initRecoverable(error_test.error_code, error_test.message, "Try again later")
else
stt.SttErrorInfo.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);
speech_handler.onSpeech(text);
}
try testing.expect(test_handler.speech_events.items.len == 100);
// Test mixed callback types
speech_handler.onSpeech("Final speech");
speech_handler.onError(stt.SttError.CallbackError, "Callback error");
const final_error = stt.SttErrorInfo.init(stt.SttError.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();
speech_handler.onSpeech("Test speech");
speech_handler.onError(stt.SttError.AudioDeviceError, "Test error");
const error_info = stt.SttErrorInfo.initWithContext(stt.SttError.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.SttErrorInfo.initRecoverable(stt.SttError.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
speech_handler.onSpeech("Hello world");
speech_handler.onSpeech("This is a test");
// 4. Error handling phase
const recoverable_error = stt.SttErrorInfo.initRecoverable(stt.SttError.AudioDeviceBusy, "Audio device temporarily busy", "Retrying in 100ms");
speech_handler.onDetailedError(recoverable_error);
// 5. Recovery phase
speech_handler.onSpeech("Speech recognition resumed");
// 6. Cleanup phase
const cleanup_info = stt.SttErrorInfo.initRecoverable(stt.SttError.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]);
}