Files
Cyrene_v2/internal/persona/loader/persona_test.go
T

226 lines
6.3 KiB
Go

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_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.<id>".
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")
}
}