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 turnErr error finish func(context.Context) error } func (p *approvedFlowPipeline) FinishKeyword(ctx context.Context) error { if p.finish != nil { return p.finish(ctx) } return nil } 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, p.turnErr } 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 TestApprovedKeywordPlaysClosingBeforeHangup(t *testing.T) { session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}} hangups := 0 pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "我不用了", Reply: "好的,祝您生活愉快。", EndedByKeyword: true, AudioPCM16: make([]byte, 6400)}, finish: func(context.Context) error { hangups++ if session.stats.SentPackets != 2 { t.Fatal("hangup occurred before closing playback completed") } return nil }} 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) != 2 || session.stats.SentPackets != 2 || pipeline.openingCalls != 1 || pipeline.turnCalls != 1 || hangups != 1 { t.Fatalf("closing playback/hangup incomplete: turns=%d outbound=%d sent=%d hangups=%d", len(result.Turns), len(result.OutboundTurns), session.stats.SentPackets, hangups) } } 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 (*approvedDeadlineProbe) FinishKeyword(context.Context) error { return 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: "最终识别"}, 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) } }