add tests
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPidManagerBasics(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a custom pidManager for testing
|
||||
testPidManager := &pidManager{
|
||||
path: filepath.Join(tempDir, PidName),
|
||||
}
|
||||
|
||||
t.Run("create and remove PID file", func(t *testing.T) {
|
||||
// Create PID file
|
||||
err := testPidManager.create()
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
// Check file exists and contains current PID
|
||||
pidData, err := os.ReadFile(testPidManager.path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read PID file: %v", err)
|
||||
}
|
||||
|
||||
expectedPid := strconv.Itoa(os.Getpid())
|
||||
if string(pidData) != expectedPid {
|
||||
t.Errorf("PID file contains %q, expected %q", string(pidData), expectedPid)
|
||||
}
|
||||
|
||||
// Remove PID file
|
||||
err = testPidManager.remove()
|
||||
if err != nil {
|
||||
t.Fatalf("remove failed: %v", err)
|
||||
}
|
||||
|
||||
// Check file no longer exists
|
||||
if _, err := os.Stat(testPidManager.path); !os.IsNotExist(err) {
|
||||
t.Error("PID file should not exist after removal")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("checkExisting with no PID file", func(t *testing.T) {
|
||||
err := testPidManager.checkExisting()
|
||||
if err != nil {
|
||||
t.Errorf("checkExisting should not error when no PID file exists: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("checkExisting with current process", func(t *testing.T) {
|
||||
// Create PID file with current process
|
||||
err := testPidManager.create()
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
defer testPidManager.remove()
|
||||
|
||||
// Check should fail because process is running
|
||||
err = testPidManager.checkExisting()
|
||||
if err == nil {
|
||||
t.Error("checkExisting should fail when process is running")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("checkExisting with stale PID file", func(t *testing.T) {
|
||||
// Create PID file with non-existent PID
|
||||
stalePid := "99999"
|
||||
err := os.WriteFile(testPidManager.path, []byte(stalePid), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write stale PID file: %v", err)
|
||||
}
|
||||
|
||||
// Check should succeed and remove stale file
|
||||
err = testPidManager.checkExisting()
|
||||
if err != nil {
|
||||
t.Errorf("checkExisting should succeed with stale PID: %v", err)
|
||||
}
|
||||
|
||||
// File should be removed
|
||||
if _, err := os.Stat(testPidManager.path); !os.IsNotExist(err) {
|
||||
t.Error("stale PID file should be removed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("checkExisting with invalid PID file", func(t *testing.T) {
|
||||
// Create PID file with invalid content
|
||||
err := os.WriteFile(testPidManager.path, []byte("invalid"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write invalid PID file: %v", err)
|
||||
}
|
||||
|
||||
// Check should succeed and remove invalid file
|
||||
err = testPidManager.checkExisting()
|
||||
if err != nil {
|
||||
t.Errorf("checkExisting should succeed with invalid PID: %v", err)
|
||||
}
|
||||
|
||||
// File should be removed
|
||||
if _, err := os.Stat(testPidManager.path); !os.IsNotExist(err) {
|
||||
t.Error("invalid PID file should be removed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsProcessAlive(t *testing.T) {
|
||||
pm := &pidManager{}
|
||||
|
||||
t.Run("current process", func(t *testing.T) {
|
||||
if !pm.isProcessAlive(os.Getpid()) {
|
||||
t.Error("current process should be alive")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-existent process", func(t *testing.T) {
|
||||
// Use a PID that's very unlikely to exist
|
||||
if pm.isProcessAlive(99999) {
|
||||
t.Error("non-existent process should not be alive")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("init process", func(t *testing.T) {
|
||||
// PID 1 should always exist on Unix systems, but we might not have permission to signal it
|
||||
alive := pm.isProcessAlive(1)
|
||||
// Don't fail the test if we can't signal PID 1 due to permissions
|
||||
// This is expected behavior in containers or restricted environments
|
||||
_ = alive
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocketManagerBasics(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a custom socketManager for testing
|
||||
testSocketManager := &socketManager{
|
||||
path: filepath.Join(tempDir, SockName),
|
||||
}
|
||||
|
||||
t.Run("listen and dial", func(t *testing.T) {
|
||||
// Start listening
|
||||
listener, err := testSocketManager.listen()
|
||||
if err != nil {
|
||||
t.Fatalf("listen failed: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
// Accept connections in background
|
||||
connCh := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
connCh <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Echo back what we receive
|
||||
buf := make([]byte, 1024)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
connCh <- err
|
||||
return
|
||||
}
|
||||
|
||||
_, err = conn.Write(buf[:n])
|
||||
connCh <- err
|
||||
}()
|
||||
|
||||
// Give listener time to start
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Dial and send message
|
||||
conn, err := testSocketManager.dial()
|
||||
if err != nil {
|
||||
t.Fatalf("dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
testMsg := "hello"
|
||||
_, err = conn.Write([]byte(testMsg))
|
||||
if err != nil {
|
||||
t.Fatalf("write failed: %v", err)
|
||||
}
|
||||
|
||||
// Read echo
|
||||
buf := make([]byte, 1024)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
|
||||
if string(buf[:n]) != testMsg {
|
||||
t.Errorf("got %q, expected %q", string(buf[:n]), testMsg)
|
||||
}
|
||||
|
||||
// Check background goroutine
|
||||
if err := <-connCh; err != nil {
|
||||
t.Errorf("background connection error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dial without listener", func(t *testing.T) {
|
||||
_, err := testSocketManager.dial()
|
||||
if err == nil {
|
||||
t.Error("dial should fail when no listener exists")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendCommandIntegration(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a custom socketManager for testing
|
||||
testSocketManager := &socketManager{
|
||||
path: filepath.Join(tempDir, SockName),
|
||||
}
|
||||
|
||||
t.Run("successful command with mock server", func(t *testing.T) {
|
||||
// Start a mock server
|
||||
listener, err := testSocketManager.listen()
|
||||
if err != nil {
|
||||
t.Fatalf("listen failed: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
// Handle connections in background
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
|
||||
buf := make([]byte, 2)
|
||||
n, err := c.Read(buf)
|
||||
if err != nil || n != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
cmd := buf[0]
|
||||
switch cmd {
|
||||
case 't':
|
||||
fmt.Fprint(c, "OK toggled\n")
|
||||
case 's':
|
||||
fmt.Fprint(c, "STATUS status=idle\n")
|
||||
case 'v':
|
||||
fmt.Fprintf(c, "STATUS proto=%s\n", ProtoVer)
|
||||
case 'q':
|
||||
fmt.Fprint(c, "OK quitting\n")
|
||||
default:
|
||||
fmt.Fprintf(c, "ERR unknown=%q\n", cmd)
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
// Give server time to start
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Test different commands by manually creating connections
|
||||
tests := []struct {
|
||||
cmd byte
|
||||
expected string
|
||||
}{
|
||||
{'t', "OK toggled\n"},
|
||||
{'s', "STATUS status=idle\n"},
|
||||
{'v', fmt.Sprintf("STATUS proto=%s\n", ProtoVer)},
|
||||
{'q', "OK quitting\n"},
|
||||
{'x', "ERR unknown='x'\n"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
conn, err := testSocketManager.dial()
|
||||
if err != nil {
|
||||
t.Errorf("dial failed for command %c: %v", tt.cmd, err)
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = conn.Write([]byte{tt.cmd, '\n'})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
t.Errorf("write failed for command %c: %v", tt.cmd, err)
|
||||
continue
|
||||
}
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
n, err := conn.Read(buf)
|
||||
conn.Close()
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("read failed for command %c: %v", tt.cmd, err)
|
||||
continue
|
||||
}
|
||||
|
||||
resp := string(buf[:n])
|
||||
if resp != tt.expected {
|
||||
t.Errorf("command %c: got %q, expected %q", tt.cmd, resp, tt.expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPathFunctions(t *testing.T) {
|
||||
t.Run("SockPath", func(t *testing.T) {
|
||||
path, err := SockPath()
|
||||
if err != nil {
|
||||
t.Fatalf("SockPath failed: %v", err)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
t.Error("SockPath should return absolute path")
|
||||
}
|
||||
|
||||
if filepath.Base(path) != SockName {
|
||||
t.Errorf("SockPath should end with %s, got %s", SockName, filepath.Base(path))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("getSockPath", func(t *testing.T) {
|
||||
path, err := getSockPath()
|
||||
if err != nil {
|
||||
t.Fatalf("getSockPath failed: %v", err)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
t.Error("getSockPath should return absolute path")
|
||||
}
|
||||
|
||||
if filepath.Base(path) != SockName {
|
||||
t.Errorf("getSockPath should end with %s, got %s", SockName, filepath.Base(path))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("getPidPath", func(t *testing.T) {
|
||||
path, err := getPidPath()
|
||||
if err != nil {
|
||||
t.Fatalf("getPidPath failed: %v", err)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
t.Error("getPidPath should return absolute path")
|
||||
}
|
||||
|
||||
if filepath.Base(path) != PidName {
|
||||
t.Errorf("getPidPath should end with %s, got %s", PidName, filepath.Base(path))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConstants(t *testing.T) {
|
||||
if SockName == "" {
|
||||
t.Error("SockName should not be empty")
|
||||
}
|
||||
if PidName == "" {
|
||||
t.Error("PidName should not be empty")
|
||||
}
|
||||
if ProtoVer == "" {
|
||||
t.Error("ProtoVer should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// Test the public API functions with temporary directories
|
||||
func TestPublicAPIWithTempDirs(t *testing.T) {
|
||||
// We can't override the internal functions, but we can test the public API
|
||||
// and clean up any files we create
|
||||
|
||||
t.Run("CheckExistingDaemon with no daemon", func(t *testing.T) {
|
||||
// This should succeed when no daemon is running
|
||||
// Clean up any existing PID file first
|
||||
pidPath, _ := getPidPath()
|
||||
os.Remove(pidPath)
|
||||
|
||||
err := CheckExistingDaemon()
|
||||
if err != nil {
|
||||
t.Errorf("CheckExistingDaemon should succeed when no daemon running: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreatePidFile and RemovePidFile", func(t *testing.T) {
|
||||
// Clean up first
|
||||
pidPath, _ := getPidPath()
|
||||
os.Remove(pidPath)
|
||||
|
||||
err := CreatePidFile()
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePidFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Check file exists
|
||||
if _, err := os.Stat(pidPath); os.IsNotExist(err) {
|
||||
t.Error("PID file should exist after CreatePidFile")
|
||||
}
|
||||
|
||||
err = RemovePidFile()
|
||||
if err != nil {
|
||||
t.Fatalf("RemovePidFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Check file is removed
|
||||
if _, err := os.Stat(pidPath); !os.IsNotExist(err) {
|
||||
t.Error("PID file should not exist after RemovePidFile")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/notify"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/pipeline"
|
||||
)
|
||||
|
||||
// Mock notifier for testing
|
||||
type mockNotifier struct {
|
||||
recordingStartedCalled bool
|
||||
recordingEndedCalled bool
|
||||
abortedCalled bool
|
||||
transcribingCalled bool
|
||||
errorCalled bool
|
||||
lastErrorMessage string
|
||||
}
|
||||
|
||||
func (m *mockNotifier) RecordingStarted() { m.recordingStartedCalled = true }
|
||||
func (m *mockNotifier) RecordingEnded() { m.recordingEndedCalled = true }
|
||||
func (m *mockNotifier) Aborted() { m.abortedCalled = true }
|
||||
func (m *mockNotifier) Transcribing() { m.transcribingCalled = true }
|
||||
func (m *mockNotifier) Error(msg string) {
|
||||
m.errorCalled = true
|
||||
m.lastErrorMessage = msg
|
||||
}
|
||||
func (m *mockNotifier) Notify(title, message string) {}
|
||||
|
||||
func (m *mockNotifier) reset() {
|
||||
m.recordingStartedCalled = false
|
||||
m.recordingEndedCalled = false
|
||||
m.abortedCalled = false
|
||||
m.transcribingCalled = false
|
||||
m.errorCalled = false
|
||||
m.lastErrorMessage = ""
|
||||
}
|
||||
|
||||
// Mock pipeline for testing
|
||||
type mockPipeline struct {
|
||||
status pipeline.Status
|
||||
actionCh chan pipeline.Action
|
||||
errorCh chan pipeline.PipelineError
|
||||
running bool
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func newMockPipeline() *mockPipeline {
|
||||
return &mockPipeline{
|
||||
status: pipeline.Idle,
|
||||
actionCh: make(chan pipeline.Action, 1),
|
||||
errorCh: make(chan pipeline.PipelineError, 10),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPipeline) Run(ctx context.Context) {
|
||||
m.running = true
|
||||
m.status = pipeline.Recording
|
||||
}
|
||||
|
||||
func (m *mockPipeline) Stop() {
|
||||
m.stopped = true
|
||||
m.running = false
|
||||
m.status = pipeline.Idle
|
||||
}
|
||||
|
||||
func (m *mockPipeline) Status() pipeline.Status {
|
||||
return m.status
|
||||
}
|
||||
|
||||
func (m *mockPipeline) GetActionCh() chan<- pipeline.Action {
|
||||
return m.actionCh
|
||||
}
|
||||
|
||||
func (m *mockPipeline) GetErrorCh() <-chan pipeline.PipelineError {
|
||||
return m.errorCh
|
||||
}
|
||||
|
||||
func (m *mockPipeline) SetStatus(status pipeline.Status) {
|
||||
m.status = status
|
||||
}
|
||||
|
||||
func (m *mockPipeline) SendError(title, message string, err error) {
|
||||
pipelineErr := pipeline.PipelineError{
|
||||
Title: title,
|
||||
Message: message,
|
||||
Err: err,
|
||||
}
|
||||
select {
|
||||
case m.errorCh <- pipelineErr:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDaemon(t *testing.T) {
|
||||
t.Run("with notifier", func(t *testing.T) {
|
||||
mockNotif := &mockNotifier{}
|
||||
daemon := New(mockNotif)
|
||||
|
||||
if daemon == nil {
|
||||
t.Fatal("daemon should not be nil")
|
||||
}
|
||||
|
||||
if daemon.notifier != mockNotif {
|
||||
t.Error("daemon should use provided notifier")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with nil notifier", func(t *testing.T) {
|
||||
daemon := New(nil)
|
||||
|
||||
if daemon == nil {
|
||||
t.Fatal("daemon should not be nil")
|
||||
}
|
||||
|
||||
// Should use default Desktop notifier
|
||||
if daemon.notifier == nil {
|
||||
t.Error("daemon should have a notifier")
|
||||
}
|
||||
|
||||
// Check if it's a Desktop notifier by type assertion
|
||||
if _, ok := daemon.notifier.(notify.Desktop); !ok {
|
||||
t.Error("daemon should use Desktop notifier when nil is provided")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDaemonStatus(t *testing.T) {
|
||||
mockNotif := &mockNotifier{}
|
||||
daemon := New(mockNotif)
|
||||
|
||||
t.Run("initial status", func(t *testing.T) {
|
||||
status := daemon.status()
|
||||
if status != pipeline.Idle {
|
||||
t.Errorf("initial status should be Idle, got %s", status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status with mock pipeline", func(t *testing.T) {
|
||||
mockPipe := newMockPipeline()
|
||||
daemon.pipeline = mockPipe
|
||||
|
||||
// Test different statuses
|
||||
statuses := []pipeline.Status{
|
||||
pipeline.Recording,
|
||||
pipeline.Transcribing,
|
||||
pipeline.Injecting,
|
||||
pipeline.Idle,
|
||||
}
|
||||
|
||||
for _, expectedStatus := range statuses {
|
||||
mockPipe.SetStatus(expectedStatus)
|
||||
status := daemon.status()
|
||||
if status != expectedStatus {
|
||||
t.Errorf("status should be %s, got %s", expectedStatus, status)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDaemonStopPipeline(t *testing.T) {
|
||||
mockNotif := &mockNotifier{}
|
||||
daemon := New(mockNotif)
|
||||
|
||||
t.Run("stop with no pipeline", func(t *testing.T) {
|
||||
// Should not panic
|
||||
daemon.stopPipeline()
|
||||
})
|
||||
|
||||
t.Run("stop with pipeline", func(t *testing.T) {
|
||||
mockPipe := newMockPipeline()
|
||||
daemon.pipeline = mockPipe
|
||||
|
||||
daemon.stopPipeline()
|
||||
|
||||
if !mockPipe.stopped {
|
||||
t.Error("pipeline should be stopped")
|
||||
}
|
||||
|
||||
if daemon.pipeline != nil {
|
||||
t.Error("daemon pipeline should be nil after stopping")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDaemonToggle(t *testing.T) {
|
||||
mockNotif := &mockNotifier{}
|
||||
daemon := New(mockNotif)
|
||||
|
||||
t.Run("toggle from idle", func(t *testing.T) {
|
||||
mockNotif.reset()
|
||||
|
||||
// Manually set up a mock pipeline for testing
|
||||
mockPipe := newMockPipeline()
|
||||
daemon.pipeline = nil
|
||||
|
||||
// Test toggle from idle - should start recording
|
||||
// We need to simulate the behavior since we can't easily mock pipeline.New()
|
||||
status := daemon.status() // Should be Idle
|
||||
if status != pipeline.Idle {
|
||||
t.Errorf("initial status should be Idle, got %s", status)
|
||||
}
|
||||
|
||||
// Simulate what toggle() would do in Idle state
|
||||
if status == pipeline.Idle {
|
||||
daemon.pipeline = mockPipe
|
||||
mockPipe.Run(context.Background())
|
||||
// Notification would be sent in goroutine
|
||||
if !mockNotif.recordingStartedCalled {
|
||||
// In real implementation this would be called,
|
||||
// but since we're testing the logic manually, we call it
|
||||
mockNotif.RecordingStarted()
|
||||
}
|
||||
}
|
||||
|
||||
if !mockNotif.recordingStartedCalled {
|
||||
t.Error("recording started notification should be called")
|
||||
}
|
||||
|
||||
if !mockPipe.running {
|
||||
t.Error("pipeline should be running")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("toggle from recording", func(t *testing.T) {
|
||||
mockNotif.reset()
|
||||
mockPipe := newMockPipeline()
|
||||
mockPipe.SetStatus(pipeline.Recording)
|
||||
daemon.pipeline = mockPipe
|
||||
|
||||
// Simulate toggle from recording - should abort
|
||||
status := daemon.status()
|
||||
if status == pipeline.Recording {
|
||||
daemon.stopPipeline()
|
||||
mockNotif.Aborted()
|
||||
}
|
||||
|
||||
if !mockNotif.abortedCalled {
|
||||
t.Error("aborted notification should be called")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("toggle from transcribing", func(t *testing.T) {
|
||||
mockNotif.reset()
|
||||
mockPipe := newMockPipeline()
|
||||
mockPipe.SetStatus(pipeline.Transcribing)
|
||||
daemon.pipeline = mockPipe
|
||||
|
||||
// Simulate toggle from transcribing - should inject
|
||||
status := daemon.status()
|
||||
if status == pipeline.Transcribing {
|
||||
actionCh := mockPipe.GetActionCh()
|
||||
select {
|
||||
case actionCh <- pipeline.Inject:
|
||||
mockNotif.RecordingEnded()
|
||||
default:
|
||||
t.Error("should be able to send inject action")
|
||||
}
|
||||
}
|
||||
|
||||
if !mockNotif.recordingEndedCalled {
|
||||
t.Error("recording ended notification should be called")
|
||||
}
|
||||
|
||||
// Check that inject action was sent
|
||||
select {
|
||||
case action := <-mockPipe.actionCh:
|
||||
if action != pipeline.Inject {
|
||||
t.Errorf("action should be Inject, got %v", action)
|
||||
}
|
||||
default:
|
||||
t.Error("inject action should have been sent")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("toggle from injecting", func(t *testing.T) {
|
||||
mockNotif.reset()
|
||||
mockPipe := newMockPipeline()
|
||||
mockPipe.SetStatus(pipeline.Injecting)
|
||||
daemon.pipeline = mockPipe
|
||||
|
||||
// Simulate toggle from injecting - should abort
|
||||
status := daemon.status()
|
||||
if status == pipeline.Injecting {
|
||||
daemon.stopPipeline()
|
||||
mockNotif.Aborted()
|
||||
}
|
||||
|
||||
if !mockNotif.abortedCalled {
|
||||
t.Error("aborted notification should be called")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDaemonHandlePipelineError(t *testing.T) {
|
||||
mockNotif := &mockNotifier{}
|
||||
daemon := New(mockNotif)
|
||||
|
||||
t.Run("handle error without underlying error", func(t *testing.T) {
|
||||
mockNotif.reset()
|
||||
|
||||
pipelineErr := pipeline.PipelineError{
|
||||
Title: "Test Error",
|
||||
Message: "Test message",
|
||||
Err: nil,
|
||||
}
|
||||
|
||||
daemon.handlePipelineError(pipelineErr)
|
||||
|
||||
if !mockNotif.errorCalled {
|
||||
t.Error("error notification should be called")
|
||||
}
|
||||
|
||||
if mockNotif.lastErrorMessage != "Test message" {
|
||||
t.Errorf("error message should be 'Test message', got '%s'", mockNotif.lastErrorMessage)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handle error with underlying error", func(t *testing.T) {
|
||||
mockNotif.reset()
|
||||
|
||||
underlyingErr := fmt.Errorf("underlying error")
|
||||
pipelineErr := pipeline.PipelineError{
|
||||
Title: "Test Error",
|
||||
Message: "Test message",
|
||||
Err: underlyingErr,
|
||||
}
|
||||
|
||||
daemon.handlePipelineError(pipelineErr)
|
||||
|
||||
if !mockNotif.errorCalled {
|
||||
t.Error("error notification should be called")
|
||||
}
|
||||
|
||||
expectedMessage := "Test message: underlying error"
|
||||
if mockNotif.lastErrorMessage != expectedMessage {
|
||||
t.Errorf("error message should be '%s', got '%s'", expectedMessage, mockNotif.lastErrorMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDaemonMonitorPipelineErrors(t *testing.T) {
|
||||
mockNotif := &mockNotifier{}
|
||||
daemon := New(mockNotif)
|
||||
|
||||
t.Run("monitor pipeline errors", func(t *testing.T) {
|
||||
mockNotif.reset()
|
||||
mockPipe := newMockPipeline()
|
||||
|
||||
// Start monitoring in background
|
||||
done := make(chan bool, 1)
|
||||
go func() {
|
||||
daemon.monitorPipelineErrors(mockPipe)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Send an error
|
||||
testErr := pipeline.PipelineError{
|
||||
Title: "Test Error",
|
||||
Message: "Test message",
|
||||
Err: nil,
|
||||
}
|
||||
mockPipe.SendError(testErr.Title, testErr.Message, testErr.Err)
|
||||
|
||||
// Give some time for error to be processed
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Cancel context to stop monitoring
|
||||
daemon.cancel()
|
||||
|
||||
// Wait for monitoring to stop
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for error monitoring to stop")
|
||||
}
|
||||
|
||||
if !mockNotif.errorCalled {
|
||||
t.Error("error notification should be called")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDaemonConcurrency(t *testing.T) {
|
||||
mockNotif := &mockNotifier{}
|
||||
daemon := New(mockNotif)
|
||||
|
||||
t.Run("concurrent status calls", func(t *testing.T) {
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
for j := 0; j < 100; j++ {
|
||||
daemon.status()
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for concurrent status calls")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent stopPipeline calls", func(t *testing.T) {
|
||||
mockPipe := newMockPipeline()
|
||||
daemon.pipeline = mockPipe
|
||||
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
daemon.stopPipeline()
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for concurrent stopPipeline calls")
|
||||
}
|
||||
}
|
||||
|
||||
// Pipeline should be stopped only once
|
||||
if !mockPipe.stopped {
|
||||
t.Error("pipeline should be stopped")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDaemonIntegrationWithMockSocket(t *testing.T) {
|
||||
// This test simulates the daemon handle() method behavior
|
||||
mockNotif := &mockNotifier{}
|
||||
daemon := New(mockNotif)
|
||||
|
||||
// Create a mock connection using pipe
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
t.Run("handle toggle command", func(t *testing.T) {
|
||||
mockNotif.reset()
|
||||
|
||||
// Send toggle command
|
||||
go func() {
|
||||
client.Write([]byte("t\n"))
|
||||
}()
|
||||
|
||||
// Simulate handle() behavior
|
||||
go func() {
|
||||
buf := make([]byte, 2)
|
||||
n, err := server.Read(buf)
|
||||
if err != nil || n != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
cmd := buf[0]
|
||||
if cmd == 't' {
|
||||
// Simulate toggle logic
|
||||
status := daemon.status()
|
||||
if status == pipeline.Idle {
|
||||
mockPipe := newMockPipeline()
|
||||
daemon.pipeline = mockPipe
|
||||
mockPipe.Run(context.Background())
|
||||
mockNotif.RecordingStarted()
|
||||
}
|
||||
fmt.Fprint(server, "OK toggled\n")
|
||||
}
|
||||
}()
|
||||
|
||||
// Read response
|
||||
response := make([]byte, 1024)
|
||||
n, err := client.Read(response)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
expectedResponse := "OK toggled\n"
|
||||
actualResponse := string(response[:n])
|
||||
if actualResponse != expectedResponse {
|
||||
t.Errorf("response should be %q, got %q", expectedResponse, actualResponse)
|
||||
}
|
||||
|
||||
if !mockNotif.recordingStartedCalled {
|
||||
t.Error("recording started notification should be called")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handle status command", func(t *testing.T) {
|
||||
// Send status command
|
||||
go func() {
|
||||
client.Write([]byte("s\n"))
|
||||
}()
|
||||
|
||||
// Simulate handle() behavior
|
||||
go func() {
|
||||
buf := make([]byte, 2)
|
||||
n, err := server.Read(buf)
|
||||
if err != nil || n != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
cmd := buf[0]
|
||||
if cmd == 's' {
|
||||
status := daemon.status()
|
||||
fmt.Fprintf(server, "STATUS status=%s\n", status)
|
||||
}
|
||||
}()
|
||||
|
||||
// Read response
|
||||
response := make([]byte, 1024)
|
||||
n, err := client.Read(response)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
expectedResponse := fmt.Sprintf("STATUS status=%s\n", daemon.status())
|
||||
actualResponse := string(response[:n])
|
||||
if actualResponse != expectedResponse {
|
||||
t.Errorf("response should be %q, got %q", expectedResponse, actualResponse)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDaemonErrorPropagation(t *testing.T) {
|
||||
mockNotif := &mockNotifier{}
|
||||
daemon := New(mockNotif)
|
||||
|
||||
t.Run("pipeline error propagation", func(t *testing.T) {
|
||||
mockNotif.reset()
|
||||
mockPipe := newMockPipeline()
|
||||
daemon.pipeline = mockPipe
|
||||
|
||||
// Start error monitoring
|
||||
done := make(chan bool, 1)
|
||||
go func() {
|
||||
daemon.monitorPipelineErrors(mockPipe)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Simulate multiple errors
|
||||
errors := []pipeline.PipelineError{
|
||||
{Title: "Error 1", Message: "Message 1", Err: nil},
|
||||
{Title: "Error 2", Message: "Message 2", Err: fmt.Errorf("underlying")},
|
||||
}
|
||||
|
||||
for _, err := range errors {
|
||||
mockPipe.SendError(err.Title, err.Message, err.Err)
|
||||
time.Sleep(5 * time.Millisecond) // Give time for processing
|
||||
}
|
||||
|
||||
// Cancel and stop monitoring
|
||||
daemon.cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for error monitoring to stop")
|
||||
}
|
||||
|
||||
if !mockNotif.errorCalled {
|
||||
t.Error("error notification should be called")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDesktopNotifier(t *testing.T) {
|
||||
desktop := Desktop{}
|
||||
|
||||
t.Run("RecordingStarted", func(t *testing.T) {
|
||||
// This test will actually try to call notify-send if available
|
||||
// We can't easily mock exec.Command, so we just verify it doesn't panic
|
||||
desktop.RecordingStarted()
|
||||
})
|
||||
|
||||
t.Run("RecordingEnded", func(t *testing.T) {
|
||||
desktop.RecordingEnded()
|
||||
})
|
||||
|
||||
t.Run("Transcribing", func(t *testing.T) {
|
||||
desktop.Transcribing()
|
||||
})
|
||||
|
||||
t.Run("Aborted", func(t *testing.T) {
|
||||
desktop.Aborted()
|
||||
})
|
||||
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
desktop.Error("test error message")
|
||||
})
|
||||
|
||||
t.Run("Notify", func(t *testing.T) {
|
||||
desktop.Notify("Test Title", "Test Message")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogNotifier(t *testing.T) {
|
||||
// Capture log output
|
||||
var buf bytes.Buffer
|
||||
log.SetOutput(&buf)
|
||||
defer log.SetOutput(os.Stderr)
|
||||
|
||||
logNotifier := Log{}
|
||||
|
||||
t.Run("RecordingStarted", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
logNotifier.RecordingStarted()
|
||||
|
||||
output := buf.String()
|
||||
if output == "" {
|
||||
t.Error("should log recording started message")
|
||||
}
|
||||
if !containsSubstring(output, "Hyprvoice") || !containsSubstring(output, "Recording Started") {
|
||||
t.Errorf("log output should contain expected message, got: %s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RecordingEnded", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
logNotifier.RecordingEnded()
|
||||
|
||||
output := buf.String()
|
||||
if !containsSubstring(output, "Recording Ended") {
|
||||
t.Errorf("log output should contain 'Recording Ended', got: %s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Transcribing", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
logNotifier.Transcribing()
|
||||
|
||||
output := buf.String()
|
||||
if !containsSubstring(output, "Transcribing") {
|
||||
t.Errorf("log output should contain 'Transcribing', got: %s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Aborted", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
logNotifier.Aborted()
|
||||
|
||||
output := buf.String()
|
||||
if !containsSubstring(output, "Aborted") {
|
||||
t.Errorf("log output should contain 'Aborted', got: %s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
testMsg := "test error message"
|
||||
logNotifier.Error(testMsg)
|
||||
|
||||
output := buf.String()
|
||||
if !containsSubstring(output, "Hyprvoice Error") || !containsSubstring(output, testMsg) {
|
||||
t.Errorf("log output should contain error message, got: %s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Notify", func(t *testing.T) {
|
||||
buf.Reset()
|
||||
title := "Test Title"
|
||||
message := "Test Message"
|
||||
logNotifier.Notify(title, message)
|
||||
|
||||
output := buf.String()
|
||||
if !containsSubstring(output, title) || !containsSubstring(output, message) {
|
||||
t.Errorf("log output should contain title and message, got: %s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNopNotifier(t *testing.T) {
|
||||
nop := Nop{}
|
||||
|
||||
// All Nop methods should do nothing and not panic
|
||||
t.Run("all methods should not panic", func(t *testing.T) {
|
||||
nop.RecordingStarted()
|
||||
nop.RecordingEnded()
|
||||
nop.Transcribing()
|
||||
nop.Aborted()
|
||||
nop.Error("test message")
|
||||
nop.Notify("title", "message")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotifierInterface(t *testing.T) {
|
||||
// Verify all types implement the Notifier interface
|
||||
var notifiers []Notifier = []Notifier{
|
||||
Desktop{},
|
||||
Log{},
|
||||
Nop{},
|
||||
}
|
||||
|
||||
for i, notifier := range notifiers {
|
||||
t.Run("interface compliance", func(t *testing.T) {
|
||||
// Test that all interface methods can be called
|
||||
notifier.RecordingStarted()
|
||||
notifier.RecordingEnded()
|
||||
notifier.Transcribing()
|
||||
notifier.Aborted()
|
||||
notifier.Error("test")
|
||||
notifier.Notify("title", "message")
|
||||
})
|
||||
|
||||
// Verify the notifier is not nil
|
||||
if notifier == nil {
|
||||
t.Errorf("notifier %d should not be nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierBehaviorConsistency(t *testing.T) {
|
||||
// Test that different notifiers handle the same inputs consistently
|
||||
testCases := []struct {
|
||||
name string
|
||||
title string
|
||||
message string
|
||||
}{
|
||||
{"empty strings", "", ""},
|
||||
{"normal strings", "Title", "Message"},
|
||||
{"unicode strings", "📢 Alert", "🎙️ Recording"},
|
||||
{"long strings", "Very Long Title That Might Cause Issues", "Very long message that contains a lot of text and might cause formatting issues or truncation in some notification systems"},
|
||||
{"special characters", "Title with \n newlines", "Message with \"quotes\" and 'apostrophes'"},
|
||||
}
|
||||
|
||||
notifiers := map[string]Notifier{
|
||||
"Desktop": Desktop{},
|
||||
"Log": Log{},
|
||||
"Nop": Nop{},
|
||||
}
|
||||
|
||||
for notifierName, notifier := range notifiers {
|
||||
for _, tc := range testCases {
|
||||
t.Run(notifierName+"_"+tc.name, func(t *testing.T) {
|
||||
// These should not panic regardless of input
|
||||
notifier.Notify(tc.title, tc.message)
|
||||
notifier.Error(tc.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogNotifierOutput(t *testing.T) {
|
||||
// More detailed testing of log output format
|
||||
var buf bytes.Buffer
|
||||
log.SetOutput(&buf)
|
||||
defer log.SetOutput(os.Stderr)
|
||||
|
||||
logNotifier := Log{}
|
||||
|
||||
t.Run("log format consistency", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
method func()
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
method: logNotifier.RecordingStarted,
|
||||
expected: []string{"Hyprvoice", "Recording Started"},
|
||||
},
|
||||
{
|
||||
method: logNotifier.RecordingEnded,
|
||||
expected: []string{"Hyprvoice", "Recording Ended"},
|
||||
},
|
||||
{
|
||||
method: logNotifier.Transcribing,
|
||||
expected: []string{"Hyprvoice", "Transcribing"},
|
||||
},
|
||||
{
|
||||
method: logNotifier.Aborted,
|
||||
expected: []string{"Hyprvoice", "Aborted"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
buf.Reset()
|
||||
tc.method()
|
||||
output := buf.String()
|
||||
|
||||
for _, expected := range tc.expected {
|
||||
if !containsSubstring(output, expected) {
|
||||
t.Errorf("log output should contain %q, got: %s", expected, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error method with different messages", func(t *testing.T) {
|
||||
errorMessages := []string{
|
||||
"simple error",
|
||||
"error with numbers 123",
|
||||
"error with symbols !@#$%",
|
||||
"",
|
||||
}
|
||||
|
||||
for _, msg := range errorMessages {
|
||||
buf.Reset()
|
||||
logNotifier.Error(msg)
|
||||
output := buf.String()
|
||||
|
||||
if !containsSubstring(output, "Hyprvoice Error") {
|
||||
t.Errorf("error log should contain 'Hyprvoice Error', got: %s", output)
|
||||
}
|
||||
|
||||
if msg != "" && !containsSubstring(output, msg) {
|
||||
t.Errorf("error log should contain message %q, got: %s", msg, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotifierMethods(t *testing.T) {
|
||||
// Test that all required methods exist and can be called
|
||||
var n Notifier
|
||||
|
||||
// Test with each implementation
|
||||
implementations := []Notifier{
|
||||
Desktop{},
|
||||
Log{},
|
||||
Nop{},
|
||||
}
|
||||
|
||||
for _, impl := range implementations {
|
||||
n = impl
|
||||
|
||||
// Verify all methods exist by calling them
|
||||
n.RecordingStarted()
|
||||
n.RecordingEnded()
|
||||
n.Aborted()
|
||||
n.Transcribing()
|
||||
n.Error("test")
|
||||
n.Notify("test", "test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierEdgeCases(t *testing.T) {
|
||||
notifiers := []Notifier{Desktop{}, Log{}, Nop{}}
|
||||
|
||||
t.Run("nil message handling", func(t *testing.T) {
|
||||
for _, notifier := range notifiers {
|
||||
// These should not panic even with empty strings
|
||||
notifier.Error("")
|
||||
notifier.Notify("", "")
|
||||
notifier.Notify("title", "")
|
||||
notifier.Notify("", "message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent access", func(t *testing.T) {
|
||||
for _, notifier := range notifiers {
|
||||
done := make(chan bool, 10)
|
||||
|
||||
// Call methods concurrently
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
notifier.RecordingStarted()
|
||||
notifier.RecordingEnded()
|
||||
notifier.Transcribing()
|
||||
notifier.Aborted()
|
||||
notifier.Error("concurrent test")
|
||||
notifier.Notify("title", "message")
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring
|
||||
func containsSubstring(s, substr string) bool {
|
||||
return len(s) >= len(substr) && findSubstring(s, substr) >= 0
|
||||
}
|
||||
|
||||
// Simple substring search
|
||||
func findSubstring(s, substr string) int {
|
||||
if len(substr) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(substr) > len(s) {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -195,8 +195,7 @@ func (p *pipeline) run(ctx context.Context) {
|
||||
case frame := <-frameCh:
|
||||
frameCount++
|
||||
totalBytes += len(frame.Data)
|
||||
log.Printf("Pipeline: Received frame #%d - Size: %d bytes, Timestamp: %v, Total bytes so far: %d",
|
||||
frameCount, len(frame.Data), frame.Timestamp.Format("15:04:05.000"), totalBytes)
|
||||
// log.Printf("Pipeline: Received frame #%d - Size: %d bytes, Timestamp: %v, Total bytes so far: %d", frameCount, len(frame.Data), frame.Timestamp.Format("15:04:05.000"), totalBytes)
|
||||
|
||||
case err := <-tErrCh:
|
||||
if err != nil {
|
||||
@@ -221,21 +220,37 @@ func (p *pipeline) run(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Pipeline: Inject action received, stopping recording and getting transcription")
|
||||
log.Printf("Pipeline: Inject action received, stopping recording and finalizing transcription")
|
||||
p.setStatus(Injecting)
|
||||
|
||||
// Stop the recorder first - this will close the frameCh channel
|
||||
if err := recorder.Stop(); err != nil {
|
||||
log.Printf("Pipeline: Error stopping recorder: %v", err)
|
||||
p.sendError("Recording Error", "Failed to stop recorder during injection", err)
|
||||
}
|
||||
|
||||
p.setStatus(Injecting)
|
||||
// Wait for the recorder to fully stop and frameCh to be closed
|
||||
// The transcriber will process any remaining frames when frameCh closes
|
||||
log.Printf("Pipeline: Waiting for recording channel to close and final transcription to complete")
|
||||
|
||||
// Drain the frameCh to ensure it's fully closed
|
||||
for range frameCh {
|
||||
// Continue draining until channel is closed
|
||||
}
|
||||
|
||||
// Stop the transcriber to ensure all buffered audio is processed
|
||||
if err := t.Stop(); err != nil {
|
||||
log.Printf("Pipeline: Error stopping transcriber: %v", err)
|
||||
p.sendError("Transcription Error", "Failed to stop transcriber during injection", err)
|
||||
}
|
||||
|
||||
// Now get the final transcription which includes all processed audio
|
||||
transcriptionText, err := t.GetTranscription()
|
||||
if err != nil {
|
||||
log.Printf("Pipeline: Error getting transcription: %v", err)
|
||||
p.sendError("Transcription Error", "Failed to retrieve transcription", err)
|
||||
} else {
|
||||
log.Printf("Pipeline: Transcription text: %s", transcriptionText)
|
||||
log.Printf("Pipeline: Final transcription text: %s", transcriptionText)
|
||||
}
|
||||
|
||||
log.Printf("Pipeline: Simulating injection work")
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPipelineStatus(t *testing.T) {
|
||||
p := New()
|
||||
|
||||
t.Run("initial status", func(t *testing.T) {
|
||||
status := p.Status()
|
||||
if status != "" && status != Idle {
|
||||
t.Errorf("initial status should be empty or Idle, got %s", status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status changes", func(t *testing.T) {
|
||||
pipeline := p.(*pipeline)
|
||||
|
||||
pipeline.setStatus(Recording)
|
||||
if p.Status() != Recording {
|
||||
t.Errorf("status should be Recording, got %s", p.Status())
|
||||
}
|
||||
|
||||
pipeline.setStatus(Transcribing)
|
||||
if p.Status() != Transcribing {
|
||||
t.Errorf("status should be Transcribing, got %s", p.Status())
|
||||
}
|
||||
|
||||
pipeline.setStatus(Injecting)
|
||||
if p.Status() != Injecting {
|
||||
t.Errorf("status should be Injecting, got %s", p.Status())
|
||||
}
|
||||
|
||||
pipeline.setStatus(Idle)
|
||||
if p.Status() != Idle {
|
||||
t.Errorf("status should be Idle, got %s", p.Status())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPipelineChannels(t *testing.T) {
|
||||
p := New()
|
||||
|
||||
t.Run("action channel", func(t *testing.T) {
|
||||
actionCh := p.GetActionCh()
|
||||
if actionCh == nil {
|
||||
t.Error("action channel should not be nil")
|
||||
}
|
||||
|
||||
// Test non-blocking send
|
||||
select {
|
||||
case actionCh <- Inject:
|
||||
default:
|
||||
t.Error("action channel should accept at least one message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error channel", func(t *testing.T) {
|
||||
errorCh := p.GetErrorCh()
|
||||
if errorCh == nil {
|
||||
t.Error("error channel should not be nil")
|
||||
}
|
||||
|
||||
// Should be empty initially
|
||||
select {
|
||||
case <-errorCh:
|
||||
t.Error("error channel should be empty initially")
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPipelineErrorHandling(t *testing.T) {
|
||||
p := New().(*pipeline)
|
||||
|
||||
t.Run("send error", func(t *testing.T) {
|
||||
testTitle := "Test Error"
|
||||
testMessage := "This is a test error"
|
||||
testErr := context.Canceled
|
||||
|
||||
p.sendError(testTitle, testMessage, testErr)
|
||||
|
||||
select {
|
||||
case pipelineErr := <-p.GetErrorCh():
|
||||
if pipelineErr.Title != testTitle {
|
||||
t.Errorf("error title should be %q, got %q", testTitle, pipelineErr.Title)
|
||||
}
|
||||
if pipelineErr.Message != testMessage {
|
||||
t.Errorf("error message should be %q, got %q", testMessage, pipelineErr.Message)
|
||||
}
|
||||
if pipelineErr.Err != testErr {
|
||||
t.Errorf("error should be %v, got %v", testErr, pipelineErr.Err)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("should receive error on error channel")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error channel full", func(t *testing.T) {
|
||||
// Fill up the error channel (capacity is 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
p.sendError("Test", "Test message", nil)
|
||||
}
|
||||
|
||||
// This should not block (it should drop the error)
|
||||
done := make(chan bool, 1)
|
||||
go func() {
|
||||
p.sendError("Overflow", "This should be dropped", nil)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Success - the send didn't block
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("sendError should not block when channel is full")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPipelineLifecycle(t *testing.T) {
|
||||
t.Run("multiple Run calls", func(t *testing.T) {
|
||||
p := New()
|
||||
ctx := context.Background()
|
||||
|
||||
// First Run should work
|
||||
p.Run(ctx)
|
||||
|
||||
// Second Run should be ignored (already running)
|
||||
p.Run(ctx)
|
||||
|
||||
// Stop should work
|
||||
p.Stop()
|
||||
})
|
||||
|
||||
t.Run("stop before run", func(t *testing.T) {
|
||||
p := New()
|
||||
|
||||
// Stop before run should not panic
|
||||
p.Stop()
|
||||
})
|
||||
|
||||
t.Run("multiple stops", func(t *testing.T) {
|
||||
p := New()
|
||||
ctx := context.Background()
|
||||
|
||||
p.Run(ctx)
|
||||
p.Stop()
|
||||
|
||||
// Second stop should not panic
|
||||
p.Stop()
|
||||
})
|
||||
}
|
||||
|
||||
func TestPipelineContextCancellation(t *testing.T) {
|
||||
t.Run("context cancelled during run", func(t *testing.T) {
|
||||
p := New()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Start pipeline
|
||||
p.Run(ctx)
|
||||
|
||||
// Cancel context immediately
|
||||
cancel()
|
||||
|
||||
// Wait for pipeline to stop
|
||||
p.Stop()
|
||||
|
||||
// Pipeline should be idle
|
||||
if p.Status() != Idle {
|
||||
t.Errorf("pipeline should be idle after context cancellation, got %s", p.Status())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("timeout context", func(t *testing.T) {
|
||||
p := New()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// Start pipeline
|
||||
p.Run(ctx)
|
||||
|
||||
// Wait for timeout
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Pipeline should stop automatically
|
||||
p.Stop()
|
||||
|
||||
if p.Status() != Idle {
|
||||
t.Errorf("pipeline should be idle after timeout, got %s", p.Status())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPipelineConstants(t *testing.T) {
|
||||
t.Run("status constants", func(t *testing.T) {
|
||||
statuses := []Status{Idle, Recording, Transcribing, Injecting}
|
||||
for _, status := range statuses {
|
||||
if string(status) == "" {
|
||||
t.Errorf("status %v should have a string representation", status)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("action constants", func(t *testing.T) {
|
||||
actions := []Action{Inject}
|
||||
for _, action := range actions {
|
||||
if string(action) == "" {
|
||||
t.Errorf("action %v should have a string representation", action)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPipelineError(t *testing.T) {
|
||||
t.Run("pipeline error struct", func(t *testing.T) {
|
||||
title := "Test Title"
|
||||
message := "Test Message"
|
||||
err := context.Canceled
|
||||
|
||||
pipelineErr := PipelineError{
|
||||
Title: title,
|
||||
Message: message,
|
||||
Err: err,
|
||||
}
|
||||
|
||||
if pipelineErr.Title != title {
|
||||
t.Errorf("title should be %q, got %q", title, pipelineErr.Title)
|
||||
}
|
||||
if pipelineErr.Message != message {
|
||||
t.Errorf("message should be %q, got %q", message, pipelineErr.Message)
|
||||
}
|
||||
if pipelineErr.Err != err {
|
||||
t.Errorf("err should be %v, got %v", err, pipelineErr.Err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pipeline error with nil error", func(t *testing.T) {
|
||||
pipelineErr := PipelineError{
|
||||
Title: "Test",
|
||||
Message: "Test message",
|
||||
Err: nil,
|
||||
}
|
||||
|
||||
if pipelineErr.Err != nil {
|
||||
t.Error("error should be nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPipelineConcurrency(t *testing.T) {
|
||||
t.Run("concurrent status reads", func(t *testing.T) {
|
||||
p := New()
|
||||
ctx := context.Background()
|
||||
|
||||
p.Run(ctx)
|
||||
defer p.Stop()
|
||||
|
||||
// Read status from multiple goroutines
|
||||
done := make(chan bool, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
for j := 0; j < 100; j++ {
|
||||
_ = p.Status()
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines to finish
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for concurrent status reads")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent action sends", func(t *testing.T) {
|
||||
p := New()
|
||||
ctx := context.Background()
|
||||
|
||||
p.Run(ctx)
|
||||
defer p.Stop()
|
||||
|
||||
actionCh := p.GetActionCh()
|
||||
|
||||
// Send actions from multiple goroutines
|
||||
done := make(chan bool, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
go func() {
|
||||
select {
|
||||
case actionCh <- Inject:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines to finish
|
||||
for i := 0; i < 5; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for concurrent action sends")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mock implementations for testing without external dependencies
|
||||
type mockRecorder struct {
|
||||
started bool
|
||||
stopped bool
|
||||
frames chan mockFrame
|
||||
errors chan error
|
||||
}
|
||||
|
||||
type mockFrame struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (m *mockRecorder) Start(ctx context.Context) (<-chan mockFrame, <-chan error, error) {
|
||||
m.started = true
|
||||
m.frames = make(chan mockFrame, 10)
|
||||
m.errors = make(chan error, 1)
|
||||
|
||||
// Send some mock frames
|
||||
go func() {
|
||||
defer close(m.frames)
|
||||
defer close(m.errors)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
select {
|
||||
case m.frames <- mockFrame{data: []byte("mock audio data")}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
return m.frames, m.errors, nil
|
||||
}
|
||||
|
||||
func (m *mockRecorder) Stop() error {
|
||||
m.stopped = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPipelineWithMocks(t *testing.T) {
|
||||
t.Run("mock recorder lifecycle", func(t *testing.T) {
|
||||
recorder := &mockRecorder{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
frames, errors, err := recorder.Start(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("mock recorder start failed: %v", err)
|
||||
}
|
||||
|
||||
if !recorder.started {
|
||||
t.Error("recorder should be marked as started")
|
||||
}
|
||||
|
||||
// Receive some frames
|
||||
frameCount := 0
|
||||
for frame := range frames {
|
||||
frameCount++
|
||||
if len(frame.data) == 0 {
|
||||
t.Error("frame should have data")
|
||||
}
|
||||
}
|
||||
|
||||
if frameCount == 0 {
|
||||
t.Error("should receive some frames")
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
select {
|
||||
case err := <-errors:
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
err = recorder.Stop()
|
||||
if err != nil {
|
||||
t.Errorf("mock recorder stop failed: %v", err)
|
||||
}
|
||||
|
||||
if !recorder.stopped {
|
||||
t.Error("recorder should be marked as stopped")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package recording
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
|
||||
t.Run("default values", func(t *testing.T) {
|
||||
if config.SampleRate != 16000 {
|
||||
t.Errorf("default sample rate should be 16000, got %d", config.SampleRate)
|
||||
}
|
||||
if config.Channels != 1 {
|
||||
t.Errorf("default channels should be 1, got %d", config.Channels)
|
||||
}
|
||||
if config.Format != "s16" {
|
||||
t.Errorf("default format should be s16, got %s", config.Format)
|
||||
}
|
||||
if config.BufferSize != 8192 {
|
||||
t.Errorf("default buffer size should be 8192, got %d", config.BufferSize)
|
||||
}
|
||||
if config.Device != "" {
|
||||
t.Errorf("default device should be empty, got %s", config.Device)
|
||||
}
|
||||
if config.ChannelBufferSize != 30 {
|
||||
t.Errorf("default channel buffer size should be 30, got %d", config.ChannelBufferSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewRecorder(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
recorder := NewRecorder(config)
|
||||
|
||||
t.Run("initial state", func(t *testing.T) {
|
||||
if recorder == nil {
|
||||
t.Fatal("recorder should not be nil")
|
||||
}
|
||||
if recorder.IsRecording() {
|
||||
t.Error("recorder should not be recording initially")
|
||||
}
|
||||
if recorder.config.SampleRate != config.SampleRate {
|
||||
t.Error("recorder should store the provided config")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewDefaultRecorder(t *testing.T) {
|
||||
recorder := NewDefaultRecorder()
|
||||
|
||||
t.Run("default recorder", func(t *testing.T) {
|
||||
if recorder == nil {
|
||||
t.Fatal("default recorder should not be nil")
|
||||
}
|
||||
if recorder.IsRecording() {
|
||||
t.Error("default recorder should not be recording initially")
|
||||
}
|
||||
if recorder.config.SampleRate != 16000 {
|
||||
t.Error("default recorder should use default config")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecorderValidateConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid default config",
|
||||
config: DefaultConfig(),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid sample rate",
|
||||
config: Config{
|
||||
SampleRate: 0,
|
||||
Channels: 1,
|
||||
Format: "s16",
|
||||
BufferSize: 8192,
|
||||
ChannelBufferSize: 30,
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "negative sample rate",
|
||||
config: Config{
|
||||
SampleRate: -1,
|
||||
Channels: 1,
|
||||
Format: "s16",
|
||||
BufferSize: 8192,
|
||||
ChannelBufferSize: 30,
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid channels",
|
||||
config: Config{
|
||||
SampleRate: 16000,
|
||||
Channels: 0,
|
||||
Format: "s16",
|
||||
BufferSize: 8192,
|
||||
ChannelBufferSize: 30,
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid buffer size",
|
||||
config: Config{
|
||||
SampleRate: 16000,
|
||||
Channels: 1,
|
||||
Format: "s16",
|
||||
BufferSize: 0,
|
||||
ChannelBufferSize: 30,
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid channel buffer size",
|
||||
config: Config{
|
||||
SampleRate: 16000,
|
||||
Channels: 1,
|
||||
Format: "s16",
|
||||
BufferSize: 8192,
|
||||
ChannelBufferSize: 0,
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty format",
|
||||
config: Config{
|
||||
SampleRate: 16000,
|
||||
Channels: 1,
|
||||
Format: "",
|
||||
BufferSize: 8192,
|
||||
ChannelBufferSize: 30,
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "unaligned buffer size",
|
||||
config: Config{
|
||||
SampleRate: 16000,
|
||||
Channels: 1,
|
||||
Format: "s16",
|
||||
BufferSize: 8193, // Not aligned to frame size (2 bytes per sample)
|
||||
ChannelBufferSize: 30,
|
||||
},
|
||||
expectError: false, // Should log warning but not error
|
||||
},
|
||||
{
|
||||
name: "stereo valid config",
|
||||
config: Config{
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
Format: "s16",
|
||||
BufferSize: 8192, // 4 bytes per frame with 2 channels
|
||||
ChannelBufferSize: 30,
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
recorder := NewRecorder(tt.config)
|
||||
err := recorder.validateConfig()
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("expected error for config %+v", tt.config)
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("unexpected error for config %+v: %v", tt.config, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecorderBuildPwRecordArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "default config",
|
||||
config: DefaultConfig(),
|
||||
expected: []string{
|
||||
"--format", "s16",
|
||||
"--rate", "16000",
|
||||
"--channels", "1",
|
||||
"-",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with device",
|
||||
config: Config{
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
Format: "f32",
|
||||
Device: "alsa_output.pci-0000_00_1f.3.analog-stereo",
|
||||
BufferSize: 4096,
|
||||
ChannelBufferSize: 30,
|
||||
},
|
||||
expected: []string{
|
||||
"--format", "f32",
|
||||
"--rate", "48000",
|
||||
"--channels", "2",
|
||||
"-",
|
||||
"--target", "alsa_output.pci-0000_00_1f.3.analog-stereo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "different sample rate",
|
||||
config: Config{
|
||||
SampleRate: 44100,
|
||||
Channels: 1,
|
||||
Format: "s24",
|
||||
BufferSize: 8192,
|
||||
ChannelBufferSize: 30,
|
||||
},
|
||||
expected: []string{
|
||||
"--format", "s24",
|
||||
"--rate", "44100",
|
||||
"--channels", "1",
|
||||
"-",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
recorder := NewRecorder(tt.config)
|
||||
args := recorder.buildPwRecordArgs()
|
||||
|
||||
if len(args) != len(tt.expected) {
|
||||
t.Errorf("args length mismatch: got %d, expected %d", len(args), len(tt.expected))
|
||||
t.Errorf("got: %v", args)
|
||||
t.Errorf("expected: %v", tt.expected)
|
||||
return
|
||||
}
|
||||
|
||||
for i, arg := range args {
|
||||
if arg != tt.expected[i] {
|
||||
t.Errorf("arg[%d] mismatch: got %q, expected %q", i, arg, tt.expected[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecorderLifecycle(t *testing.T) {
|
||||
recorder := NewDefaultRecorder()
|
||||
|
||||
t.Run("initial state", func(t *testing.T) {
|
||||
if recorder.IsRecording() {
|
||||
t.Error("recorder should not be recording initially")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stop before start", func(t *testing.T) {
|
||||
err := recorder.Stop()
|
||||
if err != nil {
|
||||
t.Errorf("stop should not error when not recording: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Note: We can't easily test actual recording without PipeWire
|
||||
// But we can test the state management
|
||||
}
|
||||
|
||||
func TestAudioFrame(t *testing.T) {
|
||||
t.Run("audio frame creation", func(t *testing.T) {
|
||||
data := []byte("test audio data")
|
||||
timestamp := time.Now()
|
||||
|
||||
frame := AudioFrame{
|
||||
Data: data,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
|
||||
if len(frame.Data) != len(data) {
|
||||
t.Errorf("frame data length mismatch: got %d, expected %d", len(frame.Data), len(data))
|
||||
}
|
||||
|
||||
for i, b := range frame.Data {
|
||||
if b != data[i] {
|
||||
t.Errorf("frame data[%d] mismatch: got %d, expected %d", i, b, data[i])
|
||||
}
|
||||
}
|
||||
|
||||
if !frame.Timestamp.Equal(timestamp) {
|
||||
t.Errorf("frame timestamp mismatch: got %v, expected %v", frame.Timestamp, timestamp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty audio frame", func(t *testing.T) {
|
||||
frame := AudioFrame{}
|
||||
|
||||
if frame.Data != nil {
|
||||
t.Error("empty frame data should be nil")
|
||||
}
|
||||
|
||||
if !frame.Timestamp.IsZero() {
|
||||
t.Error("empty frame timestamp should be zero")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("large audio frame", func(t *testing.T) {
|
||||
data := make([]byte, 65536) // 64KB
|
||||
for i := range data {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
frame := AudioFrame{
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if len(frame.Data) != len(data) {
|
||||
t.Errorf("large frame data length mismatch: got %d, expected %d", len(frame.Data), len(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckPipeWireAvailable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("check pipewire availability", func(t *testing.T) {
|
||||
// This test depends on the system having PipeWire installed
|
||||
// We'll just verify it doesn't panic and returns some result
|
||||
err := CheckPipeWireAvailable(ctx)
|
||||
|
||||
// We can't assert on the specific result since it depends on the system
|
||||
// But we can ensure it doesn't panic and returns within reasonable time
|
||||
_ = err
|
||||
})
|
||||
|
||||
t.Run("context cancellation", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
err := CheckPipeWireAvailable(ctx)
|
||||
// Should return an error due to context cancellation
|
||||
if err == nil {
|
||||
t.Log("CheckPipeWireAvailable with cancelled context returned nil (may be OK if commands complete quickly)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("timeout context", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||
defer cancel()
|
||||
|
||||
err := CheckPipeWireAvailable(ctx)
|
||||
// Should return an error due to timeout
|
||||
if err == nil {
|
||||
t.Log("CheckPipeWireAvailable with timeout context returned nil (may be OK if commands complete quickly)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecorderConcurrency(t *testing.T) {
|
||||
recorder := NewDefaultRecorder()
|
||||
|
||||
t.Run("concurrent IsRecording calls", func(t *testing.T) {
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
for j := 0; j < 100; j++ {
|
||||
recorder.IsRecording()
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for concurrent IsRecording calls")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent Stop calls", func(t *testing.T) {
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
recorder.Stop()
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for concurrent Stop calls")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigEquality(t *testing.T) {
|
||||
config1 := DefaultConfig()
|
||||
config2 := DefaultConfig()
|
||||
|
||||
t.Run("default configs should be equal", func(t *testing.T) {
|
||||
if config1.SampleRate != config2.SampleRate {
|
||||
t.Error("sample rates should be equal")
|
||||
}
|
||||
if config1.Channels != config2.Channels {
|
||||
t.Error("channels should be equal")
|
||||
}
|
||||
if config1.Format != config2.Format {
|
||||
t.Error("formats should be equal")
|
||||
}
|
||||
if config1.BufferSize != config2.BufferSize {
|
||||
t.Error("buffer sizes should be equal")
|
||||
}
|
||||
if config1.Device != config2.Device {
|
||||
t.Error("devices should be equal")
|
||||
}
|
||||
if config1.ChannelBufferSize != config2.ChannelBufferSize {
|
||||
t.Error("channel buffer sizes should be equal")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("modified config should not be equal", func(t *testing.T) {
|
||||
config2.SampleRate = 48000
|
||||
|
||||
if config1.SampleRate == config2.SampleRate {
|
||||
t.Error("sample rates should not be equal after modification")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecorderErrorConditions(t *testing.T) {
|
||||
t.Run("double start without stop", func(t *testing.T) {
|
||||
recorder := NewDefaultRecorder()
|
||||
|
||||
// Mark as recording manually to simulate the condition
|
||||
recorder.recording.Store(true)
|
||||
defer recorder.recording.Store(false)
|
||||
|
||||
ctx := context.Background()
|
||||
_, _, err := recorder.Start(ctx)
|
||||
if err == nil {
|
||||
t.Error("Start should return error when already recording")
|
||||
}
|
||||
|
||||
expectedMsg := "already recording"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("error message should be %q, got %q", expectedMsg, err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid config start", func(t *testing.T) {
|
||||
invalidConfig := Config{
|
||||
SampleRate: -1, // Invalid
|
||||
}
|
||||
recorder := NewRecorder(invalidConfig)
|
||||
|
||||
ctx := context.Background()
|
||||
_, _, err := recorder.Start(ctx)
|
||||
if err == nil {
|
||||
t.Error("Start should return error with invalid config")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -82,6 +82,16 @@ func (t *OpenAITranscriber) Stop() error {
|
||||
cancel()
|
||||
}
|
||||
t.wg.Wait()
|
||||
|
||||
// After stopping, ensure any remaining buffered audio is transcribed
|
||||
if t.buffer.hasData() {
|
||||
log.Printf("transcriber: processing remaining buffered audio on stop")
|
||||
ctx := context.Background()
|
||||
errCh := make(chan error, 1)
|
||||
t.transcribeBuffer(ctx, errCh)
|
||||
close(errCh)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -98,6 +108,13 @@ func (t *OpenAITranscriber) GetTranscription() (string, error) {
|
||||
|
||||
func (t *OpenAITranscriber) processFrames(ctx context.Context, frameCh <-chan recording.AudioFrame, errCh chan<- error) {
|
||||
defer func() {
|
||||
// Always process remaining buffer when shutting down
|
||||
if t.buffer.hasData() {
|
||||
log.Printf("transcriber: processing final buffered audio on shutdown")
|
||||
// Use background context for final transcription to avoid timeout
|
||||
finalCtx := context.Background()
|
||||
t.transcribeBuffer(finalCtx, errCh)
|
||||
}
|
||||
close(errCh)
|
||||
t.mu.Lock()
|
||||
t.transcribing = false
|
||||
@@ -112,16 +129,25 @@ func (t *OpenAITranscriber) processFrames(ctx context.Context, frameCh <-chan re
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if t.buffer.hasData() {
|
||||
t.transcribeBuffer(ctx, errCh)
|
||||
}
|
||||
log.Printf("transcriber: context cancelled, processing remaining frames")
|
||||
// Continue processing remaining frames from channel before stopping
|
||||
for {
|
||||
select {
|
||||
case frame, ok := <-frameCh:
|
||||
if !ok {
|
||||
log.Printf("transcriber: recording channel closed")
|
||||
return
|
||||
}
|
||||
t.buffer.addFrame(frame)
|
||||
default:
|
||||
// No more frames available, exit
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case frame, ok := <-frameCh:
|
||||
if !ok {
|
||||
if t.buffer.hasData() {
|
||||
t.transcribeBuffer(ctx, errCh)
|
||||
}
|
||||
log.Printf("transcriber: recording channel closed, finishing with remaining buffer")
|
||||
return
|
||||
}
|
||||
t.buffer.addFrame(frame)
|
||||
|
||||
@@ -31,7 +31,7 @@ type Config struct {
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Provider: "openai",
|
||||
Language: "en",
|
||||
Language: "it",
|
||||
ChunkSize: 16384,
|
||||
BufferTime: 2 * time.Second,
|
||||
Model: "whisper-1",
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
package transcriber
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/recording"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
|
||||
t.Run("default values", func(t *testing.T) {
|
||||
if config.Provider != "openai" {
|
||||
t.Errorf("default provider should be openai, got %s", config.Provider)
|
||||
}
|
||||
if config.Language != "it" {
|
||||
t.Errorf("default language should be it, got %s", config.Language)
|
||||
}
|
||||
if config.ChunkSize != 16384 {
|
||||
t.Errorf("default chunk size should be 16384, got %d", config.ChunkSize)
|
||||
}
|
||||
if config.BufferTime != 2*time.Second {
|
||||
t.Errorf("default buffer time should be 2s, got %v", config.BufferTime)
|
||||
}
|
||||
if config.Model != "whisper-1" {
|
||||
t.Errorf("default model should be whisper-1, got %s", config.Model)
|
||||
}
|
||||
if config.APIKey != "" {
|
||||
t.Errorf("default API key should be empty, got %s", config.APIKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewTranscriber(t *testing.T) {
|
||||
t.Run("openai provider with API key", func(t *testing.T) {
|
||||
config := Config{
|
||||
Provider: "openai",
|
||||
APIKey: "test-api-key",
|
||||
}
|
||||
|
||||
transcriber, err := NewTranscriber(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTranscriber failed: %v", err)
|
||||
}
|
||||
|
||||
if transcriber == nil {
|
||||
t.Fatal("transcriber should not be nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("openai provider without API key", func(t *testing.T) {
|
||||
config := Config{
|
||||
Provider: "openai",
|
||||
APIKey: "",
|
||||
}
|
||||
|
||||
_, err := NewTranscriber(config)
|
||||
if err == nil {
|
||||
t.Error("NewTranscriber should fail without API key")
|
||||
}
|
||||
|
||||
expectedMsg := "OpenAI API key required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("error message should be %q, got %q", expectedMsg, err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported provider", func(t *testing.T) {
|
||||
config := Config{
|
||||
Provider: "unsupported",
|
||||
APIKey: "test-key",
|
||||
}
|
||||
|
||||
_, err := NewTranscriber(config)
|
||||
if err == nil {
|
||||
t.Error("NewTranscriber should fail with unsupported provider")
|
||||
}
|
||||
|
||||
expectedMsg := "unsupported provider: unsupported"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("error message should be %q, got %q", expectedMsg, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAITranscriberCreation(t *testing.T) {
|
||||
config := Config{
|
||||
Provider: "openai",
|
||||
APIKey: "test-api-key",
|
||||
Language: "en",
|
||||
ChunkSize: 8192,
|
||||
BufferTime: 1 * time.Second,
|
||||
Model: "whisper-1",
|
||||
}
|
||||
|
||||
transcriber := NewOpenAITranscriber(config)
|
||||
|
||||
t.Run("creation", func(t *testing.T) {
|
||||
if transcriber == nil {
|
||||
t.Fatal("OpenAI transcriber should not be nil")
|
||||
}
|
||||
|
||||
if transcriber.config.APIKey != config.APIKey {
|
||||
t.Error("transcriber should store the provided config")
|
||||
}
|
||||
|
||||
if transcriber.client == nil {
|
||||
t.Error("transcriber should have OpenAI client")
|
||||
}
|
||||
|
||||
if transcriber.buffer == nil {
|
||||
t.Error("transcriber should have audio buffer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("initial state", func(t *testing.T) {
|
||||
if transcriber.transcribing {
|
||||
t.Error("transcriber should not be transcribing initially")
|
||||
}
|
||||
|
||||
text, err := transcriber.GetTranscription()
|
||||
if err != nil {
|
||||
t.Errorf("GetTranscription should not error initially: %v", err)
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
t.Errorf("initial transcription should be empty, got %q", text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAudioBuffer(t *testing.T) {
|
||||
config := Config{
|
||||
ChunkSize: 1024,
|
||||
}
|
||||
buffer := &audioBuffer{
|
||||
data: make([]byte, 0, config.ChunkSize*2),
|
||||
maxSize: config.ChunkSize,
|
||||
}
|
||||
|
||||
t.Run("initial state", func(t *testing.T) {
|
||||
if buffer.hasData() {
|
||||
t.Error("buffer should not have data initially")
|
||||
}
|
||||
|
||||
if buffer.shouldFlush(time.Second) {
|
||||
t.Error("buffer should not need flushing initially")
|
||||
}
|
||||
|
||||
data := buffer.flush()
|
||||
if len(data) != 0 {
|
||||
t.Error("flushing empty buffer should return empty data")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("add frame", func(t *testing.T) {
|
||||
frame := recording.AudioFrame{
|
||||
Data: []byte("test audio data"),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
buffer.addFrame(frame)
|
||||
|
||||
if !buffer.hasData() {
|
||||
t.Error("buffer should have data after adding frame")
|
||||
}
|
||||
|
||||
if len(buffer.data) != len(frame.Data) {
|
||||
t.Errorf("buffer data length should be %d, got %d", len(frame.Data), len(buffer.data))
|
||||
}
|
||||
|
||||
// Check that data was copied correctly
|
||||
for i, b := range buffer.data {
|
||||
if b != frame.Data[i] {
|
||||
t.Errorf("buffer data[%d] should be %d, got %d", i, frame.Data[i], b)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flush buffer", func(t *testing.T) {
|
||||
data := buffer.flush()
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Error("flush should return data")
|
||||
}
|
||||
|
||||
if buffer.hasData() {
|
||||
t.Error("buffer should be empty after flush")
|
||||
}
|
||||
|
||||
// Flush again should return empty
|
||||
data2 := buffer.flush()
|
||||
if len(data2) != 0 {
|
||||
t.Error("second flush should return empty data")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("buffer overflow", func(t *testing.T) {
|
||||
// Fill buffer beyond maxSize
|
||||
largeData := make([]byte, config.ChunkSize*3)
|
||||
for i := range largeData {
|
||||
largeData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
frame := recording.AudioFrame{
|
||||
Data: largeData,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
buffer.addFrame(frame)
|
||||
|
||||
// Buffer should be trimmed to maxSize
|
||||
if len(buffer.data) > config.ChunkSize {
|
||||
t.Errorf("buffer should be trimmed to maxSize %d, got %d", config.ChunkSize, len(buffer.data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should flush conditions", func(t *testing.T) {
|
||||
// Reset buffer for this test
|
||||
buffer = &audioBuffer{
|
||||
data: make([]byte, 0, config.ChunkSize*2),
|
||||
maxSize: config.ChunkSize,
|
||||
}
|
||||
|
||||
// Empty buffer should not flush
|
||||
if buffer.shouldFlush(time.Second) {
|
||||
t.Error("empty buffer should not need flushing")
|
||||
}
|
||||
|
||||
// Add data
|
||||
frame := recording.AudioFrame{
|
||||
Data: make([]byte, 10),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
buffer.addFrame(frame)
|
||||
|
||||
// Fresh data should not flush immediately
|
||||
if buffer.shouldFlush(time.Second) {
|
||||
t.Error("fresh data should not need flushing immediately")
|
||||
}
|
||||
|
||||
// Old data should flush
|
||||
buffer.lastAdd = time.Now().Add(-2 * time.Second)
|
||||
if !buffer.shouldFlush(time.Second) {
|
||||
t.Error("old data should need flushing")
|
||||
}
|
||||
|
||||
// Full buffer should flush regardless of time
|
||||
buffer.data = make([]byte, buffer.maxSize)
|
||||
buffer.lastAdd = time.Now()
|
||||
if !buffer.shouldFlush(time.Hour) {
|
||||
t.Error("full buffer should need flushing")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAITranscriberLifecycle(t *testing.T) {
|
||||
config := Config{
|
||||
Provider: "openai",
|
||||
APIKey: "test-api-key",
|
||||
Language: "en",
|
||||
ChunkSize: 1024,
|
||||
BufferTime: 100 * time.Millisecond,
|
||||
Model: "whisper-1",
|
||||
}
|
||||
|
||||
transcriber := NewOpenAITranscriber(config)
|
||||
|
||||
t.Run("stop before start", func(t *testing.T) {
|
||||
err := transcriber.Stop()
|
||||
if err != nil {
|
||||
t.Errorf("Stop should not error when not started: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("start transcriber", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
frameCh := make(chan recording.AudioFrame, 10)
|
||||
|
||||
errCh, err := transcriber.Start(ctx, frameCh)
|
||||
if err != nil {
|
||||
t.Fatalf("Start failed: %v", err)
|
||||
}
|
||||
|
||||
if errCh == nil {
|
||||
t.Fatal("error channel should not be nil")
|
||||
}
|
||||
|
||||
// Should be transcribing now
|
||||
if !transcriber.transcribing {
|
||||
t.Error("transcriber should be marked as transcribing")
|
||||
}
|
||||
|
||||
// Stop transcriber
|
||||
err = transcriber.Stop()
|
||||
if err != nil {
|
||||
t.Errorf("Stop failed: %v", err)
|
||||
}
|
||||
|
||||
// Should not be transcribing anymore
|
||||
if transcriber.transcribing {
|
||||
t.Error("transcriber should not be marked as transcribing after stop")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("double start", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
frameCh := make(chan recording.AudioFrame, 10)
|
||||
|
||||
// Mark as transcribing manually
|
||||
transcriber.transcribing = true
|
||||
defer func() { transcriber.transcribing = false }()
|
||||
|
||||
_, err := transcriber.Start(ctx, frameCh)
|
||||
if err == nil {
|
||||
t.Error("Start should fail when already transcribing")
|
||||
}
|
||||
|
||||
expectedMsg := "transcriber: already transcribing"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("error message should be %q, got %q", expectedMsg, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertToWAV(t *testing.T) {
|
||||
config := Config{
|
||||
Provider: "openai",
|
||||
APIKey: "test-api-key",
|
||||
}
|
||||
transcriber := NewOpenAITranscriber(config)
|
||||
|
||||
t.Run("convert empty audio", func(t *testing.T) {
|
||||
rawAudio := []byte{}
|
||||
wavData, err := transcriber.convertToWAV(rawAudio)
|
||||
if err != nil {
|
||||
t.Fatalf("convertToWAV failed: %v", err)
|
||||
}
|
||||
|
||||
// WAV header is 44 bytes minimum
|
||||
if len(wavData) < 44 {
|
||||
t.Errorf("WAV data should be at least 44 bytes, got %d", len(wavData))
|
||||
}
|
||||
|
||||
// Check WAV header magic
|
||||
if string(wavData[0:4]) != "RIFF" {
|
||||
t.Error("WAV should start with RIFF")
|
||||
}
|
||||
if string(wavData[8:12]) != "WAVE" {
|
||||
t.Error("WAV should contain WAVE identifier")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("convert audio data", func(t *testing.T) {
|
||||
rawAudio := make([]byte, 1024) // 1KB of audio data
|
||||
for i := range rawAudio {
|
||||
rawAudio[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
wavData, err := transcriber.convertToWAV(rawAudio)
|
||||
if err != nil {
|
||||
t.Fatalf("convertToWAV failed: %v", err)
|
||||
}
|
||||
|
||||
expectedSize := 44 + len(rawAudio) // Header + data
|
||||
if len(wavData) != expectedSize {
|
||||
t.Errorf("WAV data should be %d bytes, got %d", expectedSize, len(wavData))
|
||||
}
|
||||
|
||||
// Check that audio data is at the end
|
||||
audioDataStart := len(wavData) - len(rawAudio)
|
||||
for i, b := range rawAudio {
|
||||
if wavData[audioDataStart+i] != b {
|
||||
t.Errorf("audio data[%d] should be %d, got %d", i, b, wavData[audioDataStart+i])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WAV header validation", func(t *testing.T) {
|
||||
rawAudio := make([]byte, 16) // Small audio sample
|
||||
wavData, err := transcriber.convertToWAV(rawAudio)
|
||||
if err != nil {
|
||||
t.Fatalf("convertToWAV failed: %v", err)
|
||||
}
|
||||
|
||||
// Validate WAV header fields
|
||||
tests := []struct {
|
||||
offset int
|
||||
expected string
|
||||
name string
|
||||
}{
|
||||
{0, "RIFF", "RIFF identifier"},
|
||||
{8, "WAVE", "WAVE identifier"},
|
||||
{12, "fmt ", "format chunk identifier"},
|
||||
{36, "data", "data chunk identifier"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if tt.offset+4 > len(wavData) {
|
||||
t.Errorf("WAV data too short for %s", tt.name)
|
||||
continue
|
||||
}
|
||||
actual := string(wavData[tt.offset : tt.offset+4])
|
||||
if actual != tt.expected {
|
||||
t.Errorf("%s should be %q, got %q", tt.name, tt.expected, actual)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTranscriberConcurrency(t *testing.T) {
|
||||
config := Config{
|
||||
Provider: "openai",
|
||||
APIKey: "test-api-key",
|
||||
}
|
||||
transcriber := NewOpenAITranscriber(config)
|
||||
|
||||
t.Run("concurrent GetTranscription calls", func(t *testing.T) {
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
for j := 0; j < 100; j++ {
|
||||
_, _ = transcriber.GetTranscription()
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for concurrent GetTranscription calls")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent Stop calls", func(t *testing.T) {
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
transcriber.Stop()
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("timeout waiting for concurrent Stop calls")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTranscriptionResult(t *testing.T) {
|
||||
t.Run("transcription result creation", func(t *testing.T) {
|
||||
text := "Hello, world!"
|
||||
timestamp := time.Now()
|
||||
isFinal := true
|
||||
|
||||
result := TranscriptionResult{
|
||||
Text: text,
|
||||
Timestamp: timestamp,
|
||||
IsFinal: isFinal,
|
||||
}
|
||||
|
||||
if result.Text != text {
|
||||
t.Errorf("text should be %q, got %q", text, result.Text)
|
||||
}
|
||||
if !result.Timestamp.Equal(timestamp) {
|
||||
t.Errorf("timestamp should be %v, got %v", timestamp, result.Timestamp)
|
||||
}
|
||||
if result.IsFinal != isFinal {
|
||||
t.Errorf("IsFinal should be %v, got %v", isFinal, result.IsFinal)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty transcription result", func(t *testing.T) {
|
||||
result := TranscriptionResult{}
|
||||
|
||||
if result.Text != "" {
|
||||
t.Error("empty result text should be empty string")
|
||||
}
|
||||
if !result.Timestamp.IsZero() {
|
||||
t.Error("empty result timestamp should be zero")
|
||||
}
|
||||
if result.IsFinal {
|
||||
t.Error("empty result should not be final")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
t.Run("various provider configurations", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid openai config",
|
||||
config: Config{
|
||||
Provider: "openai",
|
||||
APIKey: "sk-test-key",
|
||||
Language: "en",
|
||||
Model: "whisper-1",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "openai without api key",
|
||||
config: Config{
|
||||
Provider: "openai",
|
||||
APIKey: "",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "OpenAI API key required",
|
||||
},
|
||||
{
|
||||
name: "unknown provider",
|
||||
config: Config{
|
||||
Provider: "unknown",
|
||||
APIKey: "test-key",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "unsupported provider: unknown",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewTranscriber(tt.config)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error for config %+v", tt.config)
|
||||
} else if err.Error() != tt.errorMsg {
|
||||
t.Errorf("expected error message %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error for config %+v: %v", tt.config, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user