package main
|
|
import (
|
"context"
|
"errors"
|
"strings"
|
"sync/atomic"
|
"testing"
|
"time"
|
|
"voicesnap/internal/config"
|
"voicesnap/internal/engine"
|
"voicesnap/internal/history"
|
"voicesnap/internal/overlay"
|
"voicesnap/internal/textoutput"
|
"voicesnap/internal/userdict"
|
)
|
|
func TestLiveCaptionDisplayTextFitsOverlayBuffer(t *testing.T) {
|
input := strings.Repeat("这是一句实时字幕", 12)
|
got := liveCaptionDisplayText(input)
|
if got == "" {
|
t.Fatal("caption should not be empty")
|
}
|
if len([]byte(got)) > liveCaptionMaxBytes {
|
t.Fatalf("caption bytes = %d, want <= %d", len([]byte(got)), liveCaptionMaxBytes)
|
}
|
if !strings.HasPrefix(got, "...") {
|
t.Fatalf("caption = %q, want leading truncation marker", got)
|
}
|
}
|
|
func TestReleaseTailCaptureDelayUsesEngineCapability(t *testing.T) {
|
a := &App{}
|
a.replaceEngine(tailCaptureTestEngine{delay: 300 * time.Millisecond})
|
|
if got := a.releaseTailCaptureDelay(); got != 300*time.Millisecond {
|
t.Fatalf("releaseTailCaptureDelay() = %v, want 300ms", got)
|
}
|
}
|
|
func TestReleaseTailCaptureDelayCanBeDisabledByEngineCapability(t *testing.T) {
|
a := &App{}
|
a.replaceEngine(tailCaptureTestEngine{delay: 0})
|
|
if got := a.releaseTailCaptureDelay(); got != 0 {
|
t.Fatalf("releaseTailCaptureDelay() = %v, want 0", got)
|
}
|
}
|
|
func TestReleaseTailCaptureDelayDefaultsToNoProtectionWindow(t *testing.T) {
|
a := &App{}
|
a.replaceEngine(plainTestEngine{})
|
|
if got := a.releaseTailCaptureDelay(); got != 0 {
|
t.Fatalf("releaseTailCaptureDelay() = %v, want 0", got)
|
}
|
}
|
|
func TestReleaseTailCaptureDelayDisabledWithoutEngine(t *testing.T) {
|
a := &App{}
|
a.replaceEngine(nil)
|
|
if got := a.releaseTailCaptureDelay(); got != 0 {
|
t.Fatalf("releaseTailCaptureDelay() = %v, want 0 without engine", got)
|
}
|
}
|
|
func TestSlowRecognitionReason(t *testing.T) {
|
tests := []struct {
|
name string
|
recognizeMS int64
|
totalMS int64
|
want string
|
}{
|
{name: "fast", recognizeMS: 2000, totalMS: 2500, want: ""},
|
{name: "recognize", recognizeMS: 2001, totalMS: 2400, want: "recognize"},
|
{name: "total", recognizeMS: 1000, totalMS: 2501, want: "total"},
|
{name: "both", recognizeMS: 2001, totalMS: 2501, want: "recognize,total"},
|
}
|
|
for _, tt := range tests {
|
t.Run(tt.name, func(t *testing.T) {
|
if got := slowRecognitionReason(tt.recognizeMS, tt.totalMS); got != tt.want {
|
t.Fatalf("slowRecognitionReason(%d, %d) = %q, want %q", tt.recognizeMS, tt.totalMS, got, tt.want)
|
}
|
})
|
}
|
}
|
|
func TestHoldPreCaptureUsesEngineCapability(t *testing.T) {
|
a := &App{}
|
a.replaceEngine(holdPreCaptureTestEngine{enabled: true})
|
|
if !a.holdPreCaptureEnabled.Load() {
|
t.Fatal("hold pre-capture should be enabled by engine capability")
|
}
|
}
|
|
func TestHoldPreCaptureCanBeDisabledByEngineCapability(t *testing.T) {
|
a := &App{}
|
a.replaceEngine(holdPreCaptureTestEngine{enabled: false})
|
|
if a.holdPreCaptureEnabled.Load() {
|
t.Fatal("hold pre-capture should respect engine capability false")
|
}
|
}
|
|
func TestHoldPreCaptureDefaultsToTrueForReadyEngine(t *testing.T) {
|
a := &App{}
|
a.replaceEngine(plainTestEngine{})
|
|
if !a.holdPreCaptureEnabled.Load() {
|
t.Fatal("hold pre-capture should default to true for ready engines")
|
}
|
}
|
|
func TestHoldPreCaptureDisabledWithoutEngine(t *testing.T) {
|
a := &App{}
|
a.replaceEngine(nil)
|
|
if a.holdPreCaptureEnabled.Load() {
|
t.Fatal("hold pre-capture should be disabled without an engine")
|
}
|
}
|
|
func TestHasEngineReflectsCurrentEngine(t *testing.T) {
|
a := &App{}
|
if a.hasEngine() {
|
t.Fatal("new app should not report an engine")
|
}
|
|
a.replaceEngine(plainTestEngine{})
|
if !a.hasEngine() {
|
t.Fatal("app should report an engine after replaceEngine")
|
}
|
|
a.replaceEngine(nil)
|
if a.hasEngine() {
|
t.Fatal("app should not report an engine after clearing")
|
}
|
}
|
|
func TestInitEngineSerializesAndDiscardsStaleEngine(t *testing.T) {
|
first := &closeTrackingTestEngine{name: "first"}
|
second := &closeTrackingTestEngine{name: "second"}
|
firstStarted := make(chan struct{})
|
allowFirst := make(chan struct{})
|
calls := atomic.Int32{}
|
|
a := &App{
|
engineFactory: func(initID uint64) (engine.Engine, engineMetadata, bool, error) {
|
call := calls.Add(1)
|
switch call {
|
case 1:
|
close(firstStarted)
|
<-allowFirst
|
return first, engineMetadata{InitID: initID, ResolvedModelID: "first", HardwareInfo: "first"}, true, nil
|
case 2:
|
return second, engineMetadata{InitID: initID, ResolvedModelID: "second", HardwareInfo: "second"}, true, nil
|
default:
|
t.Fatalf("unexpected engine factory call %d", call)
|
return nil, engineMetadata{}, false, nil
|
}
|
},
|
}
|
|
doneFirst := make(chan struct{})
|
go func() {
|
a.initEngine()
|
close(doneFirst)
|
}()
|
|
select {
|
case <-firstStarted:
|
case <-time.After(time.Second):
|
t.Fatal("first engine init did not start")
|
}
|
|
doneSecond := make(chan struct{})
|
go func() {
|
a.initEngine()
|
close(doneSecond)
|
}()
|
|
waitForCondition(t, time.Second, func() bool {
|
return a.engineInitSeq.Load() == 2
|
})
|
close(allowFirst)
|
|
select {
|
case <-doneFirst:
|
case <-time.After(time.Second):
|
t.Fatal("first engine init did not finish")
|
}
|
select {
|
case <-doneSecond:
|
case <-time.After(time.Second):
|
t.Fatal("second engine init did not finish")
|
}
|
|
a.engineMu.Lock()
|
gotEngine := a.eng
|
gotMeta := a.engineMeta
|
a.engineMu.Unlock()
|
|
if gotEngine != second {
|
t.Fatalf("final engine = %#v, want second", gotEngine)
|
}
|
if !first.closed.Load() {
|
t.Fatal("stale first engine should be closed")
|
}
|
if second.closed.Load() {
|
t.Fatal("current second engine should remain open")
|
}
|
if gotMeta.ResolvedModelID != "second" || gotMeta.InitID != 2 {
|
t.Fatalf("engine metadata = %+v, want second init metadata", gotMeta)
|
}
|
}
|
|
func waitForCondition(t *testing.T, timeout time.Duration, fn func() bool) {
|
t.Helper()
|
deadline := time.Now().Add(timeout)
|
for time.Now().Before(deadline) {
|
if fn() {
|
return
|
}
|
time.Sleep(10 * time.Millisecond)
|
}
|
t.Fatal("condition was not met before timeout")
|
}
|
|
func TestShouldStartHoldPreCaptureLocked(t *testing.T) {
|
a := &App{}
|
a.holdPreCaptureEnabled.Store(true)
|
|
if !a.shouldStartHoldPreCaptureLocked() {
|
t.Fatal("expected hold pre-capture to start when enabled and idle")
|
}
|
|
a.isRecording = true
|
if a.shouldStartHoldPreCaptureLocked() {
|
t.Fatal("should not pre-capture while already recording")
|
}
|
a.isRecording = false
|
|
a.isStoppingRecording = true
|
if a.shouldStartHoldPreCaptureLocked() {
|
t.Fatal("should not pre-capture while stopping")
|
}
|
a.isStoppingRecording = false
|
|
a.isFreetalking = true
|
if a.shouldStartHoldPreCaptureLocked() {
|
t.Fatal("should not pre-capture while tap recording is active")
|
}
|
a.isFreetalking = false
|
|
a.lastStopTime = time.Now()
|
if a.shouldStartHoldPreCaptureLocked() {
|
t.Fatal("should not pre-capture during post-stop debounce")
|
}
|
}
|
|
func TestHoldPreCaptureAutoConfirmsWithoutAdditionalKeyDown(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
a.hk.(*fakeHotkeyListener).down.Store(true)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && a.hotkeyActive
|
})
|
|
if got := a.recorder.(*fakeAppRecorder).starts.Load(); got != 1 {
|
t.Fatalf("recorder starts = %d, want 1", got)
|
}
|
if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusRecording) {
|
t.Fatalf("indicator status = %q, want %q", got, overlay.StatusRecording)
|
}
|
}
|
|
func TestHoldPreCaptureTimerConfirmsWhileRecorderStartBlocked(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
a.hk.(*fakeHotkeyListener).down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
blockStart := make(chan struct{})
|
recorder.startBlock = blockStart
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
return recorder.starts.Load() == 1
|
})
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && a.hotkeyActive
|
})
|
|
if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusRecording) {
|
t.Fatalf("indicator status = %q, want %q while recorder start is blocked", got, overlay.StatusRecording)
|
}
|
close(blockStart)
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isHoldRecorderStarting
|
})
|
}
|
|
func TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionWhileRecorderStartBlocked(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
listener := a.hk.(*fakeHotkeyListener)
|
listener.down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
blockStart := make(chan struct{})
|
recorder.startBlock = blockStart
|
recorder.samples = []float32{0.2, 0.1, -0.1, -0.2}
|
recorder.hasVoice.Store(true)
|
eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
|
a.replaceEngine(eng)
|
paster := &fakeAppPaster{}
|
a.outputRouter = textoutput.NewRouter(paster)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
|
})
|
|
listener.down.Store(false)
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(false, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
return paster.pasted.Load() == "PrivateVoice AX POC"
|
})
|
|
if got := recorder.stopAndGetSamples.Load(); got != 1 {
|
t.Fatalf("StopAndGetSamples while recorder start is blocked = %d, want 1", got)
|
}
|
if got := eng.recognizes.Load(); got != 1 {
|
t.Fatalf("recognizes = %d, want 1", got)
|
}
|
a.mu.Lock()
|
if a.isHoldStopPending {
|
t.Fatal("normal release while recorder is starting should not mark cancel-style pending stop")
|
}
|
a.mu.Unlock()
|
|
close(blockStart)
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
|
})
|
if got := recorder.stops.Load(); got != 0 {
|
t.Fatalf("app-level fake recorder stops = %d, want 0; native cleanup is covered by audio recorder tests", got)
|
}
|
}
|
|
func TestHoldPreCaptureReleaseAfterConfirmRunsRecognitionBeforeRecorderStartEntered(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
listener := a.hk.(*fakeHotkeyListener)
|
listener.down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
preStartBlock := make(chan struct{})
|
recorder.preStartBlock = preStartBlock
|
recorder.samples = []float32{0.2, 0.1, -0.1, -0.2}
|
recorder.hasVoice.Store(true)
|
eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
|
a.replaceEngine(eng)
|
paster := &fakeAppPaster{}
|
a.outputRouter = textoutput.NewRouter(paster)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
|
})
|
if got := recorder.starts.Load(); got != 0 {
|
t.Fatalf("recorder Start entered before release = %d, want 0", got)
|
}
|
|
listener.down.Store(false)
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(false, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
return paster.pasted.Load() == "PrivateVoice AX POC"
|
})
|
if got := recorder.stopAndGetSamples.Load(); got != 1 {
|
t.Fatalf("StopAndGetSamples before recorder Start entered = %d, want 1", got)
|
}
|
if got := eng.recognizes.Load(); got != 1 {
|
t.Fatalf("recognizes = %d, want 1", got)
|
}
|
|
close(preStartBlock)
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
|
})
|
if got := recorder.stops.Load(); got != 0 {
|
t.Fatalf("app-level fake recorder stops = %d, want 0 for normal release pipeline", got)
|
}
|
if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusDone) {
|
t.Fatalf("indicator status = %q, want %q", got, overlay.StatusDone)
|
}
|
}
|
|
func TestHoldPreCaptureReleaseWaitsForStartupSamplesBeforeRecognition(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
listener := a.hk.(*fakeHotkeyListener)
|
listener.down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
blockStart := make(chan struct{})
|
readSamplesReady := make(chan struct{})
|
recorder.startBlock = blockStart
|
recorder.readSamplesBlock = readSamplesReady
|
recorder.samples = []float32{0.2, 0.1, -0.1, -0.2}
|
recorder.hasVoice.Store(true)
|
eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
|
a.replaceEngine(eng)
|
paster := &fakeAppPaster{}
|
a.outputRouter = textoutput.NewRouter(paster)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
|
})
|
|
listener.down.Store(false)
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(false, true)
|
a.mu.Unlock()
|
|
time.Sleep(holdStartupSamplePollInterval * 2)
|
if got := recorder.stopAndGetSamples.Load(); got != 0 {
|
t.Fatalf("StopAndGetSamples before startup samples = %d, want 0", got)
|
}
|
if got := eng.recognizes.Load(); got != 0 {
|
t.Fatalf("recognizes before startup samples = %d, want 0", got)
|
}
|
|
close(readSamplesReady)
|
waitForCondition(t, time.Second, func() bool {
|
return paster.pasted.Load() == "PrivateVoice AX POC"
|
})
|
if got := recorder.stopAndGetSamples.Load(); got != 1 {
|
t.Fatalf("StopAndGetSamples after startup samples = %d, want 1", got)
|
}
|
if got := eng.recognizes.Load(); got != 1 {
|
t.Fatalf("recognizes = %d, want 1", got)
|
}
|
|
close(blockStart)
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
|
})
|
}
|
|
func TestHoldPreCaptureReleaseWithoutStartupSamplesReportsAudioNotReady(t *testing.T) {
|
oldTimeout := holdStartupSampleWaitTimeout
|
oldPoll := holdStartupSamplePollInterval
|
holdStartupSampleWaitTimeout = 40 * time.Millisecond
|
holdStartupSamplePollInterval = 5 * time.Millisecond
|
defer func() {
|
holdStartupSampleWaitTimeout = oldTimeout
|
holdStartupSamplePollInterval = oldPoll
|
}()
|
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
listener := a.hk.(*fakeHotkeyListener)
|
listener.down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
blockStart := make(chan struct{})
|
recorder.startBlock = blockStart
|
eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
|
a.replaceEngine(eng)
|
paster := &fakeAppPaster{}
|
a.outputRouter = textoutput.NewRouter(paster)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
|
})
|
|
listener.down.Store(false)
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(false, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
return recorder.stops.Load() == 1 &&
|
a.indicator.(*fakeOverlay).lastStatus.Load() == string(overlay.StatusError)
|
})
|
if got := recorder.stopAndGetSamples.Load(); got != 0 {
|
t.Fatalf("StopAndGetSamples after audio-not-ready timeout = %d, want 0", got)
|
}
|
if got := eng.recognizes.Load(); got != 0 {
|
t.Fatalf("recognizes after audio-not-ready timeout = %d, want 0", got)
|
}
|
if got := paster.pasted.Load(); got != nil {
|
t.Fatalf("pasted text after audio-not-ready timeout = %v, want nil", got)
|
}
|
if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusError) {
|
t.Fatalf("indicator status = %q, want %q", got, overlay.StatusError)
|
}
|
|
close(blockStart)
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
|
})
|
}
|
|
func TestHoldPreCaptureReleaseWhileRecorderPermissionPendingDoesNotRecognize(t *testing.T) {
|
oldTimeout := holdStartupSampleWaitTimeout
|
oldPoll := holdStartupSamplePollInterval
|
holdStartupSampleWaitTimeout = 40 * time.Millisecond
|
holdStartupSamplePollInterval = 5 * time.Millisecond
|
defer func() {
|
holdStartupSampleWaitTimeout = oldTimeout
|
holdStartupSamplePollInterval = oldPoll
|
}()
|
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
listener := a.hk.(*fakeHotkeyListener)
|
listener.down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
preStartBlock := make(chan struct{})
|
recorder.preStartBlock = preStartBlock
|
eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
|
a.replaceEngine(eng)
|
paster := &fakeAppPaster{}
|
a.outputRouter = textoutput.NewRouter(paster)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
|
})
|
if got := recorder.starts.Load(); got != 0 {
|
t.Fatalf("recorder Start entered before permission pending release = %d, want 0", got)
|
}
|
|
listener.down.Store(false)
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(false, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
return recorder.stops.Load() == 1 &&
|
a.indicator.(*fakeOverlay).lastStatus.Load() == string(overlay.StatusError)
|
})
|
if got := recorder.stopAndGetSamples.Load(); got != 0 {
|
t.Fatalf("StopAndGetSamples after permission-pending audio-not-ready = %d, want 0", got)
|
}
|
if got := eng.recognizes.Load(); got != 0 {
|
t.Fatalf("recognizes after permission-pending audio-not-ready = %d, want 0", got)
|
}
|
if got := paster.pasted.Load(); got != nil {
|
t.Fatalf("pasted text after permission-pending audio-not-ready = %v, want nil", got)
|
}
|
|
close(preStartBlock)
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
|
})
|
if got := recorder.starts.Load(); got != 1 {
|
t.Fatalf("recorder Start after permission resumes = %d, want 1", got)
|
}
|
if got := recorder.stops.Load(); got != 1 {
|
t.Fatalf("recorder stops after late permission success = %d, want 1", got)
|
}
|
}
|
|
func TestHoldPreCaptureEscapeAfterConfirmDefersStopUntilRecorderStart(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
listener := a.hk.(*fakeHotkeyListener)
|
listener.down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
blockStart := make(chan struct{})
|
recorder.startBlock = blockStart
|
captionCancelled := false
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
|
})
|
|
a.mu.Lock()
|
a.liveCaptionCancel = func() { captionCancelled = true }
|
a.mu.Unlock()
|
listener.escapeDown.Store(true)
|
|
done := make(chan struct{})
|
go func() {
|
a.pollHotkey()
|
close(done)
|
}()
|
select {
|
case <-done:
|
case <-time.After(100 * time.Millisecond):
|
t.Fatal("Escape cancel should not wait for blocked recorder start")
|
}
|
|
if got := recorder.stops.Load(); got != 1 {
|
t.Fatalf("recorder stops before start returned = %d, want 1", got)
|
}
|
if got := recorder.stopAndGetSamples.Load(); got != 0 {
|
t.Fatalf("StopAndGetSamples before recorder start returned = %d, want 0", got)
|
}
|
a.mu.Lock()
|
if !a.isHoldStopPending {
|
t.Fatal("Escape while recorder is starting should mark pending stop")
|
}
|
if a.liveCaptionCancel != nil {
|
t.Fatal("Escape while recorder is starting should clear live caption")
|
}
|
a.mu.Unlock()
|
if !captionCancelled {
|
t.Fatal("Escape while recorder is starting should cancel live caption")
|
}
|
|
close(blockStart)
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
|
})
|
|
if got := recorder.stopAndGetSamples.Load(); got != 0 {
|
t.Fatalf("StopAndGetSamples after deferred Escape stop = %d, want 0", got)
|
}
|
if got := recorder.stops.Load(); got != 1 {
|
t.Fatalf("recorder stops = %d, want 1", got)
|
}
|
if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusCancelled) {
|
t.Fatalf("indicator status = %q, want %q", got, overlay.StatusCancelled)
|
}
|
}
|
|
func TestPollHotkeyIgnoresUnsetHotkey(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
listener := a.hk.(*fakeHotkeyListener)
|
listener.down.Store(true)
|
a.cfg.HotkeyVK = config.UnsetHotkeyVK
|
|
a.pollHotkey()
|
|
if got := listener.keyChecks.Load(); got != 0 {
|
t.Fatalf("IsKeyDown calls for unset hotkey = %d, want 0", got)
|
}
|
if a.hotkeyActive {
|
t.Fatal("unset hotkey must not become active")
|
}
|
if a.isRecording || a.isFreetalking {
|
t.Fatal("unset hotkey must not start recording")
|
}
|
}
|
|
func TestHoldPreCaptureEscapeWhileRecorderPermissionPendingStopsLateStart(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
listener := a.hk.(*fakeHotkeyListener)
|
listener.down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
preStartBlock := make(chan struct{})
|
recorder.preStartBlock = preStartBlock
|
eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
|
a.replaceEngine(eng)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
|
})
|
if got := recorder.starts.Load(); got != 0 {
|
t.Fatalf("recorder Start entered before Escape during permission pending = %d, want 0", got)
|
}
|
|
listener.escapeDown.Store(true)
|
done := make(chan struct{})
|
go func() {
|
a.pollHotkey()
|
close(done)
|
}()
|
select {
|
case <-done:
|
case <-time.After(100 * time.Millisecond):
|
t.Fatal("Escape during permission pending should not wait for recorder Start")
|
}
|
if got := recorder.stops.Load(); got != 1 {
|
t.Fatalf("recorder Stop during permission-pending Escape = %d, want 1", got)
|
}
|
if got := recorder.stopAndGetSamples.Load(); got != 0 {
|
t.Fatalf("StopAndGetSamples during permission-pending Escape = %d, want 0", got)
|
}
|
if got := eng.recognizes.Load(); got != 0 {
|
t.Fatalf("recognizes during permission-pending Escape = %d, want 0", got)
|
}
|
|
close(preStartBlock)
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
|
})
|
if got := recorder.stops.Load(); got != 1 {
|
t.Fatalf("recorder stops after late permission success = %d, want 1", got)
|
}
|
if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusCancelled) {
|
t.Fatalf("indicator status = %q, want %q", got, overlay.StatusCancelled)
|
}
|
}
|
|
func TestHoldPreCaptureCombinationWhileRecorderPermissionPendingStopsLateStart(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
listener := a.hk.(*fakeHotkeyListener)
|
listener.down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
preStartBlock := make(chan struct{})
|
recorder.preStartBlock = preStartBlock
|
eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
|
a.replaceEngine(eng)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
listener.otherSince.Store(true)
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isCombination && !a.isRecording && !a.isHoldRecordingPending && a.isHoldStopPending
|
})
|
if got := recorder.stops.Load(); got != 1 {
|
t.Fatalf("recorder Stop during permission-pending combination cancel = %d, want 1", got)
|
}
|
if got := recorder.stopAndGetSamples.Load(); got != 0 {
|
t.Fatalf("StopAndGetSamples during permission-pending combination cancel = %d, want 0", got)
|
}
|
if got := eng.recognizes.Load(); got != 0 {
|
t.Fatalf("recognizes during permission-pending combination cancel = %d, want 0", got)
|
}
|
|
close(preStartBlock)
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isRecording && !a.isHoldRecorderStarting && !a.isHoldStopPending
|
})
|
if got := recorder.stops.Load(); got != 1 {
|
t.Fatalf("recorder stops after late combination permission success = %d, want 1", got)
|
}
|
}
|
|
func TestHoldPreCaptureReleaseAfterRecorderStartRunsRecognitionAndOutput(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
listener := a.hk.(*fakeHotkeyListener)
|
listener.down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
recorder.samples = []float32{0.2, 0.1, -0.1, -0.2}
|
recorder.hasVoice.Store(true)
|
eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
|
a.replaceEngine(eng)
|
paster := &fakeAppPaster{}
|
a.outputRouter = textoutput.NewRouter(paster)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && !a.isHoldRecorderStarting
|
})
|
|
listener.down.Store(false)
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(false, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
return paster.pasted.Load() == "PrivateVoice AX POC"
|
})
|
|
if got := recorder.stopAndGetSamples.Load(); got != 1 {
|
t.Fatalf("StopAndGetSamples = %d, want 1", got)
|
}
|
if got := eng.recognizes.Load(); got != 1 {
|
t.Fatalf("recognizes = %d, want 1", got)
|
}
|
if got := recorder.stops.Load(); got != 0 {
|
t.Fatalf("recorder Stop = %d, want 0 for normal recognition path", got)
|
}
|
}
|
|
func TestHoldPreCaptureReleasePipelineSuppressesLateRecorderStartError(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
listener := a.hk.(*fakeHotkeyListener)
|
listener.down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
blockStart := make(chan struct{})
|
recorder.startBlock = blockStart
|
recorder.startErr = errors.New("start failed")
|
recorder.samples = []float32{0.2, 0.1, -0.1, -0.2}
|
recorder.hasVoice.Store(true)
|
eng := &recognizingHoldEngine{text: "PrivateVoice AX POC"}
|
a.replaceEngine(eng)
|
paster := &fakeAppPaster{}
|
a.outputRouter = textoutput.NewRouter(paster)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting
|
})
|
|
listener.down.Store(false)
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(false, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
return paster.pasted.Load() == "PrivateVoice AX POC"
|
})
|
close(blockStart)
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isHoldRecorderStarting
|
})
|
|
if got := eng.recognizes.Load(); got != 1 {
|
t.Fatalf("recognizes = %d, want 1", got)
|
}
|
if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusDone) {
|
t.Fatalf("late recorder start error overwrote indicator status = %q, want %q", got, overlay.StatusDone)
|
}
|
}
|
|
func TestHoldPreCaptureRecorderStartLateErrorCleansConfirmedState(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
a.hk.(*fakeHotkeyListener).down.Store(true)
|
recorder := a.recorder.(*fakeAppRecorder)
|
blockStart := make(chan struct{})
|
recorder.startBlock = blockStart
|
recorder.startErr = errors.New("start failed")
|
captionCancelled := false
|
a.liveCaptionCancel = func() { captionCancelled = true }
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isRecording && !a.isHoldRecordingPending && a.isHoldRecorderStarting && a.liveCaptionCancel != nil
|
})
|
|
close(blockStart)
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isRecording && !a.isHoldRecorderStarting && a.liveCaptionCancel == nil
|
})
|
|
if !captionCancelled {
|
t.Fatal("late recorder start error should cancel live caption")
|
}
|
if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got != string(overlay.StatusError) {
|
t.Fatalf("indicator status = %q, want %q", got, overlay.StatusError)
|
}
|
}
|
|
func TestHoldPreCaptureReleaseBeforeActivationCancelsTimer(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
a.hk.(*fakeHotkeyListener).down.Store(true)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.hk.(*fakeHotkeyListener).down.Store(false)
|
a.pollHoldHotkeyLocked(false, true)
|
a.mu.Unlock()
|
|
time.Sleep(holdActivationDelay + 50*time.Millisecond)
|
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
if a.isRecording || a.isHoldRecordingPending {
|
t.Fatalf("recording=%t pending=%t, want both false after early release", a.isRecording, a.isHoldRecordingPending)
|
}
|
if got := a.recorder.(*fakeAppRecorder).stops.Load(); got != 1 {
|
t.Fatalf("recorder stops = %d, want 1", got)
|
}
|
}
|
|
func TestHoldPreCaptureTimerCancelsWhenKeyReleasedBeforePoll(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
a.hk.(*fakeHotkeyListener).down.Store(true)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
a.hk.(*fakeHotkeyListener).down.Store(false)
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return !a.isRecording && !a.isHoldRecordingPending
|
})
|
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
if a.hotkeyActive != true {
|
t.Fatal("hotkeyActive should remain true until release poll handles the key-up")
|
}
|
if got := a.recorder.(*fakeAppRecorder).stops.Load(); got != 1 {
|
t.Fatalf("recorder stops = %d, want 1", got)
|
}
|
if got := a.indicator.(*fakeOverlay).lastStatus.Load(); got == string(overlay.StatusRecording) {
|
t.Fatalf("indicator status = %q, should not confirm recording after physical release", got)
|
}
|
}
|
|
func TestHoldPreCaptureCombinationBeforeActivationCancels(t *testing.T) {
|
a, cleanup := newHoldPreCaptureTestApp()
|
defer cleanup()
|
a.hk.(*fakeHotkeyListener).down.Store(true)
|
|
a.mu.Lock()
|
a.pollHoldHotkeyLocked(true, true)
|
a.mu.Unlock()
|
|
a.hk.(*fakeHotkeyListener).otherSince.Store(true)
|
|
waitForCondition(t, time.Second, func() bool {
|
a.mu.Lock()
|
defer a.mu.Unlock()
|
return a.isCombination && !a.isRecording && !a.isHoldRecordingPending
|
})
|
|
if got := a.recorder.(*fakeAppRecorder).stops.Load(); got != 1 {
|
t.Fatalf("recorder stops = %d, want 1", got)
|
}
|
}
|
|
func TestStartRecordingIgnoredWhileStoppingRecording(t *testing.T) {
|
a := &App{isStoppingRecording: true}
|
|
a.startRecordingLocked()
|
|
if a.isRecording {
|
t.Fatal("startRecordingLocked should not start while stop tail capture is pending")
|
}
|
}
|
|
func TestStartTapRecordingIgnoredWhileStoppingRecording(t *testing.T) {
|
a := &App{isStoppingRecording: true}
|
|
a.startTapRecordingLocked()
|
|
if a.isRecording || a.isFreetalking {
|
t.Fatal("startTapRecordingLocked should not start while stop tail capture is pending")
|
}
|
}
|
|
type plainTestEngine struct{}
|
|
func (plainTestEngine) Recognize([]float32) (string, error) { return "", nil }
|
func (plainTestEngine) HardwareInfo() string { return "test" }
|
func (plainTestEngine) Close() {}
|
|
type closeTrackingTestEngine struct {
|
plainTestEngine
|
name string
|
closed atomic.Bool
|
}
|
|
func (e *closeTrackingTestEngine) HardwareInfo() string { return e.name }
|
func (e *closeTrackingTestEngine) Close() { e.closed.Store(true) }
|
|
type tailCaptureTestEngine struct {
|
plainTestEngine
|
delay time.Duration
|
}
|
|
func (e tailCaptureTestEngine) ReleaseTailCaptureDelay() time.Duration {
|
return e.delay
|
}
|
|
type holdPreCaptureTestEngine struct {
|
plainTestEngine
|
enabled bool
|
}
|
|
func (e holdPreCaptureTestEngine) HoldPreCaptureEnabled() bool {
|
return e.enabled
|
}
|
|
func newHoldPreCaptureTestApp() (*App, context.CancelFunc) {
|
ctx, cancel := context.WithCancel(context.Background())
|
a := &App{
|
ctx: ctx,
|
cancel: cancel,
|
cfg: &config.Config{HotkeyVK: 0x5C, HotkeyMode: config.HotkeyModeHold},
|
recorder: &fakeAppRecorder{},
|
hk: &fakeHotkeyListener{},
|
indicator: &fakeOverlay{},
|
history: history.New(),
|
userdict: userdict.New(),
|
}
|
a.replaceEngine(holdPreCaptureTestEngine{enabled: true})
|
a.holdPreCaptureEnabled.Store(true)
|
return a, cancel
|
}
|
|
type fakeAppRecorder struct {
|
starts atomic.Int32
|
stops atomic.Int32
|
stopAndGetSamples atomic.Int32
|
hasVoice atomic.Bool
|
samples []float32
|
preStartBlock <-chan struct{}
|
startBlock <-chan struct{}
|
readSamplesBlock <-chan struct{}
|
startErr error
|
}
|
|
func (r *fakeAppRecorder) OnVolume(func(float64)) {}
|
func (r *fakeAppRecorder) OnDeviceChange(func(string)) {}
|
func (r *fakeAppRecorder) Start() error {
|
if r.preStartBlock != nil {
|
<-r.preStartBlock
|
}
|
r.starts.Add(1)
|
if r.startBlock != nil {
|
<-r.startBlock
|
}
|
return r.startErr
|
}
|
func (r *fakeAppRecorder) Stop() { r.stops.Add(1) }
|
func (r *fakeAppRecorder) StopAndGetSamples() []float32 {
|
r.stopAndGetSamples.Add(1)
|
return append([]float32(nil), r.samples...)
|
}
|
func (r *fakeAppRecorder) HasVoiceActivity() bool { return r.hasVoice.Load() }
|
func (r *fakeAppRecorder) ReadSamplesSince(offset int) ([]float32, int) {
|
if r.readSamplesBlock != nil {
|
select {
|
case <-r.readSamplesBlock:
|
default:
|
return nil, 0
|
}
|
}
|
total := len(r.samples)
|
if offset < 0 {
|
offset = 0
|
}
|
if offset > total {
|
offset = total
|
}
|
return append([]float32(nil), r.samples[offset:]...), total
|
}
|
func (r *fakeAppRecorder) Close() {}
|
|
type recognizingHoldEngine struct {
|
text string
|
recognizes atomic.Int32
|
}
|
|
func (e *recognizingHoldEngine) Recognize([]float32) (string, error) {
|
e.recognizes.Add(1)
|
return e.text, nil
|
}
|
func (e *recognizingHoldEngine) HardwareInfo() string { return "test" }
|
func (e *recognizingHoldEngine) Close() {}
|
func (e *recognizingHoldEngine) HoldPreCaptureEnabled() bool { return true }
|
|
type fakeAppPaster struct {
|
pasted atomic.Value
|
typed atomic.Value
|
}
|
|
func (p *fakeAppPaster) Paste(text string, keepClipboard bool) error {
|
p.pasted.Store(text)
|
return nil
|
}
|
|
func (p *fakeAppPaster) TypeText(text string) error {
|
p.typed.Store(text)
|
return nil
|
}
|
|
type fakeHotkeyListener struct {
|
down atomic.Bool
|
escapeDown atomic.Bool
|
otherSince atomic.Bool
|
keyChecks atomic.Int32
|
}
|
|
func (h *fakeHotkeyListener) IsKeyDown(key int) bool {
|
h.keyChecks.Add(1)
|
if key == 0x1B {
|
return h.escapeDown.Load()
|
}
|
return h.down.Load()
|
}
|
func (h *fakeHotkeyListener) IsAnyOtherKeyPressed(int) bool { return false }
|
func (h *fakeHotkeyListener) IsAnyOtherKeyPressedSince(int, time.Time) bool {
|
return h.otherSince.Load()
|
}
|
func (h *fakeHotkeyListener) Close() {}
|
|
type fakeOverlay struct {
|
lastStatus atomic.Value
|
}
|
|
func (o *fakeOverlay) Show() {}
|
func (o *fakeOverlay) Hide() {}
|
func (o *fakeOverlay) SetStatus(status overlay.Status, text string) {
|
o.lastStatus.Store(string(status))
|
}
|
func (o *fakeOverlay) SetVolume(float64) {}
|
func (o *fakeOverlay) SetPosition(int, int) {}
|
func (o *fakeOverlay) GetPosition() (int, int) { return 0, 0 }
|
func (o *fakeOverlay) Size() (int, int) { return 170, 48 }
|
func (o *fakeOverlay) OnDragged(func(int, int)) {}
|
func (o *fakeOverlay) Close() {}
|
func (o *fakeOverlay) AutoPosition() {}
|