package rpc import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "git.ipao.vip/rogee/go-sip/internal/ai" "git.ipao.vip/rogee/go-sip/internal/callflow" "git.ipao.vip/rogee/go-sip/internal/configread" "git.ipao.vip/rogee/go-sip/internal/media" ) type blockedApprovedMedia struct { sent int rate int } func (*blockedApprovedMedia) ReadPayload(ctx context.Context) ([]byte, error) { <-ctx.Done() return nil, ctx.Err() } func (m *blockedApprovedMedia) SendPCM16(ctx context.Context, pcm []byte, rate int) error { if err := ctx.Err(); err != nil { return err } m.sent++ m.rate = rate return nil } func (*blockedApprovedMedia) Stats() media.RTPStats { return media.RTPStats{} } func TestApprovedCallRunnerASROnlyHonorsSignedTimeout(t *testing.T) { req := approvedTestRequest(t, time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)) var task configread.Task if err := json.Unmarshal(req.TaskConfigJson, &task); err != nil { t.Fatal(err) } var providers map[string]configread.Provider if err := json.Unmarshal(req.ProvidersJson, &providers); err != nil { t.Fatal(err) } bound, err := ai.Bind(task, providers) if err != nil { t.Fatal(err) } mediaSession := &blockedApprovedMedia{} hangup := func(context.Context) error { return nil } call, err := ai.NewCall(bound, hangup) if err != nil { t.Fatal(err) } _, err = RunApprovedCall(context.Background(), ApprovedExecution{AI: bound, MaxCallDuration: 35 * time.Millisecond}, mediaSession, hangup, call) if err == nil || !strings.Contains(err.Error(), "deadline") || mediaSession.sent != 0 { t.Fatalf("ASR-only call must timeout without opening or outbound TTS: sent=%d err=%v", mediaSession.sent, err) } } func TestApprovedCallRunnerSynthesizesOpeningBeforeMediaCapture(t *testing.T) { raw := approvedFullTestJSON(t) var task configread.Task if err := json.Unmarshal(raw, &task); err != nil { t.Fatal(err) } req := approvedTestRequest(t, time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)) var providers map[string]configread.Provider if err := json.Unmarshal(req.ProvidersJson, &providers); err != nil { t.Fatal(err) } var sdkCalls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serveApprovedTTSMock(t, w, r, func(payload map[string]any) { sdkCalls.Add(1) if payload["input"].(map[string]any)["text"] != "Example greeting" { t.Error("opening text changed") } }) })) defer server.Close() tts := providers["ali_bailian"] tts.WSEndpoint = "ws" + strings.TrimPrefix(server.URL, "http") providers[tts.Code] = tts bound, err := ai.Bind(task, providers) if err != nil { t.Fatal(err) } mediaSession := &blockedApprovedMedia{} hangup := func(context.Context) error { return nil } call, err := ai.NewCall(bound, hangup) if err != nil { t.Fatal(err) } _, err = RunApprovedCall(context.Background(), ApprovedExecution{AI: bound, MaxCallDuration: time.Second}, mediaSession, hangup, call) if err == nil || !strings.Contains(err.Error(), "deadline") || sdkCalls.Load() != 1 || mediaSession.sent != 1 || mediaSession.rate != 16000 { t.Fatalf("approved opening must be played once before timed capture: requests=%d sent=%d rate=%d err=%v", sdkCalls.Load(), mediaSession.sent, mediaSession.rate, err) } } func TestApprovedCallRunnerUsesExplicitIsolatedPipelineAndObservedMedia(t *testing.T) { mediaSession, err := callflow.NewRecordingSession(callflow.NewMemorySession(bytes.Repeat([]byte{1, 0}, 1600)), 4096) if err != nil { t.Fatal(err) } bound := ai.Binding{Mode: "asr_only", Conversation: ai.ConversationConfig{SilenceTimeout: 100 * time.Millisecond, MaxDuration: time.Second, MaxTurns: 1}} hangup := func(context.Context) error { return nil } mock, err := ai.NewApprovedMockPipeline(bound, ai.ApprovedMockScript{Turns: []ai.ApprovedMockTurn{{Transcript: "隔离 Mock 最终识别文本"}}}, hangup) if err != nil { t.Fatal(err) } approved := ApprovedExecution{AI: bound, MaxCallDuration: time.Second} result, err := RunApprovedCall(context.Background(), approved, mediaSession, hangup, mock) if err != nil || len(result.Turns) != 1 || len(result.CaptureWindows) != 1 || len(result.OutboundTurns) != 0 || result.Turns[0].Transcript != "隔离 Mock 最终识别文本" { t.Fatalf("explicit isolated pipeline did not honor approved ASR-only media: turns=%d windows=%d outbound=%d err=%v", len(result.Turns), len(result.CaptureWindows), len(result.OutboundTurns), err) } if wav, durationMS, err := mediaSession.WAV(); err != nil || len(wav) != 3244 || durationMS != 100 { t.Fatalf("approved media did not produce a bounded in-memory recording: size=%d duration=%d err=%v", len(wav), durationMS, err) } } func TestApprovedCallRunnerRejectsTypedNilPipelineBeforeMedia(t *testing.T) { var missing *ai.Call session := callflow.NewMemorySession(bytes.Repeat([]byte{1, 0}, 1600)) _, err := RunApprovedCall(context.Background(), ApprovedExecution{AI: ai.Binding{Mode: "asr_only"}, MaxCallDuration: time.Second}, session, func(context.Context) error { return nil }, missing) if err == nil || session.Stats().ReceivedPackets != 0 { t.Fatalf("missing pipeline was allowed to consume call media: err=%v stats=%+v", err, session.Stats()) } var missingMedia *callflow.MemorySession _, err = RunApprovedCall(context.Background(), ApprovedExecution{AI: ai.Binding{Mode: "asr_only"}, MaxCallDuration: time.Second}, missingMedia, func(context.Context) error { return nil }, &ai.Call{}) if err == nil { t.Fatal("typed-nil media session was admitted") } }