105 lines
3.0 KiB
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())
|
|
}
|
|
}
|