mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +00:00
记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放: 用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert 交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。 ## 一、公开 SDK:补上媒体的表达能力(全部新增,无签名变更) - `Triple` += `SentenceText`、`MediaDigests` - `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment` - `TextEvent` += `Attachments` - `DocMemoryAPI` += `InsertWithMedia` - `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia` - `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有) `MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节 去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索 可能命中几十份媒体,全塞回去会把跨进程消息撑爆。 媒体注入不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,且媒体 要等下一条 tool message 才到模型手上。插件主动发起一轮带媒体的对话、以及中断 注入,需要自己的签名,且媒体在**本轮**就送到模型。 ## 二、内核桥接层:原先在静默裁字段 `internal/sdk/memory_impl.go` 此前只搬自己认识的几个字段,其余丢弃且返回 nil: - 图记忆丢 `Confidence`/`SubjectType`/`ObjectType`/`SentenceText`,又走 `Commit` 而非 `CommitWithMedia`(不回 sentenceIDs)→ 媒体绑定链 `SentenceText → sentences → sentence_id → media_refs` 一步都走不通,插件即便按格式写好标记也永远挂不上; - 知识库 `Query` 只回 ID/Title/Content,`Insert` 只写这三个;`Remove` 不解引用, 于是那些媒体永久处于「被引用」状态,GC 收不掉、磁盘只增不减 (内核的归档路径 `releaseDocMedia` 做了这一步,插件路径漏了同一步)。 规则改为:**内部结构有的字段一律透传**。标记格式处理作为包级私有辅助留在桥接 层自己手里,但必须与内核 `mediaSummaryForEvent` 字节兼容——两边要能互读对方 写下的标记。 标记插入必须在 `ds.Insert` **之前**(向量索引取 `Summary + " " + Content`, 之后补的标记检索不到),引用绑定必须在**之后**(owner_id 是 Insert 生成的 ID)。 ## 三、跨进程链路:不接线就是全体外部插件编译失败 `go test` 直接把这一层拍出来了——`procIO does not implement sdk.IOInjector`。 公开接口加方法后,生成模板不跟上,**每个外部插件都编不过**,是硬失败不是软降级。 六处接线:`protocol.go` 四个 method 常量、`capability.go` 能力归属、 `corehandler.go` 四个分派分支、`proc_core.go` 委托、`proc_main.go.tmpl` 模板侧 实现、以及三个测试替身。 ## 四、统一输入主干:把模态从「函数选择」降级为「字段」 `processTextInput` / `processMediaInput` 合并为 `processInput`。这个分叉是历史 产物而非设计:`processTextInput` 本来就处理媒体(`bindEventMedia` + `mediaSummaryForEvent`,与媒体路径尾部完全相同),`process()` 只看 `stageCtx.Extra["media_blocks"]`、根本不认识 `evt.Type`。模态是输入的**属性**, 不是输入的**种类**。 媒体路径由此获得它一直缺的六项:去重、`no_memory`、通道 `Cleaner`、中断语义、 `_consolidation_` 路由、正确的 `EventRawInput`。 最后一项是个真 bug:媒体路径发布 `"content": evt.Payload`(一个 map),而 `webui/handler.go` 断言 `.(string)` → 断言失败、`content == ""`、提前返回。 **用户发的图从来没出现在 WebUI 聊天记录里。** `media_blocks` 同时接受 `[]agentAPI.ContentBlock` 与 `[]pubsdk.ContentBlock`: 字段一致但 Go 不自动转换,只认一种的后果是另一种被静默丢弃。 ## 五、模型可调用的三个工具 `memory_commit` 的 `sentence_text` **从未暴露给模型**,而它是绑定链上的必经环节; 连同 `media_digests` 一起补进 JSON schema 与工具文档。`doc_commit` 加 `media_digests`。`doc_query` 把关联媒体单独一行附在结果末尾(正文按 2000 字截断, 标记通常就在尾部)。 标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误 静默切断引用绑定,而全链路无人报错。 ## 六、WebUI 上传走真实媒体链路 图片/音频读回字节拼 data URL 注入 `media_blocks`(8MB 上限,超限退回按路径处理)。 此前只注入一句「文件已保存到 <路径>」,指望模型自己调 `files_read`——但那返回 文本,图片字节对模型永远不可见。附件类型识别扩展到 audio 并在缺 Content-Type 时按扩展名兜底(判错不只是卡片样式问题,图片被当普通文件就进不了视觉链路)。 ## 测试 - `internal/sdk/memory_impl_test.go`(12 例,此前该包**没有任何测试文件**) - `internal/agent/core/inputunify_test.go`(统一主干 + 双静态类型 + 三工具媒体) - `third_party/homeagent-sdk/sdk/stress_test.go`(13 例并发压测) 压测抓到两处**真**竞态(不是理论风险):`PluginSDK` 的 API 字段与 `autoRestart` 无锁,而写方(内核注入 API、插件 `SetAutoRestart`)与读方(插件后台 goroutine 注入、内核 registry 读 `AutoRestart`)天然跨 goroutine。加 `apiMu` 修掉;约定 只在持锁期间取字段值,取完即释放再调用——持锁调用会把 `InjectInputSync` 这类 阻塞到 agent 回复(可达数分钟)的方法与 `SetIOInjector` 串起来,让插件重载卡死。 测试还抓出两个自身缺陷:`bindDocMedia` 把同一份媒体数两次(`AddRef` 幂等所以表 是对的,但日志说「绑定 2 个」而实际 1 条——误导后续排查),以及用单字符实体名 时 `validEntityName` 静默跳过、`Commit` 返回 nil 却什么都没写。 存量插件不需要改一行也不需要重编:新增方法由插件调用、内核实现,不调就不受影响。 17 个 example 插件源码零改动通过类型检查。
399 lines
13 KiB
Go
399 lines
13 KiB
Go
package proc
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
|
||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||
)
|
||
|
||
// 端到端验证:内核 RunStage 并发扇出 → 真实子进程插件经共享内存读改写 → 结果回读。
|
||
//
|
||
// 这是**整个迁移最关键的一环闭环验证**(§4.4 风险 3.4):
|
||
// 机制在 shm_test.go 已被单元验证,这里验证它在真进程 + 真 RPC 下同样成立。
|
||
|
||
// fakeCoreSDK 是最简 CoreSDK 实现,记录注册行为。
|
||
type fakeCoreSDK struct {
|
||
mu sync.Mutex
|
||
tools map[string]pubsdk.ToolHandler
|
||
stages map[pubsdk.Stage][]pubsdk.StageHandler
|
||
outputs map[string]pubsdk.ToolHandler
|
||
settings map[string]interface{}
|
||
autoStart bool
|
||
}
|
||
|
||
func newFakeCore() *fakeCoreSDK {
|
||
return &fakeCoreSDK{
|
||
tools: map[string]pubsdk.ToolHandler{},
|
||
stages: map[pubsdk.Stage][]pubsdk.StageHandler{},
|
||
outputs: map[string]pubsdk.ToolHandler{},
|
||
settings: map[string]interface{}{},
|
||
}
|
||
}
|
||
|
||
func (f *fakeCoreSDK) PluginName() string { return "fake" }
|
||
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return nil }
|
||
func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil }
|
||
func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil }
|
||
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return nil }
|
||
func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return nil }
|
||
func (f *fakeCoreSDK) LLM() pubsdk.LLMAPI { return nil }
|
||
func (f *fakeCoreSDK) Social() pubsdk.SocialAPI { return nil }
|
||
func (f *fakeCoreSDK) PluginMgr() pubsdk.PluginMgrAPI { return nil }
|
||
func (f *fakeCoreSDK) RegisterPluginAPI(name string) error { return nil }
|
||
func (f *fakeCoreSDK) InjectText(s, c, t string) {}
|
||
func (f *fakeCoreSDK) InjectInterruptText(s, c, t string) {}
|
||
func (f *fakeCoreSDK) InjectTextNoMemory(s, c, t string) {}
|
||
func (f *fakeCoreSDK) InjectInputSync(s, c, t string) string { return "" }
|
||
func (f *fakeCoreSDK) InjectInputMedia(s, c, t string, b []pubsdk.ContentBlock) {}
|
||
func (f *fakeCoreSDK) InjectInputMediaSync(s, c, t string, b []pubsdk.ContentBlock) string {
|
||
return ""
|
||
}
|
||
func (f *fakeCoreSDK) InjectInterruptMedia(s, c, t string, b []pubsdk.ContentBlock) {}
|
||
func (f *fakeCoreSDK) SetAutoRestart(enabled bool) { f.autoStart = enabled }
|
||
|
||
func (f *fakeCoreSDK) RegisterTool(name string, def pubsdk.ToolDef, h pubsdk.ToolHandler) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.tools[name] = h
|
||
return nil
|
||
}
|
||
|
||
func (f *fakeCoreSDK) RegisterStage(stage pubsdk.Stage, h pubsdk.StageHandler, scope ...pubsdk.StageScope) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.stages[stage] = append(f.stages[stage], h)
|
||
}
|
||
|
||
func (f *fakeCoreSDK) RegisterOutputChannel(name string, caps int, desc string, def pubsdk.ChannelDef, h pubsdk.ToolHandler) error {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.outputs[name] = h
|
||
return nil
|
||
}
|
||
|
||
func (f *fakeCoreSDK) RegisterInputChannel(name string, def pubsdk.ChannelDef) error { return nil }
|
||
|
||
func (f *fakeCoreSDK) stageHandlers(stage pubsdk.Stage) []pubsdk.StageHandler {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
out := make([]pubsdk.StageHandler, len(f.stages[stage]))
|
||
copy(out, f.stages[stage])
|
||
return out
|
||
}
|
||
|
||
// runStageLikeKernel 复刻 internal/agent/core.StageHost.RunStage 的并发扇出语义
|
||
// (stages.go:124 的 go func + wg.Wait),验证外部插件在同样的并发模型下正确工作。
|
||
func runStageLikeKernel(handlers []pubsdk.StageHandler, sc *pubsdk.StageContext) []error {
|
||
var wg sync.WaitGroup
|
||
errCh := make(chan error, len(handlers))
|
||
for _, h := range handlers {
|
||
wg.Add(1)
|
||
go func(fn pubsdk.StageHandler) {
|
||
defer wg.Done()
|
||
if err := fn(sc); err != nil {
|
||
errCh <- err
|
||
}
|
||
}(h)
|
||
}
|
||
wg.Wait()
|
||
close(errCh)
|
||
var errs []error
|
||
for err := range errCh {
|
||
errs = append(errs, err)
|
||
}
|
||
return errs
|
||
}
|
||
|
||
// 单插件 stage 读改写:验证共享段 + RPC + 锁的完整链路。
|
||
func TestPlugin_StageReadModifyWriteOverSharedMemory(t *testing.T) {
|
||
bin := buildTestPlugin(t, "stageplugin.go")
|
||
core := newFakeCore()
|
||
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
p := New("sanitizer", bin, t.TempDir(), nil, host, nil)
|
||
if err := p.Start(core); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
defer p.Close()
|
||
|
||
handlers := core.stageHandlers(pubsdk.StageAfterToolcall)
|
||
if len(handlers) != 1 {
|
||
t.Fatalf("插件应注册 1 个 after_toolcall handler,实际 %d", len(handlers))
|
||
}
|
||
|
||
dirty := "结果:\x1b[31m脏数据\x1b[0m"
|
||
clean := "结果:脏数据"
|
||
sc := &pubsdk.StageContext{
|
||
Phase: pubsdk.StageAfterToolcall,
|
||
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "x_tool", Result: dirty}},
|
||
}
|
||
|
||
if errs := runStageLikeKernel(handlers, sc); len(errs) > 0 {
|
||
t.Fatalf("stage 执行失败: %v", errs)
|
||
}
|
||
|
||
got, _ := sc.ToolResults[0].Result.(string)
|
||
if got != clean {
|
||
t.Fatalf("插件的清洗结果未回到内核 StageContext:期望 %q,实际 %q", clean, got)
|
||
}
|
||
}
|
||
|
||
// **核心断言**:改写型插件 + 只读插件并发时,清洗结果不被覆盖。
|
||
// 复刻现网 sanitizer + weather 场景(§8.6 实测 C ABI 下 1.6~4.3% 被覆盖)。
|
||
func TestPlugin_ConcurrentWriterAndReaderNoLostUpdate(t *testing.T) {
|
||
bin := buildTestPlugin(t, "stageplugin.go")
|
||
|
||
// ❗ 两个插件进程**共享同一个 Host**(同一 memfd)——这是消除 lost update 的前提。
|
||
// 若各持一段,「内核 ctx → 段 → 插件改 → 回读 ctx」会退化成副本模型,
|
||
// 最后回读者覆盖前者,§8.4 的 35.8~36.8% 丢失原样复现。
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
writerCore := newFakeCore()
|
||
writer := New("sanitizer", bin, t.TempDir(), nil, host, nil)
|
||
if err := writer.Start(writerCore); err != nil {
|
||
t.Fatalf("writer Start: %v", err)
|
||
}
|
||
defer writer.Close()
|
||
|
||
readerBin := buildTestPlugin(t, "readonlyplugin.go")
|
||
readerCore := newFakeCore()
|
||
reader := New("weather", readerBin, t.TempDir(), nil, host, nil)
|
||
if err := reader.Start(readerCore); err != nil {
|
||
t.Fatalf("reader Start: %v", err)
|
||
}
|
||
defer reader.Close()
|
||
|
||
handlers := append(
|
||
writerCore.stageHandlers(pubsdk.StageAfterToolcall),
|
||
readerCore.stageHandlers(pubsdk.StageAfterToolcall)...,
|
||
)
|
||
if len(handlers) != 2 {
|
||
t.Fatalf("应有 2 个 handler,实际 %d", len(handlers))
|
||
}
|
||
|
||
dirty := "天气:晴 \x1b[31m28°C\x1b[0m"
|
||
clean := "天气:晴 28°C"
|
||
sc := &pubsdk.StageContext{
|
||
Phase: pubsdk.StageAfterToolcall,
|
||
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
|
||
}
|
||
|
||
if errs := runStageLikeKernel(handlers, sc); len(errs) > 0 {
|
||
t.Fatalf("stage 执行失败: %v", errs)
|
||
}
|
||
|
||
got, _ := sc.ToolResults[0].Result.(string)
|
||
if got != clean {
|
||
t.Fatalf("只读插件覆盖了改写插件的清洗结果:期望 %q,实际 %q", clean, got)
|
||
}
|
||
}
|
||
|
||
// 插件注册的工具可被内核调用,并把结果带回。
|
||
func TestPlugin_RegisteredToolInvokable(t *testing.T) {
|
||
bin := buildTestPlugin(t, "stageplugin.go")
|
||
core := newFakeCore()
|
||
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
p := New("demo", bin, t.TempDir(), nil, host, nil)
|
||
if err := p.Start(core); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
defer p.Close()
|
||
|
||
core.mu.Lock()
|
||
h, ok := core.tools["demo_upper"]
|
||
core.mu.Unlock()
|
||
if !ok {
|
||
t.Fatal("插件应注册 demo_upper 工具")
|
||
}
|
||
|
||
res, err := h(map[string]interface{}{"text": "abc"})
|
||
if err != nil {
|
||
t.Fatalf("调用工具: %v", err)
|
||
}
|
||
if res != "ABC" {
|
||
t.Fatalf("工具结果应为 ABC,实际 %v", res)
|
||
}
|
||
}
|
||
|
||
// 输出通道**同步等真实结果**:失败必须上报(§9.4 根治)。
|
||
func TestPlugin_OutputChannelReportsRealFailure(t *testing.T) {
|
||
bin := buildTestPlugin(t, "stageplugin.go")
|
||
core := newFakeCore()
|
||
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
p := New("demo", bin, t.TempDir(), nil, host, nil)
|
||
if err := p.Start(core); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
defer p.Close()
|
||
|
||
core.mu.Lock()
|
||
h, ok := core.outputs["demo_ch"]
|
||
core.mu.Unlock()
|
||
if !ok {
|
||
t.Fatal("插件应注册 demo_ch 输出通道")
|
||
}
|
||
|
||
// 成功路径
|
||
res, err := h(map[string]interface{}{"payload": "hi", "type": "text"})
|
||
if err != nil {
|
||
t.Fatalf("发送应成功: %v", err)
|
||
}
|
||
m, _ := res.(map[string]interface{})
|
||
if m["status"] != "sent" {
|
||
t.Errorf("成功应返回 status=sent,实际 %v", m)
|
||
}
|
||
|
||
// 失败路径:插件返回错误 → 调用方必须收到 error(而非假成功)
|
||
_, err = h(map[string]interface{}{"payload": "fail", "type": "text"})
|
||
if err == nil {
|
||
t.Fatal("发送失败时必须上报 error(C ABI 路径此处永远假成功)")
|
||
}
|
||
if !strings.Contains(err.Error(), "缺少 user_id") {
|
||
t.Errorf("应透传插件的失败原因,实际: %v", err)
|
||
}
|
||
}
|
||
|
||
// 插件在 plugin.start 期间反向调用内核(settings/autoRestart 等)。
|
||
func TestPlugin_ReverseCallsDuringStart(t *testing.T) {
|
||
bin := buildTestPlugin(t, "stageplugin.go")
|
||
core := newFakeCore()
|
||
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
p := New("demo", bin, t.TempDir(), nil, host, nil)
|
||
if err := p.Start(core); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
defer p.Close()
|
||
|
||
if !core.autoStart {
|
||
t.Error("插件调用 lifecycle.autoRestart 后内核状态应更新")
|
||
}
|
||
}
|
||
|
||
// 权限梯度显式化(§3.8):CoreSDK 不提供内核内部机制,
|
||
// 插件请求这些能力时必须被拒绝而非静默忽略。
|
||
func TestCoreHandler_RejectsUnknownAndUnimplementedMethods(t *testing.T) {
|
||
h := &coreHandler{sdk: newFakeCore(), name: "x", locks: &lockRegistry{}}
|
||
|
||
// 未知 method
|
||
if _, err := h.Handle("supervisor.restart", nil); err == nil {
|
||
t.Error("内核内部机制不应可达(应报未知 method)")
|
||
}
|
||
|
||
// 事件订阅:今日 C ABI 是空实现(静默成功),这里必须明确报未实现
|
||
if _, err := h.Handle(MethodEventsSubscribe, json.RawMessage(`{}`)); err == nil {
|
||
t.Error("事件订阅未落地时应明确报错,而非静默成功后收不到事件")
|
||
}
|
||
|
||
// 多模态注入同理
|
||
if _, err := h.Handle(MethodIOSetToolBlocks, json.RawMessage(`{}`)); err == nil {
|
||
t.Error("多模态注入未落地时应明确报错")
|
||
}
|
||
}
|
||
|
||
// stage 锁在无进行中 stage 时申请应被拒绝(防止插件在 stage 外乱加锁)。
|
||
func TestCoreHandler_StageLockOutsideStageRejected(t *testing.T) {
|
||
h := &coreHandler{sdk: newFakeCore(), name: "x", locks: &lockRegistry{}}
|
||
if _, err := h.Handle(MethodStageLock, nil); err == nil {
|
||
t.Error("stage 外加锁应被拒绝")
|
||
}
|
||
if !strings.Contains(fmt.Sprint(mustErr(h.Handle(MethodStageUnlock, nil))), "无进行中的 stage") {
|
||
t.Error("stage 外解锁的错误信息应说明原因")
|
||
}
|
||
}
|
||
|
||
func mustErr(_ interface{}, err error) error { return err }
|
||
|
||
// **跨进程 lost update 终极验证**:5 个独立插件进程并发读-改-写同一个
|
||
// FinalText,全部标记必须保留。
|
||
//
|
||
// 这是实验 8(5 进程 × 300 轮零丢失)在真实 RPC + 真实 RunStage 并发扇出
|
||
// 下的复刻。对照今日 C ABI 副本模型实测 35.8~36.8% 丢失(§8.4)。
|
||
func TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate(t *testing.T) {
|
||
bin := buildTestPlugin(t, "appendplugin.go")
|
||
|
||
// 关键:全部插件共享同一个 Host(同一 memfd)
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
tags := []string{"A", "B", "C", "D", "E"}
|
||
var handlers []pubsdk.StageHandler
|
||
for _, tag := range tags {
|
||
core := newFakeCore()
|
||
p := New("append-"+tag, bin, t.TempDir(), nil, host, nil)
|
||
p.env = []string{"PLUGIN_TAG=" + tag}
|
||
if err := p.Start(core); err != nil {
|
||
t.Fatalf("插件 %s Start: %v", tag, err)
|
||
}
|
||
defer p.Close()
|
||
handlers = append(handlers, core.stageHandlers(pubsdk.StageAfterToolcall)...)
|
||
}
|
||
if len(handlers) != len(tags) {
|
||
t.Fatalf("应有 %d 个 handler,实际 %d", len(tags), len(handlers))
|
||
}
|
||
|
||
sc := &pubsdk.StageContext{
|
||
Phase: pubsdk.StageAfterToolcall,
|
||
FinalText: "",
|
||
}
|
||
|
||
if errs := runStageLikeKernel(handlers, sc); len(errs) > 0 {
|
||
t.Fatalf("并发 stage 执行失败: %v", errs)
|
||
}
|
||
|
||
// 断言:各标记出现次数之和 == 最终长度 == 插件数 ⇒ 无丢失、无撕裂
|
||
total := 0
|
||
counts := map[string]int{}
|
||
for _, tag := range tags {
|
||
c := strings.Count(sc.FinalText, tag)
|
||
counts[tag] = c
|
||
total += c
|
||
}
|
||
if total != len(sc.FinalText) {
|
||
t.Fatalf("出现撕裂:各标记计数之和 %d != 最终长度 %d(final=%q counts=%v)",
|
||
total, len(sc.FinalText), sc.FinalText, counts)
|
||
}
|
||
if total != len(tags) {
|
||
t.Fatalf("出现 lost update:期望 %d 个插件的写入全部保留,实际 %d(final=%q counts=%v)",
|
||
len(tags), total, sc.FinalText, counts)
|
||
}
|
||
for tag, c := range counts {
|
||
if c != 1 {
|
||
t.Errorf("插件 %s 的写入丢失:期望 1 次,实际 %d 次", tag, c)
|
||
}
|
||
}
|
||
}
|