Files
HomeAgent/internal/memory/distill/blocks_test.go
JianFeeeee 6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00

596 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package distill
import (
"context"
"errors"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/pkg/generation"
)
// 拆解产物的块落库。判据核心:三元组应是【句子】【contains 边】【块】,
// 而不是 entities/relations(本会话最初的错误做法)。
func newBlockGraph(t *testing.T) *memory.GraphDB {
t.Helper()
g, err := memory.NewGraphDB(t.TempDir() + "/graph.db")
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { g.Close() })
return g
}
func recGraphTriples(rec string) *fakeGen {
return &fakeGen{text: `{"fields":[
{"name":"停机时长","value":"4分"},
{"name":"回滚版本","value":"v2.29.5"},
{"name":"灰度比例","value":"10%"}]}`}
}
const blockRec = "第112批周四凌晨2点·停机4分·回滚v2.29.5·灰度10%"
// ★ 落库形态:句子 + 块 + contains 边,**零 entity**。
func TestWritePayload_句子块边形态(t *testing.T) {
g := newBlockGraph(t)
e := NewExtractor(recGraphTriples(blockRec), nil)
payload, err := e.Blocks(context.Background(), blockRec)
if err != nil {
t.Fatalf("Blocks: %v", err)
}
if payload.Sentence != blockRec {
t.Errorf("原句应保留,实际 %q", payload.Sentence)
}
if len(payload.Fields) != 3 {
t.Fatalf("期望 3 个字段,实际 %d: %+v", len(payload.Fields), payload.Fields)
}
n, err := WritePayload(context.Background(), g, payload, nil)
if err != nil {
t.Fatalf("WritePayload: %v", err)
}
if n != 3 {
t.Fatalf("应写入 3 个块,实际 %d", n)
}
blocks, err := g.MemoryBlocks()
if err != nil {
t.Fatal(err)
}
// ★ 原句块 + 3 个字段块 = 4(原为 3,方案 A 让原句也成了块)
if len(blocks) != 4 {
t.Fatalf("库中应有 4 个块(1 原句 + 3 字段),实际 %d", len(blocks))
}
edges, err := g.MemoryBlockEdges()
if err != nil {
t.Fatal(err)
}
if len(edges) != 3 {
t.Fatalf("应有 3 条 contains 边,实际 %d", len(edges))
}
for _, ed := range edges {
if ed.SourceKind != "block" || ed.TargetKind != "block" || ed.Type != "contains" {
t.Errorf("边形态应为 sentence--contains-->block,实际 %+v", ed)
}
}
// ★ 关键:块路径**不写旧表 entities**。
//
// ★ 判据口径变了(2026-10-04):原来断言「Recall 返回 0 个实体」。
// 那是在 Recall 仍读旧表时的写法 —— Recall 切块之后,
// 它必然返回块(那正是块路径该做的事),于是判据恒红。
//
// 判据的**意图**是「没有走旧表双写」,所以直接查旧表本身 ——
// 与实现无关,且比间接断言更可靠。
legacy, err := g.LegacyEntities(0, 1)
if err != nil {
t.Fatalf("LegacyEntities: %v", err)
}
if len(legacy) != 0 {
t.Errorf("块路径不该写 entities(旧表 %d 个): %+v", len(legacy), legacy)
}
}
// 块文本承载「维度=值」的完整语义。
func TestWritePayload_块文本形态(t *testing.T) {
g := newBlockGraph(t)
e := NewExtractor(recGraphTriples(blockRec), nil)
payload, _ := e.Blocks(context.Background(), blockRec)
if _, err := WritePayload(context.Background(), g, payload, nil); err != nil {
t.Fatal(err)
}
blocks, _ := g.MemoryBlocks()
texts := map[string]bool{}
for _, b := range blocks {
texts[b.Text] = true
}
// ★ 块文本形态是「<主语>|<维度>=<值>」而不是「<维度>=<值>」。
//
// 本轮补的缺口:上一版 FieldBlock 没有 Subject,块文本里只有维度与值。
// 单主语时就丢了「这条是关于谁的」(第112批),一句话多主语时更致命
// (三个服务的端口会全变成「端口=8861」而无法区分)。所以这里跟着改成
// 带主语的形态断言 —— 不是放宽,是把旧形态的漏洞写进判据。
for _, want := range []string{
"第112批|停机时长=4分",
"第112批|回滚版本=v2.29.5",
"第112批|灰度比例=10%",
} {
if !texts[want] {
t.Errorf("缺少块文本 %q,实际 %v", want, texts)
}
}
}
// ★ 幂等:同一记录重复拆解得到同一批块 ID,不堆重复记忆。
//
// 蒸馏是可重试的(distillOnce 失败会把记录写回队列),随机 ID 会让
// 每次重试都产生新块,同一句记忆堆成 N 份。
func TestWritePayload_幂等(t *testing.T) {
g := newBlockGraph(t)
e := NewExtractor(recGraphTriples(blockRec), nil)
for i := 0; i < 3; i++ {
payload, _ := e.Blocks(context.Background(), blockRec)
if _, err := WritePayload(context.Background(), g, payload, nil); err != nil {
t.Fatalf("第 %d 次写入: %v", i, err)
}
}
blocks, _ := g.MemoryBlocks()
// ★ 同上:1 原句块 + 3 字段块,重复写不增
if len(blocks) != 4 {
t.Fatalf("重复写入 3 次后仍应只有 4 个块,实际 %d", len(blocks))
}
}
// ★ 无 provider 时块仍入库但不带向量——**不编造零向量**。
//
// 编造零向量会让它参与向量检索并永远排在最后,那是静默的错误记忆。
func TestWritePayload_无provider不编造向量(t *testing.T) {
g := newBlockGraph(t)
e := NewExtractor(recGraphTriples(blockRec), nil)
payload, _ := e.Blocks(context.Background(), blockRec)
if _, err := WritePayload(context.Background(), g, payload, nil); err != nil {
t.Fatal(err)
}
blocks, _ := g.MemoryBlocks()
for _, b := range blocks {
if len(b.Vector) != 0 {
t.Errorf("无 provider 时不该有向量,%s 有 %d 维", b.ID, len(b.Vector))
}
if b.Fingerprint != "" {
t.Errorf("无 provider 时不该有指纹,%s 有 %q", b.ID, b.Fingerprint)
}
}
// 但它们仍可被按边查到
sid := SentenceBlockID(blockRec)
got, err := g.BlocksForNode("block", sid)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("无向量的块仍应按边可查,实际 %d", len(got))
}
}
// 有 provider 时向量与指纹都落库。
func TestWritePayload_带向量落库(t *testing.T) {
g := newBlockGraph(t)
e := NewExtractor(recGraphTriples(blockRec), nil)
payload, _ := e.Blocks(context.Background(), blockRec)
embed := func(string) ([]float64, string) {
return []float64{1, 0, 0}, "fp-test"
}
if _, err := WritePayload(context.Background(), g, payload, embed); err != nil {
t.Fatal(err)
}
blocks, _ := g.MemoryBlocks()
var withVec, sentenceBlocks int
for _, b := range blocks {
if b.Source == "sentence" {
// ★ 原句块**刻意不带向量**
//
// 它是溯源锚点,不参与向量召回。理由:
// 长整句的句向量会把关键值稀释掉 ——
// 实测「13000端口」(7字) 容易被召回,
// 而「13010/13011 而非 12011」(20字) 召不回。
// 让原句进召回只会引入噪声。
sentenceBlocks++
if len(b.Vector) != 0 {
t.Errorf("原句块 %s 不该带向量,实际 %d 维", b.ID, len(b.Vector))
}
continue
}
withVec++
if len(b.Vector) != 3 || b.Fingerprint != "fp-test" {
t.Errorf("%s 应带 3 维向量与 fp-test,实际 %d 维 %q",
b.ID, len(b.Vector), b.Fingerprint)
}
}
if sentenceBlocks != 1 {
t.Errorf("应恰好 1 个原句块,实际 %d", sentenceBlocks)
}
if withVec == 0 {
t.Error("字段块都该带向量")
}
}
// 空字段是正常结果:0 块、不报错、不写句子。
func TestWritePayload_空字段(t *testing.T) {
g := newBlockGraph(t)
e := NewExtractor(&fakeGen{text: `{"fields":[]}`}, nil)
payload, err := e.Blocks(context.Background(), "第129~143批均仅评审通过")
if err != nil {
t.Fatal(err)
}
n, err := WritePayload(context.Background(), g, payload, nil)
if err != nil {
t.Fatalf("空字段不该报错: %v", err)
}
if n != 0 {
t.Fatalf("应写入 0 块,实际 %d", n)
}
blocks, _ := g.MemoryBlocks()
if len(blocks) != 0 {
t.Fatalf("空字段不该产生块,实际 %d", len(blocks))
}
}
// 空 payload / nil graph 必须显式报错,不能静默成功。
func TestWritePayload_非法输入报错(t *testing.T) {
g := newBlockGraph(t)
if _, err := WritePayload(context.Background(), g, nil, nil); err == nil {
t.Error("nil payload 应报错")
}
if _, err := WritePayload(context.Background(), nil,
&BlockPayload{Sentence: "x", Fields: []FieldBlock{{Dimension: "d", Value: "v"}}}, nil); err == nil {
t.Error("nil graph 应报错")
}
if _, err := WritePayload(context.Background(), g, &BlockPayload{}, nil); err == nil {
t.Error("空句子应报错")
}
}
// 模型失败时透传错误(调用方据此重试)。
func TestBlocks_模型失败透传(t *testing.T) {
g := newBlockGraph(t)
e := NewExtractor(&fakeGen{err: errors.New("model down")}, nil)
if _, err := e.Blocks(context.Background(), blockRec); err == nil {
t.Fatal("应透传模型错误")
}
_ = g
}
// BlockID 内容派生且稳定。
func TestBlockID_稳定且区分(t *testing.T) {
a := BlockID("s", "主语", "d", "v")
b := BlockID("s", "主语", "d", "v")
if a != b {
t.Error("同输入应得同 ID(幂等的前提)")
}
if !strings.HasPrefix(a, "blk_") {
t.Errorf("ID 应有 blk_ 前缀,实际 %q", a)
}
// ★ 三个输入分量都必须参与派生。
//
// 变异自证抓出来的漏洞:上一版只测了「不同 value」,于是把 dimension
// 从派生里去掉后测试仍然通过——而那会让「停机时长」与「回滚版本」在
// 同一句话里撞成同一个块 ID,后者覆盖前者,**静默丢失一条记忆**。
distinct := map[string]string{
"同句同值不同维度": BlockID("s", "主语", "d2", "v"),
"同句同维度不同值": BlockID("s", "主语", "d", "v2"),
"同维度同值不同句": BlockID("s2", "主语", "d", "v"),
// ★ 主语必须参与派生:三个服务的端口同维度同值时,
// 不含 subject 就会撞 ID —— 静默丢一条记忆(无任何报错)。
"同维度同值不同主语": BlockID("s", "主语2", "d", "v"),
}
for name, id := range distinct {
if id == a {
t.Errorf("%s 应与基准 ID 不同(该分量未参与派生): %q", name, id)
}
}
// 两两之间也要不同
seen := map[string]string{}
for name, id := range distinct {
if prev, dup := seen[id]; dup {
t.Errorf("%s 与 %s 撞 ID: %q", name, prev, id)
}
seen[id] = name
}
}
// ★ 同一句话里不同维度必须得到不同块 ID(上面的漏洞会造成静默覆盖)。
func TestWritePayload_同句不同维度不撞块(t *testing.T) {
g := newBlockGraph(t)
e := NewExtractor(&fakeGen{text: `{"fields":[
{"name":"停机时长","value":"4分"},
{"name":"回滚版本","value":"4分"}]}`}, nil) // 值相同、维度不同
payload, err := e.Blocks(context.Background(), "第112批 停机4分 回滚4分")
if err != nil {
t.Fatal(err)
}
n, err := WritePayload(context.Background(), g, payload, nil)
if err != nil {
t.Fatal(err)
}
blocks, _ := g.MemoryBlocks()
// ★ n 是**字段块**数(WritePayload 的返回值语义),不含原句块。
// 总块数 = n 字段 + 1 原句。
if len(blocks) != n+1 || len(blocks) != 3 {
t.Fatalf("值相同但维度不同应产生 3 个块(1 原句 + 2 字段),实际 %d(块 ID 撞了会少)", len(blocks))
}
}
// Dimension/Value 从块文本取回结构化字段。
func TestDimensionValue(t *testing.T) {
for _, c := range []struct{ text, dim, val string }{
{"停机时长=4分", "停机时长", "4分"},
{"回滚版本=v2.29.5", "回滚版本", "v2.29.5"},
{"无等号", "", "无等号"},
} {
if got := Dimension(c.text); got != c.dim {
t.Errorf("Dimension(%q)=%q,期望 %q", c.text, got, c.dim)
}
if got := Value(c.text); got != c.val {
t.Errorf("Value(%q)=%q,期望 %q", c.text, got, c.val)
}
}
}
func itoa64(i int64) string {
if i == 0 {
return "0"
}
var b []byte
for i > 0 {
b = append([]byte{byte('0' + i%10)}, b...)
i /= 10
}
return string(b)
}
var _ = generation.ErrSchemaUnsupported
// ★ 主语必须进块文本(不只是进 ID)。
//
// 这是本轮修的真缺口:一句多主语时
// 「admin服务端口8861·billing服务端口8499·oauth服务端口8271」
// 拆出三条同维度不同值的字段,少了主语就只剩「端口=8861」,
// 三个服务的端口落库后无法区分 —— 查「billing 的端口」时块文本里
// 根本没有 billing,向量也召回不到。
func TestFieldBlock_主语进块文本(t *testing.T) {
f := FieldBlock{Subject: "billing服务", Dimension: "端口", Value: "8499"}
b := f.ToMemoryBlock("admin服务端口8861·billing服务端口8499·oauth服务端口8271")
if b.Text == "端口=8499" {
t.Errorf("块文本必须含主语,实际 %q", b.Text)
}
if got := BlockSubject(b.Text); got != "billing服务" {
t.Errorf("BlockSubject(%q) = %q,期望 billing服务", b.Text, got)
}
if got := Dimension(b.Text); got != "端口" {
t.Errorf("Dimension(%q) = %q,期望 端口(不该混进主语)", b.Text, got)
}
if got := Value(b.Text); got != "8499" {
t.Errorf("Value(%q) = %q,期望 8499", b.Text, got)
}
// 往返:文本 → 三段全还原
if BlockSubject(b.Text)+"|"+Dimension(b.Text)+"="+Value(b.Text) != b.Text {
t.Errorf("往返不一致:%q", b.Text)
}
}
// 三个服务的端口必须是三个不同的块(ID 不撞、文本可区分)。
func TestFieldBlock_多主语不撞ID(t *testing.T) {
sent := "admin服务端口8861·billing服务端口8499·oauth服务端口8271"
fields := []FieldBlock{
{Subject: "admin服务", Dimension: "端口", Value: "8861"},
{Subject: "billing服务", Dimension: "端口", Value: "8499"},
{Subject: "oauth服务", Dimension: "端口", Value: "8271"},
}
ids := map[string]string{}
for _, f := range fields {
b := f.ToMemoryBlock(sent)
if prev, dup := ids[b.ID]; dup {
t.Errorf("块 ID 撞了:%s 与 %s 同为 %s", prev, b.Text, b.ID)
}
ids[b.ID] = b.Text
}
if len(ids) != 3 {
t.Errorf("三个服务的端口应得 3 个块,实际 %d", len(ids))
}
}
// 无主语时(不该出现,但 BlockPayload 可能来自别的路径)文本不带 '|',
// 切分函数仍要正确工作而不是崩。
func TestFieldBlock_无主语时切分(t *testing.T) {
f := FieldBlock{Dimension: "停机时长", Value: "4分"}
b := f.ToMemoryBlock("第112批周四凌晨2点·停机4分")
if b.Text != "停机时长=4分" {
t.Errorf("无主语时文本不该带分隔符,实际 %q", b.Text)
}
if got := BlockSubject(b.Text); got != "" {
t.Errorf("无主语时 BlockSubject 应为空,实际 %q", got)
}
if got := Dimension(b.Text); got != "停机时长" {
t.Errorf("Dimension = %q", got)
}
if got := Value(b.Text); got != "4分" {
t.Errorf("Value = %q", got)
}
}
// Blocks() 必须把 Split 算出的主语带到 FieldBlock 里。
//
// ★ 这条直接盯着本轮修的缺口:上一版 FieldBlock 没有 Subject 字段,
// 于是多主语在落库时被丢弃 —— 编译通过、单测全绿,但数据是错的。
func TestBlocks_主语透传到字段块(t *testing.T) {
e := NewExtractor(&fakeGen{text: `{"fields":[{"name":"端口","value":"8861"},{"name":"端口","value":"8499"}]}`}, nil)
payload, err := e.Blocks(context.Background(),
"admin服务端口8861·billing服务端口8499")
if err != nil {
t.Fatal(err)
}
if len(payload.Fields) != 2 {
t.Fatalf("应拆出 2 个字段,实际 %d", len(payload.Fields))
}
if payload.Fields[0].Subject != "admin服务" {
t.Errorf("字段0 主语应为 admin服务,实际 %q", payload.Fields[0].Subject)
}
if payload.Fields[1].Subject != "billing服务" {
t.Errorf("字段1 主语应为 billing服务,实际 %q", payload.Fields[1].Subject)
}
}
// ★★ 方案 A 判据:蒸馏落块**不得再写 sentences 表**。
//
// 这是退场的最后依赖点 —— distill 曾是 sentences 表的写入者之一。
func TestWritePayload_不写sentences表(t *testing.T) {
g := newBlockGraph(t)
defer g.Close()
pl := &BlockPayload{
Sentence: "值班室分机号 4324,值班 老周",
Fields: []FieldBlock{
{Subject: "值班室", Dimension: "分机号", Value: "4324"},
{Subject: "值班", Dimension: "人员", Value: "老周"},
},
}
n, err := WritePayload(context.Background(), g, pl, nil)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("应写 2 个字段块,实际 %d", n)
}
// ★ sentences 表必须仍是空的
sentCount, err := g.CountSentences()
if err != nil {
t.Fatal(err)
}
if sentCount != 0 {
t.Errorf("★ distill 写了 %d 条 sentences —— 方案 A 要求退场该表", sentCount)
}
// 字段块与原句块都要在
blocks, err2 := g.MemoryBlocks()
err = err2
if err != nil {
t.Fatal(err)
}
var hasSentence, hasField int
for _, b := range blocks {
if b.Source == "sentence" {
hasSentence++
}
if b.Source == "distill" {
hasField++
}
}
if hasSentence != 1 {
t.Errorf("应恰好 1 个原句块,实际 %d", hasSentence)
}
if hasField != 2 {
t.Errorf("应恰好 2 个字段块,实际 %d", hasField)
}
}
// ★ 边必须是 block→block(不再是 sentence→block)。
func TestWritePayload_边是块到块(t *testing.T) {
g := newBlockGraph(t)
defer g.Close()
pl := &BlockPayload{
Sentence: "连接池容量 128/256 扩容后",
Fields: []FieldBlock{{Subject: "连接池", Dimension: "容量", Value: "128/256"}},
}
if _, err := WritePayload(context.Background(), g, pl, nil); err != nil {
t.Fatal(err)
}
edges, err := g.MemoryBlockEdges()
if err != nil {
t.Fatal(err)
}
found := 0
for _, e := range edges {
if e.Type == "contains" {
found++
if e.SourceKind != "block" {
t.Errorf("contains 边起点应是 block,实际 %q", e.SourceKind)
}
if e.TargetKind != "block" {
t.Errorf("contains 边终点应是 block,实际 %q", e.TargetKind)
}
}
}
if found != 1 {
t.Errorf("应恰好 1 条 contains 边,实际 %d", found)
}
}
// ★ 幂等:同一原句重复跑不产生重复块,也不产生重复边。
//
// —— 记忆里明确记着「重试不能产生重复记忆」。
func TestWritePayload_重复跑幂等(t *testing.T) {
g := newBlockGraph(t)
defer g.Close()
pl := &BlockPayload{
Sentence: "第130批 v2.31.5 评审通过",
Fields: []FieldBlock{{Subject: "第130批", Dimension: "版本", Value: "v2.31.5"}},
}
for i := 0; i < 3; i++ {
if _, err := WritePayload(context.Background(), g, pl, nil); err != nil {
t.Fatalf("第 %d 次失败: %v", i+1, err)
}
}
blocks, err := g.MemoryBlocks()
if err != nil {
t.Fatal(err)
}
if len(blocks) != 2 {
ids := make([]string, 0, len(blocks))
for _, b := range blocks {
ids = append(ids, b.ID)
}
t.Errorf("重复跑 3 次后应仍是 2 个块(原句+字段),实际 %d: %v", len(blocks), ids)
}
edges, err := g.MemoryBlockEdges()
if err != nil {
t.Fatal(err)
}
var contains int
for _, e := range edges {
if e.Type == "contains" {
contains++
}
}
if contains != 1 {
t.Errorf("重复跑 3 次后应仍是 1 条 contains 边,实际 %d", contains)
}
}
// ★ 变异自证:把原句块 ID 改成非确定性,幂等判据必须变红。
func TestSentenceBlockID_确定性(t *testing.T) {
s := "值班室分机号 4324,值班 老周"
a := SentenceBlockID(s)
b := SentenceBlockID(s)
if a != b {
t.Fatalf("同原句必须得到同 ID:%s vs %s", a, b)
}
// ★ 首尾空格**应当**得到同 ID —— TrimSpace 的目的正是如此:
// 原句带的换行/空格不该让它与另一个库里的同一句话分成两块。
if a != SentenceBlockID(s+" \n") {
t.Error("首尾空白差异不该产生不同 ID(那是同一句话)")
}
if a == SentenceBlockID("别的句子") {
t.Error("不同原句必须得到不同 ID")
}
if len(a) < 8 || a[:8] != "blk_src_" {
t.Errorf("ID 前缀应为 blk_src_,实际 %q", a)
}
}