1fdeda904b
* feat: add voice mode for chat input Adds a voice input button to the chat composer with three transcription engines: in-browser Web Speech API, in-browser Whisper via @huggingface/transformers, and a server-side OpenAI Whisper fallback gated on KANDEV_VOICE_OPENAI_API_KEY. User-configurable from a new /settings/voice-mode page: - Master on/off toggle (defaults to on; opt-out, not opt-in) - Engine preference (auto / Web Speech / Whisper Web / Whisper Server) - Language hint (auto-detect or BCP-47 pinned) - Activation mode (click-to-toggle or hold-to-talk) - Auto-send after transcription - Whisper Web model size (tiny / base / small) - Editable Cmd+Shift+M keyboard shortcut Voice settings live under user_settings.voice_mode as a nested JSON object — no schema migration. Setting changes broadcast via the existing user.settings.updated WS event so any open chat tab updates live. The mic button is rendered on the right of the composer alongside the send button (round primary fill; destructive fill with pulse ring while recording). On browsers without a usable engine (e.g. iOS Chrome over HTTP) the button stays visible as a muted greyed icon — tapping it surfaces a toast with the reason (typically a missing secure context). Whisper Web runs in a Web Worker with fp16 decoder weights (avoids the MatMulNBits scale-missing crash that q4/q8 hit on WASM). Models are pulled from the onnx-community/* mirror and cached by transformers.js in the Cache Storage on first use. * fix: address voice mode PR review feedback and E2E selector clash Bot-review fixes from #1159: - Map http.MaxBytesError from c.FormFile to 413 instead of bare 400. - Strip BCP-47 region suffix before passing language hint to Whisper Web. - Recover from worker crash: terminate + null worker + reset ready flag so next init/transcribe creates a fresh worker instead of hanging. - Read live store state via storeApi.getState() in voice-mode settings savers to avoid stale-closure clobbering concurrent settings updates. - Detach Web Speech callbacks before abort() so trailing onend/onerror events don't mutate hook state after cancel(). - Spread caller init before method/body in voice-api fetch so a caller- supplied init can't override the multipart POST. - Replace t.Fatalf with t.Errorf+return inside test HTTP handler goroutine (FailNow from non-test goroutine is unsafe). - Add Enabled/AutoSend zeroing assertions in partial-update test so the bool-overwrite-on-PATCH behavior is locked in explicitly. E2E fix: - toolbar-overflow spec used button.rounded-full which now matches both the send and voice buttons (strict-mode violation). Switched to the submit-message-button testid for an unambiguous locator. * fix: scope voice settings rollback + whisper worker error to changed keys Round 2 of PR review feedback on #1159: - voice-mode settings rollback now restores only the keys patched by the failing request (not the whole snapshot), preserving concurrent edits to unrelated voiceMode fields. - Shortcut rollback diffs previous vs next and reverts only the changed keys (restore prior value or delete if absent), preserving other concurrent shortcut edits. - Whisper worker error handler captures the worker reference at attach time and only clears refs when this.worker still matches it — a stale error from a previously replaced worker no longer nukes the active one. - Update stale whisper-web-models JSDoc that still pointed at Xenova mirrors. * fix: voice mode progress scale, hold-mode race, race-aware rollback Round 3 of PR review feedback on #1159: - Normalise Whisper progress to 0–1 in ensureWhisperClient (transformers.js emits 0–100, the button's display + the `ready: 1` convention expect 0–1 — fixes the 5000% mid-download display). - Claim driverRef synchronously at the top of finishCapture so concurrent pointerup + pointerleave invocations early-return instead of racing and clobbering a freshly-started recording's ref. - Race-aware rollback: voice-mode and shortcut catch branches now skip reverting a key when latest[key] !== next[key]. A failed earlier request no longer overwrites a later successful save to the same key. - Update the Worker wire-protocol comment to show onnx-community/whisper-base instead of the stale Xenova/* example. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
555 lines
18 KiB
Go
555 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/user/models"
|
|
)
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
func makeLayouts(n int) []models.SavedLayout {
|
|
layouts := make([]models.SavedLayout, n)
|
|
for i := range layouts {
|
|
layouts[i] = models.SavedLayout{
|
|
ID: fmt.Sprintf("layout-%d", i),
|
|
Name: fmt.Sprintf("Layout %d", i),
|
|
IsDefault: false,
|
|
Layout: json.RawMessage(`{}`),
|
|
CreatedAt: "2026-01-01T00:00:00Z",
|
|
}
|
|
}
|
|
return layouts
|
|
}
|
|
|
|
func TestApplyBasicSettings_ReleaseNotes(t *testing.T) {
|
|
t.Run("nil fields leave settings unchanged", func(t *testing.T) {
|
|
settings := &models.UserSettings{
|
|
ShowReleaseNotification: true,
|
|
ReleaseNotesLastSeenVersion: "1.0.0",
|
|
}
|
|
req := &UpdateUserSettingsRequest{}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.ShowReleaseNotification != true {
|
|
t.Fatalf("expected ShowReleaseNotification=true, got %v", settings.ShowReleaseNotification)
|
|
}
|
|
if settings.ReleaseNotesLastSeenVersion != "1.0.0" {
|
|
t.Fatalf("expected ReleaseNotesLastSeenVersion=1.0.0, got %s", settings.ReleaseNotesLastSeenVersion)
|
|
}
|
|
})
|
|
|
|
t.Run("ShowReleaseNotification set to false", func(t *testing.T) {
|
|
settings := &models.UserSettings{ShowReleaseNotification: true}
|
|
req := &UpdateUserSettingsRequest{ShowReleaseNotification: ptr(false)}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.ShowReleaseNotification != false {
|
|
t.Fatalf("expected ShowReleaseNotification=false, got %v", settings.ShowReleaseNotification)
|
|
}
|
|
})
|
|
|
|
t.Run("ShowReleaseNotification set to true", func(t *testing.T) {
|
|
settings := &models.UserSettings{ShowReleaseNotification: false}
|
|
req := &UpdateUserSettingsRequest{ShowReleaseNotification: ptr(true)}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.ShowReleaseNotification != true {
|
|
t.Fatalf("expected ShowReleaseNotification=true, got %v", settings.ShowReleaseNotification)
|
|
}
|
|
})
|
|
|
|
t.Run("ReleaseNotesLastSeenVersion updated", func(t *testing.T) {
|
|
settings := &models.UserSettings{ReleaseNotesLastSeenVersion: "1.0.0"}
|
|
req := &UpdateUserSettingsRequest{ReleaseNotesLastSeenVersion: ptr("2.0.0")}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.ReleaseNotesLastSeenVersion != "2.0.0" {
|
|
t.Fatalf("expected ReleaseNotesLastSeenVersion=2.0.0, got %s", settings.ReleaseNotesLastSeenVersion)
|
|
}
|
|
})
|
|
|
|
t.Run("ReleaseNotesLastSeenVersion cleared with empty string", func(t *testing.T) {
|
|
settings := &models.UserSettings{ReleaseNotesLastSeenVersion: "1.0.0"}
|
|
req := &UpdateUserSettingsRequest{ReleaseNotesLastSeenVersion: ptr("")}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.ReleaseNotesLastSeenVersion != "" {
|
|
t.Fatalf("expected empty ReleaseNotesLastSeenVersion, got %s", settings.ReleaseNotesLastSeenVersion)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestApplyBasicSettings_TerminalFontFamily(t *testing.T) {
|
|
t.Run("nil leaves settings unchanged", func(t *testing.T) {
|
|
settings := &models.UserSettings{TerminalFontFamily: "Fira Code"}
|
|
req := &UpdateUserSettingsRequest{}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.TerminalFontFamily != "Fira Code" {
|
|
t.Fatalf("expected TerminalFontFamily=Fira Code, got %s", settings.TerminalFontFamily)
|
|
}
|
|
})
|
|
|
|
t.Run("sets value when provided", func(t *testing.T) {
|
|
settings := &models.UserSettings{}
|
|
req := &UpdateUserSettingsRequest{TerminalFontFamily: ptr("JetBrains Mono")}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.TerminalFontFamily != "JetBrains Mono" {
|
|
t.Fatalf("expected TerminalFontFamily=JetBrains Mono, got %s", settings.TerminalFontFamily)
|
|
}
|
|
})
|
|
|
|
t.Run("trims whitespace", func(t *testing.T) {
|
|
settings := &models.UserSettings{}
|
|
req := &UpdateUserSettingsRequest{TerminalFontFamily: ptr(" Fira Code ")}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.TerminalFontFamily != "Fira Code" {
|
|
t.Fatalf("expected TerminalFontFamily=Fira Code, got %q", settings.TerminalFontFamily)
|
|
}
|
|
})
|
|
|
|
t.Run("clears with empty string", func(t *testing.T) {
|
|
settings := &models.UserSettings{TerminalFontFamily: "Fira Code"}
|
|
req := &UpdateUserSettingsRequest{TerminalFontFamily: ptr("")}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.TerminalFontFamily != "" {
|
|
t.Fatalf("expected empty TerminalFontFamily, got %s", settings.TerminalFontFamily)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestApplyChangesPanelLayout(t *testing.T) {
|
|
t.Run("nil leaves settings unchanged", func(t *testing.T) {
|
|
settings := &models.UserSettings{ChangesPanelLayout: "tree"}
|
|
req := &UpdateUserSettingsRequest{}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.ChangesPanelLayout != "tree" {
|
|
t.Fatalf("expected ChangesPanelLayout=tree, got %s", settings.ChangesPanelLayout)
|
|
}
|
|
})
|
|
|
|
t.Run("sets tree when provided", func(t *testing.T) {
|
|
settings := &models.UserSettings{}
|
|
req := &UpdateUserSettingsRequest{ChangesPanelLayout: ptr("tree")}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.ChangesPanelLayout != "tree" {
|
|
t.Fatalf("expected ChangesPanelLayout=tree, got %s", settings.ChangesPanelLayout)
|
|
}
|
|
})
|
|
|
|
t.Run("sets flat when provided", func(t *testing.T) {
|
|
settings := &models.UserSettings{ChangesPanelLayout: "tree"}
|
|
req := &UpdateUserSettingsRequest{ChangesPanelLayout: ptr("flat")}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.ChangesPanelLayout != "flat" {
|
|
t.Fatalf("expected ChangesPanelLayout=flat, got %s", settings.ChangesPanelLayout)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects invalid value", func(t *testing.T) {
|
|
settings := &models.UserSettings{}
|
|
req := &UpdateUserSettingsRequest{ChangesPanelLayout: ptr("grid")}
|
|
if err := applyBasicSettings(settings, req); err == nil {
|
|
t.Fatal("expected error for invalid layout, got nil")
|
|
}
|
|
})
|
|
|
|
t.Run("trims whitespace before validation", func(t *testing.T) {
|
|
settings := &models.UserSettings{}
|
|
req := &UpdateUserSettingsRequest{ChangesPanelLayout: ptr(" tree ")}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.ChangesPanelLayout != "tree" {
|
|
t.Fatalf("expected ChangesPanelLayout=tree, got %q", settings.ChangesPanelLayout)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestApplyBasicSettings_TerminalFontSize(t *testing.T) {
|
|
t.Run("nil leaves settings unchanged", func(t *testing.T) {
|
|
settings := &models.UserSettings{TerminalFontSize: 14}
|
|
req := &UpdateUserSettingsRequest{}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.TerminalFontSize != 14 {
|
|
t.Fatalf("expected TerminalFontSize=14, got %d", settings.TerminalFontSize)
|
|
}
|
|
})
|
|
|
|
t.Run("sets value when provided", func(t *testing.T) {
|
|
settings := &models.UserSettings{}
|
|
req := &UpdateUserSettingsRequest{TerminalFontSize: ptr(16)}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.TerminalFontSize != 16 {
|
|
t.Fatalf("expected TerminalFontSize=16, got %d", settings.TerminalFontSize)
|
|
}
|
|
})
|
|
|
|
t.Run("value below 8 returns error", func(t *testing.T) {
|
|
settings := &models.UserSettings{}
|
|
req := &UpdateUserSettingsRequest{TerminalFontSize: ptr(7)}
|
|
if err := applyBasicSettings(settings, req); err == nil {
|
|
t.Fatal("expected error for font size 7, got nil")
|
|
}
|
|
})
|
|
|
|
t.Run("value above 24 returns error", func(t *testing.T) {
|
|
settings := &models.UserSettings{}
|
|
req := &UpdateUserSettingsRequest{TerminalFontSize: ptr(25)}
|
|
if err := applyBasicSettings(settings, req); err == nil {
|
|
t.Fatal("expected error for font size 25, got nil")
|
|
}
|
|
})
|
|
|
|
t.Run("resets to 0 when 0 is provided", func(t *testing.T) {
|
|
settings := &models.UserSettings{TerminalFontSize: 14}
|
|
req := &UpdateUserSettingsRequest{TerminalFontSize: ptr(0)}
|
|
if err := applyBasicSettings(settings, req); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.TerminalFontSize != 0 {
|
|
t.Fatalf("expected TerminalFontSize=0, got %d", settings.TerminalFontSize)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestApplySavedLayouts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
req *UpdateUserSettingsRequest
|
|
wantErr string
|
|
wantCount int
|
|
wantApplied bool
|
|
}{
|
|
{
|
|
name: "nil request is a no-op",
|
|
req: &UpdateUserSettingsRequest{SavedLayouts: nil},
|
|
wantApplied: false,
|
|
},
|
|
{
|
|
name: "empty slice is accepted",
|
|
req: &UpdateUserSettingsRequest{SavedLayouts: ptr([]models.SavedLayout{})},
|
|
wantCount: 0,
|
|
wantApplied: true,
|
|
},
|
|
{
|
|
name: "valid single layout is applied",
|
|
req: &UpdateUserSettingsRequest{
|
|
SavedLayouts: ptr(makeLayouts(1)),
|
|
},
|
|
wantCount: 1,
|
|
wantApplied: true,
|
|
},
|
|
{
|
|
name: "exactly max layouts is accepted",
|
|
req: &UpdateUserSettingsRequest{
|
|
SavedLayouts: ptr(makeLayouts(maxSavedLayouts)),
|
|
},
|
|
wantCount: maxSavedLayouts,
|
|
wantApplied: true,
|
|
},
|
|
{
|
|
name: "exceeding max layouts returns error",
|
|
req: &UpdateUserSettingsRequest{
|
|
SavedLayouts: ptr(makeLayouts(maxSavedLayouts + 1)),
|
|
},
|
|
wantErr: fmt.Sprintf("saved_layouts: max %d layouts allowed", maxSavedLayouts),
|
|
},
|
|
{
|
|
name: "empty name returns error",
|
|
req: &UpdateUserSettingsRequest{
|
|
SavedLayouts: ptr([]models.SavedLayout{
|
|
{ID: "l1", Name: "", Layout: json.RawMessage(`{}`)},
|
|
}),
|
|
},
|
|
wantErr: "saved_layouts: layout name must not be empty",
|
|
},
|
|
{
|
|
name: "whitespace-only name returns error",
|
|
req: &UpdateUserSettingsRequest{
|
|
SavedLayouts: ptr([]models.SavedLayout{
|
|
{ID: "l1", Name: " ", Layout: json.RawMessage(`{}`)},
|
|
}),
|
|
},
|
|
wantErr: "saved_layouts: layout name must not be empty",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
settings := &models.UserSettings{
|
|
SavedLayouts: makeLayouts(2), // pre-existing layouts
|
|
}
|
|
err := applySavedLayouts(settings, tt.req)
|
|
|
|
if tt.wantErr != "" {
|
|
if err == nil {
|
|
t.Fatalf("expected error %q, got nil", tt.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if !tt.wantApplied {
|
|
// Nil request should leave settings unchanged
|
|
if len(settings.SavedLayouts) != 2 {
|
|
t.Fatalf("expected settings unchanged (2 layouts), got %d", len(settings.SavedLayouts))
|
|
}
|
|
return
|
|
}
|
|
|
|
if len(settings.SavedLayouts) != tt.wantCount {
|
|
t.Fatalf("expected %d layouts, got %d", tt.wantCount, len(settings.SavedLayouts))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func makeSidebarViews(n int) []models.SidebarView {
|
|
views := make([]models.SidebarView, n)
|
|
for i := range views {
|
|
views[i] = models.SidebarView{
|
|
ID: fmt.Sprintf("view-%d", i),
|
|
Name: fmt.Sprintf("View %d", i),
|
|
Filters: []models.SidebarViewClause{},
|
|
Sort: models.SidebarViewSort{Key: "state", Direction: "asc"},
|
|
Group: "repository",
|
|
CollapsedGroups: []string{},
|
|
}
|
|
}
|
|
return views
|
|
}
|
|
|
|
func TestApplySidebarViews(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
req *UpdateUserSettingsRequest
|
|
wantErr string
|
|
wantCount int
|
|
wantApplied bool
|
|
}{
|
|
{
|
|
name: "nil request is a no-op",
|
|
req: &UpdateUserSettingsRequest{SidebarViews: nil},
|
|
wantApplied: false,
|
|
},
|
|
{
|
|
name: "empty slice is accepted",
|
|
req: &UpdateUserSettingsRequest{SidebarViews: ptr([]models.SidebarView{})},
|
|
wantCount: 0,
|
|
wantApplied: true,
|
|
},
|
|
{
|
|
name: "valid single view is applied",
|
|
req: &UpdateUserSettingsRequest{SidebarViews: ptr(makeSidebarViews(1))},
|
|
wantCount: 1,
|
|
wantApplied: true,
|
|
},
|
|
{
|
|
name: "exactly max views is accepted",
|
|
req: &UpdateUserSettingsRequest{SidebarViews: ptr(makeSidebarViews(maxSidebarViews))},
|
|
wantCount: maxSidebarViews,
|
|
wantApplied: true,
|
|
},
|
|
{
|
|
name: "exceeding max views returns error",
|
|
req: &UpdateUserSettingsRequest{SidebarViews: ptr(makeSidebarViews(maxSidebarViews + 1))},
|
|
wantErr: fmt.Sprintf("sidebar_views: max %d views allowed", maxSidebarViews),
|
|
},
|
|
{
|
|
name: "empty id returns error",
|
|
req: &UpdateUserSettingsRequest{SidebarViews: ptr([]models.SidebarView{
|
|
{ID: "", Name: "X"},
|
|
})},
|
|
wantErr: "sidebar_views: view id must not be empty",
|
|
},
|
|
{
|
|
name: "empty name returns error",
|
|
req: &UpdateUserSettingsRequest{SidebarViews: ptr([]models.SidebarView{
|
|
{ID: "v1", Name: ""},
|
|
})},
|
|
wantErr: "sidebar_views: view name must not be empty",
|
|
},
|
|
{
|
|
name: "duplicate ids return error",
|
|
req: &UpdateUserSettingsRequest{SidebarViews: ptr([]models.SidebarView{
|
|
{ID: "v1", Name: "A"},
|
|
{ID: "v1", Name: "B"},
|
|
})},
|
|
wantErr: `sidebar_views: duplicate view id "v1"`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
settings := &models.UserSettings{SidebarViews: makeSidebarViews(2)}
|
|
err := applySidebarViews(settings, tt.req)
|
|
|
|
if tt.wantErr != "" {
|
|
if err == nil {
|
|
t.Fatalf("expected error %q, got nil", tt.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !tt.wantApplied {
|
|
if len(settings.SidebarViews) != 2 {
|
|
t.Fatalf("expected settings unchanged (2 views), got %d", len(settings.SidebarViews))
|
|
}
|
|
return
|
|
}
|
|
if len(settings.SidebarViews) != tt.wantCount {
|
|
t.Fatalf("expected %d views, got %d", tt.wantCount, len(settings.SidebarViews))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyVoiceMode(t *testing.T) {
|
|
t.Run("nil value leaves settings unchanged", func(t *testing.T) {
|
|
settings := &models.UserSettings{
|
|
VoiceMode: models.VoiceModeSettings{Engine: "webSpeech", Language: "en-US"},
|
|
}
|
|
if err := applyVoiceMode(settings, nil); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.VoiceMode.Engine != "webSpeech" || settings.VoiceMode.Language != "en-US" {
|
|
t.Fatalf("expected unchanged, got %+v", settings.VoiceMode)
|
|
}
|
|
})
|
|
|
|
t.Run("happy path: applies a full update", func(t *testing.T) {
|
|
settings := &models.UserSettings{}
|
|
err := applyVoiceMode(settings, &models.VoiceModeSettings{
|
|
Enabled: true,
|
|
Engine: "whisperWeb",
|
|
Language: "pt-PT",
|
|
Mode: "hold",
|
|
AutoSend: true,
|
|
WhisperWebModel: "small",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
want := models.VoiceModeSettings{
|
|
Enabled: true,
|
|
Engine: "whisperWeb",
|
|
Language: "pt-PT",
|
|
Mode: "hold",
|
|
AutoSend: true,
|
|
WhisperWebModel: "small",
|
|
}
|
|
if settings.VoiceMode != want {
|
|
t.Fatalf("expected %+v, got %+v", want, settings.VoiceMode)
|
|
}
|
|
})
|
|
|
|
t.Run("enabled=false is honored (user disabled the feature)", func(t *testing.T) {
|
|
settings := &models.UserSettings{VoiceMode: models.VoiceModeSettings{Enabled: true}}
|
|
if err := applyVoiceMode(settings, &models.VoiceModeSettings{Enabled: false}); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.VoiceMode.Enabled {
|
|
t.Fatalf("expected Enabled=false after disable, got true")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid engine is rejected", func(t *testing.T) {
|
|
err := applyVoiceMode(&models.UserSettings{}, &models.VoiceModeSettings{Engine: "bogus"})
|
|
if err == nil || !strings.Contains(err.Error(), "voice_mode.engine") {
|
|
t.Fatalf("expected engine validation error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid mode is rejected", func(t *testing.T) {
|
|
err := applyVoiceMode(&models.UserSettings{}, &models.VoiceModeSettings{Mode: "tap"})
|
|
if err == nil || !strings.Contains(err.Error(), "voice_mode.mode") {
|
|
t.Fatalf("expected mode validation error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid whisper_web_model is rejected", func(t *testing.T) {
|
|
err := applyVoiceMode(&models.UserSettings{}, &models.VoiceModeSettings{WhisperWebModel: "huge"})
|
|
if err == nil || !strings.Contains(err.Error(), "voice_mode.whisper_web_model") {
|
|
t.Fatalf("expected model validation error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("partial update preserves string fields but zeroes booleans", func(t *testing.T) {
|
|
settings := &models.UserSettings{
|
|
VoiceMode: models.VoiceModeSettings{
|
|
Enabled: true,
|
|
Engine: "whisperServer",
|
|
Language: "en-GB",
|
|
Mode: "toggle",
|
|
AutoSend: true,
|
|
WhisperWebModel: "tiny",
|
|
},
|
|
}
|
|
// Empty strings on the new payload mean "no change" for the string fields,
|
|
// but bools have no "unset" sentinel — every PATCH carries them. The settings
|
|
// UI always sends the full VoiceMode object so partial updates here would
|
|
// only happen in test or hand-crafted requests; the assertions below lock in
|
|
// that explicit behavior so it doesn't drift silently.
|
|
err := applyVoiceMode(settings, &models.VoiceModeSettings{Engine: "webSpeech"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if settings.VoiceMode.Engine != "webSpeech" {
|
|
t.Fatalf("expected engine=webSpeech, got %q", settings.VoiceMode.Engine)
|
|
}
|
|
if settings.VoiceMode.Language != "en-GB" {
|
|
t.Fatalf("expected language preserved, got %q", settings.VoiceMode.Language)
|
|
}
|
|
if settings.VoiceMode.Mode != "toggle" {
|
|
t.Fatalf("expected mode preserved, got %q", settings.VoiceMode.Mode)
|
|
}
|
|
if settings.VoiceMode.WhisperWebModel != "tiny" {
|
|
t.Fatalf("expected whisper model preserved, got %q", settings.VoiceMode.WhisperWebModel)
|
|
}
|
|
if settings.VoiceMode.Enabled {
|
|
t.Fatalf("expected Enabled zeroed on partial update, got true")
|
|
}
|
|
if settings.VoiceMode.AutoSend {
|
|
t.Fatalf("expected AutoSend zeroed on partial update, got true")
|
|
}
|
|
})
|
|
}
|