package gateway import ( "encoding/json" "sort" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件是历史 DTO 的 document 对象与持久化取值的测试(design/24 §2.4、§6.1): // 引用与统计往返、**不回传抽取正文**、坏列可见,以及 session_persist 的 // messageTextImageDocument 取值。 // TestHistoryMessageDocumentsCarryReferenceOnly 验证 documents 数组的元素只带 // 引用与统计:字段集被逐个钉住,将来谁往 DocumentRef 里加一个正文/摘要字段, // 这里就会红。 func TestHistoryMessageDocumentsCarryReferenceOnly(t *testing.T) { t.Parallel() raw, err := history.EncodeDocuments([]history.DocumentRef{{ Name: "0123456789abcdef0123456789abcdef.txt", MIME: "text/plain", Size: 12345, Chars: 8000, Truncated: true, Status: history.DocumentStatusExtracted, }}) if err != nil { t.Fatalf("encode: %v", err) } wire := toHistoryMessage(history.Message{ID: "m1", SessionID: "web", Role: "user", Content: "总结这份报告", Document: raw}) b, err := json.Marshal(wire) if err != nil { t.Fatalf("marshal: %v", err) } var decoded map[string]any if err := json.Unmarshal(b, &decoded); err != nil { t.Fatalf("unmarshal: %v", err) } docs, ok := decoded["documents"].([]any) if !ok || len(docs) != 1 { t.Fatalf("documents array missing in %s", b) } doc, ok := docs[0].(map[string]any) if !ok { t.Fatalf("documents[0] is not an object in %s", b) } keys := make([]string, 0, len(doc)) for k := range doc { keys = append(keys, k) } sort.Strings(keys) if got := strings.Join(keys, ","); got != "chars,mime,name,size,status,truncated" { t.Fatalf("documents[0] keys = %q, want chars,mime,name,size,status,truncated", got) } if doc["name"] != "0123456789abcdef0123456789abcdef.txt" || doc["status"] != history.DocumentStatusExtracted { t.Fatalf("documents[0] = %v, want the persisted reference", doc) } } // TestHistoryMessageDocumentsAbsentWhenNoColumn 验证没有附件的消息省略该字段: // 空列与「带附件」必须可区分(省略 = 没有附件)。 func TestHistoryMessageDocumentsAbsentWhenNoColumn(t *testing.T) { t.Parallel() wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "没有附件"}) b, err := json.Marshal(wire) if err != nil { t.Fatalf("marshal: %v", err) } if strings.Contains(string(b), `"documents"`) { t.Fatalf("empty column should omit documents, got %s", b) } } // TestHistoryMessageDocumentsMultipleKeepOrder 验证一回合多个附件原样回传 // (缺口①):数组长度与顺序都一致,客户端因此不会只看到第一个附件。 func TestHistoryMessageDocumentsMultipleKeepOrder(t *testing.T) { t.Parallel() raw, err := history.EncodeDocuments([]history.DocumentRef{ {Name: "aaa.txt", MIME: "text/plain", Size: 1}, {Name: "bbb.pdf", MIME: "application/pdf", Size: 2, Status: history.DocumentStatusUnreadable}, {Name: "ccc.csv", MIME: "text/csv", Size: 3, Chars: 9, Status: history.DocumentStatusExtracted}, }) if err != nil { t.Fatalf("encode: %v", err) } wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "三个附件", Document: raw}) if len(wire.Documents) != 3 { t.Fatalf("Documents = %+v, want 3 条", wire.Documents) } for i, want := range []string{"aaa.txt", "bbb.pdf", "ccc.csv"} { if wire.Documents[i].Name != want { t.Fatalf("Documents[%d].Name = %q, want %q(顺序必须与发送一致)", i, wire.Documents[i].Name, want) } } } // TestHistoryMessageDocumentsMalformedColumnIsVisible 验证列里是坏内容时 // 不静默当成「没有附件」:回一条标着 status=unreadable 的最小引用,让客户端 // 看得见这条引用读不出来(design/24 §4.4 的「失败必须可见」)。 func TestHistoryMessageDocumentsMalformedColumnIsVisible(t *testing.T) { t.Parallel() wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "带附件", Document: "{not json"}) if len(wire.Documents) != 1 { t.Fatalf("Documents = %+v, want 一条 unreadable 的最小引用", wire.Documents) } if wire.Documents[0].Status != history.DocumentStatusUnreadable { t.Fatalf("status = %q, want %q", wire.Documents[0].Status, history.DocumentStatusUnreadable) } } // TestMessageTextImageDocument 验证持久化取值:文本、首个图片 URL 与首个文档 // 引用各归各列,且文档列是引用(JSON 小对象)而不是正文。 func TestMessageTextImageDocument(t *testing.T) { t.Parallel() doc := model.Document{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", URL: "/api/v1/documents/aaaabbbbccccdddd.txt", Size: 1234} m := model.DocumentMessage(model.RoleUser, "读一下", doc) m.Content = append(m.Content, model.Content{Type: model.ContentTypeImage, ImageURL: "data:image/png;base64,abc"}) text, image, raw, err := messageTextImageDocument(m) if err != nil { t.Fatalf("messageTextImageDocument: %v", err) } if text != "读一下" { t.Fatalf("text = %q, want 读一下", text) } if image != "data:image/png;base64,abc" { t.Fatalf("image = %q", image) } refs, err := history.DecodeDocuments(raw) if err != nil { t.Fatalf("decode documents: %v", err) } if len(refs) != 1 || refs[0].Name != doc.Name || refs[0].MIME != doc.MIME || refs[0].Size != doc.Size { t.Fatalf("refs = %+v, want name/mime/size of %+v", refs, doc) } } // TestMessageTextImageDocumentWithoutDocument 验证没有文档部件时文档列为空 // (而不是 "[]"/"{}"):拼一个空形状会让读取侧把它当成一条读不出来的附件。 func TestMessageTextImageDocumentWithoutDocument(t *testing.T) { t.Parallel() _, _, raw, err := messageTextImageDocument(model.TextMessage(model.RoleUser, "只有文本")) if err != nil { t.Fatalf("messageTextImageDocument: %v", err) } if raw != "" { t.Fatalf("document = %q, want empty", raw) } }