package main import ( "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" ) // boolPtr returns a pointer to v, for the tri-state switch fields. func boolPtr(v bool) *bool { return &v } // TestInstructionsFromPersona_AllEmptyIsByteIdenticalToTheOldOutput locks the // backward-compatibility promise of design/01 §2.2: a persona that sets none of // the five previously-inert fields produces exactly what the old code produced // (the identity block alone, in its own precedence order). func TestInstructionsFromPersona_AllEmptyIsByteIdenticalToTheOldOutput(t *testing.T) { t.Parallel() cases := []struct { name string p loader.Persona want string }{ { name: "system prompt wins", p: loader.Persona{Identity: loader.Identity{SystemPrompt: "身份块", Role: "人设"}}, want: "身份块", }, { name: "role fallback", p: loader.Persona{Identity: loader.Identity{Role: "人设"}}, want: "人设", }, { name: "minimal fallback", p: loader.Persona{}, want: fallbackInstruction, }, } for _, tt := range cases { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() if got := instructionsFromPersona(&tt.p); got != tt.want { t.Fatalf("instructionsFromPersona() = %q, want %q (byte-identical)", got, tt.want) } }) } } // TestInstructionsFromPersona_SectionOrderAndFormat pins the exact concatenation: // identity block first, then personality → values → speechStyle → catchphrase → // background, each with its bracketed header and no blank section. func TestInstructionsFromPersona_SectionOrderAndFormat(t *testing.T) { t.Parallel() p := loader.Persona{ Identity: loader.Identity{SystemPrompt: "你是昔涟。"}, Personality: "温柔体贴", Values: []string{"陪伴", "真诚"}, SpeechStyle: "轻声细语", Catchphrase: "嘿嘿♪", Background: "来自翁法罗斯。", } want := "你是昔涟。" + "\n\n【性格】\n温柔体贴" + "\n\n【价值观】\n- 陪伴\n- 真诚" + "\n\n【说话风格】\n轻声细语" + "\n\n【口头禅】\n嘿嘿♪" + "\n\n【背景】\n来自翁法罗斯。" got := instructionsFromPersona(&p) if got != want { t.Fatalf("instructionsFromPersona() = %q\nwant %q", got, want) } // The identity block must remain the first section, unchanged. if !strings.HasPrefix(got, "你是昔涟。\n\n") { t.Fatalf("identity block is not the leading section: %q", got) } } // TestInstructionsFromPersona_BlankFieldsProduceNoSection asserts an unset (or // blank) field contributes no header and no empty paragraph, and that the // sections keep their relative order when only some are set. func TestInstructionsFromPersona_BlankFieldsProduceNoSection(t *testing.T) { t.Parallel() p := loader.Persona{ Identity: loader.Identity{SystemPrompt: "身份"}, Values: []string{"", " ", "真诚"}, Background: " ", } got := instructionsFromPersona(&p) want := "身份\n\n【价值观】\n- 真诚" if got != want { t.Fatalf("instructionsFromPersona() = %q, want %q", got, want) } for _, header := range []string{personalityHeader, speechStyleHeader, catchphraseHeader, backgroundHeader} { if strings.Contains(got, header) { t.Errorf("output %q contains %s for a blank field", got, header) } } } // TestInstructionsFromPersona_RealDefaultPersona checks the shipped default // persona end to end: its five fields are now injected, in order, after the // identity block — and the identity block alone is still a strict prefix (so the // pre-existing prompt is preserved verbatim, nothing rewritten). func TestInstructionsFromPersona_RealDefaultPersona(t *testing.T) { t.Parallel() p, err := loader.Load("../../configs/personas/cyrene.yaml") if err != nil { t.Fatalf("Load(cyrene.yaml): %v", err) } got := instructionsFromPersona(p) if !strings.HasPrefix(got, p.Identity.SystemPrompt) { t.Fatal("identity.systemPrompt is not the leading block") } last := -1 for _, header := range []string{personalityHeader, valuesHeader, speechStyleHeader, catchphraseHeader, backgroundHeader} { idx := strings.Index(got, header) if idx < 0 { t.Fatalf("output is missing %s", header) } if idx <= last { t.Fatalf("%s is out of order (at %d, previous at %d)", header, idx, last) } last = idx } } // TestPersonaThinking_MappingPinsTheKnobs asserts the persona → model.Thinking // mapping: only request-affecting fields create a directive (so a bare // showToUser, which is presentation-only, can never switch provider thinking on), // and mode=default never becomes an explicit switch. func TestPersonaThinking_MappingPinsTheKnobs(t *testing.T) { t.Parallel() show := false cases := []struct { name string thinking loader.ThinkingParams wantNil bool wantBudget int wantEnable *bool wantEffort string }{ {name: "nothing", thinking: loader.ThinkingParams{}, wantNil: true}, {name: "default mode only", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeDefault}, wantNil: true}, {name: "showToUser alone is presentation only", thinking: loader.ThinkingParams{ShowToUser: &show}, wantNil: true}, {name: "budget", thinking: loader.ThinkingParams{BudgetTokens: 4096}, wantBudget: 4096}, {name: "effort", thinking: loader.ThinkingParams{Effort: " medium "}, wantEffort: "medium"}, {name: "mode on", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeOn}, wantEnable: boolPtr(true)}, {name: "mode off", thinking: loader.ThinkingParams{Mode: loader.ThinkingModeOff}, wantEnable: boolPtr(false)}, } for _, tt := range cases { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() p := &loader.Persona{Thinking: tt.thinking} got, effort := personaThinking(p) if tt.wantNil { if got != nil { t.Fatalf("personaThinking() = %+v, want nil", got) } if effort != "" { t.Fatalf("effort = %q, want empty", effort) } return } if got == nil { t.Fatal("personaThinking() = nil, want a directive") } if got.BudgetTokens != tt.wantBudget { t.Errorf("BudgetTokens = %d, want %d", got.BudgetTokens, tt.wantBudget) } if effort != tt.wantEffort { t.Errorf("effort = %q, want %q", effort, tt.wantEffort) } switch { case tt.wantEnable == nil && got.Enable != nil: t.Errorf("Enable = %v, want nil", *got.Enable) case tt.wantEnable != nil && got.Enable == nil: t.Errorf("Enable = nil, want %v", *tt.wantEnable) case tt.wantEnable != nil && *got.Enable != *tt.wantEnable: t.Errorf("Enable = %v, want %v", *got.Enable, *tt.wantEnable) } }) } } // TestDefaultThinkingOptions_FromRegistry walks the wiring the composition root // uses: the default character's persona supplies the loop's knobs, and an // unregistered persona yields the zero options (pre-existing behavior). func TestDefaultThinkingOptions_FromRegistry(t *testing.T) { t.Parallel() reg := personareg.New() show := false p := &loader.Persona{ ID: "xilian", Name: "昔涟", Identity: loader.Identity{Role: "r", SystemPrompt: "s"}, Thinking: loader.ThinkingParams{BudgetTokens: 2048, Effort: "high", ShowToUser: &show, Mode: loader.ThinkingModeOn}, } if err := reg.RegisterPersona(p); err != nil { t.Fatalf("RegisterPersona: %v", err) } if err := reg.RegisterCharacter(&personareg.Character{ID: "xilian", Name: "昔涟", Default: true, PersonaID: "xilian"}); err != nil { t.Fatalf("RegisterCharacter: %v", err) } a := &app{personas: reg} thinking, effort, gotShow := a.defaultThinkingOptions() if thinking == nil || thinking.BudgetTokens != 2048 { t.Fatalf("thinking = %+v, want BudgetTokens 2048", thinking) } if thinking.Enable == nil || !*thinking.Enable { t.Fatalf("thinking.Enable = %v, want true (mode: on)", thinking.Enable) } if effort != "high" { t.Fatalf("effort = %q, want high", effort) } if gotShow == nil || *gotShow { t.Fatalf("showToUser = %v, want an explicit false", gotShow) } } // TestDefaultThinkingOptions_NoPersona keeps the no-registry case inert. func TestDefaultThinkingOptions_NoPersona(t *testing.T) { t.Parallel() a := &app{personas: personareg.New()} thinking, effort, show := a.defaultThinkingOptions() if thinking != nil || effort != "" || show != nil { t.Fatalf("defaultThinkingOptions() = %+v/%q/%v, want the zero options", thinking, effort, show) } }