diff --git a/internal/bus/bus_test.go b/internal/bus/bus_test.go new file mode 100644 index 0000000..0ac4f8d --- /dev/null +++ b/internal/bus/bus_test.go @@ -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") + } + }) +} diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go new file mode 100644 index 0000000..ea6bbd0 --- /dev/null +++ b/internal/daemon/daemon_test.go @@ -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") + } + }) +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..a75bf1a --- /dev/null +++ b/internal/notify/notify_test.go @@ -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 +} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 92d11a9..364214a 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -195,8 +195,7 @@ func (p *pipeline) run(ctx context.Context) { case frame := <-frameCh: frameCount++ totalBytes += len(frame.Data) - log.Printf("Pipeline: Received frame #%d - Size: %d bytes, Timestamp: %v, Total bytes so far: %d", - frameCount, len(frame.Data), frame.Timestamp.Format("15:04:05.000"), totalBytes) + // log.Printf("Pipeline: Received frame #%d - Size: %d bytes, Timestamp: %v, Total bytes so far: %d", frameCount, len(frame.Data), frame.Timestamp.Format("15:04:05.000"), totalBytes) case err := <-tErrCh: if err != nil { @@ -221,21 +220,37 @@ func (p *pipeline) run(ctx context.Context) { continue } - log.Printf("Pipeline: Inject action received, stopping recording and getting transcription") + log.Printf("Pipeline: Inject action received, stopping recording and finalizing transcription") + p.setStatus(Injecting) + // Stop the recorder first - this will close the frameCh channel if err := recorder.Stop(); err != nil { log.Printf("Pipeline: Error stopping recorder: %v", err) p.sendError("Recording Error", "Failed to stop recorder during injection", err) } - p.setStatus(Injecting) + // Wait for the recorder to fully stop and frameCh to be closed + // The transcriber will process any remaining frames when frameCh closes + log.Printf("Pipeline: Waiting for recording channel to close and final transcription to complete") + // Drain the frameCh to ensure it's fully closed + for range frameCh { + // Continue draining until channel is closed + } + + // Stop the transcriber to ensure all buffered audio is processed + if err := t.Stop(); err != nil { + log.Printf("Pipeline: Error stopping transcriber: %v", err) + p.sendError("Transcription Error", "Failed to stop transcriber during injection", err) + } + + // Now get the final transcription which includes all processed audio transcriptionText, err := t.GetTranscription() if err != nil { log.Printf("Pipeline: Error getting transcription: %v", err) p.sendError("Transcription Error", "Failed to retrieve transcription", err) } else { - log.Printf("Pipeline: Transcription text: %s", transcriptionText) + log.Printf("Pipeline: Final transcription text: %s", transcriptionText) } log.Printf("Pipeline: Simulating injection work") diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go new file mode 100644 index 0000000..737996e --- /dev/null +++ b/internal/pipeline/pipeline_test.go @@ -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") + } + }) +} diff --git a/internal/recording/recording_test.go b/internal/recording/recording_test.go new file mode 100644 index 0000000..d5d40fa --- /dev/null +++ b/internal/recording/recording_test.go @@ -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") + } + }) +} diff --git a/internal/transcriber/openai.go b/internal/transcriber/openai.go index a291620..5cae942 100644 --- a/internal/transcriber/openai.go +++ b/internal/transcriber/openai.go @@ -82,6 +82,16 @@ func (t *OpenAITranscriber) Stop() error { cancel() } t.wg.Wait() + + // After stopping, ensure any remaining buffered audio is transcribed + if t.buffer.hasData() { + log.Printf("transcriber: processing remaining buffered audio on stop") + ctx := context.Background() + errCh := make(chan error, 1) + t.transcribeBuffer(ctx, errCh) + close(errCh) + } + return nil } @@ -98,6 +108,13 @@ func (t *OpenAITranscriber) GetTranscription() (string, error) { func (t *OpenAITranscriber) processFrames(ctx context.Context, frameCh <-chan recording.AudioFrame, errCh chan<- error) { defer func() { + // Always process remaining buffer when shutting down + if t.buffer.hasData() { + log.Printf("transcriber: processing final buffered audio on shutdown") + // Use background context for final transcription to avoid timeout + finalCtx := context.Background() + t.transcribeBuffer(finalCtx, errCh) + } close(errCh) t.mu.Lock() t.transcribing = false @@ -112,16 +129,25 @@ func (t *OpenAITranscriber) processFrames(ctx context.Context, frameCh <-chan re for { select { case <-ctx.Done(): - if t.buffer.hasData() { - t.transcribeBuffer(ctx, errCh) + log.Printf("transcriber: context cancelled, processing remaining frames") + // Continue processing remaining frames from channel before stopping + for { + select { + case frame, ok := <-frameCh: + if !ok { + log.Printf("transcriber: recording channel closed") + return + } + t.buffer.addFrame(frame) + default: + // No more frames available, exit + return + } } - return case frame, ok := <-frameCh: if !ok { - if t.buffer.hasData() { - t.transcribeBuffer(ctx, errCh) - } + log.Printf("transcriber: recording channel closed, finishing with remaining buffer") return } t.buffer.addFrame(frame) diff --git a/internal/transcriber/transcriber.go b/internal/transcriber/transcriber.go index 5e5248f..4246c56 100644 --- a/internal/transcriber/transcriber.go +++ b/internal/transcriber/transcriber.go @@ -31,7 +31,7 @@ type Config struct { func DefaultConfig() Config { return Config{ Provider: "openai", - Language: "en", + Language: "it", ChunkSize: 16384, BufferTime: 2 * time.Second, Model: "whisper-1", diff --git a/internal/transcriber/transcriber_test.go b/internal/transcriber/transcriber_test.go new file mode 100644 index 0000000..e0506a9 --- /dev/null +++ b/internal/transcriber/transcriber_test.go @@ -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) + } + } + }) + } + }) +}