Files
fenrir/internal/speech/replacement_test.go

105 lines
3.0 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package speech
import (
"errors"
"sync/atomic"
"testing"
"time"
)
type replacementTransport struct {
*recordingTransport
closed atomic.Bool
configureErr error
configured chan struct{}
resume chan struct{}
}
func (f *replacementTransport) Close() { f.closed.Store(true); f.recordingTransport.Close() }
func (f *replacementTransport) Configure(p Parameters) error {
if f.configured != nil {
close(f.configured)
<-f.resume
}
if f.configureErr != nil {
return f.configureErr
}
return f.recordingTransport.Configure(p)
}
func TestReplacementPreservesMuteParametersAndFailureRecovery(t *testing.T) {
old := &replacementTransport{recordingTransport: new_recording_transport()}
service := New(old)
defer service.Close()
service.Configure(Parameters{Rate: 55, Pitch: 12, Volume: 80})
wait_event(t, old.changed, "configure:55:12:80")
bad := &replacementTransport{recordingTransport: new_recording_transport(), configureErr: errors.New("fixture refusal")}
if err := service.Replace(bad); err == nil || !bad.closed.Load() || old.closed.Load() {
t.Fatal("failed replacement changed active client", err)
}
service.Say("old survives")
wait_event(t, old.changed, "sent:old survives")
service.Toggle_mute()
service.Say("discarded")
next := &replacementTransport{recordingTransport: new_recording_transport()}
if err := service.Replace(next); err != nil {
t.Fatal(err)
}
if !old.closed.Load() {
t.Fatal("old client not released")
}
wait_event(t, next.changed, "configure:55:12:80")
service.Toggle_mute()
service.Say_with_pitch("E", 70)
service.Say("new feedback")
wait_event(t, next.changed, "sent:new feedback")
next.mu.Lock()
defer next.mu.Unlock()
for _, event := range next.events {
if event == "say:discarded" {
t.Fatal("muted backlog replayed")
}
}
}
func TestShutdownDuringReplacementClosesBothClients(t *testing.T) {
old := &replacementTransport{recordingTransport: new_recording_transport()}
service := New(old)
service.Configure(Parameters{Volume: 100})
wait_event(t, old.changed, "configure:0:0:100")
next := &replacementTransport{recordingTransport: new_recording_transport(), configured: make(chan struct{}), resume: make(chan struct{})}
done := make(chan error, 1)
go func() { done <- service.Replace(next) }()
<-next.configured
closed := make(chan struct{})
go func() { service.Close(); close(closed) }()
// Observe shutdown admission before releasing the in-flight candidate.
deadline := time.After(time.Second)
for {
service.mu.Lock()
closing := service.closed
service.mu.Unlock()
if closing {
break
}
select {
case <-deadline:
t.Fatal("shutdown not admitted")
default:
time.Sleep(time.Millisecond)
}
}
close(next.resume)
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("shutdown stalled")
}
if err := <-done; err == nil || !old.closed.Load() || !next.closed.Load() {
t.Fatal("candidate leaked", err, old.closed.Load(), next.closed.Load())
}
}