package loader import ( "errors" "strings" "testing" "gopkg.in/yaml.v3" ) // 一份满足必填字段的最小人格,用作 awakening 用例的载体。 const awakeningBaseYAML = `id: a name: b characterRef: c identity: role: r systemPrompt: s ` // TestLoad_AwakeningAbsentIsNil 锁定裁决 14.4.3 的「缺席就是没有」:角色档案里 // 没写 `awakening` 块 → 解析结果为空(nil),**不报错、也不补默认档位**; // 序列化回去时也不会凭空长出该键。 func TestLoad_AwakeningAbsentIsNil(t *testing.T) { t.Parallel() path := writeFile(t, t.TempDir(), "a.yaml", awakeningBaseYAML) p, err := Load(path) if err != nil { t.Fatalf("Load() error: %v", err) } if p.Awakening != nil { t.Fatalf("Awakening = %+v, want nil(缺席就是没有)", p.Awakening) } out, err := yaml.Marshal(p) if err != nil { t.Fatalf("Marshal() error: %v", err) } if strings.Contains(string(out), "awakening") { t.Fatalf("marshal output grew an awakening key: %s", out) } } // TestLoad_AwakeningParsesAndNormalizes 覆盖两种写法:设计原文的中文档位名 // (静息/唤醒/全能)与决策层的规范态名(quiet/awake/full)都必须被接受, // 并在载入期归一化成规范态名,供组合根直接映射。 func TestLoad_AwakeningParsesAndNormalizes(t *testing.T) { t.Parallel() cases := []struct { name string yaml string wantDefault string wantForce string wantMinConf float64 }{ { name: "chinese_labels", yaml: "awakening:\n default_state: 唤醒\n overrides:\n min_confidence: 0.7\n force_state: 全能\n", wantDefault: AwakeningStateAwake, wantForce: AwakeningStateFull, wantMinConf: 0.7, }, { name: "canonical_names", yaml: "awakening:\n default_state: full\n overrides:\n force_state: quiet\n", wantDefault: AwakeningStateFull, wantForce: AwakeningStateQuiet, }, { name: "default_state_only", yaml: "awakening:\n default_state: 静息\n", wantDefault: AwakeningStateQuiet, }, { name: "empty_overrides_block", yaml: "awakening:\n default_state: awake\n overrides: {}\n", wantDefault: AwakeningStateAwake, }, { name: "whitespace_and_case", yaml: "awakening:\n default_state: ' AWAKE '\n", wantDefault: AwakeningStateAwake, }, } for _, tt := range cases { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() path := writeFile(t, t.TempDir(), "a.yaml", awakeningBaseYAML+tt.yaml) p, err := Load(path) if err != nil { t.Fatalf("Load() error: %v", err) } if p.Awakening == nil { t.Fatal("Awakening = nil, want the parsed block") } if p.Awakening.DefaultState != tt.wantDefault { t.Errorf("DefaultState = %q, want %q", p.Awakening.DefaultState, tt.wantDefault) } gotForce, gotMinConf := "", 0.0 if ov := p.Awakening.Overrides; ov != nil { gotForce, gotMinConf = ov.ForceState, ov.MinConfidence } if gotForce != tt.wantForce { t.Errorf("ForceState = %q, want %q", gotForce, tt.wantForce) } if gotMinConf != tt.wantMinConf { t.Errorf("MinConfidence = %v, want %v", gotMinConf, tt.wantMinConf) } }) } } // TestLoad_AwakeningInvalidIsExplicitError 锁定 design/01 §2.1 的红线:写了却不 // 合法的档位/阈值必须**显式报错**(%w 包裹 ErrInvalidAwakening),绝不静默忽略。 func TestLoad_AwakeningInvalidIsExplicitError(t *testing.T) { t.Parallel() cases := []struct { name string yaml string want string }{ {name: "unknown_default_state", yaml: "awakening:\n default_state: 睡觉\n", want: "default_state"}, {name: "english_typo", yaml: "awakening:\n default_state: awke\n", want: "default_state"}, {name: "unknown_force_state", yaml: "awakening:\n overrides:\n force_state: 暴力\n", want: "force_state"}, {name: "negative_confidence", yaml: "awakening:\n overrides:\n min_confidence: -0.1\n", want: "min_confidence"}, {name: "confidence_above_one", yaml: "awakening:\n overrides:\n min_confidence: 1.5\n", want: "min_confidence"}, {name: "confidence_nan", yaml: "awakening:\n overrides:\n min_confidence: .nan\n", want: "min_confidence"}, } for _, tt := range cases { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() path := writeFile(t, t.TempDir(), "a.yaml", awakeningBaseYAML+tt.yaml) _, err := Load(path) if err == nil { t.Fatal("Load() want error, got nil") } if !errors.Is(err, ErrInvalidAwakening) { t.Fatalf("error = %v, want ErrInvalidAwakening", err) } if !strings.Contains(err.Error(), tt.want) { t.Fatalf("error %v does not name the offending field %q", err, tt.want) } }) } } // TestAwakening_MarshalRoundTrip 保证写盘路径不丢块:带块的人格序列化后含 // default_state / min_confidence,且能被同一套载入规则重新读回(管理面写人格 // 走同一个 loader,写出去的 YAML 必须必然可载入)。 func TestAwakening_MarshalRoundTrip(t *testing.T) { t.Parallel() p := &Persona{ ID: "a", Name: "b", CharacterRef: "c", Identity: Identity{Role: "r", SystemPrompt: "s"}, Awakening: &AwakeningParams{ DefaultState: AwakeningStateAwake, Overrides: &AwakeningOverrides{MinConfidence: 0.5, ForceState: AwakeningStateFull}, }, } out, err := yaml.Marshal(p) if err != nil { t.Fatalf("Marshal() error: %v", err) } for _, key := range []string{"awakening:", "default_state: awake", "min_confidence: 0.5", "force_state: full"} { if !strings.Contains(string(out), key) { t.Fatalf("marshaled persona missing %q:\n%s", key, out) } } path := writeFile(t, t.TempDir(), "a.yaml", string(out)) back, err := Load(path) if err != nil { t.Fatalf("reload error: %v", err) } if back.Awakening == nil || back.Awakening.DefaultState != AwakeningStateAwake { t.Fatalf("reloaded Awakening = %+v, want default_state awake", back.Awakening) } if back.Awakening.Overrides == nil || back.Awakening.Overrides.ForceState != AwakeningStateFull { t.Fatalf("reloaded Overrides = %+v, want force_state full", back.Awakening.Overrides) } } // TestAwakening_ZeroValueIsNoOverride 是反证靶子:没有块时决策层看到的覆盖为零值 // (据此走全局默认),因此「把解析接上 / 断开」会在端到端用例里产生可观测差异 // —— 见 cmd/cyrene 的 awakening 端到端用例。 func TestAwakening_ZeroValueIsNoOverride(t *testing.T) { t.Parallel() var p Persona if err := p.ValidateAwakening(); err != nil { t.Fatalf("ValidateAwakening(no block) = %v, want nil", err) } if p.Awakening != nil { t.Fatalf("Awakening = %+v, want nil", p.Awakening) } }