diff --git a/internal/bus/bus_test.go b/internal/bus/bus_test.go deleted file mode 100644 index 0ac4f8d..0000000 --- a/internal/bus/bus_test.go +++ /dev/null @@ -1,416 +0,0 @@ -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 deleted file mode 100644 index ea6bbd0..0000000 --- a/internal/daemon/daemon_test.go +++ /dev/null @@ -1,575 +0,0 @@ -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 deleted file mode 100644 index a75bf1a..0000000 --- a/internal/notify/notify_test.go +++ /dev/null @@ -1,336 +0,0 @@ -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_test.go b/internal/pipeline/pipeline_test.go deleted file mode 100644 index 737996e..0000000 --- a/internal/pipeline/pipeline_test.go +++ /dev/null @@ -1,401 +0,0 @@ -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 deleted file mode 100644 index d5d40fa..0000000 --- a/internal/recording/recording_test.go +++ /dev/null @@ -1,476 +0,0 @@ -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/transcriber_test.go b/internal/transcriber/transcriber_test.go deleted file mode 100644 index e0506a9..0000000 --- a/internal/transcriber/transcriber_test.go +++ /dev/null @@ -1,557 +0,0 @@ -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) - } - } - }) - } - }) -}