From 2842bd1cdc250751fac8854cfba54e9fae59c48b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Anderss=C3=A9n?= Date: Fri, 13 Feb 2026 16:17:21 +0200 Subject: [PATCH] refactor: abstract malgo functions into malgo_hooks_linux.go and update usage in capture and playback test: add tests for StartCapture and StartPlayback error handling and success scenarios chore: enhance coverage script to generate HTML report --- .gitignore | 2 +- cmd/client/chat.go | 12 +- cmd/client/chat_test.go | 35 +++ internal/audio/capture.go | 18 +- internal/audio/malgo_hooks_linux.go | 14 ++ internal/audio/playback.go | 18 +- internal/audio/startup_test.go | 371 ++++++++++++++++++++++++++++ scripts/coverage.sh | 29 ++- 8 files changed, 468 insertions(+), 31 deletions(-) create mode 100644 internal/audio/malgo_hooks_linux.go create mode 100644 internal/audio/startup_test.go diff --git a/.gitignore b/.gitignore index b4126d7..7503ae8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,4 @@ bin/ .env* !.env.example -coverage.out \ No newline at end of file +coverage.* \ No newline at end of file diff --git a/cmd/client/chat.go b/cmd/client/chat.go index a239ca4..0b542c2 100644 --- a/cmd/client/chat.go +++ b/cmd/client/chat.go @@ -143,6 +143,8 @@ type channelRefreshTick struct{} var errDirectoryKeyPending = fmt.Errorf("directory key pending") +var teaTick = tea.Tick + func newChatModel(api *APIClient, auth *AuthResponse, kp *crypto.KeyPair, keystorePassphrase string, width, height int, voiceIPCAddr string) chatModel { input := textinput.New() input.Placeholder = "type a message..." @@ -1110,7 +1112,7 @@ func (m *chatModel) persistDirectoryKey() error { } func (m *chatModel) scheduleDirectoryTick() tea.Cmd { - return tea.Tick(10*time.Second, func(time.Time) tea.Msg { + return teaTick(10*time.Second, func(time.Time) tea.Msg { return directoryTick{} }) } @@ -1890,13 +1892,13 @@ func (m *chatModel) refreshPresence() { } func (m *chatModel) schedulePresenceTick() tea.Cmd { - return tea.Tick(5*time.Second, func(time.Time) tea.Msg { + return teaTick(5*time.Second, func(time.Time) tea.Msg { return presenceTick{} }) } func (m *chatModel) scheduleVoicePing() tea.Cmd { - return tea.Tick(15*time.Second, func(time.Time) tea.Msg { + return teaTick(15*time.Second, func(time.Time) tea.Msg { return voicePingTick{} }) } @@ -1915,13 +1917,13 @@ func (m *chatModel) scheduleVoiceReconnect(attempt int) tea.Cmd { } func (m *chatModel) scheduleShareTick() tea.Cmd { - return tea.Tick(shareKeysInterval, func(time.Time) tea.Msg { + return teaTick(shareKeysInterval, func(time.Time) tea.Msg { return shareTick{} }) } func (m *chatModel) scheduleChannelRefresh() tea.Cmd { - return tea.Tick(channelRefreshDelay, func(time.Time) tea.Msg { + return teaTick(channelRefreshDelay, func(time.Time) tea.Msg { return channelRefreshTick{} }) } diff --git a/cmd/client/chat_test.go b/cmd/client/chat_test.go index ba1c5cc..1fe4fb6 100644 --- a/cmd/client/chat_test.go +++ b/cmd/client/chat_test.go @@ -1361,17 +1361,52 @@ func TestChatModelSendCurrentMessageChannel(t *testing.T) { func TestChatModelScheduleTicks(t *testing.T) { m := newChatForTest(t, &APIClient{serverURL: "http://server", httpClient: http.DefaultClient}) + originalTeaTick := teaTick + durations := make([]time.Duration, 0, 5) + teaTick = func(delay time.Duration, callback func(time.Time) tea.Msg) tea.Cmd { + durations = append(durations, delay) + return func() tea.Msg { + return callback(time.Now()) + } + } + t.Cleanup(func() { + teaTick = originalTeaTick + }) + if cmd := m.scheduleDirectoryTick(); cmd == nil { t.Fatalf("expected directory tick cmd") + } else if _, ok := cmd().(directoryTick); !ok { + t.Fatalf("expected directoryTick message") } if cmd := m.schedulePresenceTick(); cmd == nil { t.Fatalf("expected presence tick cmd") + } else if _, ok := cmd().(presenceTick); !ok { + t.Fatalf("expected presenceTick message") + } + if cmd := m.scheduleVoicePing(); cmd == nil { + t.Fatalf("expected voice ping cmd") + } else if _, ok := cmd().(voicePingTick); !ok { + t.Fatalf("expected voicePingTick message") } if cmd := m.scheduleShareTick(); cmd == nil { t.Fatalf("expected share tick cmd") + } else if _, ok := cmd().(shareTick); !ok { + t.Fatalf("expected shareTick message") } if cmd := m.scheduleChannelRefresh(); cmd == nil { t.Fatalf("expected channel refresh cmd") + } else if _, ok := cmd().(channelRefreshTick); !ok { + t.Fatalf("expected channelRefreshTick message") + } + + expectedDurations := []time.Duration{10 * time.Second, 5 * time.Second, 15 * time.Second, shareKeysInterval, channelRefreshDelay} + if len(durations) != len(expectedDurations) { + t.Fatalf("captured durations length=%d want=%d", len(durations), len(expectedDurations)) + } + for index := range expectedDurations { + if durations[index] != expectedDurations[index] { + t.Fatalf("duration[%d]=%s want=%s", index, durations[index], expectedDurations[index]) + } } } diff --git a/internal/audio/capture.go b/internal/audio/capture.go index 16430bd..36bb412 100644 --- a/internal/audio/capture.go +++ b/internal/audio/capture.go @@ -25,12 +25,12 @@ type Capture struct { func StartCapture(ctx context.Context) (*Capture, <-chan []int16, error) { config := malgo.ContextConfig{} - malgoCtx, err := malgo.InitContext(nil, config, nil) + malgoCtx, err := malgoInitContext(nil, config, nil) if err != nil { return nil, nil, fmt.Errorf("init malgo context: %w", err) } - deviceConfig := malgo.DefaultDeviceConfig(malgo.Capture) + deviceConfig := malgoDefaultDeviceConfig(malgo.Capture) deviceConfig.Capture.Format = malgo.FormatS16 deviceConfig.Capture.Channels = Channels deviceConfig.SampleRate = SampleRate @@ -52,14 +52,14 @@ func StartCapture(ctx context.Context) (*Capture, <-chan []int16, error) { }, } - device, err := malgo.InitDevice(malgoCtx.Context, deviceConfig, callback) + device, err := malgoInitDevice(malgoCtx.Context, deviceConfig, callback) if err != nil { - malgoCtx.Uninit() + malgoContextUninit(malgoCtx) return nil, nil, fmt.Errorf("init capture device: %w", err) } - if err := device.Start(); err != nil { - device.Uninit() - malgoCtx.Uninit() + if err := malgoDeviceStart(device); err != nil { + malgoDeviceUninit(device) + malgoContextUninit(malgoCtx) return nil, nil, fmt.Errorf("start capture: %w", err) } @@ -78,11 +78,11 @@ func (c *Capture) Close() error { } c.closeOnce.Do(func() { if c.device != nil { - c.device.Uninit() + malgoDeviceUninit(c.device) c.device = nil } if c.ctx != nil { - c.ctx.Uninit() + malgoContextUninit(c.ctx) c.ctx = nil } }) diff --git a/internal/audio/malgo_hooks_linux.go b/internal/audio/malgo_hooks_linux.go new file mode 100644 index 0000000..b291908 --- /dev/null +++ b/internal/audio/malgo_hooks_linux.go @@ -0,0 +1,14 @@ +//go:build linux + +package audio + +import "github.com/gen2brain/malgo" + +var ( + malgoInitContext = malgo.InitContext + malgoDefaultDeviceConfig = malgo.DefaultDeviceConfig + malgoInitDevice = malgo.InitDevice + malgoContextUninit = (*malgo.AllocatedContext).Uninit + malgoDeviceStart = (*malgo.Device).Start + malgoDeviceUninit = (*malgo.Device).Uninit +) diff --git a/internal/audio/playback.go b/internal/audio/playback.go index 83adf68..618be53 100644 --- a/internal/audio/playback.go +++ b/internal/audio/playback.go @@ -25,12 +25,12 @@ type Playback struct { func StartPlayback(ctx context.Context) (*Playback, error) { config := malgo.ContextConfig{} - malgoCtx, err := malgo.InitContext(nil, config, nil) + malgoCtx, err := malgoInitContext(nil, config, nil) if err != nil { return nil, fmt.Errorf("init malgo context: %w", err) } - deviceConfig := malgo.DefaultDeviceConfig(malgo.Playback) + deviceConfig := malgoDefaultDeviceConfig(malgo.Playback) deviceConfig.Playback.Format = malgo.FormatS16 deviceConfig.Playback.Channels = Channels deviceConfig.SampleRate = SampleRate @@ -46,14 +46,14 @@ func StartPlayback(ctx context.Context) (*Playback, error) { }, } - device, err := malgo.InitDevice(malgoCtx.Context, deviceConfig, callback) + device, err := malgoInitDevice(malgoCtx.Context, deviceConfig, callback) if err != nil { - malgoCtx.Uninit() + malgoContextUninit(malgoCtx) return nil, fmt.Errorf("init playback device: %w", err) } - if err := device.Start(); err != nil { - device.Uninit() - malgoCtx.Uninit() + if err := malgoDeviceStart(device); err != nil { + malgoDeviceUninit(device) + malgoContextUninit(malgoCtx) return nil, fmt.Errorf("start playback: %w", err) } @@ -118,11 +118,11 @@ func (p *Playback) Close() error { } p.closeOnce.Do(func() { if p.device != nil { - p.device.Uninit() + malgoDeviceUninit(p.device) p.device = nil } if p.ctx != nil { - p.ctx.Uninit() + malgoContextUninit(p.ctx) p.ctx = nil } }) diff --git a/internal/audio/startup_test.go b/internal/audio/startup_test.go new file mode 100644 index 0000000..68108f9 --- /dev/null +++ b/internal/audio/startup_test.go @@ -0,0 +1,371 @@ +//go:build linux + +package audio + +import ( + "context" + "encoding/binary" + "errors" + "strings" + "testing" + "time" + + "github.com/gen2brain/malgo" +) + +func saveAndRestoreMalgoHooks(t *testing.T) { + t.Helper() + origInitContext := malgoInitContext + origDefaultDeviceConfig := malgoDefaultDeviceConfig + origInitDevice := malgoInitDevice + origContextUninit := malgoContextUninit + origDeviceStart := malgoDeviceStart + origDeviceUninit := malgoDeviceUninit + + t.Cleanup(func() { + malgoInitContext = origInitContext + malgoDefaultDeviceConfig = origDefaultDeviceConfig + malgoInitDevice = origInitDevice + malgoContextUninit = origContextUninit + malgoDeviceStart = origDeviceStart + malgoDeviceUninit = origDeviceUninit + }) +} + +func waitFor(t *testing.T, timeout time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("condition not met before timeout") +} + +func decodeInt16LE(buf []byte, idx int) int16 { + return int16(binary.LittleEndian.Uint16(buf[idx*2:])) +} + +func TestStartCaptureInitContextError(t *testing.T) { + saveAndRestoreMalgoHooks(t) + + malgoInitContext = func([]malgo.Backend, malgo.ContextConfig, malgo.LogProc) (*malgo.AllocatedContext, error) { + return nil, errors.New("boom") + } + + capture, ch, err := StartCapture(context.Background()) + if err == nil || !strings.Contains(err.Error(), "init malgo context") { + t.Fatalf("error = %v, want init malgo context failure", err) + } + if capture != nil || ch != nil { + t.Fatalf("expected nil capture/channel on init context error, got capture=%v channel=%v", capture, ch) + } +} + +func TestStartCaptureInitDeviceErrorUninitsContext(t *testing.T) { + saveAndRestoreMalgoHooks(t) + + ctxUninitCalls := 0 + malgoInitContext = func([]malgo.Backend, malgo.ContextConfig, malgo.LogProc) (*malgo.AllocatedContext, error) { + return &malgo.AllocatedContext{}, nil + } + malgoDefaultDeviceConfig = func(malgo.DeviceType) malgo.DeviceConfig { + return malgo.DeviceConfig{} + } + malgoInitDevice = func(malgo.Context, malgo.DeviceConfig, malgo.DeviceCallbacks) (*malgo.Device, error) { + return nil, errors.New("no device") + } + malgoContextUninit = func(*malgo.AllocatedContext) error { + ctxUninitCalls++ + return nil + } + + capture, ch, err := StartCapture(context.Background()) + if err == nil || !strings.Contains(err.Error(), "init capture device") { + t.Fatalf("error = %v, want init capture device failure", err) + } + if capture != nil || ch != nil { + t.Fatalf("expected nil capture/channel on init device error, got capture=%v channel=%v", capture, ch) + } + if ctxUninitCalls != 1 { + t.Fatalf("context uninit calls = %d, want 1", ctxUninitCalls) + } +} + +func TestStartCaptureStartErrorUninitsDeviceAndContext(t *testing.T) { + saveAndRestoreMalgoHooks(t) + + ctxUninitCalls := 0 + deviceUninitCalls := 0 + malgoInitContext = func([]malgo.Backend, malgo.ContextConfig, malgo.LogProc) (*malgo.AllocatedContext, error) { + return &malgo.AllocatedContext{}, nil + } + malgoDefaultDeviceConfig = func(malgo.DeviceType) malgo.DeviceConfig { + return malgo.DeviceConfig{} + } + malgoInitDevice = func(malgo.Context, malgo.DeviceConfig, malgo.DeviceCallbacks) (*malgo.Device, error) { + return &malgo.Device{}, nil + } + malgoDeviceStart = func(*malgo.Device) error { + return errors.New("start failed") + } + malgoDeviceUninit = func(*malgo.Device) { + deviceUninitCalls++ + } + malgoContextUninit = func(*malgo.AllocatedContext) error { + ctxUninitCalls++ + return nil + } + + capture, ch, err := StartCapture(context.Background()) + if err == nil || !strings.Contains(err.Error(), "start capture") { + t.Fatalf("error = %v, want start capture failure", err) + } + if capture != nil || ch != nil { + t.Fatalf("expected nil capture/channel on start error, got capture=%v channel=%v", capture, ch) + } + if deviceUninitCalls != 1 || ctxUninitCalls != 1 { + t.Fatalf("uninit calls device=%d ctx=%d, want 1 each", deviceUninitCalls, ctxUninitCalls) + } +} + +func TestStartCaptureSuccessConvertsSamplesDropsOverflowAndClosesOnCancel(t *testing.T) { + saveAndRestoreMalgoHooks(t) + + ctxUninitCalls := 0 + deviceUninitCalls := 0 + var callbacks malgo.DeviceCallbacks + + malgoInitContext = func([]malgo.Backend, malgo.ContextConfig, malgo.LogProc) (*malgo.AllocatedContext, error) { + return &malgo.AllocatedContext{}, nil + } + malgoDefaultDeviceConfig = func(malgo.DeviceType) malgo.DeviceConfig { + return malgo.DeviceConfig{} + } + malgoInitDevice = func(_ malgo.Context, cfg malgo.DeviceConfig, cb malgo.DeviceCallbacks) (*malgo.Device, error) { + if cfg.Capture.Channels != Channels { + t.Fatalf("capture channels = %d, want %d", cfg.Capture.Channels, Channels) + } + if cfg.SampleRate != SampleRate { + t.Fatalf("sample rate = %d, want %d", cfg.SampleRate, SampleRate) + } + if cfg.Capture.Format != malgo.FormatS16 { + t.Fatalf("capture format = %v, want %v", cfg.Capture.Format, malgo.FormatS16) + } + callbacks = cb + return &malgo.Device{}, nil + } + malgoDeviceStart = func(*malgo.Device) error { return nil } + malgoDeviceUninit = func(*malgo.Device) { deviceUninitCalls++ } + malgoContextUninit = func(*malgo.AllocatedContext) error { + ctxUninitCalls++ + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + capture, ch, err := StartCapture(ctx) + if err != nil { + t.Fatalf("StartCapture() error: %v", err) + } + if capture == nil || ch == nil { + t.Fatalf("StartCapture() returned capture=%v channel=%v", capture, ch) + } + if callbacks.Data == nil { + t.Fatal("expected data callback to be set") + } + + callbacks.Data(nil, []byte{1, 0, 255, 127}, 0) + got := <-ch + if len(got) != 2 || got[0] != 1 || got[1] != 32767 { + t.Fatalf("decoded samples = %#v, want [1 32767]", got) + } + + callbacks.Data(nil, nil, 0) + select { + case extra := <-ch: + t.Fatalf("unexpected extra samples for empty input: %#v", extra) + default: + } + + for i := 0; i < 9; i++ { + callbacks.Data(nil, []byte{9, 0}, 0) + } + if len(ch) != 8 { + t.Fatalf("channel length = %d, want bounded length 8", len(ch)) + } + + cancel() + waitFor(t, 200*time.Millisecond, func() bool { + return deviceUninitCalls == 1 && ctxUninitCalls == 1 + }) + + if err := capture.Close(); err != nil { + t.Fatalf("capture.Close() error: %v", err) + } + if deviceUninitCalls != 1 || ctxUninitCalls != 1 { + t.Fatalf("close should be idempotent; got device=%d ctx=%d", deviceUninitCalls, ctxUninitCalls) + } +} + +func TestStartPlaybackInitContextError(t *testing.T) { + saveAndRestoreMalgoHooks(t) + + malgoInitContext = func([]malgo.Backend, malgo.ContextConfig, malgo.LogProc) (*malgo.AllocatedContext, error) { + return nil, errors.New("boom") + } + + p, err := StartPlayback(context.Background()) + if err == nil || !strings.Contains(err.Error(), "init malgo context") { + t.Fatalf("error = %v, want init malgo context failure", err) + } + if p != nil { + t.Fatalf("expected nil playback on init context error, got %v", p) + } +} + +func TestStartPlaybackInitDeviceErrorUninitsContext(t *testing.T) { + saveAndRestoreMalgoHooks(t) + + ctxUninitCalls := 0 + malgoInitContext = func([]malgo.Backend, malgo.ContextConfig, malgo.LogProc) (*malgo.AllocatedContext, error) { + return &malgo.AllocatedContext{}, nil + } + malgoDefaultDeviceConfig = func(malgo.DeviceType) malgo.DeviceConfig { + return malgo.DeviceConfig{} + } + malgoInitDevice = func(malgo.Context, malgo.DeviceConfig, malgo.DeviceCallbacks) (*malgo.Device, error) { + return nil, errors.New("no output") + } + malgoContextUninit = func(*malgo.AllocatedContext) error { + ctxUninitCalls++ + return nil + } + + p, err := StartPlayback(context.Background()) + if err == nil || !strings.Contains(err.Error(), "init playback device") { + t.Fatalf("error = %v, want init playback device failure", err) + } + if p != nil { + t.Fatalf("expected nil playback on init device error, got %v", p) + } + if ctxUninitCalls != 1 { + t.Fatalf("context uninit calls = %d, want 1", ctxUninitCalls) + } +} + +func TestStartPlaybackStartErrorUninitsDeviceAndContext(t *testing.T) { + saveAndRestoreMalgoHooks(t) + + ctxUninitCalls := 0 + deviceUninitCalls := 0 + malgoInitContext = func([]malgo.Backend, malgo.ContextConfig, malgo.LogProc) (*malgo.AllocatedContext, error) { + return &malgo.AllocatedContext{}, nil + } + malgoDefaultDeviceConfig = func(malgo.DeviceType) malgo.DeviceConfig { + return malgo.DeviceConfig{} + } + malgoInitDevice = func(malgo.Context, malgo.DeviceConfig, malgo.DeviceCallbacks) (*malgo.Device, error) { + return &malgo.Device{}, nil + } + malgoDeviceStart = func(*malgo.Device) error { + return errors.New("start failed") + } + malgoDeviceUninit = func(*malgo.Device) { + deviceUninitCalls++ + } + malgoContextUninit = func(*malgo.AllocatedContext) error { + ctxUninitCalls++ + return nil + } + + p, err := StartPlayback(context.Background()) + if err == nil || !strings.Contains(err.Error(), "start playback") { + t.Fatalf("error = %v, want start playback failure", err) + } + if p != nil { + t.Fatalf("expected nil playback on start error, got %v", p) + } + if deviceUninitCalls != 1 || ctxUninitCalls != 1 { + t.Fatalf("uninit calls device=%d ctx=%d, want 1 each", deviceUninitCalls, ctxUninitCalls) + } +} + +func TestStartPlaybackSuccessFillsOutputAndClosesOnCancel(t *testing.T) { + saveAndRestoreMalgoHooks(t) + + ctxUninitCalls := 0 + deviceUninitCalls := 0 + var callbacks malgo.DeviceCallbacks + + malgoInitContext = func([]malgo.Backend, malgo.ContextConfig, malgo.LogProc) (*malgo.AllocatedContext, error) { + return &malgo.AllocatedContext{}, nil + } + malgoDefaultDeviceConfig = func(malgo.DeviceType) malgo.DeviceConfig { + return malgo.DeviceConfig{} + } + malgoInitDevice = func(_ malgo.Context, cfg malgo.DeviceConfig, cb malgo.DeviceCallbacks) (*malgo.Device, error) { + if cfg.Playback.Channels != Channels { + t.Fatalf("playback channels = %d, want %d", cfg.Playback.Channels, Channels) + } + if cfg.SampleRate != SampleRate { + t.Fatalf("sample rate = %d, want %d", cfg.SampleRate, SampleRate) + } + if cfg.Playback.Format != malgo.FormatS16 { + t.Fatalf("playback format = %v, want %v", cfg.Playback.Format, malgo.FormatS16) + } + callbacks = cb + return &malgo.Device{}, nil + } + malgoDeviceStart = func(*malgo.Device) error { return nil } + malgoDeviceUninit = func(*malgo.Device) { deviceUninitCalls++ } + malgoContextUninit = func(*malgo.AllocatedContext) error { + ctxUninitCalls++ + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + p, err := StartPlayback(ctx) + if err != nil { + t.Fatalf("StartPlayback() error: %v", err) + } + if p == nil { + t.Fatal("StartPlayback() returned nil playback") + } + if p.maxBuf != SampleRate*maxPlaybackBufferSeconds { + t.Fatalf("maxBuf = %d, want %d", p.maxBuf, SampleRate*maxPlaybackBufferSeconds) + } + if callbacks.Data == nil { + t.Fatal("expected playback callback to be set") + } + + p.Write([]int16{7, 8}) + out := make([]byte, 6) + callbacks.Data(out, nil, 0) + if got := decodeInt16LE(out, 0); got != 7 { + t.Fatalf("sample0 = %d, want 7", got) + } + if got := decodeInt16LE(out, 1); got != 8 { + t.Fatalf("sample1 = %d, want 8", got) + } + if got := decodeInt16LE(out, 2); got != 0 { + t.Fatalf("sample2 = %d, want 0", got) + } + + cancel() + waitFor(t, 200*time.Millisecond, func() bool { + return deviceUninitCalls == 1 && ctxUninitCalls == 1 + }) + + if err := p.Close(); err != nil { + t.Fatalf("playback.Close() error: %v", err) + } + if deviceUninitCalls != 1 || ctxUninitCalls != 1 { + t.Fatalf("close should be idempotent; got device=%d ctx=%d", deviceUninitCalls, ctxUninitCalls) + } +} diff --git a/scripts/coverage.sh b/scripts/coverage.sh index e9b108d..b586b64 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -4,6 +4,7 @@ set -euo pipefail SHOW_ZERO_ONLY=false FAIL_ON_ZERO=false +OPEN_HTML=false # Function to display help print_help() { @@ -13,13 +14,8 @@ Usage: $0 [OPTIONS] Options: --zero Show only functions with 0.0% coverage --fail-on-zero Fail (exit non-zero) if any function has 0.0% coverage + --html Generate coverage.html --help Show this help message and exit - -Examples: - $0 # Run tests and show full coverage - $0 --zero # Show only functions with 0% coverage - $0 --fail-on-zero # Fail if any function has 0% coverage (CI-friendly) - $0 --zero --fail-on-zero # Show 0% functions and fail if any exist EOF } @@ -34,6 +30,10 @@ while [[ $# -gt 0 ]]; do FAIL_ON_ZERO=true shift ;; + --html) + OPEN_HTML=true + shift + ;; --help) print_help exit 0 @@ -46,7 +46,7 @@ while [[ $# -gt 0 ]]; do esac done -mapfile -t PKGS < <(go list ./... | grep -vE '/cmd/voiced$') +mapfile -t PKGS < <(go list ./...) # Run tests with coverage and preserve failure output for diagnosability. if [[ "$SHOW_ZERO_ONLY" == true || "$FAIL_ON_ZERO" == true ]]; then @@ -77,3 +77,18 @@ else # Full coverage output go tool cover -func=coverage.out fi + +if [[ "$OPEN_HTML" == true ]]; then + HTML_REPORT=coverage.html + go tool cover -html=coverage.out -o "$HTML_REPORT" + + if command -v xdg-open >/dev/null 2>&1; then + xdg-open "$HTML_REPORT" >/dev/null 2>&1 || true + elif command -v open >/dev/null 2>&1; then + open "$HTML_REPORT" >/dev/null 2>&1 || true + elif command -v start >/dev/null 2>&1; then + start "$HTML_REPORT" >/dev/null 2>&1 || true + fi + + echo "HTML report generated: $HTML_REPORT" +fi