diff --git a/internal/config/test/registry_contract_test.go b/internal/config/test/registry_contract_test.go index 35775d00..0f6c134c 100644 --- a/internal/config/test/registry_contract_test.go +++ b/internal/config/test/registry_contract_test.go @@ -4,6 +4,8 @@ package test // 输入 → 输出。不读源码、不碰 registry 内部结构。 import ( + "os" + "path/filepath" "testing" "github.com/JianFeeeee/HologramAgent/internal/config" @@ -106,28 +108,60 @@ func TestDefRoundTripPreservesSecret(t *testing.T) { } } -// TestSecretValueIsNotPlaintextOnDisk:SetSecret → GetSecret 往返,且 DB 里无明文。 -func TestSecretValueIsNotPlaintextOnDisk(t *testing.T) { - reg := newRegistry(t) - reg.SetCodec(config.NewTestCodec(t)) - p := reg.PluginConfig("alpha") +// TestSecretSurvivesRestartAndStaysEncrypted 覆盖配置模块的完整职责: +// 敏感项写入 → 磁盘上无明文 → 关闭并重新打开同一数据库后仍可正确解密读回。 +// 只用公开 API 观察,不查询内部表结构。 +func TestSecretSurvivesRestartAndStaysEncrypted(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.db") + + first := config.NewConfigRegistry(path) + first.SetCodec(config.NewTestCodec(t)) + p := first.PluginConfig("alpha") p.RegisterDef(config.ConfigDef{Key: "api_key", Secret: true}) - if err := p.Set("api_key", "PLAINTEXT-SECRET-123"); err != nil { - t.Fatal(err) + const secret = "PLAINTEXT-SECRET-123" + if err := p.Set("api_key", secret); err != nil { + first.Close() + t.Fatalf("Set: %v", err) } - got, err := p.GetSecret("api_key") + if got, err := p.GetSecret("api_key"); err != nil || got != secret { + first.Close() + t.Fatalf("GetSecret before restart = %q / %v", got, err) + } + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + // 落盘文件里不得出现明文。 + raw, err := os.ReadFile(path) if err != nil { - t.Fatalf("GetSecret: %v", err) - } - if got != "PLAINTEXT-SECRET-123" { - t.Fatalf("★ 解密后 = %q,期望原文", got) - } - var raw string - if err := reg.DB().QueryRow( - "SELECT value FROM config_alpha WHERE key='api_key'").Scan(&raw); err != nil { t.Fatal(err) } - if raw == "PLAINTEXT-SECRET-123" { - t.Fatal("★ 磁盘上是明文 —— 敏感项没有加密") + if string(raw) == secret || containsBytes(raw, []byte(secret)) { + t.Fatalf("secret written in plaintext on disk") + } + + second := config.NewConfigRegistry(path) + defer second.Close() + second.SetCodec(config.NewTestCodec(t)) + q := second.PluginConfig("alpha") + if got, err := q.GetSecret("api_key"); err != nil || got != secret { + t.Fatalf("GetSecret after restart = %q / %v", got, err) } } + +func containsBytes(haystack, needle []byte) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + match := true + for j := range needle { + if haystack[i+j] != needle[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/internal/memory/pipeline/test/distiller_contract_test.go b/internal/memory/pipeline/test/distiller_contract_test.go index ed394559..807ca5f9 100644 --- a/internal/memory/pipeline/test/distiller_contract_test.go +++ b/internal/memory/pipeline/test/distiller_contract_test.go @@ -1,72 +1,155 @@ package test -// internal/memory/pipeline 的黑盒判据:NewDistiller → Append → 可观察输出。 -// 不启动后台循环、不碰 d.records。 +// internal/memory/pipeline 的模块级黑盒判据:Append 原始记录 → 蒸馏批次 +// 经公开 SetSplitter/Start/Stop → 图记忆可查询到的三元组,以及原始记录 +// 落盘/重启回读与暂停语义。不碰 d.records 等私有字段。 import ( + "context" "path/filepath" + "sync/atomic" "testing" + "time" + "github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory/pipeline" ) -func newDistiller(t *testing.T) *pipeline.Distiller { +type spySplitter struct{ calls atomic.Int32 } + +func (s *spySplitter) Split(_ context.Context, record string) ([]memory.Triple, error) { + s.calls.Add(1) + return []memory.Triple{{ + Subject: "billing服务", + Relation: "端口", + Object: "8499", + SentenceText: record, + }}, nil +} + +type failSplitter struct{} + +func (failSplitter) Split(context.Context, string) ([]memory.Triple, error) { + return nil, context.DeadlineExceeded +} + +func newHarness(t *testing.T) (*pipeline.Distiller, *memory.GraphDB, *spySplitter) { t.Helper() - return pipeline.NewDistiller(nil, t.TempDir(), pipeline.DistillerConfig{ - Interval: 0, RetentionDays: 7, BatchSize: 10, - }) -} - -// TestAppendThenRecentRecords:Append 后 GetRecentRecords 读回内容。 -func TestAppendThenRecentRecords(t *testing.T) { - d := newDistiller(t) - d.Append("s1", "user", "你好") - d.Append("s1", "assistant", "在") - got := d.GetRecentRecords(5) - if len(got) != 2 { - t.Fatalf("★ GetRecentRecords = %d 条,期望 2", len(got)) - } - if got[0].Content != "你好" || got[1].Content != "在" { - t.Fatalf("★ 记录内容/顺序不符:%+v", got) - } -} - -// TestGetRecentRecordsRespectsLimit:limit 生效且取的是最新的 N 条。 -func TestGetRecentRecordsRespectsLimit(t *testing.T) { - d := newDistiller(t) - for _, c := range []string{"A", "B", "C"} { - d.Append("s", "user", c) - } - got := d.GetRecentRecords(2) - if len(got) != 2 { - t.Fatalf("★ limit 未生效:%d 条", len(got)) - } - if got[0].Content != "B" || got[1].Content != "C" { - t.Fatalf("★ 不是最新的两条:%+v", got) - } -} - -// TestPauseResumeStopped:暂停/恢复是可观察状态。 -func TestPauseResumeStopped(t *testing.T) { - d := newDistiller(t) - d.Pause() - d.Resume() - if d.Stopped() { - t.Fatal("★ 刚构造的 Distiller 报告已停止") - } -} - -// TestStatsIsUsable:Stats 输出可用。 -func TestStatsIsUsable(t *testing.T) { - d := newDistiller(t) - if st := d.Stats(); st == nil { - t.Fatal("★ Stats() 返回 nil") - } -} - -// TestRawPathIsUnderDataDir:构造参数 dataDir 决定落盘根(通过导出行为间接验证)。 -func TestRawPathIsUnderDataDir(t *testing.T) { dir := t.TempDir() - _ = pipeline.NewDistiller(nil, dir, pipeline.DistillerConfig{}) - _ = filepath.Join(dir, "memory", "raw") // 契约:raw 落在此处;不读内部字段 + g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) + if err != nil { + t.Fatalf("NewGraphDB: %v", err) + } + t.Cleanup(func() { _ = g.Close() }) + spy := &spySplitter{} + d := pipeline.NewDistiller(g, dir, pipeline.DistillerConfig{ + Interval: 20 * time.Millisecond, RetentionDays: 7, BatchSize: 10, + }) + d.SetSplitter(spy) + return d, g, spy +} + +func assertFactRecalled(t *testing.T, g *memory.GraphDB) { + t.Helper() + got, err := g.Recall([]string{"billing服务"}, nil, 2, "") + if err != nil { + t.Fatalf("Recall: %v", err) + } + for _, r := range got.Relations { + if r.SourceName == "billing服务" && r.RelationType == "端口" && r.TargetName == "8499" { + return + } + } + t.Fatalf("distilled fact not queryable: %+v", got) +} + +func TestAppendThenDistillWritesQueryableFact(t *testing.T) { + d, g, spy := newHarness(t) + d.Append("s1", "user", "billing服务端口是8499") + d.Start() + t.Cleanup(d.Stop) + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if spy.calls.Load() > 0 { + assertFactRecalled(t, g) + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("distiller never invoked the injected splitter") +} + +func TestRawRecordsPersistAcrossRestart(t *testing.T) { + dir := t.TempDir() + cfg := pipeline.DistillerConfig{Interval: time.Hour, RetentionDays: 7, BatchSize: 10} + + first := pipeline.NewDistiller(nil, dir, cfg) + first.Append("s1", "user", "第一条原始记录") + first.Start() + first.Stop() + + second := pipeline.NewDistiller(nil, dir, cfg) + second.Start() + defer second.Stop() + // Start 载入磁盘上的原始记录后,未蒸馏记录必须可读到。 + got := second.GetRecentRecords(10) + second.Stop() + found := false + for _, r := range got { + if r.Content == "第一条原始记录" && r.Role == "user" && r.SessionID == "s1" { + found = true + } + } + if !found { + t.Fatalf("raw record not restored after restart: %+v", got) + } +} + +func TestSplitterFailureDoesNotFabricateFacts(t *testing.T) { + dir := t.TempDir() + g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + d := pipeline.NewDistiller(g, dir, pipeline.DistillerConfig{ + Interval: 20 * time.Millisecond, RetentionDays: 7, BatchSize: 10, + }) + d.SetSplitter(failSplitter{}) + d.Append("s1", "user", "会失败的记录") + d.Start() + time.Sleep(200 * time.Millisecond) + d.Stop() + + // 拆分器失败时必须回退而不是伪造事实:图记忆里不能出现这条记录编出来的关系。 + got, err := g.Recall([]string{"会失败的记录"}, nil, 2, "") + if err != nil { + t.Fatalf("Recall: %v", err) + } + for _, r := range got.Relations { + t.Fatalf("failed batch fabricated a fact: %+v", r) + } +} + +func TestPauseResumeAndStopAreObservable(t *testing.T) { + d, _, _ := newHarness(t) + d.Append("s1", "user", "record") + d.Start() + if err := d.Pause(); err != nil { + t.Fatalf("Pause: %v", err) + } + if err := d.Pause(); err != nil { + t.Fatalf("Pause must be idempotent: %v", err) + } + if err := d.Resume(); err != nil { + t.Fatalf("Resume: %v", err) + } + if d.Stopped() { + t.Fatal("reported stopped before Stop") + } + d.Stop() + if !d.Stopped() { + t.Fatal("Stop did not report stopped immediately") + } } diff --git a/internal/memory/test/memory_contract_test.go b/internal/memory/test/memory_contract_test.go index 77f95abc..b0786775 100644 --- a/internal/memory/test/memory_contract_test.go +++ b/internal/memory/test/memory_contract_test.go @@ -110,27 +110,40 @@ func newDB(t *testing.T) *memory.GraphDB { return g } -// TestCommitThenGraphData:Commit 一条三元组后,GraphData 必须能反映它。 -func TestCommitThenGraphData(t *testing.T) { - g := newDB(t) +// TestCommitThenRecallAfterReopen verifies that a committed fact is durable and +// queryable through the memory module, rather than merely producing graph keys. +func TestCommitThenRecallAfterReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "graph.db") + g, err := memory.NewGraphDB(path) + if err != nil { + t.Fatal(err) + } tri := memory.Triple{ Subject: "billing服务", Relation: "端口", Object: "8499", SentenceText: "billing服务端口是8499", } if _, _, err := g.Commit([]memory.Triple{tri}, "contract-session", 1); err != nil { + _ = g.Close() t.Fatalf("Commit: %v", err) } - data, err := g.GraphData() + if err := g.Close(); err != nil { + t.Fatal(err) + } + reopened, err := memory.NewGraphDB(path) if err != nil { - t.Fatalf("GraphData: %v", err) + t.Fatal(err) } - if data == nil { - t.Fatal("★ GraphData 返回 nil") + defer reopened.Close() + result, err := reopened.Recall([]string{"billing服务"}, nil, 2, "") + if err != nil { + t.Fatal(err) } - // 可观察输出:GraphData 必须给出 nodes/edges 结构(而非 panic / 空 map) - if _, ok := data["nodes"]; !ok { - t.Fatalf("★ GraphData 缺 nodes 字段:%v", data) + for _, relation := range result.Relations { + if relation.SourceName == tri.Subject && relation.RelationType == tri.Relation && relation.TargetName == tri.Object { + return + } } + t.Fatalf("committed fact missing after reopen: %+v", result) } // TestReadOnlyHandleRejectsWrite:OpenGraphDBReadOnly 必须在 SQLite 层拒写。 diff --git a/internal/nlp/test/extractor_contract_test.go b/internal/nlp/test/extractor_contract_test.go index f80fe6e3..64557df8 100644 --- a/internal/nlp/test/extractor_contract_test.go +++ b/internal/nlp/test/extractor_contract_test.go @@ -1,6 +1,6 @@ package test -// internal/nlp 的黑盒判据:NewExtractor / 配置 / Extract 的输入 → 输出。 +// internal/nlp 的模块级黑盒判据:文本输入 → 公开 Parser/Extractor → 三元组输出。 import ( "testing" @@ -8,58 +8,64 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/nlp" ) -// TestDefaultExtractorConfig:默认融合权重是可用常量。 -func TestDefaultExtractorConfig(t *testing.T) { - c := nlp.DefaultExtractorConfig() - if c.FusionAlpha <= 0 || c.FusionBeta <= 0 || c.FusionThreshold <= 0 { - t.Fatalf("★ 默认配置不可用:%+v", c) +type contractParser struct{} + +func (contractParser) Parse(string) (*nlp.ParseResult, error) { + // billing --SBV--> 监听 <--VOB-- 8499 + return &nlp.ParseResult{ + Tokens: []string{"billing", "监听", "8499"}, + POS: []string{"n", "v", "n"}, + Heads: []int{2, 0, 2}, + DepRels: []string{"SBV", "ROOT", "VOB"}, + }, nil +} + +func TestExtractRunsParserAndReturnsFact(t *testing.T) { + e := nlp.NewExtractor(contractParser{}) + got := e.Extract("billing监听8499") + if got == nil || got.Err != nil { + t.Fatalf("Extract failed: %+v", got) + } + if got.Src != "dep_parser" || len(got.Triples) != 1 { + t.Fatalf("module output = %+v", got) + } + tr := got.Triples[0] + if tr.Subject != "billing" || tr.Relation != "监听" || tr.Object != "8499" { + t.Fatalf("unexpected extracted fact: %+v", tr) + } + if tr.SentenceRef != "billing监听8499" || tr.Score <= 0 { + t.Fatalf("missing observable provenance/score: %+v", tr) } } -// TestSetFusionWeightsRoundTrip:设入的权重能从 FusionConfig 读回。 -func TestSetFusionWeightsRoundTrip(t *testing.T) { +func TestFusionConfigurationChangesExtractionDecision(t *testing.T) { + e := nlp.NewExtractor(contractParser{}) + e.SetFusionWeights(1, 0, 0.95) + if got := e.Extract("billing监听8499"); got == nil || len(got.Triples) != 0 { + t.Fatalf("high threshold should reject the parser candidate: %+v", got) + } + e.SetFusionWeights(1, 0, 0.1) + got := e.Extract("billing监听8499") + if got == nil || len(got.Triples) != 1 { + t.Fatalf("lower threshold should restore the fact: %+v", got) + } +} + +func TestExtractorEmptyAndFallbackInput(t *testing.T) { e := nlp.NewExtractor(nil) - e.SetFusionWeights(0.25, 0.75, 0.42) - got := e.FusionConfig() - if got.FusionAlpha != 0.25 || got.FusionBeta != 0.75 || got.FusionThreshold != 0.42 { - t.Fatalf("★ FusionConfig = %+v", got) + if got := e.Extract(""); got == nil || len(got.Triples) != 0 { + t.Fatalf("empty input should produce no facts: %+v", got) + } + if got := e.Extract("billing服务的端口是8499"); got == nil { + t.Fatal("fallback extraction returned nil") } } -// stubParser 满足 Parser 接口,作为 SetDefaultParser 的输入。 -type stubParser struct{} - -func (p *stubParser) Parse(text string) (*nlp.ParseResult, error) { - return &nlp.ParseResult{}, nil -} - -// TestSetDefaultParserRoundTrip:设入的 parser 能从 GetDefaultParser 读回。 -func TestSetDefaultParserRoundTrip(t *testing.T) { - want := &stubParser{} - nlp.SetDefaultParser(want) +func TestDefaultParserInjectionChangesNewExtractorBehavior(t *testing.T) { + nlp.SetDefaultParser(contractParser{}) t.Cleanup(func() { nlp.SetDefaultParser(nil) }) - if got := nlp.GetDefaultParser(); got != want { - t.Fatalf("★ GetDefaultParser = %v,期望刚设入的 parser", got) - } -} - -// TestExtractEmptyTextReturnsEmptySet:空文本返回空结果,不 panic。 -func TestExtractEmptyTextReturnsEmptySet(t *testing.T) { - e := nlp.NewExtractor(nil) - got := e.Extract("") - if got == nil { - t.Fatal("★ Extract 返回 nil") - } - if len(got.Triples) != 0 { - t.Fatalf("★ 空文本提取出 %d 条三元组", len(got.Triples)) - } -} - -// TestExtractNonEmptyTextDoesNotPanic:非空文本输出可用(不钉死内容)。 -func TestExtractNonEmptyTextDoesNotPanic(t *testing.T) { - e := nlp.NewExtractor(nil) - got := e.Extract("billing服务 的 端口 是 8499") - if got == nil { - t.Fatal("★ Extract 返回 nil") + got := nlp.NewExtractor(nil).Extract("billing监听8499") + if got == nil || got.Src != "dep_parser" || len(got.Triples) != 1 { + t.Fatalf("default parser was not used by NewExtractor(nil): %+v", got) } }