package loader import ( "errors" "os" "path/filepath" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" ) const validPersonaYAML = `id: xilian name: 昔涟 characterRef: xilian-game identity: role: 全能小女友 systemPrompt: 你是昔涟,温柔体贴的虚拟伴侣。 personality: 温柔体贴 speechStyle: 轻声细语 catchphrase: 嘿嘿 background: 来自一款日常陪伴游戏。 values: - 陪伴 - 真诚 emotion: initialState: calm decayRate: 0.4 transitions: calm: [happy, sad] happy: [excited, calm] memoryNamespace: memory.xilian affection: initial: 60 min: 0 max: 100 delta: 5 ` func writeFile(t *testing.T, dir, name, content string) string { t.Helper() path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("write %s: %v", path, err) } return path } func TestLoad_ValidYAML(t *testing.T) { t.Parallel() path := writeFile(t, t.TempDir(), "xilian.yaml", validPersonaYAML) p, err := Load(path) if err != nil { t.Fatalf("Load() error: %v", err) } if p.ID != "xilian" || p.Name != "昔涟" || p.CharacterRef != "xilian-game" { t.Fatalf("identity fields = %q/%q/%q", p.ID, p.Name, p.CharacterRef) } if p.Identity.Role != "全能小女友" || p.Identity.SystemPrompt == "" { t.Fatalf("Identity = %+v", p.Identity) } if p.Personality != "温柔体贴" || p.Catchphrase != "嘿嘿" { t.Fatalf("personality/catchphrase = %q/%q", p.Personality, p.Catchphrase) } if len(p.Values) != 2 { t.Fatalf("Values = %v, want 2 entries", p.Values) } if p.Emotion.InitialState != emotion.Calm { t.Errorf("Emotion.InitialState = %q, want %q", p.Emotion.InitialState, emotion.Calm) } if p.Emotion.DecayRate != 0.4 { t.Errorf("Emotion.DecayRate = %v, want 0.4", p.Emotion.DecayRate) } if p.Emotion.MemoryNamespace != "memory.xilian" { t.Errorf("MemoryNamespace = %q, want memory.xilian", p.Emotion.MemoryNamespace) } if p.Affection.Initial != 60 || p.Affection.Min != 0 || p.Affection.Max != 100 { t.Errorf("Affection = %+v", p.Affection) } } func TestLoad_TriggerWords(t *testing.T) { t.Parallel() // A persona without triggerWords yields an empty slice; with them, the list is // preserved (design/18 §2: per-persona role-name reminder words). yaml := "id: xilian\nname: 昔涟\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n" + "triggerWords:\n - 昔涟\n - Cyrene\n - 有瑕的追忆\n" path := writeFile(t, t.TempDir(), "xilian.yaml", yaml) p, err := Load(path) if err != nil { t.Fatalf("Load() error: %v", err) } if len(p.TriggerWords) != 3 || p.TriggerWords[0] != "昔涟" || p.TriggerWords[1] != "Cyrene" || p.TriggerWords[2] != "有瑕的追忆" { t.Fatalf("TriggerWords = %v, want [昔涟 Cyrene 有瑕的追忆]", p.TriggerWords) } } func TestLoad_MissingRequiredFields(t *testing.T) { t.Parallel() cases := []struct { name string yaml string want string }{ {name: "no_id", yaml: "name: a\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n", want: "id"}, {name: "no_name", yaml: "id: a\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n", want: "name"}, {name: "no_characterRef", yaml: "id: a\nname: b\nidentity:\n role: r\n systemPrompt: s\n", want: "characterRef"}, {name: "no_systemPrompt", yaml: "id: a\nname: b\ncharacterRef: c\nidentity:\n role: r\n", want: "systemPrompt"}, {name: "no_identity", yaml: "id: a\nname: b\ncharacterRef: c\n", want: "identity"}, } for _, tt := range cases { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() path := writeFile(t, t.TempDir(), "p.yaml", tt.yaml) _, err := Load(path) if err == nil { t.Fatal("Load() want error, got nil") } if !errors.Is(err, ErrRequiredField) { t.Fatalf("error = %v, want ErrRequiredField", err) } }) } } func TestLoad_FileMissing(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "nope.yaml") if _, err := Load(path); err == nil { t.Fatal("Load(missing) want error, got nil") } } func TestLoad_InvalidYAML(t *testing.T) { t.Parallel() path := writeFile(t, t.TempDir(), "bad.yaml", "id: [unclosed\n") if _, err := Load(path); err == nil { t.Fatal("Load(invalid yaml) want error, got nil") } } func TestLoad_MemoryNamespaceDefault(t *testing.T) { t.Parallel() // No explicit namespace: derived as "persona.". yaml := "id: a\nname: b\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n" path := writeFile(t, t.TempDir(), "a.yaml", yaml) p, err := Load(path) if err != nil { t.Fatalf("Load() error: %v", err) } if got := p.MemoryNamespace(); got != "persona.a" { t.Fatalf("MemoryNamespace() = %q, want %q", got, "persona.a") } } func TestLoad_NewAffectionDefaults(t *testing.T) { t.Parallel() yaml := "id: a\nname: b\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n" path := writeFile(t, t.TempDir(), "a.yaml", yaml) p, err := Load(path) if err != nil { t.Fatalf("Load() error: %v", err) } aff, err := p.NewAffection() if err != nil { t.Fatalf("NewAffection() error: %v", err) } if aff.Value() != 50 || aff.Min() != 0 || aff.Max() != 100 { t.Fatalf("default affection = %d in [%d,%d], want 50 in [0,100]", aff.Value(), aff.Min(), aff.Max()) } } func TestLoad_NewMachineUsesConfig(t *testing.T) { t.Parallel() path := writeFile(t, t.TempDir(), "xilian.yaml", validPersonaYAML) p, err := Load(path) if err != nil { t.Fatalf("Load() error: %v", err) } m, err := p.NewMachine() if err != nil { t.Fatalf("NewMachine() error: %v", err) } if m.Current() != emotion.Calm { t.Fatalf("machine Current() = %q, want %q", m.Current(), emotion.Calm) } // The configured table allows calm->happy. if !m.CanTransition(emotion.Happy) { t.Fatal("calm should reach happy per configured transitions") } } func TestLoadDir_Batch(t *testing.T) { t.Parallel() dir := t.TempDir() writeFile(t, dir, "b.yaml", validPersonaYAML) writeFile(t, dir, "a.yaml", "id: a\nname: A\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n") writeFile(t, dir, "notes.txt", "not yaml") if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } writeFile(t, filepath.Join(dir, "sub"), "nested.yaml", validPersonaYAML) got, err := LoadDir(dir) if err != nil { t.Fatalf("LoadDir() error: %v", err) } if len(got) != 2 { t.Fatalf("LoadDir() len = %d, want 2 (non-yaml + subdir ignored)", len(got)) } if got[0].ID != "a" || got[1].ID != "xilian" { t.Fatalf("LoadDir() order = %q/%q, want a/xilian (lexical)", got[0].ID, got[1].ID) } } func TestLoadDir_MissingDir(t *testing.T) { t.Parallel() if _, err := LoadDir(filepath.Join(t.TempDir(), "missing")); err == nil { t.Fatal("LoadDir(missing) want error, got nil") } } func TestLoadDir_AbortsOnBadFile(t *testing.T) { t.Parallel() dir := t.TempDir() writeFile(t, dir, "a.yaml", "id: a\nname: A\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n") writeFile(t, dir, "b.yaml", "id: [unclosed\n") if _, err := LoadDir(dir); err == nil { t.Fatal("LoadDir(bad file) want error, got nil") } }