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

110 lines
5.1 KiB
Go

package callflow
import (
"bytes"
"context"
"strings"
"testing"
"time"
"git.ipao.vip/rogee/go-sip/internal/ai"
)
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
}
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)}}
result, err := ExecuteApproved(context.Background(), session, ai.ModeASROnly, pipeline, CaptureConfig{
FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 1,
})
if err != nil {
t.Fatal(err)
}
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.CurrentBound{Conversation: ai.CurrentConversation{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.CurrentBound{Conversation: ai.CurrentConversation{
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.CurrentBound{Conversation: ai.CurrentConversation{
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)
}
}