package callflow import ( "context" "errors" "testing" "time" "git.ipao.vip/rogee/go-sip/internal/ai" ) type closingSendSession struct { *scriptedTurnSession sends int closing func(context.Context) error } func (s *closingSendSession) SendPCM16(ctx context.Context, pcm []byte, rate int) error { s.sends++ if s.sends == 2 && s.closing != nil { if err := s.closing(ctx); err != nil { return err } } return s.scriptedTurnSession.SendPCM16(ctx, pcm, rate) } func TestKeywordClosingFailuresRemainVisibleAndHangupOnce(t *testing.T) { synthesisErr := errors.New("injected closing synthesis failure") playbackErr := errors.New("injected closing playback failure") hangupErr := errors.New("injected unknown hangup outcome") for _, tc := range []struct { name string synthErr, sendErr, finishErr error empty bool }{ {name: "synthesis", synthErr: synthesisErr}, {name: "playback", sendErr: playbackErr}, {name: "hangup", finishErr: hangupErr}, {name: "both-playback-and-hangup", sendErr: playbackErr, finishErr: hangupErr}, {name: "empty-closing", empty: true}, } { t.Run(tc.name, func(t *testing.T) { session := &closingSendSession{scriptedTurnSession: &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}, closing: func(context.Context) error { return tc.sendErr }} hangups := 0 turn := ai.TurnResult{Transcript: "不需要", Reply: "好的,再见。", EndedByKeyword: true, AudioPCM16: make([]byte, 6400)} if tc.empty { turn.AudioPCM16 = nil } pipeline := &approvedFlowPipeline{turn: turn, turnErr: tc.synthErr, finish: func(context.Context) error { hangups++; return tc.finishErr }} result, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 3}) if err == nil || hangups != 1 || pipeline.turnCalls != 1 || len(result.Turns) != 1 || !result.Turn.EndedByKeyword || result.Turn.Transcript != "不需要" { t.Fatalf("closing error/facts lost or new turn started: err=%v hangups=%d turns=%d result=%+v", err, hangups, pipeline.turnCalls, result.Turn) } for _, want := range []error{tc.synthErr, tc.sendErr, tc.finishErr} { if want != nil && !errors.Is(err, want) { t.Fatalf("cause hidden: want=%v got=%v", want, err) } } if (tc.synthErr != nil || tc.sendErr != nil || tc.empty) && len(result.OutboundTurns) != 1 { t.Fatal("unsent closing reported as played") } if tc.synthErr != nil && session.sends != 1 { t.Fatal("partial failed synthesis was played") } if session.sends > 2 { t.Fatal("closing replayed") } }) } } func TestKeywordHangupWaitsForPlaybackReturn(t *testing.T) { started, release, hungup := make(chan struct{}), make(chan struct{}), make(chan struct{}) session := &closingSendSession{scriptedTurnSession: &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}, closing: func(context.Context) error { close(started) <-release return nil }} pipeline := &approvedFlowPipeline{turn: ai.TurnResult{Transcript: "再见", Reply: "再见。", EndedByKeyword: true, AudioPCM16: make([]byte, 6400)}, finish: func(context.Context) error { close(hungup); return nil }} done := make(chan error, 1) go func() { _, err := ExecuteApproved(context.Background(), session, ai.ModeFullAI, pipeline, CaptureConfig{FirstSpeechTimeout: time.Second, MaxDuration: 2 * time.Millisecond, MaxTurns: 3}) done <- err }() select { case <-started: case <-time.After(5 * time.Second): close(release) t.Fatal("closing playback never started") } select { case <-hungup: close(release) t.Fatal("hung up while closing playback was still in progress") default: } close(release) if err := <-done; err != nil { t.Fatal(err) } select { case <-hungup: default: t.Fatal("playback completed without hangup") } if session.sends != 2 || pipeline.turnCalls != 1 { t.Fatal("new turn/replay after closing") } }