110 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|