mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
补齐媒体记忆的最后两块:容量上限真正生效,描述文本成为持久语义记忆。
## mediaGCLoop:让容量上限不再形同虚设
CAS 的 GC 只在被显式调用时执行,Put 路径不触发它。此前配置项
core.memory.media.max_mb 注册了却没有任何调用方——一次 see_video 抽 10 帧,
帧本身在工具结果被 Prune 后就没人引用了,若无人清理会一直堆在磁盘上。
现在按 gc_interval(默认 6h)周期调 GC(gc_min_age)。两个不变量:
- 有引用的内容永不删除,即使超容量(宁可超限也不断引用)
- gc_min_age(默认 1h)保护刚 Put 还没来得及 AddRef 的项——它们
refcount 也是 0
## mediaDescribeLoop:描述才是能活过 GC 的那部分
blob 会被容量 GC 淘汰,而描述留在 media 表里,并经 mediaSummaryForEvent
写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。于是「那张紫蓝红三色
带图」在原始字节早已被清掉之后仍然可被检索到。
复用既有的视觉回退链(resolveModalFallback + chatModalFallbackBatch),
不新造一套模型调用。
三个刻意的决定:
- **走后台而非入库时同步**:视觉模型一次调用生产实测 9.6s。放在对话
路径上会让每张图都给回复加十几秒,而描述的价值是几个月后还能检索到,
不是这一轮——这一轮模型本来就直接看着图。
- **逐条而非批量**:批量拿回来是一整段文字,无法可靠切分回各自的
digest(模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次
往返也要保证「描述 ↔ digest」的对应关系确定。
- **默认关闭**(describe_on_ingest=false):它消耗视觉模型配额。开启后
每 30s 最多处理 4 条,不跟对话抢额度。
失败处理分三类:
- 网络抖动/配额 → 不标记,下轮重试
- 空回复 → 视作失败(上游剥离媒体时通常回空,与 modalfallback 同理)
- 不可描述(kind=other、blob 已丢失)→ 标记 described_by=unsupported/
content-missing,退出队列
## 顺带修掉 Pending 的一个真缺陷
测试写出来才发现:Pending 原先只看 `description = ''`,于是被标记为
described_by=unsupported 但 description 仍空的项**每轮都会被重新取出来
重试**,永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
改为同时要求 described_by 也为空。
这是「先写断言再看它是否成立」抓到的——原本我以为标记一下就够了。
## 测试
medialoop_test.go 7 例:两条循环在禁用时立即返回(nil store / 零间隔 /
describe 关闭三种形态,不留空转 goroutine)、GC 清孤儿保留有引用项、
minAge 保护新项、无可用源时不误标记、不可描述大类被标记后退出队列。
media_test.go 补 1 例专测 Pending 的排除逻辑。
全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。
514 lines
14 KiB
Go
514 lines
14 KiB
Go
package media
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func newTestStore(t *testing.T, maxBytes int64) *Store {
|
||
t.Helper()
|
||
s, err := New(t.TempDir(), maxBytes)
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
t.Cleanup(func() { s.Close() })
|
||
return s
|
||
}
|
||
|
||
func TestPut_ContentAddressedDedup(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
data := []byte("fake-png-bytes")
|
||
|
||
d1, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/a.png"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
d2, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/b.png"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if d1 != d2 {
|
||
t.Fatalf("同一内容应得同一 digest:%s vs %s", d1, d2)
|
||
}
|
||
|
||
// 去重的意义:同一张图反复注入(连问几轮同一截图、see_video 相邻帧)
|
||
// 只占一份磁盘。
|
||
st := s.Stats()
|
||
if st["count"].(int) != 1 {
|
||
t.Fatalf("同一内容应只有 1 条记录,实际 %v", st["count"])
|
||
}
|
||
}
|
||
|
||
func TestPut_KindInferredFromMIME(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
cases := map[string]Kind{
|
||
"image/png": KindImage,
|
||
"image/jpeg": KindImage,
|
||
"audio/wav": KindAudio,
|
||
"video/mp4": KindVideo,
|
||
"text/plain": KindOther,
|
||
}
|
||
for mime, want := range cases {
|
||
d, err := s.Put([]byte("payload-"+mime), Item{MIME: mime})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, err := s.Stat(d)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if it.Kind != want {
|
||
t.Fatalf("%s 应归为 %s,实际 %s", mime, want, it.Kind)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestGet_DetectsCorruption(t *testing.T) {
|
||
// CAS 的全部保证建立在「文件名 == 内容摘要」上。外部误改或位翻转必须
|
||
// 被发现——把损坏的图喂给模型只会得到无从追溯的幻觉。
|
||
s := newTestStore(t, 0)
|
||
d, err := s.Put([]byte("original-content"), Item{MIME: "image/png"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := s.Get(d); err != nil {
|
||
t.Fatalf("正常读取应成功: %v", err)
|
||
}
|
||
|
||
if err := os.WriteFile(s.blobPath(d), []byte("tampered!"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, err = s.Get(d)
|
||
if err == nil {
|
||
t.Fatal("内容被改后应报 digest 不匹配,却读成功了")
|
||
}
|
||
if !strings.Contains(err.Error(), "digest mismatch") {
|
||
t.Fatalf("错误应指明 digest 不匹配,得到: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPut_NoPartialBlobOnDisk(t *testing.T) {
|
||
// 先写 .tmp 再 rename:确认落地后目录里不留临时文件。
|
||
s := newTestStore(t, 0)
|
||
d, err := s.Put([]byte("some-bytes"), Item{MIME: "image/png"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
dir := filepath.Dir(s.blobPath(d))
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, e := range entries {
|
||
if strings.HasSuffix(e.Name(), ".tmp") {
|
||
t.Fatalf("落地后不该留临时文件: %s", e.Name())
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRefCount_AddIsIdempotent(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||
|
||
for i := 0; i < 3; i++ {
|
||
if err := s.AddRef(d, "context", "evt-1"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
it, _ := s.Stat(d)
|
||
// 重复 AddRef 若都递增,计数会虚高,GC 永远不敢清。
|
||
if it.RefCount != 1 {
|
||
t.Fatalf("同一 owner 重复 AddRef 应只计 1,实际 %d", it.RefCount)
|
||
}
|
||
|
||
if err := s.AddRef(d, "document", "doc-9"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ = s.Stat(d)
|
||
if it.RefCount != 2 {
|
||
t.Fatalf("不同 owner 应各计一次,实际 %d", it.RefCount)
|
||
}
|
||
}
|
||
|
||
func TestRefCount_DropAndNeverNegative(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||
s.AddRef(d, "context", "e1")
|
||
|
||
if err := s.DropRef(d, "context", "e1"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ := s.Stat(d)
|
||
if it.RefCount != 0 {
|
||
t.Fatalf("应归零,实际 %d", it.RefCount)
|
||
}
|
||
|
||
// 多余的 DropRef 不该把计数压成负数(负数会让容量 GC 的排序失去意义)
|
||
for i := 0; i < 3; i++ {
|
||
s.DropRef(d, "context", "e1")
|
||
}
|
||
it, _ = s.Stat(d)
|
||
if it.RefCount != 0 {
|
||
t.Fatalf("重复 DropRef 后仍应为 0,实际 %d", it.RefCount)
|
||
}
|
||
}
|
||
|
||
func TestDropOwner_RemovesAllItsRefs(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
d1, _ := s.Put([]byte("frame1"), Item{MIME: "image/jpeg"})
|
||
d2, _ := s.Put([]byte("frame2"), Item{MIME: "image/jpeg"})
|
||
s.AddRef(d1, "context", "evt-x")
|
||
s.AddRef(d2, "context", "evt-x")
|
||
s.AddRef(d1, "document", "doc-y") // 别的 owner 也引了 d1
|
||
|
||
n, err := s.DropOwner("context", "evt-x")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 2 {
|
||
t.Fatalf("应注销 2 条引用,实际 %d", n)
|
||
}
|
||
|
||
it1, _ := s.Stat(d1)
|
||
it2, _ := s.Stat(d2)
|
||
if it1.RefCount != 1 {
|
||
t.Fatalf("d1 仍被 document 引用,应剩 1,实际 %d", it1.RefCount)
|
||
}
|
||
if it2.RefCount != 0 {
|
||
t.Fatalf("d2 应归零,实际 %d", it2.RefCount)
|
||
}
|
||
}
|
||
|
||
func TestRefs_ListsOwnerDigests(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
|
||
d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
|
||
s.AddRef(d1, "context", "e1")
|
||
s.AddRef(d2, "context", "e1")
|
||
|
||
got, err := s.Refs("context", "e1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(got) != 2 {
|
||
t.Fatalf("应返回 2 个 digest,实际 %d", len(got))
|
||
}
|
||
}
|
||
|
||
func TestGC_KeepsReferencedContent(t *testing.T) {
|
||
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,
|
||
// 正是本包要避免的。
|
||
s := newTestStore(t, 0)
|
||
kept, _ := s.Put([]byte("referenced"), Item{MIME: "image/png"})
|
||
orphan, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"})
|
||
s.AddRef(kept, "context", "e1")
|
||
|
||
// minAge=0 让刚 Put 的都算超龄
|
||
removed, _, err := s.GC(0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if removed != 1 {
|
||
t.Fatalf("应只清 1 条无引用项,实际 %d", removed)
|
||
}
|
||
if _, err := s.Get(kept); err != nil {
|
||
t.Fatalf("被引用的内容不该被清: %v", err)
|
||
}
|
||
if _, err := s.Stat(orphan); err == nil {
|
||
t.Fatal("无引用项的元数据应已删除")
|
||
}
|
||
}
|
||
|
||
func TestGC_MinAgeProtectsFreshUnreferenced(t *testing.T) {
|
||
// 刚 Put 还没来得及 AddRef 的项 refcount 也是 0;
|
||
// minAge 必须保护它们,否则「Put 完还没挂上就被 GC 清掉」。
|
||
s := newTestStore(t, 0)
|
||
d, _ := s.Put([]byte("just-arrived"), Item{MIME: "image/png"})
|
||
|
||
removed, _, err := s.GC(time.Hour)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if removed != 0 {
|
||
t.Fatalf("新入库项应被 minAge 保护,却清掉了 %d 条", removed)
|
||
}
|
||
if _, err := s.Get(d); err != nil {
|
||
t.Fatalf("内容应还在: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestGC_EnforcesCapacity(t *testing.T) {
|
||
// 容量上限:清完超龄项后仍超限,继续按 last_seen 从旧到新淘汰无引用项。
|
||
blob := make([]byte, 1024)
|
||
s := newTestStore(t, 2048) // 只容 2KB
|
||
|
||
var digests []string
|
||
for i := 0; i < 4; i++ {
|
||
b := append([]byte{byte(i)}, blob...) // 内容各异,避免去重
|
||
d, err := s.Put(b, Item{MIME: "image/png"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
digests = append(digests, d)
|
||
time.Sleep(2 * time.Millisecond) // 拉开 last_seen
|
||
}
|
||
|
||
// 保护最后一个,确认容量 GC 也不碰有引用的
|
||
s.AddRef(digests[3], "context", "e1")
|
||
|
||
removed, freed, err := s.GC(0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if removed == 0 {
|
||
t.Fatal("超限应触发淘汰")
|
||
}
|
||
if _, err := s.Get(digests[3]); err != nil {
|
||
t.Fatalf("有引用项即使超限也不该删: %v", err)
|
||
}
|
||
t.Logf("removed=%d freed=%d", removed, freed)
|
||
|
||
st := s.Stats()
|
||
if total := st["total_bytes"].(int64); total > 2048 {
|
||
// 有引用项可能让总量降不到线下,这是刻意的(宁可超限也不断引用)
|
||
t.Logf("总量 %d 仍超 2048,因有引用项不可删(预期行为)", total)
|
||
}
|
||
}
|
||
|
||
func TestDescribe_OverwritesExplicitly(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||
|
||
if err := s.Describe(d, "一只橘猫", "vis-a"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ := s.Stat(d)
|
||
if it.Description != "一只橘猫" || it.DescribedBy != "vis-a" {
|
||
t.Fatalf("描述未写入: %+v", it)
|
||
}
|
||
|
||
// Describe 是显式操作,允许覆盖(换更强模型重描述)
|
||
if err := s.Describe(d, "一只橘色虎斑猫坐在窗台", "vis-b"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ = s.Stat(d)
|
||
if !strings.Contains(it.Description, "虎斑") || it.DescribedBy != "vis-b" {
|
||
t.Fatalf("Describe 应覆盖旧描述: %+v", it)
|
||
}
|
||
}
|
||
|
||
func TestDescribe_UnknownDigestErrors(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
err := s.Describe("deadbeef", "x", "y")
|
||
if err == nil {
|
||
t.Fatal("未知 digest 应报错而非静默成功")
|
||
}
|
||
}
|
||
|
||
func TestPut_DoesNotClobberExistingDescription(t *testing.T) {
|
||
// 先到的描述可能来自更强的模型;后到的空值不该把它冲掉。
|
||
s := newTestStore(t, 0)
|
||
data := []byte("img")
|
||
d, _ := s.Put(data, Item{MIME: "image/png", Description: "详细描述", DescribedBy: "strong-model"})
|
||
|
||
// 第二次 Put 同内容但不带描述
|
||
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ := s.Stat(d)
|
||
if it.Description != "详细描述" || it.DescribedBy != "strong-model" {
|
||
t.Fatalf("重复 Put 的空描述不该冲掉已有描述: %+v", it)
|
||
}
|
||
}
|
||
|
||
func TestPut_BackfillsMissingDimensions(t *testing.T) {
|
||
// 后来者可能带着前一次没有的信息(尺寸、工具名)
|
||
s := newTestStore(t, 0)
|
||
data := []byte("img")
|
||
d, _ := s.Put(data, Item{MIME: "image/png"})
|
||
|
||
if _, err := s.Put(data, Item{MIME: "image/png", Width: 640, Height: 480, Tool: "multimodal_see_picture"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ := s.Stat(d)
|
||
if it.Width != 640 || it.Height != 480 {
|
||
t.Fatalf("尺寸应被补写: %dx%d", it.Width, it.Height)
|
||
}
|
||
if it.Tool != "multimodal_see_picture" {
|
||
t.Fatalf("工具名应被补写: %q", it.Tool)
|
||
}
|
||
}
|
||
|
||
func TestSearch_FiltersByDescriptionAndKind(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
di, _ := s.Put([]byte("chart-img"), Item{MIME: "image/png"})
|
||
da, _ := s.Put([]byte("speech-aud"), Item{MIME: "audio/wav"})
|
||
dn, _ := s.Put([]byte("no-desc"), Item{MIME: "image/png"})
|
||
s.Describe(di, "一张蓝色的柱状图表", "vis")
|
||
s.Describe(da, "一段关于图表的讲解录音", "aud")
|
||
|
||
all, err := s.Search("图表", "", 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(all) != 2 {
|
||
t.Fatalf("两条描述都含「图表」,应返回 2,实际 %d", len(all))
|
||
}
|
||
|
||
imgs, _ := s.Search("图表", KindImage, 10)
|
||
if len(imgs) != 1 || imgs[0].Digest != di {
|
||
t.Fatalf("按 image 过滤应只剩图片,实际 %d 条", len(imgs))
|
||
}
|
||
|
||
// 无描述的项不该出现在语义检索结果里
|
||
for _, it := range all {
|
||
if it.Digest == dn {
|
||
t.Fatal("无描述的项不该被 Search 返回")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPending_ReturnsUndescribed(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
described, _ := s.Put([]byte("has-desc"), Item{MIME: "image/png"})
|
||
undescribed, _ := s.Put([]byte("needs-desc"), Item{MIME: "image/png"})
|
||
s.Describe(described, "已有描述", "vis")
|
||
|
||
pending, err := s.Pending(10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(pending) != 1 || pending[0].Digest != undescribed {
|
||
t.Fatalf("应只返回未描述项,实际 %d 条", len(pending))
|
||
}
|
||
}
|
||
|
||
func TestParseDataURL(t *testing.T) {
|
||
// 与 agent/api 的 parseAudioDataURL 不同:这里要解码后的字节。
|
||
raw := []byte{0x89, 'P', 'N', 'G'}
|
||
url := DataURL("image/png", raw)
|
||
|
||
mime, data, ok := ParseDataURL(url)
|
||
if !ok {
|
||
t.Fatal("应解析成功")
|
||
}
|
||
if mime != "image/png" {
|
||
t.Fatalf("MIME 应为 image/png,得到 %q", mime)
|
||
}
|
||
if string(data) != string(raw) {
|
||
t.Fatalf("字节应还原,得到 %v", data)
|
||
}
|
||
|
||
for _, bad := range []string{
|
||
"http://example.com/x.png", // 不是 data URL
|
||
"data:image/png,notbase64", // 缺 ;base64
|
||
"data:;base64,", // 空 MIME 与空载荷
|
||
"data:image/png;base64,!!!", // 非法 base64
|
||
} {
|
||
if _, _, ok := ParseDataURL(bad); ok {
|
||
t.Fatalf("%q 应解析失败", bad)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestStats_CountsByKindAndDescription(t *testing.T) {
|
||
s := newTestStore(t, 4096)
|
||
d1, _ := s.Put([]byte("i1"), Item{MIME: "image/png"})
|
||
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
|
||
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
|
||
s.Describe(d1, "描述", "vis")
|
||
s.AddRef(d1, "context", "e1")
|
||
|
||
st := s.Stats()
|
||
if st["count"].(int) != 3 {
|
||
t.Fatalf("count 应为 3,实际 %v", st["count"])
|
||
}
|
||
if st["described"].(int) != 1 {
|
||
t.Fatalf("described 应为 1,实际 %v", st["described"])
|
||
}
|
||
if st["unreferenced"].(int) != 2 {
|
||
t.Fatalf("unreferenced 应为 2,实际 %v", st["unreferenced"])
|
||
}
|
||
byKind := st["by_kind"].(map[string]int)
|
||
if byKind["image"] != 2 || byKind["audio"] != 1 {
|
||
t.Fatalf("by_kind 不对: %v", byKind)
|
||
}
|
||
}
|
||
|
||
func TestPut_RejectsEmpty(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil {
|
||
t.Fatal("空内容应报错")
|
||
}
|
||
}
|
||
|
||
func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
||
// 记忆的意义就在于跨重启还在。
|
||
dir := t.TempDir()
|
||
s1, err := New(dir, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"})
|
||
s1.Describe(d, "跨重启的描述", "vis")
|
||
s1.AddRef(d, "context", "e1")
|
||
s1.Close()
|
||
|
||
s2, err := New(dir, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s2.Close()
|
||
|
||
it, err := s2.Stat(d)
|
||
if err != nil {
|
||
t.Fatalf("重开后应能查到: %v", err)
|
||
}
|
||
if it.Description != "跨重启的描述" || it.RefCount != 1 {
|
||
t.Fatalf("元数据应持久化: %+v", it)
|
||
}
|
||
data, err := s2.Get(d)
|
||
if err != nil || string(data) != "persistent-img" {
|
||
t.Fatalf("内容应持久化: %v / %q", err, data)
|
||
}
|
||
}
|
||
|
||
func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) {
|
||
// 「已尝试但无法描述」的项必须退出待描述队列。
|
||
//
|
||
// 这些项被标记为 described_by=unsupported/content-missing 而 description
|
||
// 仍为空。若 Pending 只看 description,它们每轮都会被取出来重试、
|
||
// 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
|
||
s := newTestStore(t, 0)
|
||
|
||
fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"})
|
||
unsupported, _ := s.Put([]byte("cannot-describe"), Item{MIME: "application/octet-stream"})
|
||
described, _ := s.Put([]byte("已描述"), Item{MIME: "image/png"})
|
||
|
||
// 标记「尝试过但不支持」:description 空,described_by 非空
|
||
if err := s.Describe(unsupported, "", "unsupported"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Describe(described, "一张图", "visionllm"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
pending, err := s.Pending(10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(pending) != 1 {
|
||
var names []string
|
||
for _, p := range pending {
|
||
names = append(names, shortDigest(p.Digest))
|
||
}
|
||
t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names)
|
||
}
|
||
if pending[0].Digest != fresh {
|
||
t.Fatalf("待描述的应是未处理项,实际 %s", shortDigest(pending[0].Digest))
|
||
}
|
||
}
|