mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-15 08:51:49 +08:00
test: 把 helper/getter 判据改写为模块职责级黑盒契约
四个模块从「导出符号调用」改为「模块职责 → 可观察结果」。
## internal/memory
TestCommitThenRecallAfterReopen
Commit → Close → 重开同一库 → Recall 核对主/谓/宾
(原来只检查 GraphData 返回 map 里有 nodes 键)
变异:Recall 恒空 → 断言红
## internal/memory/pipeline
TestAppendThenDistillWritesQueryableFact
Append → Start → 注入 splitter → 图库 Recall 命中
TestRawRecordsPersistAcrossRestart
Append → Stop → 新实例 Start → 回读原始记录
TestSplitterFailureDoesNotFabricateFacts
拆分失败必须回退,不得编出事实
TestPauseResumeAndStopAreObservable
变异:跳过蒸馏/跳过落盘 → 均断言红
## internal/nlp
真实 Parser → 三元组 + 句子溯源 + 分值
融合阈值改变取舍(高阈值空、低阈值还原)
SetDefaultParser 影响 NewExtractor(nil)
变异:丢弃 dep 抽取 / 取消阈值截断 → 均断言红
## internal/config
新增 TestSecretSurvivesRestartAndStaysEncrypted
敏感项 Set → 磁盘无明文 → Close → 重开 → GetSecret 还原明文
变异:GetSecret 不解密 / Set 不加密落盘 → 均断言红
删除了一条不成立的断言(普通 Get 本应返回明文),按真实契约收紧。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 51 测试包 / 100 包无测试 / 0 FAIL
4 个改动包 -race 全绿
源码目录 _test.go 0
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 层拒写。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user