package orchestration import ( "encoding/json" "errors" "strings" "testing" ) func TestConstructorAndBoundaryValidation(t *testing.T) { if _, err := NewStartProcessor(nil, nil, nil, 0); err == nil { t.Fatal("invalid start processor dependencies were accepted") } if err := (&StartProcessor{}).ConfigureStartProtection(nil, 0); err == nil { t.Fatal("invalid provider start protection was accepted") } if _, err := NewStopProcessor(nil, nil, nil); err == nil { t.Fatal("invalid stop processor dependencies were accepted") } if _, err := NewProviderEventProcessor(nil, 0); err == nil { t.Fatal("invalid provider event processor dependencies were accepted") } if _, err := NewProviderHealth(nil, 0, 0); err == nil { t.Fatal("invalid provider health dependencies were accepted") } if _, err := NewHeartbeatService(nil, nil, nil, nil, HeartbeatServiceOptions{}); err == nil { t.Fatal("invalid heartbeat dependencies were accepted") } if _, err := NewRecoveryScanner(nil, nil, nil, RecoveryOptions{}); err == nil { t.Fatal("invalid recovery scanner dependencies were accepted") } if _, err := NewTranscriptBatchService(nil, nil, nil, nil); err == nil { t.Fatal("invalid transcript dependencies were accepted") } if _, err := NewService(nil, nil, nil, nil, nil, nil, ServiceOptions{}); err == nil { t.Fatal("invalid orchestration dependencies were accepted") } conflict := &TranscriptConflictError{} if !errors.Is(conflict, conflict) || conflict.Error() == "" { t.Fatal("transcript conflict error contract is invalid") } if got := truncateContextText(" 长者今日精神状态良好 ", 4); got != "长者今日…" { t.Fatalf("truncated context=%q", got) } if got := truncateContextText(" 简短 ", 10); got != "简短" { t.Fatalf("short context=%q", got) } if _, err := NewRedisLeaseCache(nil, "test"); err == nil { t.Fatal("nil redis client was accepted") } } func TestAuthorizedProfileContextRejectsMalformedWhitelistedFields(t *testing.T) { valid, err := authorizedProfileContext( []byte(`{"background":"退休教师","interests":["戏曲","散步"],"internal_note":{"ignored":true}}`), []byte(`{"conversation_goal":"轻松交流","pace":"slow","preferred_topics":["家乡"],"avoided_topics":["收入","家庭矛盾"]}`), ) if err != nil || valid.Background != "退休教师" || len(valid.Interests) != 2 || valid.Pace != "slow" || len(valid.AvoidedTopics) != 2 { t.Fatalf("valid context=%+v err=%v", valid, err) } for _, malformed := range []struct { profile, preferences string }{ {`{"background":42}`, `{}`}, {`{"interests":["戏曲",42]}`, `{}`}, {`{}`, `{"preferred_topics":"家乡"}`}, {`{}`, `{"avoided_topics":"收入"}`}, } { if _, err := authorizedProfileContext([]byte(malformed.profile), []byte(malformed.preferences)); err == nil { t.Fatalf("malformed whitelisted context was accepted: profile=%s preferences=%s", malformed.profile, malformed.preferences) } } } func TestBuildContextSummaryIncludesContinuityAndBoundariesWithinSnapshotLimit(t *testing.T) { profile := authorizedSubjectContext{ Background: "退休教师", Interests: []string{"戏曲"}, ConversationGoal: "自然陪伴", Pace: "slow", PreferredTopics: []string{"家乡"}, AvoidedTopics: []string{"收入", "家庭矛盾"}, } result := buildCompanionContextSummary("王阿姨", profile, []recentConversationContext{{Summary: "上次聊到刚刚注册了个体工商户,准备开始新的工作。", OpenTopic: "如果她愿意,可以聊聊开始新工作后最期待的事。"}}, []string{"喜欢在周末听越剧。"}) for _, expected := range []string{"交流边界", "收入", "王阿姨", "历史资料仅作背景", "先回应长者当前说的话", "可选续聊线索", "最期待的事", "本次交流目标:自然陪伴", "交流节奏:放慢语速", "优先话题:家乡", "背景资料:退休教师", "兴趣:戏曲", "最近第1次通话摘要", "注册了个体工商户", "已确认的长期记忆", "越剧"} { if !strings.Contains(result, expected) { t.Fatalf("context %q does not contain %q", result, expected) } } oversized := buildCompanionContextSummary(strings.Repeat("长", 120), authorizedSubjectContext{ Background: strings.Repeat("背", 500), AvoidedTopics: []string{strings.Repeat("禁", 50), "家庭矛盾"}, PreferredTopics: []string{strings.Repeat("优", 50)}, Interests: []string{strings.Repeat("兴", 50)}, }, []recentConversationContext{ {Summary: strings.Repeat("摘", 500), OpenTopic: strings.Repeat("续", 500)}, {Summary: "第二次摘要", OpenTopic: "较旧的续聊线索"}, {Summary: "第三次摘要"}, }, []string{strings.Repeat("忆", 500), "第二条记忆", "第三条记忆"}) if len([]rune(oversized)) > contextSummaryLimit { t.Fatalf("context length=%d exceeds snapshot budget", len([]rune(oversized))) } for _, expected := range []string{"交流边界", "家庭矛盾", "可选续聊线索", strings.Repeat("续", 20), "最近第3次通话摘要", "第三次摘要", "已确认的长期记忆"} { if !strings.Contains(oversized, expected) { t.Fatalf("oversized context %q lost priority field %q", oversized, expected) } } } func TestCompanionContextUsesOnlyNewestAvailableOpenTopic(t *testing.T) { recent := []recentConversationContext{ {Summary: "最近一次只聊了天气。"}, {Summary: "上一次谈到老同事。", OpenTopic: "有机会可以聊聊最难忘的一位老同事。"}, {Summary: "更早谈到家乡。", OpenTopic: "可以聊聊家乡。"}, } if got := suggestedOpenTopic(recent); got != "有机会可以聊聊最难忘的一位老同事。" { t.Fatalf("selected open topic=%q", got) } result := buildCompanionContextSummary("李叔叔", authorizedSubjectContext{}, recent, nil) if strings.Count(result, "可选续聊线索") != 1 || strings.Contains(result, "可以聊聊家乡") { t.Fatalf("context exposed multiple continuation cues: %q", result) } } func TestConversationContinuityRequiresBothExplicitConsents(t *testing.T) { for _, test := range []struct { value json.RawMessage want bool }{ {json.RawMessage(`{"personalized_memory":true,"story_extraction":true}`), true}, {json.RawMessage(`{"personalized_memory":true}`), false}, {json.RawMessage(`{"story_extraction":true}`), false}, {json.RawMessage(`not-json`), false}, } { if got := consentAllowsConversationContinuity(test.value); got != test.want { t.Fatalf("consent %s allowed=%v want=%v", test.value, got, test.want) } } }