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:
|
case frame := <-frameCh:
|
||||||
frameCount++
|
frameCount++
|
||||||
totalBytes += len(frame.Data)
|
totalBytes += len(frame.Data)
|
||||||
log.Printf("Pipeline: Received frame #%d - Size: %d bytes, Timestamp: %v, Total bytes so far: %d",
|
// 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)
|
||||||
frameCount, len(frame.Data), frame.Timestamp.Format("15:04:05.000"), totalBytes)
|
|
||||||
|
|
||||||
case err := <-tErrCh:
|
case err := <-tErrCh:
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -221,21 +220,37 @@ func (p *pipeline) run(ctx context.Context) {
|
|||||||
continue
|
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 {
|
if err := recorder.Stop(); err != nil {
|
||||||
log.Printf("Pipeline: Error stopping recorder: %v", err)
|
log.Printf("Pipeline: Error stopping recorder: %v", err)
|
||||||
p.sendError("Recording Error", "Failed to stop recorder during injection", 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()
|
transcriptionText, err := t.GetTranscription()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Pipeline: Error getting transcription: %v", err)
|
log.Printf("Pipeline: Error getting transcription: %v", err)
|
||||||
p.sendError("Transcription Error", "Failed to retrieve transcription", err)
|
p.sendError("Transcription Error", "Failed to retrieve transcription", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Pipeline: Transcription text: %s", transcriptionText)
|
log.Printf("Pipeline: Final transcription text: %s", transcriptionText)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Pipeline: Simulating injection work")
|
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()
|
cancel()
|
||||||
}
|
}
|
||||||
t.wg.Wait()
|
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
|
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) {
|
func (t *OpenAITranscriber) processFrames(ctx context.Context, frameCh <-chan recording.AudioFrame, errCh chan<- error) {
|
||||||
defer func() {
|
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)
|
close(errCh)
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
t.transcribing = false
|
t.transcribing = false
|
||||||
@@ -112,16 +129,25 @@ func (t *OpenAITranscriber) processFrames(ctx context.Context, frameCh <-chan re
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
if t.buffer.hasData() {
|
log.Printf("transcriber: context cancelled, processing remaining frames")
|
||||||
t.transcribeBuffer(ctx, errCh)
|
// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
|
||||||
|
|
||||||
case frame, ok := <-frameCh:
|
case frame, ok := <-frameCh:
|
||||||
if !ok {
|
if !ok {
|
||||||
if t.buffer.hasData() {
|
log.Printf("transcriber: recording channel closed, finishing with remaining buffer")
|
||||||
t.transcribeBuffer(ctx, errCh)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.buffer.addFrame(frame)
|
t.buffer.addFrame(frame)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type Config struct {
|
|||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Provider: "openai",
|
Provider: "openai",
|
||||||
Language: "en",
|
Language: "it",
|
||||||
ChunkSize: 16384,
|
ChunkSize: 16384,
|
||||||
BufferTime: 2 * time.Second,
|
BufferTime: 2 * time.Second,
|
||||||
Model: "whisper-1",
|
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