Files
Maven/internal/voice/voice_test.go
T
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:20:16 +04:00

224 lines
5.9 KiB
Go

package voice
import (
"context"
"encoding/json"
"net"
"os"
"syscall"
"testing"
"time"
"github.com/kami/maven/internal/audio"
)
// newTestListener returns a TCP listener on a random port with SO_REUSEADDR.
// It retries a few times if the port is temporarily unavailable (e.g., TIME_WAIT).
func newTestListener(t *testing.T) net.Listener {
t.Helper()
lc := net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
var err error
c.Control(func(fd uintptr) {
err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
})
return err
},
}
var ln net.Listener
var err error
for i := 0; i < 5; i++ {
ln, err = lc.Listen(context.Background(), "tcp", "127.0.0.1:0")
if err == nil {
break
}
if !isAddrInUse(err) {
t.Fatalf("listen: %v", err)
}
time.Sleep(100 * time.Millisecond)
}
if err != nil {
t.Fatalf("listen after retries: %v", err)
}
// Close the probe before returning: it only existed to reserve a free port
// (127.0.0.1:0 → a concrete port). The Server rebinds that exact addr in its
// own Listen(), which fails with EADDRINUSE while the probe still holds it.
// Addr() keeps returning the address after Close, and an unconnected listener
// leaves no TIME_WAIT, so the rebind is immediate and clean.
_ = ln.Close()
return ln
}
func isAddrInUse(err error) bool {
if err == nil {
return false
}
if opErr, ok := err.(*net.OpError); ok {
if sysErr, ok := opErr.Err.(*os.SyscallError); ok {
if err := sysErr.Err; err == syscall.EADDRINUSE {
return true
}
}
}
return false
}
// wait a bit for OS to release the port after close.
func waitPort() { time.Sleep(500 * time.Millisecond) }
// stubHandler — satisfies voice.Handler for tests.
type stubHandler struct {
lastReq PushToTalkReq
}
func (h *stubHandler) HandlePushToTalk(_ context.Context, req PushToTalkReq, _ uint64) (PushToTalkResp, error) {
h.lastReq = req
return PushToTalkResp{
ReplyAudio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("reply")},
ReplyText: "got it",
}, nil
}
func TestServerAcceptsAndRemovesSession(t *testing.T) {
l := newTestListener(t)
sess := NewSessions()
h := &stubHandler{}
srv := NewServer(l.Addr().String(), h, sess)
if err := srv.Listen(); err != nil {
t.Fatalf("listen: %v", err)
}
defer func() {
_ = srv.Close()
waitPort()
}()
// Accept connections in the background; Serve blocks until Close. Without
// it the listener binds but never accepts, so a client round-trip hangs.
go func() { _ = srv.Serve() }()
if sess.Active() != 0 {
t.Fatalf("active before connect: %d", sess.Active())
}
conn, err := net.Dial("tcp", l.Addr().String())
if err != nil {
t.Fatalf("dial: %v", err)
}
conn.Close() // quick disconnect
// Give serveConn a moment to register then remove.
time.Sleep(50 * time.Millisecond)
if sess.Active() != 0 {
t.Fatalf("active after disconnect: %d, want 0", sess.Active())
}
}
func TestClientPushToTalkRoundTrip(t *testing.T) {
l := newTestListener(t)
sess := NewSessions()
h := &stubHandler{}
srv := NewServer(l.Addr().String(), h, sess)
if err := srv.Listen(); err != nil {
t.Fatalf("listen: %v", err)
}
defer func() {
_ = srv.Close()
waitPort()
}()
// Accept connections in the background; Serve blocks until Close. Without
// it the listener binds but never accepts, so a client round-trip hangs.
go func() { _ = srv.Serve() }()
c := Dial(l.Addr().String())
defer c.Close()
resp, err := c.PushToTalk(context.Background(), audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("hello")}, "ru")
if err != nil {
t.Fatalf("PushToTalk: %v", err)
}
if resp.ReplyText != "got it" {
t.Fatalf("ReplyText: %q, want %q", resp.ReplyText, "got it")
}
if len(resp.ReplyAudio.Bytes) == 0 {
t.Fatalf("ReplyAudio empty")
}
if h.lastReq.Audio.Bytes == nil {
t.Fatalf("handler never got audio")
}
}
func TestClientListenModeReceivesPush(t *testing.T) {
l := newTestListener(t)
sess := NewSessions()
h := &stubHandler{}
srv := NewServer(l.Addr().String(), h, sess)
if err := srv.Listen(); err != nil {
t.Fatalf("listen: %v", err)
}
defer func() {
_ = srv.Close()
waitPort()
}()
// Accept connections in the background; Serve blocks until Close. Without
// it the listener binds but never accepts, so a client round-trip hangs.
go func() { _ = srv.Serve() }()
c := Dial(l.Addr().String())
defer c.Close()
// Run the push receiver in the background: it blocks reading frames until
// the conn closes (ctx cancel alone can't interrupt a blocking read). A
// proactive push from the server is delivered to the handler, which hands
// the audio to a channel the test waits on.
got := make(chan audio.Audio, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
_ = c.RunPushReceiver(ctx, pushHandlerFunc(func(p Push) {
if p.Kind != PushKindAudioNudge {
return
}
var ap AudioNudgePush
if err := json.Unmarshal(p.Params, &ap); err == nil {
select {
case got <- ap.Audio:
default:
}
}
}))
}()
// serveConn registers the session on accept, but Accept→Add is async — wait
// for it before pushing, or PushToMostRecent finds no session.
for i := 0; i < 100 && sess.Active() < 1; i++ {
time.Sleep(10 * time.Millisecond)
}
if sess.Active() < 1 {
t.Fatal("session never registered")
}
// Server pushes to the session.
push := AudioNudgePush{
RuleName: "test",
Severity: 3,
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("proactive")},
Text: "proactive text",
Ts: time.Now(),
}
if err := sess.PushToMostRecent(context.Background(), push); err != nil {
t.Fatalf("PushToMostRecent: %v", err)
}
select {
case received := <-got:
if string(received.Bytes) != "proactive" {
t.Fatalf("received audio mismatch: %q", string(received.Bytes))
}
case <-time.After(2 * time.Second):
t.Fatal("never received pushed audio")
}
}
type pushHandlerFunc func(Push)
func (f pushHandlerFunc) OnPush(p Push) { f(p) }