// 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()) } }