Files
go-sip/internal/callflow/approved_test.go
T

201 lines
9.2 KiB
Go

package callflow
import (
"bytes"
"context"
"strings"
"testing"
"time"
"git.ipao.vip/rogee/go-sip/internal/ai"
"git.ipao.vip/rogee/go-sip/internal/media"
)
type approvedFlowPipeline struct {
openingCalls int
turnCalls int
turn ai.TurnResult
}
func (p *approvedFlowPipeline) Open(context.Context) ([]byte, error) {
p.openingCalls++
return make([]byte, 6400), nil
}
func (p *approvedFlowPipeline) RunTurn(context.Context, []byte) (ai.TurnResult, error) {
p.turnCalls++
return p.turn, nil
}
type scriptedTurnSession struct {
turns [][]byte
index int
served bool
stats media.RTPStats
}
func (s *scriptedTurnSession) ReadPayload(ctx context.Context) ([]byte, error) {
if !s.served && s.index < len(s.turns) {
s.served = true
payload := append([]byte(nil), s.turns[s.index]...)
s.index++
s.stats.ReceivedPackets++
s.stats.ReceivedBytes += uint64(len(payload))
return payload, nil
}
<-ctx.Done()
s.served = false
return nil, ctx.Err()
}
func (s *scriptedTurnSession) SendPCM16(ctx context.Context, pcm []byte, _ int) error {
if err := ctx.Err(); err != nil {
return err
}
s.stats.SentPackets++
s.stats.SentBytes += uint64(len(pcm))
return nil
}
func (s *scriptedTurnSession) Stats() media.RTPStats { return s.stats }
type rejectedOpeningSession struct{ MediaSession }
func (rejectedOpeningSession) SendPCM16(context.Context, []byte, int) error {
return context.Canceled
}
func TestApprovedInitialMediaWaitIsBounded(t *testing.T) {
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "不应执行"}}
_, err := ExecuteApproved(context.Background(), NewMemorySession(nil), ai.ModeASROnly, pipeline, CaptureConfig{
FirstSpeechTimeout: time.Millisecond, MaxDuration: time.Millisecond, MaxTurns: 1,
})
if err == nil || pipeline.turnCalls != 0 {
t.Fatalf("missing speech was not bounded: calls=%d err=%v", pipeline.turnCalls, err)
}
}
func TestApprovedSharedFlowRunsThreeConversationTurns(t *testing.T) {
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "最终识别", Reply: "获批回答", AudioPCM16: make([]byte, 6400)}}
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400), make([]byte, 6400), make([]byte, 6400)}}
result, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{
FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 3,
})
if err != nil {
t.Fatal(err)
}
if pipeline.openingCalls != 1 || pipeline.turnCalls != 3 || len(result.Turns) != 3 || len(result.InboundTurns) != 3 || len(result.OutboundTurns) != 4 || result.RTP.ReceivedBytes != 19200 {
t.Fatalf("approved three-turn flow incomplete: opening=%d turns=%d inbound=%d outbound=%d received=%d", pipeline.openingCalls, pipeline.turnCalls, len(result.InboundTurns), len(result.OutboundTurns), result.RTP.ReceivedBytes)
}
}
func TestApprovedOpeningSendFailureDoesNotInventPlayback(t *testing.T) {
session := rejectedOpeningSession{MediaSession: NewMemorySession(nil)}
pipeline := &approvedFlowPipeline{}
result, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{MaxTurns: 1})
if err == nil || len(result.OutboundTurns) != 0 || result.RTP.SentPackets != 0 || pipeline.turnCalls != 0 {
t.Fatalf("failed opening was reported as played: err=%v outbound=%d sent=%d turns=%d", err, len(result.OutboundTurns), result.RTP.SentPackets, pipeline.turnCalls)
}
}
func TestApprovedInvalidCallStopsBeforeReply(t *testing.T) {
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "打错了", InvalidCall: true, InvalidReason: "llm_invalid_call_marker", AudioPCM16: make([]byte, 6400)}}
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
result, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{
FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 3,
})
if err != nil {
t.Fatal(err)
}
if !result.Turn.InvalidCall || result.Turn.InvalidReason != "llm_invalid_call_marker" || len(result.Turns) != 1 || session.stats.SentPackets != 1 {
t.Fatalf("invalid call did not stop before reply: turn=%+v turns=%d sent=%d", result.Turn, len(result.Turns), session.stats.SentPackets)
}
}
func TestApprovedKeywordStopsBeforeSendingReply(t *testing.T) {
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "我不用了", EndedByKeyword: true, AudioPCM16: make([]byte, 6400)}}
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
result, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{
FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 3,
})
if err != nil {
t.Fatal(err)
}
if !result.Turn.EndedByKeyword || len(result.Turns) != 1 || len(result.OutboundTurns) != 1 || session.stats.SentPackets != 1 || pipeline.openingCalls != 1 || pipeline.turnCalls != 1 {
t.Fatalf("keyword hangup must stop before LLM/TTS reply: turns=%d outbound=%d sent=%d", len(result.Turns), len(result.OutboundTurns), session.stats.SentPackets)
}
}
func TestApprovedUnconfiguredRefusalTextDoesNotInventHangup(t *testing.T) {
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "不用了", Reply: "继续为您服务", AudioPCM16: make([]byte, 6400)}}
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
result, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{
FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 1,
})
if err != nil {
t.Fatal(err)
}
if result.Turn.InvalidCall || result.Turn.EndedByKeyword || len(result.OutboundTurns) != 2 || session.stats.SentPackets != 2 {
t.Fatalf("no unapproved hardcoded refusal match may terminate call: %+v", result.Turn)
}
}
func TestApprovedASROnlySkipsOpeningAndReplyEvenIfPipelineHasAudio(t *testing.T) {
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "最终识别", AudioPCM16: make([]byte, 6400)}}
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
before := time.Now()
result, err := ExecuteApproved(context.Background(), session, ai.ModeASROnly, pipeline, CaptureConfig{
FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 1,
})
after := time.Now()
if err != nil {
t.Fatal(err)
}
if len(result.CaptureWindows) != 1 || result.CaptureWindows[0].StartedAt.Before(before) || result.CaptureWindows[0].EndedAt.After(after) || result.CaptureWindows[0].EndedAt.Before(result.CaptureWindows[0].StartedAt) {
t.Fatalf("ASR-only has no observed media interval: %+v", result.CaptureWindows)
}
if pipeline.openingCalls != 0 || pipeline.turnCalls != 1 || len(result.OutboundTurns) != 0 || session.stats.SentPackets != 0 {
t.Fatalf("ASR-only cannot play opening or TTS reply: opening=%d turns=%d sent=%d", pipeline.openingCalls, pipeline.turnCalls, session.stats.SentPackets)
}
}
func TestApprovedCapturePreservesConversationControls(t *testing.T) {
bound := ai.Binding{Conversation: ai.ConversationConfig{SilenceTimeout: 3 * time.Second, MaxDuration: 2 * time.Minute, MaxTurns: 20, SentenceMaxChars: 80, MaxPendingAudioChunks: 32}}
capture := ApprovedCapture(bound)
if capture.FirstSpeechTimeout != 3*time.Second || capture.EndSilence != 3*time.Second || capture.CallDuration != 2*time.Minute || capture.MaxDuration != 0 || capture.MaxTurns != 20 || capture.VoiceThreshold <= 0 || capture.MaxPendingAudioChunks != 32 {
t.Fatalf("approved conversation limits were not handed to media controller: %+v", capture)
}
}
type approvedDeadlineProbe struct{ deadlinePresent bool }
func (*approvedDeadlineProbe) Open(context.Context) ([]byte, error) { return nil, nil }
func (p *approvedDeadlineProbe) RunTurn(ctx context.Context, _ []byte) (ai.TurnResult, error) {
deadline, ok := ctx.Deadline()
p.deadlinePresent = ok && time.Until(deadline) > 0 && time.Until(deadline) <= time.Second
return ai.TurnResult{Transcript: "最终识别", EndedByKeyword: true}, nil
}
func TestApprovedConversationDurationBoundsWholeCall(t *testing.T) {
pipeline := &approvedDeadlineProbe{}
session := &scriptedTurnSession{turns: [][]byte{bytes.Repeat([]byte{1, 0}, 3200)}}
capture := ApprovedCapture(ai.Binding{Conversation: ai.ConversationConfig{
SilenceTimeout: 2 * time.Millisecond, MaxDuration: time.Second, MaxTurns: 1,
}})
if _, err := ExecuteApproved(context.Background(), session, ai.ModeASROnly, pipeline, capture); err != nil || !pipeline.deadlinePresent {
t.Fatalf("approved overall duration was not enforced throughout media and AI: deadline=%t err=%v", pipeline.deadlinePresent, err)
}
}
func TestApprovedSilenceTimeoutRejectsMissingSpeech(t *testing.T) {
pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "不应执行"}}
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
capture := ApprovedCapture(ai.Binding{Conversation: ai.ConversationConfig{
SilenceTimeout: 10 * time.Millisecond, MaxDuration: 100 * time.Millisecond, MaxTurns: 1,
}})
_, err := ExecuteApproved(context.Background(), session, ai.ModeASROnly, pipeline, capture)
if err == nil || !strings.Contains(err.Error(), "speech") || pipeline.turnCalls != 0 {
t.Fatalf("configured silence timeout did not stop before AI: turns=%d err=%v", pipeline.turnCalls, err)
}
}